Files
interactive-story/frontend/src/pages/ScenarioEditor.jsx
T
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-05 07:01:50 -04:00

396 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
import ArtPicker from '../ArtPicker'
import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
export default function ScenarioEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [scenario, setScenario] = useState(null)
const [status, setStatus] = useState('')
// Raw text buffer for the stat_schema JSON editor + a live parse error.
const [schemaText, setSchemaText] = useState('')
const [schemaError, setSchemaError] = useState('')
// Last successfully-parsed schema (drives the form editor + preview).
const [parsedSchema, setParsedSchema] = useState(null)
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
const toast = useToast()
const debounceSave = useDebouncedSave()
useEffect(() => {
api.getScenario(id).then((s) => {
setScenario(s)
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
setParsedSchema(s.stat_schema || null)
}).catch(() => navigate('/scenarios'))
}, [id, navigate])
const setField = (field, value) => {
const next = { ...scenario, [field]: value }
setScenario(next)
debounceSave(field, async () => {
await api.updateScenario(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
// Several fields at once, in one PATCH — the art picker sets `image` and
// `icon` together (choosing one clears the other, and they must not race).
const setFields = (patch) => {
setScenario((prev) => ({ ...prev, ...patch }))
debounceSave('art', async () => {
await api.updateScenario(id, patch)
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
// JSON view: typing only updates the text buffer; nothing is parsed or saved
// until the user clicks Save (so a half-typed edit doesn't spam errors).
const onJsonText = (text) => {
setSchemaText(text)
if (schemaError) setSchemaError('')
}
// Parse the JSON box on demand: apply + save if valid, else show the error.
const commitJson = () => {
const trimmed = schemaText.trim()
if (!trimmed) {
setSchemaError('')
setParsedSchema(null)
saveSchema(null)
return
}
let parsed
try {
parsed = JSON.parse(trimmed)
} catch (err) {
setSchemaError(`Invalid JSON: ${err.message}`)
return
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
setSchemaError('The schema must be a JSON object (e.g. { "player": { … } }).')
return
}
setSchemaError('')
setParsedSchema(parsed)
setSchemaText(JSON.stringify(parsed, null, 2)) // normalize / pretty-print
saveSchema(parsed)
}
const saveSchema = async (parsed) => {
await api.updateScenario(id, { stat_schema: parsed })
setScenario((s) => ({ ...s, stat_schema: parsed }))
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}
// Edits from the form editor: keep the JSON text in sync and save. An empty
// schema clears the RPG layer (null).
const applySchema = (next) => {
const cleaned = next && Object.keys(next).length ? next : null
setParsedSchema(cleaned)
setSchemaText(cleaned ? JSON.stringify(cleaned, null, 2) : '')
setSchemaError('')
debounceSave('stat_schema', () => saveSchema(cleaned))
}
// NPCs live in the same stat_schema, but are edited in their own page
// section — so this is just the `npcs` slice of the same save path.
const setNpcs = (npcs) => {
const next = { ...(parsedSchema || {}) }
if (npcs && Object.keys(npcs).length) next.npcs = npcs
else delete next.npcs
applySchema(next)
}
const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
}
const updateCard = (card) => {
setScenario({
...scenario,
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
})
debounceSave(`card-${card.id}`, () => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
})
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
}
const exportCards = async () => {
const cards = await api.exportStoryCards({ scenario_id: id })
downloadJSON(cards, `${scenario.title.replace(/\W+/g, '-')}-cards.json`)
}
const importCards = async () => {
try {
const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
const created = await api.importStoryCards({ scenario_id: Number(id), cards })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] })
} catch (err) {
toast(err.message, 'error')
}
}
const exportScenario = async () => {
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
}
const deleteScenario = async () => {
if (!confirm('Delete this scenario permanently?')) return
await api.deleteScenario(id)
navigate('/scenarios')
}
const play = async () => {
const adv = await api.createAdventure({ scenario_id: Number(id) })
navigate(`/play/${adv.id}`)
}
if (!scenario) return null
// Shared demo scenarios (Phase 8) are visible to everyone but owned by no
// one; the backend rejects edits, so present them read-only.
const readOnly = !!scenario.is_public
return (
<div className="page">
<div className="page-header">
<h1>{readOnly ? 'Scenario (read-only)' : 'Edit Scenario'}</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScenario}>Export</button>
{!readOnly && <button className="danger" onClick={deleteScenario}>Delete</button>}
<button className="primary" onClick={play}>Play</button>
</div>
</div>
{readOnly && (
<div className="demo-banner">
This is a shared demo scenario — it can’t be edited, but you can hit
<strong> Play</strong> to start your own adventure from it, or <strong>Export</strong> and
re-import it as your own copy.
</div>
)}
<fieldset disabled={readOnly} style={{ border: 'none', padding: 0, margin: 0, minWidth: 0 }}>
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." />
<ArtPicker
title={scenario.title}
image={scenario.image}
icon={scenario.icon}
disabled={readOnly}
onChange={setFields}
/>
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
textarea placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={scenario.authors_note} onChange={(v) => setField('authors_note', v)}
textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={scenario.ai_instructions} onChange={(v) => setField('ai_instructions', v)}
textarea rows={2} placeholder="Behavioral guidance for the model (always included)." />
<Field label="Tags" value={scenario.tags} onChange={(v) => setField('tags', v)} placeholder="fantasy, mystery" />
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={exportCards} disabled={scenario.story_cards.length === 0}>Export</button>
{!readOnly && <button onClick={importCards}>Import</button>}
<button onClick={addCard}>+ Add Card</button>
</div>
</div>
{scenario.story_cards.length === 0 && (
<div className="empty" style={{ padding: '20px 0' }}>
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
</div>
)}
{scenario.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
<div className="panel-toggles">
<button type="button" className={schemaView === 'form' ? 'active' : ''}
onClick={() => setSchemaView('form')}>Editor</button>
<button type="button" className={schemaView === 'json' ? 'active' : ''}
onClick={() => setSchemaView('json')}>JSON</button>
</div>
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Optional, and no longer how the story is tracked. Every campaign now keeps
genre-neutral narrative state — who exists, where they are, what they hold,
what has been established — without any schema at all, and that is what the
narrator is told. Numbers defined here are recorded alongside it for
scenarios that were built on them. Leave blank unless you have one.
</p>
{schemaView === 'form' ? (
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
) : (
<>
<textarea
className="schema-editor"
value={schemaText}
onChange={(e) => onJsonText(e.target.value)}
rows={14}
spellCheck={false}
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "npcs": {\n "gwen": { "name": "Gwen", "keys": "Gwen, ranger", "desc": "...",\n "stats": { "trust": { "min": -100, "max": 100, "initial": 20 } } }\n },\n "flags": { "has_key": { "desc": "...", "initial": false } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
/>
<div className="schema-json-actions">
<button type="button" className="primary" onClick={commitJson}>Save JSON</button>
{schemaError
? <span className="schema-error">⚠ {schemaError}</span>
: <span className="dim" style={{ fontSize: '0.8rem' }}>
Edits apply when you click Save.
</span>}
</div>
<SchemaPreview schema={parsedSchema} />
</>
)}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Cast &amp; NPCs</h2>
{schemaView === 'form' && (
<button onClick={() => setNpcs(addNpc(parsedSchema?.npcs))}>+ Add NPC</button>
)}
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Characters the AI tracks by name. Each has its own trigger words, description and
stats — a dragon’s <em>ferocity</em>, a ranger’s <em>trust</em> — addressed as
<code> npc.&lt;id&gt;.&lt;stat&gt;</code>. Adding one here also creates a story card
for it when an adventure starts.
</p>
{schemaView === 'form' ? (
<NpcEditor npcs={parsedSchema?.npcs || {}} onChange={setNpcs} />
) : (
<div className="empty" style={{ padding: '20px 0' }}>
NPCs are part of the schema JSON above — switch to <strong>Editor</strong> to edit
them as cards.
</div>
)}
</fieldset>
</div>
)
}
// ---- Structured preview of a parsed stat_schema (read-only) ----
function statMeta(def) {
const parts = []
if (def.initial !== undefined) parts.push(`start ${def.initial}`)
if (typeof def.min === 'number' && typeof def.max === 'number') parts.push(`${def.min}–${def.max}`)
if (def.type === 'counter') parts.push('counter')
if (typeof def.max_delta_per_turn === 'number') parts.push(`±${def.max_delta_per_turn}/turn`)
if (def.cooldown) parts.push(`cooldown ${def.cooldown}`)
return parts.join(' · ')
}
function StatDefRow({ name, def }) {
if (!def || typeof def !== 'object') return null
const bands = Array.isArray(def.bands)
? def.bands.filter((b) => Array.isArray(b) && b.length === 3)
.map((b) => `${b[0]}–${b[1]} ${b[2]}`).join(', ')
: null
return (
<div className="schema-stat">
<div className="schema-stat-head">
<span className="schema-stat-name">{name}</span>
<span className="schema-stat-meta">{statMeta(def)}</span>
</div>
{def.desc && <div className="schema-desc">{def.desc}</div>}
{bands && <div className="schema-bands">bands: {bands}</div>}
</div>
)
}
function StatDefGroup({ title, defs }) {
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
if (entries.length === 0) return null
return (
<div className="schema-group">
<div className="schema-group-title">{title}</div>
{entries.map(([n, d]) => <StatDefRow key={n} name={n} def={d} />)}
</div>
)
}
function SchemaPreview({ schema }) {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null
const world = schema.world || {}
const player = schema.player || {}
const npcs = schema.npcs || {}
const flags = schema.flags || {}
const milestones = schema.milestones || {}
const count = [world, player, npcs, flags, milestones]
.reduce((n, o) => n + Object.keys(o || {}).length, 0)
if (count === 0) return null
return (
<div className="schema-preview">
<div className="schema-preview-title">Parsed structure</div>
<StatDefGroup title="World" defs={world} />
<StatDefGroup title="Player" defs={player} />
{Object.entries(npcs).map(([npcId, npc]) => (
npc && typeof npc === 'object' ? (
<div key={npcId} className="schema-group">
<div className="schema-group-title">
{npc.name || npcId} <span className="schema-npc-id">npc.{npcId}</span>
</div>
{npc.keys && <div className="schema-desc">triggers: {npc.keys}</div>}
{npc.desc && <div className="schema-desc">{npc.desc}</div>}
{Object.entries(npc.stats || {}).map(([n, d]) => (
<StatDefRow key={n} name={n} def={d} />
))}
</div>
) : null
))}
{Object.keys(flags).length > 0 && (
<div className="schema-group">
<div className="schema-group-title">Flags</div>
{Object.entries(flags).map(([fid, f]) => (
<div key={fid} className="schema-stat">
<div className="schema-stat-head">
<span className="schema-stat-name">{fid}</span>
<span className="schema-stat-meta">{f?.initial ? 'on by default' : 'off by default'}</span>
</div>
{f?.desc && <div className="schema-desc">{f.desc}</div>}
</div>
))}
</div>
)}
{Object.keys(milestones).length > 0 && (
<div className="schema-group">
<div className="schema-group-title">Milestones</div>
{Object.entries(milestones).map(([mid, m]) => (
<div key={mid} className="schema-stat">
<div className="schema-stat-head">
<span className="schema-stat-name">{mid}</span>
</div>
{m?.desc && <div className="schema-desc">{m.desc}</div>}
</div>
))}
</div>
)}
</div>
)
}