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:
@@ -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 Locate the gene-code terminal …"
|
||||
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 Reinforcements arrive round 3 …"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user