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
396 lines
16 KiB
React
396 lines
16 KiB
React
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 & 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.<id>.<stat></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>
|
||
)
|
||
}
|