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
This commit is contained in:
JesseMarkowitz
2026-09-05 07:01:50 -04:00
co-authored by Claude Opus 5
parent 62a997f364
commit b7005e6fdd
57 changed files with 7257 additions and 474 deletions
+11
View File
@@ -106,6 +106,17 @@ export const api = {
}),
deleteBranch: (advId, branchId) =>
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
// Narrative state (M5). The browser reads state and proposes corrections; it
// never writes state directly. A correction goes through the same validator a
// narration's proposal does — the difference is the authority recorded on it.
getNarrativeState: (advId) => request(`/adventures/${advId}/state`),
correctNarrativeState: (advId, events, note = '') =>
request(`/adventures/${advId}/state/corrections`, {
method: 'POST', body: JSON.stringify({ events, note }),
}),
getStateEvents: (advId, limit) =>
request(`/adventures/${advId}/state/events${limit ? `?limit=${limit}` : ''}`),
// Save Points (M4). A Save Point is a durable name for a story position; the
// server stores the coordinate and nothing else. Create takes no position —
// it is always made at the campaign's active head, which is where the reader
+30 -4
View File
@@ -23,6 +23,7 @@ import { InsightsPanel } from './panels/InsightsPanel'
import { MemoryPanel } from './panels/MemoryPanel'
import { PlotPanel } from './panels/PlotPanel'
import { SavePointPanel } from './panels/SavePointPanel'
import { StatePanel } from './panels/StatePanel'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -69,7 +70,7 @@ export default function Play() {
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState(null)
const [editing, setEditing] = useState(null)
const [panel, setPanel] = useState(null) // null | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
const [panel, setPanel] = useState(null) // null | 'state' | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
// Bumped when something outside the turn loop changes the drawers' state
// (currently "Update from scenario"), which no action count would reflect.
const [stateKey, setStateKey] = useState(0)
@@ -453,6 +454,15 @@ export default function Play() {
}
try {
const updated = await api.updateAction(id, actionId, text)
// Correcting narrator prose the story is telling is not a rewrite of a
// row: STORY-BRANCH-SEMANTICS §§14-15 make it a new continuation, so the
// server answers with a different node and the old narration keeps its
// future on the line it was written on. The shape of the story changed,
// and only the server can say what the transcript is now.
if (updated.id !== actionId) {
await resync()
return
}
// A take that is only being read is not in `actions` — the row there is
// the live one — so the new text goes back into the preview, which is
// what that row is drawing. The pager holds the take list it fetched, so
@@ -464,6 +474,10 @@ export default function Play() {
} else {
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
}
// Editing a take the story is not telling changes that take's words and
// nothing else, but the panel is keyed on this and would otherwise keep
// drawing what it last read.
setStateKey((k) => k + 1)
} catch (err) {
setToast({ text: err.message, isError: true })
}
@@ -540,6 +554,8 @@ export default function Play() {
<div className="page-header">
<h1>{adventure.title}</h1>
<div className="panel-toggles">
<button className={panel === 'state' ? 'active' : ''}
onClick={() => setPanel(panel === 'state' ? null : 'state')}>Story State</button>
<button className={panel === 'plot' ? 'active' : ''}
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
<button className={panel === 'memory' ? 'active' : ''}
@@ -763,12 +779,22 @@ export default function Play() {
<div className="side-panel">
<div className="side-panel-header">
<h2>{{
plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches',
savepoints: 'Save Points', insights: 'Insights',
state: 'Story State', plot: 'Plot Components', memory: 'Memory Bank',
branches: 'Branches', savepoints: 'Save Points', insights: 'Insights',
}[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
{panel === 'state' ? (
// Keyed on the same signal the other live panels use, so the state
// follows Undo, Redo and a Save Point restore — what it shows is
// the state at the position being read, not at the newest turn.
<StatePanel
advId={id}
refreshKey={`${actions.length}:${stateKey}`}
onCorrected={() => setStateKey((k) => k + 1)}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : panel === 'plot' ? (
<PlotPanel adventure={adventure} setAdventure={setAdventure}
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
) : panel === 'memory' ? (
@@ -0,0 +1,208 @@
// The story's current state: what the campaign believes right now.
//
// M5's inspector foundation. Deliberately small — M8 owns the polished state
// editor — but real: it shows the authoritative state at the position being
// read, and it can correct it.
//
// Two rules shape it.
//
// The browser is a presentation layer. It renders what the server groups and
// sends; it does not decide what is true, and it does not write state directly.
// A correction is proposed as typed events and goes through the same validator a
// narration's proposal does, so a typo here is refused the same way a model's
// would be.
//
// And what is shown is the state at the **active head**, not at the newest turn.
// Undo, Redo and a Save Point restore all move where the story is being read,
// and the panel follows — so after stepping back, the reader sees what was true
// then rather than what the campaign later became.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
function StatePanel({ advId, refreshKey, onCorrected, onError }) {
const [state, setState] = useState(null)
const [failed, setFailed] = useState(null)
const [correcting, setCorrecting] = useState(null) // { key, label, text }
const [saving, setSaving] = useState(false)
const [tick, setTick] = useState(0)
const [history, setHistory] = useState(null)
useEffect(() => {
let cancelled = false
setFailed(null)
api.getNarrativeState(advId)
.then((body) => { if (!cancelled) setState(body) })
.catch((err) => { if (!cancelled) setFailed(err.message) })
return () => { cancelled = true }
}, [advId, refreshKey, tick])
async function showHistory() {
if (history) { setHistory(null); return }
try {
setHistory(await api.getStateEvents(advId, 40))
} catch (err) {
onError(err.message)
}
}
// A correction is expressed as a fact the reader asserts. That is the one
// shape a person can write without knowing the event vocabulary, and it is
// enough for the correction C04 asks for — "Mara never learned where the
// silver key was found" is a fact about Mara.
async function saveCorrection(event) {
event.preventDefault()
const text = (correcting?.text || '').trim()
if (!text || saving) return
setSaving(true)
try {
const events = [{
type: 'add_fact',
predicate: text,
...(correcting.key ? { subject: correcting.key } : {}),
}]
await api.correctNarrativeState(advId, events, text)
setCorrecting(null)
setTick((t) => t + 1)
// The corrected state is what the next turn is built from, so anything
// showing the old value has to re-read.
onCorrected()
} catch (err) {
onError(err.message)
} finally {
setSaving(false)
}
}
async function withdraw(factId) {
try {
await api.correctNarrativeState(
advId, [{ type: 'invalidate_fact', fact_id: factId }])
setTick((t) => t + 1)
onCorrected()
} catch (err) {
onError(err.message)
}
}
if (failed) return <div className="panel-empty">Couldn’t read the story state — {failed}</div>
if (!state) return <div className="panel-empty">Reading the story state…</div>
return (
<div className="state-panel">
{state.empty && (
<p className="state-intro">
Nothing established yet. As the story names people, places and things,
and moves them around, what the story believes appears here.
</p>
)}
{state.groups.map((group) => (
<div key={group.title} className="state-group">
<h3>{group.title}</h3>
{group.rows.map((row, i) => (
<div key={`${row.key}-${i}`} className="state-row">
<div className="state-label">{row.label}</div>
{row.detail && <div className="state-detail">{row.detail}</div>}
<div className="state-tools">
{/* Correcting is offered against a named thing, so the
correction can say who it is about. */}
{row.key && (
<button type="button"
onClick={() => setCorrecting({ key: row.key, label: row.label, text: '' })}>
Correct
</button>
)}
{group.title === 'Important Facts' && row.key && (
<button type="button" onClick={() => withdraw(row.key)}>
That’s wrong
</button>
)}
</div>
</div>
))}
</div>
))}
<div className="state-correction">
{correcting ? (
<form onSubmit={saveCorrection}>
<label htmlFor="state-correction-text">
{correcting.key
? `Set the story straight about ${correcting.label}`
: 'Set the story straight'}
</label>
<input
id="state-correction-text"
autoFocus
maxLength={500}
placeholder="never learned where the silver key was found"
value={correcting.text}
disabled={saving}
onChange={(e) => setCorrecting({ ...correcting, text: e.target.value })}
/>
<div className="state-tools">
<button type="submit" className="primary" disabled={saving || !correcting.text.trim()}>
Save correction
</button>
<button type="button" onClick={() => setCorrecting(null)}>Cancel</button>
</div>
<p className="state-hint">
This becomes what the storyteller works from. It does not change
anything already written.
</p>
</form>
) : (
<button type="button" className="state-correct-open"
onClick={() => setCorrecting({ key: '', label: '', text: '' })}>
Correct something
</button>
)}
</div>
<button type="button" className="state-history-open" onClick={showHistory}>
{history ? '▾ Hide what changed' : '▸ What changed, and why'}
</button>
{history && (
<div className="state-history">
{history.length === 0 && <p className="state-hint">Nothing recorded yet.</p>}
{history.map((entry) => (
<div key={entry.id} className="state-history-row">
<span className="state-history-what">{describe(entry)}</span>
<span className="state-history-when">
{entry.turn ? `moment ${entry.turn}` : 'before the story'}
{entry.source === 'manual_correction' && ' · your correction'}
{entry.source === 'narrator_edit' && ' · your edit'}
</span>
</div>
))}
</div>
)}
</div>
)
}
// One accepted event, in a sentence. The payload is the server's own record of
// what happened, so this reads it rather than re-deriving anything.
function describe(entry) {
const p = entry.payload || {}
switch (entry.event_type) {
case 'create_entity': return `${p.name || p.entity} enters the story`
case 'set_entity_status': return `${p.entity} is ${p.status}`
case 'set_entity_attribute': return `${p.entity} ${p.attribute} = ${p.value}`
case 'set_entity_conditions': return `${p.entity}: ${(p.conditions || []).join(', ') || 'nothing'}`
case 'set_current_location': return `${p.entity} is at ${p.location}`
case 'set_possession': return `${p.owner} holds ${p.item}`
case 'clear_possession': return `${p.item} is held by nobody`
case 'add_fact': return [p.subject, p.predicate, p.object, p.value].filter(Boolean).join(' ')
case 'invalidate_fact': return `withdrew ${p.fact_id}${p.reason ? ` — ${p.reason}` : ''}`
case 'add_relationship': return `${p.source} ${p.relationship} ${p.target}`
case 'end_relationship': return `${p.source} no longer ${p.relationship} ${p.target}`
case 'open_story_thread': return `opened: ${p.title || p.thread}`
case 'resolve_story_thread': return `resolved: ${p.thread}`
case 'set_scene': return `scene: ${p.summary || p.location || ''}`
default: return entry.event_type
}
}
export { StatePanel }
+5 -4
View File
@@ -236,10 +236,11 @@ export default function ScenarioEditor() {
</div>
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Optional. Define stats (with bands and rules), flags, and milestones, and the AI
will track them each turn — HP, mana, a raised alarm, quest objectives. NPCs are
part of this same schema but have their own section below. Leave blank for a plain
narrative scenario.
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} />
+90
View File
@@ -410,3 +410,93 @@
}
.mode-select button.active + button { border-left-width: 0; }
/* Story State (M5). The same furniture as the other panels: what the campaign
believes is not a different species of thing from the lines it believes them
on, and a reader moving between panels should not have to relearn the shapes. */
.state-panel { display: flex; flex-direction: column; gap: 14px; }
.state-intro, .state-hint {
margin: 0;
color: var(--text-dim);
font-size: 0.78rem;
line-height: 1.55;
}
.state-group { display: flex; flex-direction: column; gap: 6px; }
.state-group h3 {
margin: 0;
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-dim);
font-weight: normal;
}
.state-row {
display: flex;
flex-direction: column;
gap: 3px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-input);
}
.state-label {
font-family: var(--font-story);
font-size: 0.94rem;
color: var(--text);
}
.state-detail {
font-size: 0.74rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.state-tools { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.state-tools button {
padding: 2px 8px;
font-size: 0.7rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.state-tools button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.state-tools button:disabled { opacity: 0.4; cursor: default; }
.state-correction { display: flex; flex-direction: column; gap: 7px; }
.state-correction form { display: flex; flex-direction: column; gap: 7px; }
.state-correction label {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-dim);
}
.state-correction input {
padding: 5px 9px;
font-family: var(--font-story);
font-size: 0.92rem;
}
.state-correct-open, .state-history-open {
align-self: flex-start;
padding: 3px 9px;
font-size: 0.72rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.state-correct-open:hover, .state-history-open:hover {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.state-history { display: flex; flex-direction: column; gap: 4px; }
.state-history-row {
display: flex;
flex-direction: column;
gap: 1px;
padding: 5px 9px;
border-left: 2px solid var(--border);
}
.state-history-what { font-size: 0.8rem; color: var(--text); }
.state-history-when {
font-size: 0.68rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}