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:
@@ -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 (1–100):</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">
|
||||
|
||||
Reference in New Issue
Block a user