feat: granular per-element reveal system + GM manual dice entry

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 14:57:29 +02:00
parent d6f2f0e5c7
commit 5d98ebec13

View File

@@ -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 }) {
<p className="text-xs uppercase tracking-wide text-blue-300">
{isGM ? 'Current Scene' : 'Mission Briefing'}
</p>
<h3 className="text-xl font-semibold">{titleForScene(currentScene, activeMission.current_scene)}</h3>
{/* Title gated for players */}
{isGM || sceneReveal.title ? (
<h3 className="text-xl font-semibold">{titleForScene(currentScene, activeMission.current_scene)}</h3>
) : (
<h3 className="text-xl font-semibold text-slate-500">Scene {activeMission.current_scene + 1}</h3>
)}
</div>
{isGM && (
<span className="rounded bg-slate-950/80 px-2 py-1 text-xs text-slate-300">{currentScene.type || 'Scene'}</span>
)}
</div>
{/* #2 — scene text gated by revealed flag */}
{isGM || sceneRevealed ? (
<>
{/* Per-element reveal gates for players */}
{isGM ? (
/* GM always sees everything */
<div className="space-y-3">
{currentScene.mood && (
<div className="mb-3 rounded border border-amber-500/20 bg-amber-950/20 px-4 py-3">
<div className="rounded border border-amber-500/20 bg-amber-950/20 px-4 py-3">
<p className="text-sm italic leading-relaxed text-amber-100">{currentScene.mood}</p>
</div>
)}
<p className="whitespace-pre-wrap text-slate-200">{textForScene(currentScene) || 'No scene text stored.'}</p>
{sceneObjectives.length > 0 && (
<div className="mt-4">
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-blue-200">Objectives</h4>
<div className="grid gap-2">
{sceneObjectives.map((obj, i) => (
@@ -895,7 +957,7 @@ export default function MissionTab({ authedPlayer }) {
</div>
)}
{sceneComplications.length > 0 && (
<div className="mt-4">
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-200">Pressure</h4>
<div className="grid gap-2">
{sceneComplications.map((comp, i) => (
@@ -904,12 +966,52 @@ export default function MissionTab({ authedPlayer }) {
</div>
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">🔒</div>
<p className="text-sm text-slate-400">Awaiting GM scene briefing.<br />Stand by, Battle-Brother.</p>
</div>
) : (
/* Players see only what GM has unlocked */
(() => {
const anyVisible = sceneReveal.mood || sceneReveal.description || sceneReveal.objectives || sceneReveal.complications;
if (!anyVisible) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">🔒</div>
<p className="text-sm text-slate-400">Awaiting GM scene briefing.<br />Stand by, Kampbroder.</p>
</div>
);
}
return (
<div className="space-y-3">
{sceneReveal.mood && currentScene.mood && (
<div className="rounded border border-amber-500/20 bg-amber-950/20 px-4 py-3">
<p className="text-sm italic leading-relaxed text-amber-100">{currentScene.mood}</p>
</div>
)}
{sceneReveal.description && (
<p className="whitespace-pre-wrap text-slate-200">{textForScene(currentScene)}</p>
)}
{sceneReveal.objectives && sceneObjectives.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-blue-200">Objectives</h4>
<div className="grid gap-2">
{sceneObjectives.map((obj, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-950/40 px-3 py-2 text-sm text-slate-200">{obj}</div>
))}
</div>
</div>
)}
{sceneReveal.complications && sceneComplications.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-200">Pressure</h4>
<div className="grid gap-2">
{sceneComplications.map((comp, i) => (
<div key={i} className="rounded border border-amber-500/30 bg-amber-950/20 px-3 py-2 text-sm text-amber-100">{comp}</div>
))}
</div>
</div>
)}
</div>
);
})()
)}
{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 (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">📋</div>
<p className="text-sm text-slate-400">No check open yet.<br />Awaiting GM orders.</p>
</div>
);
}
return (
<div className="grid gap-3">
{sceneChecks.map((check, index) => {
{revealedChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
<div key={`${check.name}-${index}`} className="rounded border border-slate-700 bg-slate-900 p-3">
@@ -1029,13 +1140,8 @@ export default function MissionTab({ authedPlayer }) {
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">📋</div>
<p className="text-sm text-slate-400">No check open yet.<br />Awaiting GM orders.</p>
</div>
)
)}
);
})()}
{/* GM: full check list */}
{isGM && (
@@ -1461,18 +1567,33 @@ export default function MissionTab({ authedPlayer }) {
</div>
</div>
{/* #2 — reveal scene toggle */}
<div className={`mb-4 flex items-center justify-between rounded border px-3 py-2 text-sm ${sceneRevealed ? 'border-emerald-700/40 bg-emerald-950/20' : 'border-slate-700 bg-slate-900/60'}`}>
<div>
<div className="font-medium text-slate-200">Scene Briefing</div>
<div className="text-xs text-slate-400">{sceneRevealed ? 'Visible to players' : 'Hidden from players'}</div>
{/* #2 — per-element reveal controls */}
<div className="mb-4 rounded border border-slate-700 bg-slate-900/60 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">Reveal to Players</div>
<div className="flex gap-1">
<button onClick={revealAll} className="rounded bg-emerald-900/60 px-2 py-0.5 text-xs text-emerald-300 hover:bg-emerald-800/60">All</button>
<button onClick={lockAll} className="rounded bg-slate-800 px-2 py-0.5 text-xs text-slate-400 hover:bg-slate-700">Lock</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-xs">
{[
{ key: 'title', label: 'Title' },
{ key: 'mood', label: 'Mood' },
{ key: 'description', label: 'Description' },
{ key: 'objectives', label: 'Objectives' },
{ key: 'complications', label: 'Complications' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => toggleReveal(key)}
className={`flex items-center justify-between gap-1 rounded px-2 py-1 text-left ${sceneReveal[key] ? 'bg-emerald-900/50 text-emerald-300' : 'bg-slate-800 text-slate-400'}`}
>
<span>{label}</span>
<span>{sceneReveal[key] ? '👁' : '🔒'}</span>
</button>
))}
</div>
<button
onClick={toggleSceneRevealed}
className={`rounded px-2 py-1 text-xs ${sceneRevealed ? 'bg-emerald-700 hover:bg-emerald-600' : 'bg-slate-700 hover:bg-slate-600'}`}
>
{sceneRevealed ? 'Revealed ✓' : 'Reveal Scene'}
</button>
</div>
{/* 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 (
<div key={check.name} className={`rounded border ${isOpen ? 'border-emerald-700/60 bg-emerald-950/30' : 'border-slate-700 bg-slate-900/60'}`}>
<div className="flex items-center gap-2 px-3 py-2">
@@ -1517,6 +1640,22 @@ export default function MissionTab({ authedPlayer }) {
<div className="truncate text-sm font-medium">{check.name}</div>
<div className="text-xs text-slate-400">{check.skill} ({check.characteristic}) · {target}</div>
</div>
{/* Reveal toggle */}
<button
onClick={() => toggleRevealCheck(check.name)}
title={isCheckRevealed ? 'Hide from players' : 'Reveal to players'}
className={`shrink-0 rounded px-2 py-1 text-xs ${isCheckRevealed ? 'bg-emerald-900/50 text-emerald-300 hover:bg-emerald-800/50' : 'bg-slate-800 text-slate-500 hover:bg-slate-700'}`}
>
{isCheckRevealed ? '👁' : '🔒'}
</button>
{/* Manual roll entry toggle */}
<button
onClick={() => setGmManualRoll(isManualEntry ? null : { checkName: check.name, playerName: playerList[0] || '', value: '' })}
title="Enter a physical dice result"
className={`shrink-0 rounded px-2 py-1 text-xs ${isManualEntry ? 'bg-blue-900/60 text-blue-300' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>
🎲
</button>
{isOpen ? (
<button
onClick={clearPlayPrompt}
@@ -1543,6 +1682,56 @@ export default function MissionTab({ authedPlayer }) {
)}
</div>
{/* Manual roll entry form */}
{isManualEntry && (
<div className="border-t border-blue-900/40 bg-blue-950/20 px-3 py-2 space-y-2">
<div className="text-xs font-semibold text-blue-300">Enter physical dice result</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-16 shrink-0">Player:</label>
<select
value={gmManualRoll.playerName}
onChange={e => setGmManualRoll(prev => ({ ...prev, playerName: e.target.value }))}
className="flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs"
>
<option value=""> select </option>
{playerList.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-16 shrink-0">Roll (1100):</label>
<input
type="number"
min="1"
max="100"
value={gmManualRoll.value}
onChange={e => setGmManualRoll(prev => ({ ...prev, value: e.target.value }))}
placeholder="e.g. 42"
className="w-24 rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs"
/>
{gmManualRoll.value && !isNaN(parseInt(gmManualRoll.value)) && (
<span className={`text-xs font-semibold ${parseInt(gmManualRoll.value) <= target ? 'text-emerald-300' : 'text-rose-300'}`}>
{parseInt(gmManualRoll.value) <= target ? 'Success' : 'Fail'}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={submitGmManualRoll}
disabled={!gmManualRoll.playerName || !gmManualRoll.value}
className="rounded bg-blue-700 px-3 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-40"
>
Submit Roll
</button>
<button
onClick={() => setGmManualRoll(null)}
className="rounded bg-slate-700 px-3 py-1 text-xs text-slate-300 hover:bg-slate-600"
>
Cancel
</button>
</div>
</div>
)}
{/* #6 + #8 — expanded options */}
{isExpanded && !isOpen && (
<div className="border-t border-slate-700 px-3 py-2 space-y-2 bg-slate-950/30">