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
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""M5: the authoritative narrative state, and what shape it has.
|
|
|
|
This is the genre-neutral state ADR 006 requires and ADR 010's typed events
|
|
write into. It replaces the inherited RPG world state, which assumed stats,
|
|
bands, cooldowns and per-turn delta caps — assumptions that are a *game system*,
|
|
not a story.
|
|
|
|
## What a state document is
|
|
|
|
One JSON document per story position, holding what the campaign currently
|
|
believes:
|
|
|
|
entities the things that exist: who, where, what
|
|
possessions which entity holds which item
|
|
facts assertions about the world, with an authority
|
|
relationships directed ties between entities
|
|
threads narrative business that is open or resolved
|
|
scene the immediate situation
|
|
|
|
Nothing here names a genre. A character, a location, an organization, an item
|
|
and a vehicle are all `entities` with a `type`, which is a descriptive label the
|
|
campaign chooses, not a branch in the code (`DATA-MODEL.md` §9). The same
|
|
document holds Aldric in an abbey and the Persephone at Ceres Station, and
|
|
`J03` is satisfied because moving between them is data.
|
|
|
|
## Why a document rather than normalised tables
|
|
|
|
`DATA-MODEL.md` §17 selects the **hybrid**: validated events for audit, plus a
|
|
snapshot for reads and restore. M3 and M4 make that choice load-bearing rather
|
|
than an optimisation. Every position in a retained story must be recoverable in
|
|
bounded time — `TECHNICAL-DESIGN.md` §10.4 — because Undo, Redo and Save Point
|
|
restore all resolve a coordinate and read the state recorded there. Current-value
|
|
tables would leave the *future's* values standing when the head moves back, which
|
|
`BUILD-MILESTONES.md` M5 forbids in as many words, and rebuilding them would mean
|
|
replaying the campaign.
|
|
|
|
So the authoritative current state is this document, snapshotted per node exactly
|
|
as the world state was, and the event log beside it is the audit record rather
|
|
than the reconstruction path. The events say *why* the document changed; the
|
|
document says what is true now.
|
|
|
|
Everything in this module is pure. It builds and reads documents; it does not
|
|
touch the database, and it does not decide whether a proposal is acceptable —
|
|
that is `validate.py`, and applying an accepted event is `apply.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
# The document version, so a later milestone can migrate a stored snapshot
|
|
# without guessing what it was written by. Bump only for a shape change that a
|
|
# reader cannot infer.
|
|
VERSION = 1
|
|
|
|
# Entity categories the product suggests. This is a vocabulary, not a
|
|
# constraint: `DATA-MODEL.md` §9 calls these "descriptive categories, not
|
|
# separate game systems", so an unknown type is accepted and simply described.
|
|
# Rejecting one would make the schema genre-specific by the back door.
|
|
SUGGESTED_TYPES = (
|
|
"character", "location", "organization", "item", "vehicle",
|
|
"creature", "structure", "concept", "other",
|
|
)
|
|
|
|
# Entity lifecycle status. `DATA-MODEL.md` §9.
|
|
ENTITY_STATUSES = ("active", "inactive", "destroyed", "dead", "unknown")
|
|
|
|
# Where a fact came from, in descending authority. `DATA-MODEL.md` §14 lists the
|
|
# minimum categories; the order here is what a later context builder ranks by.
|
|
AUTHORITIES = (
|
|
"campaign_canon", # the campaign's own rules — the highest
|
|
"manual_correction", # the user said so, explicitly (C04)
|
|
"accepted_story", # derived from narration the user accepted
|
|
"current_state",
|
|
"imported_canon", # M7
|
|
"reference", # M7
|
|
"heuristic",
|
|
"inspiration", # M7
|
|
)
|
|
|
|
FACT_STATUSES = ("active", "superseded", "disputed", "invalidated")
|
|
THREAD_STATUSES = ("open", "dormant", "resolved", "abandoned")
|
|
RELATIONSHIP_STATUSES = ("active", "ended")
|
|
|
|
|
|
def empty() -> dict:
|
|
"""A campaign that has established nothing yet.
|
|
|
|
Every key is present, so no reader needs a `.get` with a default and no
|
|
writer has to decide whether a section exists. An empty document is a real
|
|
document, not a missing one.
|
|
"""
|
|
return {
|
|
"version": VERSION,
|
|
"entities": {},
|
|
"possessions": {},
|
|
"facts": [],
|
|
"relationships": [],
|
|
"threads": {},
|
|
"scene": {},
|
|
}
|
|
|
|
|
|
def normalize(state) -> dict:
|
|
"""Returns `state` as a well-formed document, repairing what it can.
|
|
|
|
Called on every read of a stored snapshot. A document can arrive from a
|
|
hand-edited database, an imported bundle, or a snapshot written by an older
|
|
version of this module, and a read must not raise on any of them: the story
|
|
is the valuable thing, and a malformed state section should cost the
|
|
section, not the campaign.
|
|
|
|
Repair is deliberately shallow — wrong-typed sections are replaced with
|
|
empty ones rather than coerced, because guessing what a malformed section
|
|
meant is exactly the kind of invention `§19` of the M5 brief forbids.
|
|
"""
|
|
if not isinstance(state, dict):
|
|
return empty()
|
|
out = empty()
|
|
out["version"] = state.get("version") if isinstance(state.get("version"), int) else VERSION
|
|
for key in ("entities", "possessions", "threads", "scene"):
|
|
value = state.get(key)
|
|
if isinstance(value, dict):
|
|
out[key] = copy.deepcopy(value)
|
|
for key in ("facts", "relationships"):
|
|
value = state.get(key)
|
|
if isinstance(value, list):
|
|
out[key] = copy.deepcopy([item for item in value if isinstance(item, dict)])
|
|
return out
|
|
|
|
|
|
def is_empty(state) -> bool:
|
|
"""Whether a document says nothing about the world.
|
|
|
|
`version` alone does not count as content, so a freshly created campaign
|
|
reads as empty and the prompt builder can leave the section out entirely
|
|
rather than showing a heading with nothing under it.
|
|
"""
|
|
document = normalize(state)
|
|
return not any(
|
|
document[key] for key in
|
|
("entities", "possessions", "facts", "relationships", "threads", "scene")
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------ entities
|
|
|
|
def entity(state: dict, key: str) -> dict | None:
|
|
"""Returns the entity stored under `key`, or None."""
|
|
entities = state.get("entities")
|
|
if not isinstance(entities, dict):
|
|
return None
|
|
found = entities.get(key)
|
|
return found if isinstance(found, dict) else None
|
|
|
|
|
|
def entity_name(state: dict, key: str) -> str:
|
|
"""The display name for `key`, falling back to the key itself.
|
|
|
|
A key is a slug the campaign chose, so it is readable enough to show when an
|
|
entity was referenced before it was described.
|
|
"""
|
|
found = entity(state, key)
|
|
if found and isinstance(found.get("name"), str) and found["name"].strip():
|
|
return found["name"]
|
|
return key
|
|
|
|
|
|
def new_entity(
|
|
*, type: str = "other", name: str = "", description: str = "",
|
|
status: str = "active", aliases: list | None = None,
|
|
) -> dict:
|
|
return {
|
|
"type": type or "other",
|
|
"name": name,
|
|
"description": description,
|
|
"status": status or "active",
|
|
"aliases": list(aliases or []),
|
|
# Where this entity currently is, as another entity's key. None means
|
|
# the campaign has not placed it, which is different from placing it
|
|
# nowhere.
|
|
"location": None,
|
|
# Free-form condition labels: "injured", "depressurised", "asleep".
|
|
# Labels rather than numbers, because a number implies a scale and a
|
|
# scale implies a game system.
|
|
"conditions": [],
|
|
# Named values the campaign cares about. Genre-neutral by construction:
|
|
# the campaign chooses the names, and every write is an absolute
|
|
# assignment (ADR 010).
|
|
"attributes": {},
|
|
}
|
|
|
|
|
|
def entities_of_type(state: dict, wanted: str) -> dict:
|
|
"""Every entity whose `type` matches, keyed as they are stored."""
|
|
entities = state.get("entities")
|
|
if not isinstance(entities, dict):
|
|
return {}
|
|
return {
|
|
key: value for key, value in entities.items()
|
|
if isinstance(value, dict) and value.get("type") == wanted
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------- possessions
|
|
|
|
def owner_of(state: dict, item_key: str) -> str | None:
|
|
"""Which entity holds `item_key`, or None if nobody does.
|
|
|
|
Possession is stored as one map from item to owner rather than as a list per
|
|
owner, because an item has exactly one holder and the map makes that
|
|
structural. Two owners for one item is then unrepresentable rather than
|
|
merely invalid.
|
|
"""
|
|
possessions = state.get("possessions")
|
|
if not isinstance(possessions, dict):
|
|
return None
|
|
owner = possessions.get(item_key)
|
|
return owner if isinstance(owner, str) else None
|
|
|
|
|
|
def held_by(state: dict, owner_key: str) -> list[str]:
|
|
"""Every item `owner_key` currently holds, in stable order."""
|
|
possessions = state.get("possessions")
|
|
if not isinstance(possessions, dict):
|
|
return []
|
|
return sorted(
|
|
item for item, owner in possessions.items() if owner == owner_key
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------- facts
|
|
|
|
def withdrawn_facts(state: dict) -> list[dict]:
|
|
"""Facts a correction or retcon took back, newest last.
|
|
|
|
The prompt needs these as well as the ones that stand. Dropping a withdrawn
|
|
fact silently leaves the narration that first asserted it as the only
|
|
account in the prompt, and the model reads surviving prose as current truth
|
|
(M5 review, Finding 4). Naming the withdrawal is what makes the reader's
|
|
correction win.
|
|
"""
|
|
facts = state.get("facts")
|
|
if not isinstance(facts, list):
|
|
return []
|
|
return [
|
|
fact for fact in facts
|
|
if isinstance(fact, dict) and fact.get("status") == "invalidated"
|
|
]
|
|
|
|
|
|
def active_facts(state: dict) -> list[dict]:
|
|
"""Facts that still stand, newest last.
|
|
|
|
An invalidated fact stays in the document rather than being removed. C04
|
|
requires a correction to be auditable, and a fact that vanished would leave
|
|
nothing to audit — the record of what the campaign used to believe is the
|
|
point.
|
|
"""
|
|
facts = state.get("facts")
|
|
if not isinstance(facts, list):
|
|
return []
|
|
return [
|
|
fact for fact in facts
|
|
if isinstance(fact, dict) and fact.get("status", "active") == "active"
|
|
]
|
|
|
|
|
|
def facts_about(state: dict, subject_key: str) -> list[dict]:
|
|
return [f for f in active_facts(state) if f.get("subject") == subject_key]
|
|
|
|
|
|
def knows(state: dict, subject_key: str, object_key: str) -> bool:
|
|
"""Whether an accepted fact says `subject` knows `object`.
|
|
|
|
C03's question, asked the way the state model can answer it. "The campaign
|
|
knows X" is a fact with no subject; "Mara knows X" is a fact whose subject
|
|
is Mara. The distinction is structural, so nothing has to infer it.
|
|
"""
|
|
return any(
|
|
fact.get("predicate") == "knows" and fact.get("object") == object_key
|
|
for fact in facts_about(state, subject_key)
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------- relationships
|
|
|
|
def active_relationships(state: dict) -> list[dict]:
|
|
relationships = state.get("relationships")
|
|
if not isinstance(relationships, list):
|
|
return []
|
|
return [
|
|
r for r in relationships
|
|
if isinstance(r, dict) and r.get("status", "active") == "active"
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------- threads
|
|
|
|
def open_threads(state: dict) -> dict:
|
|
threads = state.get("threads")
|
|
if not isinstance(threads, dict):
|
|
return {}
|
|
return {
|
|
key: value for key, value in threads.items()
|
|
if isinstance(value, dict) and value.get("status", "open") in ("open", "dormant")
|
|
}
|