feat: GM mid-play scene editor in Mission Tab

Edit Scene button in Scene Secrets header opens an inline editor card
below Table State. Lets the GM edit scene title, type, description,
checks (name/skill/char/target/modifier/reward, add/remove), enemies
(name/wounds/BS/WS/damage/AP, add/remove), objectives, and
complications — all saved live via the existing patchScene path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:08:48 +02:00
parent 2a63e43120
commit 7d038dd942

View File

@@ -168,6 +168,10 @@ export default function MissionTab({ authedPlayer }) {
const [showMissionReport, setShowMissionReport] = useState(false);
const [outcomeNotes, setOutcomeNotes] = useState('');
// Scene editor
const [editingScene, setEditingScene] = useState(false);
const [sceneEdit, setSceneEdit] = useState(null);
// ─── Data loading ───────────────────────────────────────────────
const loadMissions = useCallback(async function loadMissions() {
@@ -624,6 +628,80 @@ export default function MissionTab({ authedPlayer }) {
await saveProgress(activeMission.current_scene, nextScenes);
}
// ─── Scene editor helpers ────────────────────────────────────────
function openSceneEdit() {
if (!currentScene) return;
setSceneEdit({
title: currentScene.title || currentScene.name || '',
type: currentScene.type || 'combat',
description: currentScene.description || currentScene.briefing || currentScene.summary || '',
checks: (Array.isArray(currentScene.checks) ? currentScene.checks : []).map(c => ({ ...c })),
enemies: (Array.isArray(currentScene.enemies) ? currentScene.enemies : []).map(e => ({ ...e })),
objectives: (Array.isArray(currentScene.objectives) ? currentScene.objectives : []).join('\n'),
complications: (Array.isArray(currentScene.complications) ? currentScene.complications : []).join('\n'),
});
setEditingScene(true);
}
async function saveSceneEdit() {
if (!sceneEdit) return;
const toArr = (str) => str.split('\n').map(s => s.trim()).filter(Boolean);
await patchScene({
title: sceneEdit.title.trim() || undefined,
name: sceneEdit.title.trim() || undefined,
type: sceneEdit.type,
description: sceneEdit.description,
briefing: sceneEdit.description,
checks: sceneEdit.checks,
enemies: sceneEdit.enemies,
objectives: toArr(sceneEdit.objectives),
complications: toArr(sceneEdit.complications),
});
setEditingScene(false);
setSceneEdit(null);
}
function seField(field, value) {
setSceneEdit(prev => ({ ...prev, [field]: value }));
}
function seCheck(i, field, value) {
setSceneEdit(prev => {
const checks = prev.checks.map((c, idx) => idx === i ? { ...c, [field]: value } : c);
return { ...prev, checks };
});
}
function addCheck() {
setSceneEdit(prev => ({
...prev,
checks: [...prev.checks, { name: 'New Check', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 0, reward: '' }],
}));
}
function removeCheck(i) {
setSceneEdit(prev => ({ ...prev, checks: prev.checks.filter((_, idx) => idx !== i) }));
}
function seEnemy(i, field, value) {
setSceneEdit(prev => {
const enemies = prev.enemies.map((e, idx) => idx === i ? { ...e, [field]: value } : e);
return { ...prev, enemies };
});
}
function addEnemy() {
setSceneEdit(prev => ({
...prev,
enemies: [...prev.enemies, { name: 'Enemy', wounds: 10, bs: 45, ws: 45, damage: '1d10+4', ap: 0 }],
}));
}
function removeEnemy(i) {
setSceneEdit(prev => ({ ...prev, enemies: prev.enemies.filter((_, idx) => idx !== i) }));
}
// ─── Render ──────────────────────────────────────────────────────
if (!authedPlayer) {
@@ -1334,11 +1412,19 @@ export default function MissionTab({ authedPlayer }) {
<div className="rounded-lg border border-amber-500/30 bg-amber-950/10 p-5">
<div className="mb-4 flex items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-200">Scene Secrets</h3>
{playPrompt && (
<button onClick={clearPlayPrompt} className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-600">
Close All
<div className="flex gap-1.5">
<button
onClick={() => editingScene ? setEditingScene(false) : openSceneEdit()}
className={`rounded px-2 py-1 text-xs ${editingScene ? 'bg-amber-700 text-amber-100 hover:bg-amber-600' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}
>
{editingScene ? 'Close Editor' : '✏ Edit Scene'}
</button>
)}
{playPrompt && (
<button onClick={clearPlayPrompt} className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-600">
Close All
</button>
)}
</div>
</div>
{/* #2 — reveal scene toggle */}
@@ -1511,6 +1597,196 @@ export default function MissionTab({ authedPlayer }) {
</div>
</div>
</div>
{/* Scene Editor */}
{editingScene && sceneEdit && (
<div className="rounded-lg border border-amber-600/40 bg-amber-950/10 p-5 space-y-5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-200">Edit Scene</h3>
<div className="flex gap-2">
<button
onClick={saveSceneEdit}
disabled={saving}
className="rounded bg-amber-700 px-3 py-1.5 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
<button
onClick={() => { setEditingScene(false); setSceneEdit(null); }}
className="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
>
Cancel
</button>
</div>
</div>
{/* Title + Type */}
<div className="grid grid-cols-[1fr_auto] gap-3">
<div>
<label className="mb-1 block text-xs text-slate-400">Scene Title</label>
<input
type="text"
value={sceneEdit.title}
onChange={e => seField('title', e.target.value)}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Type</label>
<select
value={sceneEdit.type}
onChange={e => seField('type', e.target.value)}
className="rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
>
{['intro','investigation','combat','social','puzzle','boss'].map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
{/* Description */}
<div>
<label className="mb-1 block text-xs text-slate-400">Scene Description / Briefing</label>
<textarea
value={sceneEdit.description}
onChange={e => seField('description', e.target.value)}
rows={4}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
{/* Checks */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Checks</label>
<button onClick={addCheck} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add Check</button>
</div>
<div className="space-y-3">
{sceneEdit.checks.map((check, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={check.name}
onChange={e => seCheck(i, 'name', e.target.value)}
placeholder="Check name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
<button onClick={() => removeCheck(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Skill</label>
<input type="text" value={check.skill || ''} onChange={e => seCheck(i, 'skill', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Char</label>
<input type="text" value={check.characteristic || ''} onChange={e => seCheck(i, 'characteristic', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Target</label>
<input type="number" value={check.target || 45} onChange={e => seCheck(i, 'target', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Modifier</label>
<input type="number" value={check.modifier || 0} onChange={e => seCheck(i, 'modifier', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Reward (shown after roll)</label>
<input type="text" value={check.reward || ''} onChange={e => seCheck(i, 'reward', e.target.value)} placeholder="What succeeding unlocks…" className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
))}
{sceneEdit.checks.length === 0 && (
<p className="text-xs text-slate-500">No checks defaults will be generated from scene type.</p>
)}
</div>
</div>
{/* Enemies */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Enemies</label>
<button onClick={addEnemy} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add Enemy</button>
</div>
<div className="space-y-3">
{sceneEdit.enemies.map((enemy, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={enemy.name || ''}
onChange={e => seEnemy(i, 'name', e.target.value)}
placeholder="Enemy name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
<button onClick={() => removeEnemy(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Wounds</label>
<input type="number" value={enemy.wounds || 10} onChange={e => seEnemy(i, 'wounds', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">BS</label>
<input type="number" value={enemy.bs || 45} onChange={e => seEnemy(i, 'bs', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">WS</label>
<input type="number" value={enemy.ws || 45} onChange={e => seEnemy(i, 'ws', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Damage</label>
<input type="text" value={enemy.damage || '1d10+4'} onChange={e => seEnemy(i, 'damage', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">AP (pen)</label>
<input type="number" value={enemy.ap || 0} onChange={e => seEnemy(i, 'ap', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
</div>
))}
{sceneEdit.enemies.length === 0 && (
<p className="text-xs text-slate-500">No enemies defined defaults generated from scene index.</p>
)}
</div>
</div>
{/* Objectives */}
<div>
<label className="mb-1 block text-xs text-slate-400">Objectives (one per line)</label>
<textarea
value={sceneEdit.objectives}
onChange={e => seField('objectives', e.target.value)}
rows={3}
placeholder="Secure the perimeter&#10;Locate the gene-code terminal&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
{/* Complications */}
<div>
<label className="mb-1 block text-xs text-slate-400">Complications / Pressure (one per line)</label>
<textarea
value={sceneEdit.complications}
onChange={e => seField('complications', e.target.value)}
rows={3}
placeholder="Spore mines trigger on failed stealth&#10;Reinforcements arrive round 3&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
<button
onClick={saveSceneEdit}
disabled={saving}
className="w-full rounded bg-amber-700 px-3 py-2 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
)}
</div>
)}
</div>