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
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""M5: showing the narrative state — to the model, and to the reader.
|
|
|
|
Two audiences, one document, and they want different things. The model needs the
|
|
state compactly, in the vocabulary it must answer in, close to where it
|
|
generates. The reader needs it grouped and named, in the words the campaign uses.
|
|
|
|
Both are read-only views. Neither can change state, and the browser gets its own
|
|
data from the API rather than from anything assembled here, because
|
|
`BUILD-MILESTONES.md` M5 is explicit that the browser is a presentation layer and
|
|
must not become the owner of state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from . import model
|
|
|
|
# How much of a long section reaches the prompt. A campaign accumulates facts
|
|
# faster than it accumulates anything else, and the context budget is finite;
|
|
# the newest are the ones the current scene is most likely to need. M6 owns
|
|
# retrieval-ranked selection, so this is deliberately a simple recency cut and
|
|
# is documented as such rather than pretending to be a relevance model.
|
|
PROMPT_FACTS = 30
|
|
PROMPT_RELATIONSHIPS = 20
|
|
PROMPT_THREADS = 12
|
|
|
|
|
|
def for_prompt(state) -> str:
|
|
"""The current state as the narrator is shown it.
|
|
|
|
Empty string when the campaign has established nothing, so a new story's
|
|
prompt carries no heading with nothing under it.
|
|
"""
|
|
document = model.normalize(state)
|
|
if model.is_empty(document):
|
|
return ""
|
|
|
|
lines: list[str] = []
|
|
scene = document.get("scene") or {}
|
|
if scene.get("summary") or scene.get("location"):
|
|
where = scene.get("location")
|
|
head = "Scene: " + str(scene.get("summary") or "").strip()
|
|
if where:
|
|
head += f" (at {model.entity_name(document, where)})"
|
|
lines.append(head.strip())
|
|
|
|
entities = document["entities"]
|
|
if entities:
|
|
lines.append("")
|
|
lines.append("Who and what exists:")
|
|
for key, entity in entities.items():
|
|
lines.append(f" {key}: {_entity_line(document, key, entity)}")
|
|
|
|
possessions = document["possessions"]
|
|
if possessions:
|
|
lines.append("")
|
|
lines.append("Held:")
|
|
for item, owner in sorted(possessions.items()):
|
|
lines.append(
|
|
f" {model.entity_name(document, item)} — "
|
|
f"{model.entity_name(document, owner)}"
|
|
)
|
|
|
|
facts = model.active_facts(document)
|
|
if facts:
|
|
lines.append("")
|
|
lines.append("Established:")
|
|
for fact in facts[-PROMPT_FACTS:]:
|
|
lines.append(f" {_fact_line(document, fact)}")
|
|
|
|
# What the campaign has taken back. Placed straight after what stands, so
|
|
# the contradiction is resolved in the same breath it could be raised: the
|
|
# story above may still narrate the moment, and this says it did not hold
|
|
# (C04, M5 review Finding 4).
|
|
withdrawn = model.withdrawn_facts(document)
|
|
if withdrawn:
|
|
lines.append("")
|
|
lines.append("No longer true — do not treat these as established:")
|
|
for fact in withdrawn[-PROMPT_FACTS:]:
|
|
line = f" {_fact_line(document, fact)}"
|
|
reason = fact.get("invalidated_reason")
|
|
if reason:
|
|
line += f" — {reason}"
|
|
lines.append(line)
|
|
|
|
relationships = model.active_relationships(document)
|
|
if relationships:
|
|
lines.append("")
|
|
lines.append("Between them:")
|
|
for relationship in relationships[-PROMPT_RELATIONSHIPS:]:
|
|
lines.append(
|
|
f" {model.entity_name(document, relationship['source'])} "
|
|
f"{relationship['type']} "
|
|
f"{model.entity_name(document, relationship['target'])}"
|
|
)
|
|
|
|
threads = model.open_threads(document)
|
|
if threads:
|
|
lines.append("")
|
|
lines.append("Still open:")
|
|
for key, thread in list(threads.items())[:PROMPT_THREADS]:
|
|
lines.append(f" {key}: {thread.get('title', key)}")
|
|
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def _entity_line(document: dict, key: str, entity: dict) -> str:
|
|
parts = [entity.get("name") or key]
|
|
kind = entity.get("type")
|
|
if kind and kind != "other":
|
|
parts.append(f"({kind})")
|
|
status = entity.get("status")
|
|
if status and status != "active":
|
|
parts.append(f"[{status}]")
|
|
where = entity.get("location")
|
|
if where:
|
|
parts.append(f"at {model.entity_name(document, where)}")
|
|
conditions = entity.get("conditions") or []
|
|
if conditions:
|
|
parts.append("— " + ", ".join(conditions))
|
|
attributes = entity.get("attributes") or {}
|
|
if attributes:
|
|
parts.append(
|
|
"— " + ", ".join(f"{name}={value}" for name, value in sorted(attributes.items()))
|
|
)
|
|
return " ".join(str(p) for p in parts)
|
|
|
|
|
|
def _fact_line(document: dict, fact: dict) -> str:
|
|
parts = []
|
|
if fact.get("subject"):
|
|
parts.append(model.entity_name(document, fact["subject"]))
|
|
parts.append(str(fact.get("predicate", "")))
|
|
if fact.get("object"):
|
|
parts.append(model.entity_name(document, fact["object"]))
|
|
if fact.get("value") is not None:
|
|
parts.append(str(fact["value"]))
|
|
line = " ".join(str(p) for p in parts if p)
|
|
if fact.get("authority") == "manual_correction":
|
|
# The reader corrected this. Saying so in the prompt is what stops the
|
|
# model re-deriving the thing the correction removed.
|
|
line += " [corrected by the player]"
|
|
return line
|
|
|
|
|
|
def for_inspector(state) -> dict:
|
|
"""The current state grouped for the browser panel.
|
|
|
|
Only categories that actually hold something are returned, so the panel can
|
|
render what it is given without deciding what to hide — a category with no
|
|
rows is a heading that tells the reader nothing.
|
|
|
|
Every entry carries the key as well as the name. The key is what a manual
|
|
correction has to name, so the panel can offer a correction without the user
|
|
having to guess at an identifier.
|
|
"""
|
|
document = model.normalize(state)
|
|
groups: list[dict] = []
|
|
|
|
scene = document.get("scene") or {}
|
|
if scene.get("summary") or scene.get("location"):
|
|
rows = []
|
|
if scene.get("summary"):
|
|
rows.append({"key": "summary", "label": str(scene["summary"])})
|
|
if scene.get("location"):
|
|
rows.append({
|
|
"key": scene["location"],
|
|
"label": model.entity_name(document, scene["location"]),
|
|
"detail": "location",
|
|
})
|
|
groups.append({"title": "Current Scene", "rows": rows})
|
|
|
|
by_type: dict[str, list] = {}
|
|
for key, entity in document["entities"].items():
|
|
by_type.setdefault(entity.get("type") or "other", []).append((key, entity))
|
|
|
|
# Characters and locations first because they are what a reader looks for;
|
|
# everything else in whatever categories the campaign actually used, so a
|
|
# science-fiction campaign's `vehicle` appears without this code knowing the
|
|
# word (J02).
|
|
order = ["character", "location"] + sorted(
|
|
set(by_type) - {"character", "location"}
|
|
)
|
|
for kind in order:
|
|
members = by_type.get(kind)
|
|
if not members:
|
|
continue
|
|
rows = []
|
|
for key, entity in sorted(members):
|
|
detail = []
|
|
if entity.get("status") and entity["status"] != "active":
|
|
detail.append(str(entity["status"]))
|
|
if entity.get("location"):
|
|
detail.append("at " + model.entity_name(document, entity["location"]))
|
|
if entity.get("conditions"):
|
|
detail.append(", ".join(entity["conditions"]))
|
|
for name, value in sorted((entity.get("attributes") or {}).items()):
|
|
detail.append(f"{name}: {value}")
|
|
held = model.held_by(document, key)
|
|
if held:
|
|
detail.append(
|
|
"carrying " + ", ".join(model.entity_name(document, i) for i in held)
|
|
)
|
|
rows.append({
|
|
"key": key,
|
|
"label": entity.get("name") or key,
|
|
"detail": " · ".join(detail),
|
|
})
|
|
groups.append({"title": _title_for(kind), "rows": rows})
|
|
|
|
possessions = document["possessions"]
|
|
if possessions:
|
|
groups.append({"title": "Possessions", "rows": [
|
|
{
|
|
"key": item,
|
|
"label": model.entity_name(document, item),
|
|
"detail": "held by " + model.entity_name(document, owner),
|
|
}
|
|
for item, owner in sorted(possessions.items())
|
|
]})
|
|
|
|
facts = model.active_facts(document)
|
|
if facts:
|
|
groups.append({"title": "Important Facts", "rows": [
|
|
{
|
|
"key": fact.get("id") or "",
|
|
"label": _fact_line(document, fact),
|
|
"detail": _source_label(fact),
|
|
}
|
|
for fact in facts
|
|
]})
|
|
|
|
relationships = model.active_relationships(document)
|
|
if relationships:
|
|
groups.append({"title": "Relationships", "rows": [
|
|
{
|
|
"key": relationship.get("id") or "",
|
|
"label": (
|
|
f"{model.entity_name(document, relationship['source'])} "
|
|
f"{relationship['type']} "
|
|
f"{model.entity_name(document, relationship['target'])}"
|
|
),
|
|
"detail": relationship.get("description") or "",
|
|
}
|
|
for relationship in relationships
|
|
]})
|
|
|
|
threads = model.open_threads(document)
|
|
if threads:
|
|
groups.append({"title": "Open Story Threads", "rows": [
|
|
{
|
|
"key": key,
|
|
"label": thread.get("title") or key,
|
|
"detail": thread.get("description") or "",
|
|
}
|
|
for key, thread in sorted(threads.items())
|
|
]})
|
|
|
|
return {"groups": groups, "empty": not groups}
|
|
|
|
|
|
def _title_for(kind: str) -> str:
|
|
"""A heading for an entity category the campaign chose.
|
|
|
|
Pluralised generically rather than from a table, because the categories are
|
|
open: `DATA-MODEL.md` §9 suggests nine and permits any, so a lookup would
|
|
silently mislabel the tenth.
|
|
"""
|
|
known = {
|
|
"character": "Characters",
|
|
"location": "Locations",
|
|
"organization": "Organizations",
|
|
"item": "Items",
|
|
"vehicle": "Vehicles",
|
|
"creature": "Creatures",
|
|
"structure": "Structures",
|
|
"concept": "Concepts",
|
|
"other": "Other",
|
|
}
|
|
if kind in known:
|
|
return known[kind]
|
|
word = kind.replace("_", " ").strip().title()
|
|
return word if word.endswith("s") else word + "s"
|
|
|
|
|
|
def _source_label(fact: dict) -> str:
|
|
source = fact.get("authority") or fact.get("source") or ""
|
|
return {
|
|
"manual_correction": "your correction",
|
|
"campaign_canon": "campaign canon",
|
|
"accepted_story": "from the story",
|
|
}.get(source, str(source).replace("_", " "))
|