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:
co-authored by
Claude Opus 5
parent
62a997f364
commit
b7005e6fdd
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user