From 5d98ebec13e634b1fa917b150078de6143f126ee Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 14:57:29 +0200 Subject: [PATCH] feat: granular per-element reveal system + GM manual dice entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace single scene reveal toggle with individual unlock buttons for title, mood, description, objectives, complications, and each check โ€” GM controls exactly what players see and when. Add GM manual roll entry (๐ŸŽฒ button per check): select player, type the physical dice result, submit records it to the mission roll feed under that player's name. Co-Authored-By: Claude Sonnet 4.6 --- src/components/MissionTab.jsx | 267 +++++++++++++++++++++++++++++----- 1 file changed, 228 insertions(+), 39 deletions(-) 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 && ( -
+

{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 && ( +
+

{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 ? ( + +
+
+ )} + {/* #6 + #8 โ€” expanded options */} {isExpanded && !isOpen && (