diff --git a/src/components/MissionTab.jsx b/src/components/MissionTab.jsx
index fc1514e..e7abc44 100644
--- a/src/components/MissionTab.jsx
+++ b/src/components/MissionTab.jsx
@@ -172,6 +172,9 @@ export default function MissionTab({ authedPlayer }) {
const [editingScene, setEditingScene] = useState(false);
const [sceneEdit, setSceneEdit] = useState(null);
+ // GM manual roll entry
+ const [gmManualRoll, setGmManualRoll] = useState(null); // { checkName, playerName, value }
+
// ─── Data loading ───────────────────────────────────────────────
const loadMissions = useCallback(async function loadMissions() {
@@ -275,8 +278,21 @@ export default function MissionTab({ authedPlayer }) {
const playPrompt = currentScene?.playPrompt || null;
const combatActive = playPrompt?.type === 'combat';
- // #2 — scene revealed flag (undefined = treat as revealed for backwards compat)
- const sceneRevealed = currentScene?.revealed !== false;
+ // #2 — per-element reveal flags
+ // New scenes use scene.reveal.{title,mood,description,objectives,complications}
+ // Old scenes with revealed:true are treated as fully revealed for backwards compat
+ const sceneReveal = useMemo(() => {
+ const legacy = currentScene?.revealed === true; // old all-or-nothing flag
+ const r = currentScene?.reveal || {};
+ return {
+ title: r.title ?? legacy,
+ mood: r.mood ?? legacy,
+ description: r.description ?? legacy,
+ objectives: r.objectives ?? legacy,
+ complications: r.complications ?? false, // never auto-revealed
+ checks: r.checks || {},
+ };
+ }, [currentScene]);
const combatState = useMemo(() => {
const cs = currentScene?.combatState || {};
@@ -434,9 +450,49 @@ export default function MissionTab({ authedPlayer }) {
});
}
- // #2 — toggle scene revealed
- async function toggleSceneRevealed() {
- await patchScene({ revealed: !sceneRevealed });
+ // #2 — toggle individual reveal element
+ async function toggleReveal(key) {
+ const current = currentScene?.reveal || {};
+ await patchScene({ reveal: { ...current, [key]: !sceneReveal[key] } });
+ }
+
+ async function revealAll() {
+ await patchScene({ reveal: { title: true, mood: true, description: true, objectives: true, complications: true } });
+ }
+
+ async function lockAll() {
+ const currentChecks = currentScene?.reveal?.checks || {};
+ const lockedChecks = Object.fromEntries(Object.keys(currentChecks).map(k => [k, false]));
+ await patchScene({ reveal: { title: false, mood: false, description: false, objectives: false, complications: false, checks: lockedChecks } });
+ }
+
+ async function toggleRevealCheck(checkName) {
+ const current = currentScene?.reveal || {};
+ const currentChecks = current.checks || {};
+ await patchScene({ reveal: { ...current, checks: { ...currentChecks, [checkName]: !currentChecks[checkName] } } });
+ }
+
+ async function submitGmManualRoll() {
+ if (!gmManualRoll) return;
+ const { checkName, playerName, value } = gmManualRoll;
+ const check = sceneChecks.find(c => c.name === checkName);
+ if (!check || !playerName || !value) return;
+ const target = clampTarget((check.target || 45) + (check.modifier || 0));
+ const roll = parseInt(value);
+ if (isNaN(roll) || roll < 1 || roll > 100) return;
+ const result = degrees(target, roll);
+ try {
+ await axios.post('/api/missions/rolls', {
+ missionId: activeMission?.id,
+ playerName,
+ rollType: 'check',
+ sceneIndex: activeSceneIndex,
+ label: checkName,
+ payload: { name: checkName, skill: check.skill, roll, target, ...result },
+ });
+ await loadRollFeed();
+ setGmManualRoll(null);
+ } catch (e) { /* non-blocking */ }
}
async function toggleSceneRollsUnlocked() {
@@ -868,24 +924,30 @@ export default function MissionTab({ authedPlayer }) {
{isGM ? 'Current Scene' : 'Mission Briefing'}
- {titleForScene(currentScene, activeMission.current_scene)}
+ {/* Title gated for players */}
+ {isGM || sceneReveal.title ? (
+ {titleForScene(currentScene, activeMission.current_scene)}
+ ) : (
+ Scene {activeMission.current_scene + 1}
+ )}
{isGM && (
{currentScene.type || 'Scene'}
)}
- {/* #2 — scene text gated by revealed flag */}
- {isGM || sceneRevealed ? (
- <>
+ {/* Per-element reveal gates for players */}
+ {isGM ? (
+ /* GM always sees everything */
+
{currentScene.mood && (
-
+
)}
{textForScene(currentScene) || 'No scene text stored.'}
{sceneObjectives.length > 0 && (
-
+
Objectives
{sceneObjectives.map((obj, i) => (
@@ -895,7 +957,7 @@ export default function MissionTab({ authedPlayer }) {
)}
{sceneComplications.length > 0 && (
-
+
Pressure
{sceneComplications.map((comp, i) => (
@@ -904,12 +966,52 @@ export default function MissionTab({ authedPlayer }) {
)}
- >
- ) : (
-
-
🔒
-
Awaiting GM scene briefing.
Stand by, Battle-Brother.
+ ) : (
+ /* Players see only what GM has unlocked */
+ (() => {
+ const anyVisible = sceneReveal.mood || sceneReveal.description || sceneReveal.objectives || sceneReveal.complications;
+ if (!anyVisible) {
+ return (
+
+
🔒
+
Awaiting GM scene briefing.
Stand by, Kampbroder.
+
+ );
+ }
+ return (
+
+ {sceneReveal.mood && currentScene.mood && (
+
+ )}
+ {sceneReveal.description && (
+
{textForScene(currentScene)}
+ )}
+ {sceneReveal.objectives && sceneObjectives.length > 0 && (
+
+
Objectives
+
+ {sceneObjectives.map((obj, i) => (
+
{obj}
+ ))}
+
+
+ )}
+ {sceneReveal.complications && sceneComplications.length > 0 && (
+
+
Pressure
+
+ {sceneComplications.map((comp, i) => (
+
{comp}
+ ))}
+
+
+ )}
+
+ );
+ })()
)}
{isGM && currentScene.completed && (
@@ -1002,11 +1104,20 @@ export default function MissionTab({ authedPlayer }) {
);
})()}
- {/* Player: visible scene checks for mid-play reference */}
- {!isGM && (
- sceneChecks.length > 0 ? (
+ {/* Player: visible scene checks (only GM-revealed ones) */}
+ {!isGM && (() => {
+ const revealedChecks = sceneChecks.filter(c => sceneReveal.checks[c.name]);
+ if (revealedChecks.length === 0) {
+ return (
+
+
📋
+
No check open yet.
Awaiting GM orders.
+
+ );
+ }
+ return (
- {sceneChecks.map((check, index) => {
+ {revealedChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
@@ -1029,13 +1140,8 @@ export default function MissionTab({ authedPlayer }) {
);
})}
- ) : (
-
-
📋
-
No check open yet.
Awaiting GM orders.
-
- )
- )}
+ );
+ })()}
{/* GM: full check list */}
{isGM && (
@@ -1461,18 +1567,33 @@ export default function MissionTab({ authedPlayer }) {
- {/* #2 — reveal scene toggle */}
-
-
-
Scene Briefing
-
{sceneRevealed ? 'Visible to players' : 'Hidden from players'}
+ {/* #2 — per-element reveal controls */}
+
+
+
Reveal to Players
+
+
+
+
+
+
+ {[
+ { key: 'title', label: 'Title' },
+ { key: 'mood', label: 'Mood' },
+ { key: 'description', label: 'Description' },
+ { key: 'objectives', label: 'Objectives' },
+ { key: 'complications', label: 'Complications' },
+ ].map(({ key, label }) => (
+
+ ))}
-
{/* Currently revealed */}
@@ -1510,6 +1631,8 @@ export default function MissionTab({ authedPlayer }) {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
const isOpen = playPrompt?.type === 'check' && playPrompt.title === check.name;
const isExpanded = checkOpenOptions?.name === check.name;
+ const isCheckRevealed = !!sceneReveal.checks[check.name];
+ const isManualEntry = gmManualRoll?.checkName === check.name;
return (
@@ -1517,6 +1640,22 @@ export default function MissionTab({ authedPlayer }) {
{check.name}
{check.skill} ({check.characteristic}) · {target}
+ {/* Reveal toggle */}
+
+ {/* Manual roll entry toggle */}
+
{isOpen ? (