Compare commits

...
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 b7005e6fdd 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
2026-09-05 07:01:50 -04:00
JesseMarkowitzandClaude Opus 5 62a997f364 M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-04 06:34:56 -04:00
JesseMarkowitzandClaude Opus 5 279a871a77 Planning: add the M4 implementation review report and rotate M3's
Reporting pass only. No application code, no test, and no product
requirement changes.

Result: PASS WITH CORRECTIVE WORK REQUIRED.

M4's Definition of Done is met and demonstrated at the API level, including
across a real two-process restart. The load-bearing constraint holds under
inspection rather than assertion: the only head-field assignment M4 added
anywhere in the backend is one line in head.py, and an exhaustive grep of the
diff finds no second mechanism that forks, prunes memories, reconstructs
state, filters the transcript or recomputes Redo.

Three corrective items, all M4's own, none in the head model:

- GET /checkpoints is an N+1 fetching whole Action rows including prose --
  measured at 53 SELECTs for 25 Save Points against 4 for the branch panel,
  in a codebase that keeps test_egress.py for this exact class of mistake;
- deleting a branch silently deletes Save Points naming it, and the branch
  panel's confirmation does not say so. M4 added the consequence to an
  existing destructive action without updating its warning;
- the shipped D11/L03 tests restart a client, not a process, so the suite is
  weaker than the acceptance items it is named for. Both pass here only
  because the report re-ran them across a real process boundary by hand.

The browser smoke test is NOT PERFORMED, for M4 and still for M3. Firefox is
a snap that hangs past 90s on a trivial headless screenshot; there is no
Xvfb, no display, no driver library. Two consecutive milestones now carry an
unperformed browser requirement, which the report raises as a standing
acceptance risk rather than a defect in either milestone's code.

Evidence recorded: 680 backend tests pass (42 M4, 130 M3 invariants, 93
security/local-only), frontend lint and build clean, Docker build clean,
migration 80 verified against a representative pre-M4 database with both
cascades and zero possible orphans, and I04 verified through a real round
trip with branch ids remapped 1->3 and 2->4.

M4 is NOT accepted by this report, and M5 is NOT authorized. That decision
belongs to whoever reviews this.

Rotation: planning/reports/M3-IMPLEMENTATION-REPORT.md moves to
planning/archive/milestone-reports/ as a pure rename, contents unedited
(git reports 100% similarity, 0 insertions, 0 deletions). Six path
references in five active documents are updated because the path changed
and for no other reason -- no status claim, no wording change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 19:15:53 -04:00
JesseMarkowitzandClaude Opus 5 e08d49c3eb M4: add durable named Save Points
A Save Point is a name for a story position, and restoring one is head
movement. That is the whole architecture, and it is what ADR 012 and
BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored
(branch, depth) and made arriving at one a row lookup plus a state restore,
so a Save Point needs no restore machinery of its own.

What the user gets:

- Name the moment they are reading, keep playing, restart the app, and come
  back to it. Restoring moves the story back and deletes nothing: the later
  turns stay, Redo still walks forward into them, and writing something
  different is what starts a new line while the old one is kept.
- Rename, delete, and a list, in a Save Points panel beside the branch panel,
  with a Save Point button next to Undo and Redo. Both confirmations say what
  is *not* destroyed, because that is the part the screen cannot show.
- Save Points survive export and import.

What was deliberately not built:

- No second restore path. `head.move_to_node` is the only new movement: its
  depth half is M3's `head.move_to` unchanged, and its branch half is the
  single assignment `switch_branch` already makes. No head field is written
  in the checkpoint router, nothing reconstructs state, nothing prunes a
  memory, nothing copies or deletes a turn, and restore never forks — the
  first write below the restored head does, through `fork_if_behind_head`.
- No automatic cleanup. A Save Point behind the head, or naming a line the
  story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal
  is a cascade: deleting a branch takes its Save Points, as it takes its
  memories, because the story they named went with it.
- No new ADR. ADR 012 already decides the architecture, and a table is not a
  decision.

The one call the planning package did not already make: restore moves the
branch half of the head only when the coordinate is off the path being read.
Doing it unconditionally would quietly hand back an abandoned continuation
whenever a Save Point in a shared prefix was restored; never doing it would
make a Save Point on a departed line unrestorable, which contradicts §19.
TECHNICAL-DESIGN §8.8 records it.

Schema: a `checkpoints` table holding a name, an optional note and a
(branch, depth) coordinate — no copy of any story. `create_all` builds it as
it did `memories` and `branches`; migration 80 adds the index. No backfill,
because nobody had named a position before M4.

The coordinate is deliberately not an action id: one coordinate holds every
attempt at a turn and exactly one is live, so a coordinate follows a retry
where a row id would pin a take the story no longer tells.

Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering
D11-D14, I04, L03, E-series lineage and memory isolation after restore and
divergence, the edge cases, and an M3-database migration. One pre-existing
fixture in test_tree_migration.py needed `checkpoints` added to its drop
list — SQLite refuses to drop a table another table references.

Not verified: the browser. No session has had a usable one, so the Save
Point panel's DOM behaviour is unobserved — as M3's Redo control still is.
The twenty-step sequence was driven over HTTP against a live server with a
real process restart instead, and all seventeen checks pass. M4 is
implemented, not accepted: no review has been written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 18:48:54 -04:00
JesseMarkowitzandClaude Opus 5 3c8e91f644 Docs: correct post-M3 status and Ollama configuration
Three stale claims found in active documentation after the post-M3
consolidation:

- BUILD-MILESTONES.md still opened "M1 and M2 complete; M3 next", which
  contradicted its own M3 "Status: COMPLETE" block, planning/README.md and
  VERSION.md. It now states M1, M2 and M3 complete and accepted, M4 next.
- DEVELOPMENT.md described the settings row as "endpoint, model and (unused)
  API key" and sent "api_key":"" in its curl example. M2 removed the field;
  schemas.SettingsUpdate has no api_key. The prose and the example now match
  the real request shape. No application code was changed.
- README.md listed LM Studio as a supported local endpoint. ADR 002 and ADR 011
  make Ollama the only v1 backend; LM Studio is a rejected alternative there.
  The row is removed and the surrounding wording now says Ollama is the
  supported backend, same-host is the default, trusted-LAN Ollama is supported,
  public/cloud is prohibited, and the OpenAI-compatible adapter is an
  implementation detail rather than a support promise. The claude_shim section
  stays, relabelled "(development only)".

Documentation only; no code, schema or test changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 16:39:29 -04:00
JesseMarkowitzandClaude Opus 5 d27ee34901 Docs: consolidate active planning and archive historical material
The planning package had grown to where a new agent could not tell what was
authoritative. Phase 0 execution prompts sat beside the specification; four
completed milestone reports sat beside the current one; and upstream AI-DnD's
own `plan/` build log and `docs/` project site still described a hosted,
scripted, multi-user product with accounts — every screenshot in it showed a
Scripts tab and a Sign up button, none of which has existed since M2.

`planning/archive/` now holds the history and says so in its own README:
`phase0/` for the research that chose AI-DnD, `milestone-reports/` for M1 and
M2, `decisions/` for ADR 008, the Phase-0-before-build gate Phase 0 satisfied.
`planning/reports/` holds only the current milestone's report, because that is
the one M4 planning has to read; it moves to the archive when M4's replaces it.

Deleted rather than archived: the Phase 0B execution prompts and the
handoff/status/summary documents, the Phase 0A discovery and triage reports,
upstream's `plan/` and `docs/` trees, and `frontend/README.md`, which was Vite's
template boilerplate. All of it is in Git history, and the two recommendation
reports carry every conclusion the deleted research reached.

Archived documents are kept verbatim. Paths written inside them point at where
those files were when the document was written, which is the point: an evidence
record that has been quietly edited is no longer evidence.

Active documentation is corrected where it pointed at the removed trees or
described removed capability as present. `DEVELOPMENT.md`'s "things M1 did not
touch" list had gone stale at M2 and claimed QuickJS scripting was still tested;
its test count was 604 against an actual 638. `README.md` loses the upstream CI
badge, which reported upstream's pipeline rather than this fork's, and a
reference to `backend/app/worldstate/engine.py`, a file that does not exist.
`planning/README.md` is rewritten as the documentation index.

New: `planning/PROJECT-SOURCES.md` and `planning/project-sources.txt`, the
manifest of what belongs in the ChatGPT project's Sources.

Source comments referring to the deleted trees are reworded; no behaviour
changes. 638 backend tests pass, frontend lints and builds, and a reference scan
over all 48 tracked Markdown files reports no unresolved path in active
documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCbwH7yLGKsj1rhXXzKSCu
2026-09-03 14:33:07 -04:00
JesseMarkowitzandClaude Opus 5 c8755c21c2 Planning: close M3 and record active-head architecture
M3's review recommended planning changes and, following the M2 pattern,
reported rather than applied them. This applies them, and adds the ADR the
review asked for.

ADR 012 records the architecture rather than the requirement. ADR 005
already says that going backward must preserve abandoned history and that
the user sees Undo/Redo/Retry rather than branch management; it names a
movable active head as the direction and stops. What M3 settled is the
shape: the head is stored rather than derived, every read of the story is
capped at it in one place, one mechanism moves it, the state of a position
comes off the node rather than from a replay, the first write below a
moved-back head is the divergence, and whether Redo exists is decided by
the lineage rather than by a flag that could be stale. The last of those
is the property worth keeping — a flag can be wrong and make the story
wrong; a lineage cannot.

Two semantics are ratified in STORY-BRANCH-SEMANTICS.md, both of them
reversals or narrowings that a reader would otherwise take for bugs. Undo
now crosses fork points and continues to the campaign opening, because
refusing at the fork was a consequence of deleting rows the parent line
was also reading, and nothing is deleted any more. And the system refuses
to switch which take is live while a later story is off screen, because
doing it quietly would leave retained history continuing from words the
story no longer says.

A new §14A covers editing in place. §14-15 describe the finished
behaviour — the edit becomes authoritative, the state it implies is
re-evaluated, a new continuation is created, the original is retained —
and that requirement is intact and explicitly not weakened here. It is
also not built, because re-evaluating state from prose a user typed needs
M5's extraction pass. §14A says what exists in the meantime and why
refusing is the minimum that holds the invariant rather than the
destination.

TECHNICAL-DESIGN.md gains §8.7 and §9.1, recording the implemented model
and the bundle behaviour as fact in the way §5.2 records M1 and M2. §10.4
gains a constraint that is easy to lose: the snapshot half of the hybrid
state model is a requirement, not an optimization. Head movement is a row
lookup plus a restore, which is why Undo, Redo and Save Point restore cost
the same at any distance into a campaign; a state model recoverable only
by replaying from the opening would make all three proportional to
campaign length, on exactly the long campaigns this product is for.

DATA-MODEL.md records the head as stored on the campaign rather than
derived from its newest turn — two campaigns holding identical turns can
be read at different places, and nothing about the turns can tell them
apart — and the branch disposition as implemented: the depth a divergent
write left the branch at, deliberately advisory, and carried through
export because every row of an abandoned line is exported either way.

BUILD-MILESTONES.md marks M3 complete and states the one condition still
open. M4 is told a Save Point is a durable pointer and that restoring one
is head movement with a bounds check, not a restore system: a second
mover is the specific failure to avoid, because the two paths would
silently disagree about what restore means. M5 gets three constraints —
keep state efficiently recoverable, move the test instrumentation rather
than the assertions when the world-state protocol goes, and finish the
narrator edit §14A defers.

V1-ACCEPTANCE-TESTS.md clarifies ownership without lowering a bar. D10
keeps all three pass conditions and is explicitly recorded as *not*
satisfied at the end of M3; what changed is that the document now says
which milestone delivers which condition. D03's result is recorded as a
full pass rather than the partial the text allowed for, I07 gains the
pre-M3 bundle clause, and L01 gains the note that resolves its apparent
conflict with A05 — a failed turn does advance the head by one, onto the
player's retained input, and that is A05 working rather than L01 failing.

README.md described a different application: a hosted demo, guest
accounts, cloud providers, Postgres, a Render blueprint, an analytics
dashboard, a QuickJS scripting engine, and 549 tests. M2 removed all of
that and the README was never updated — a gap M2's own debt table missed.
It now describes what this fork is, including the endpoint policy and the
TLS behaviour, and the numbers in it are the current ones.

M3's report is included here as its own evidence record: no separate
baseline report was produced, so it carries the raw counts and runtime
observations as well as the review, and §W records this closeout.

SPECIFICATION.md and SECURITY-THREAT-MODEL.md are unchanged. M3 altered no
product requirement and touched no path in the threat model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 13:54:44 -04:00
JesseMarkowitzandClaude Opus 5 7f082b61d8 M3: complete non-destructive history and active-head export
The head-cursor model landed in 903fa7a and stopped there: the backend
moved the head instead of deleting turns, but a bundle still reopened at
its newest row, the browser had no way forward, and five inherited tests
still asserted the contract Undo had just stopped honouring. This is the
rest of the milestone, plus the one unsafe operation the review found.

Export now writes headDepth, and it belongs on the other side of the rule
app/bundle.py states about itself. The head depth used to be derived —
the tip of the head branch, a fact about the nodes that arrived with it —
and that was true while Undo deleted, because the newest row was the only
place a story could be read. It is a decision now: the same tree exports
identically whether the user undid three turns or none, so the file has
to say. An import that ignored it would silently Redo the story to its
newest retained turn, which is the Phase 0B export finding this milestone
exists to close. A file with no headDepth is opened at the tip, which is
not a fallback but the position such a file recorded; a file naming a
depth its own rows do not reach is refused in plan(), before a row is
written, for the reason that module gives about half-written trees.

Which branches the story has left goes into the file for the same reason.
Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing telling it apart from an active one, and a
restored backup that had lost it would have nothing for the later cleanup
and recovery screens to select on. Both keys or neither: a time with no
depth cannot say what was displaced.

The browser gets a Redo button beside Undo, on Ctrl+Shift+Z, and both are
enabled from can_undo/can_redo rather than from the transcript. Neither
is derivable on the client — Undo stops at the campaign opening, which
may be off the top of the loaded window, and Redo depends on the retained
future, which the client is never sent — so the flags now ride on every
window the server hands back, including a scrolled-up page and the
response to an import. Moving the head also refreshes the state panels,
which undo never did: it has rolled the world state back since long
before M3 and the drawer kept showing the old numbers.

An in-place edit is now refused when story descends from the turn and is
not on screen. Editing rewrites one row and re-evaluates nothing, which
is what makes it a correction rather than a continuation, and that is
harmless while everything below the turn is visible — the reader can see
what their change has to agree with. It stops being harmless when the
continuation is undone, or was left behind by a divergence, because the
edit then silently changes the words an invisible stretch of story was
written from. That was the one way M3's retained history could be made to
contradict itself. Refusing is deliberately the whole of the fix: making
such an edit fork is STORY-BRANCH-SEMANTICS.md §14-15, and §15 wants the
state the edited prose implies re-evaluated, which is M5's extraction
pass. The requirement is not weakened, only deferred, and §14A now says
so.

The predicate asks one question rather than two. A descendant is
invisible either because it is past the head on this lineage or because
it is past a fork on a branch the story left, and both are "a live node,
deeper than this one, descending from it, off the path being read". A
first attempt scoped the search to branches other than the active one and
failed the divergence case, correctly: the departed branch is usually an
ancestor of the branch now being read. Only the deepest live node on each
descending branch is examined, because visibility is monotone in depth.

Five inherited tests are rewritten rather than deleted, because what they
were protecting is still worth protecting and only the mechanism changed.
The undo-state pair keeps its state assertions and swaps "the rows are
gone" for "the rows are all here and the story is read from earlier". The
memory test stops asserting that undo prunes memories and starts
asserting the property that replaced it: a memory past the head is
unreachable, still on disk, and retrievable again after Redo, with no
re-embedding. The attempt-group test still proves the group moves as one,
out of the story rather than out of the database. And the fork test
reverses: Undo used to refuse at a fork point because it deleted rows the
parent branch was also reading, and with nothing deleted there is nothing
to protect the parent from, so it now walks into the story the branch
inherits and stops at the campaign opening instead.

tests/test_head_cursor.py is the milestone's acceptance contract, named
by the items it discharges: D01-D10, E01-E04, I01-I03, I07, L01-L02, and
the invariant they all rest on — Undo deletes zero accepted turns,
asserted on row ids over the whole retained tree. E02 has both controls,
because a negative control alone would pass if memory retrieval were
simply broken. L01 records what it does not claim: the head does move by
one on a failed turn, onto the player's retained input, which is A05
rather than a gap. Two of the edit-guard tests exist to prove the guard
stays out of the way — a correction at the tip and a correction mid-story
with everything visible must both still work.

638 backend tests pass. Frontend lint is unchanged at seven pre-existing
warnings, none in the files touched; the bundle builds at 395.85 kB; the
production image builds.

Verified at runtime against the trusted-LAN Ollama over HTTPS: three
turns, two Undos, a Redo, a Retry, an Undo, a divergent continuation,
Redo correctly refused with 400, Undo back to the opening, export, a
process restart that reopened the campaign still undone, and an import
that opened at the same position with its retained future intact. The
browser click-through of that sequence has not been run — no session in
this milestone had a browser to drive — so the Redo control itself is
verified by its endpoint and its lint and build, not by a click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 13:54:43 -04:00
JesseMarkowitzandClaude Opus 5 903fa7a74f M3: move the story's head instead of deleting its turns
Undo deleted. It removed the trailing AI action and the player action in
front of it, pruned the memories covering them, and let the tip fall back
to whatever survived. That made it the one operation in the application
that destroyed accepted story, and it was why there was no Redo: the
turns to move forward into no longer existed. Phase 0B demonstrated the
head-cursor alternative in a disposable spike; this is that concept as
production code.

backend/app/head.py is the whole of it. Three questions that used to be
one — where the story is being read, how far it is retained, and where it
opens — are now three functions, and every caller that moves the head or
asks about it goes through this module. The spike put the fork check in
the write path and left Retry and Add-take on the old one; sharing the
rules is what stops that divergence coming back.

lineage.Path now caps every entry at the head, so hiding the retained
future costs nothing at the call sites: the transcript, the context
builder, attempts.preceding and memory retrieval already funnelled
through path_of and narrow together. Path.uncapped() is the deliberate
exception, and only Redo and the fork check may use it. The memory bank
needs no pruning for the same reason — a memory carries the coordinate of
the node its block ends on, so one derived past the head falls outside
the capped clause and becomes retrievable again on Redo without having
been deleted and re-embedded.

Undo alone does not fork. Moving the head is not a decision to abandon
anything, since the user may be reading or about to Redo; the first write
below the head is where the story states which continuation it means. A
head already at the tip forks nothing, so a story that is never undone
forks exactly as often as it did before and the branch table does not
fill up with one branch per turn. Redo follows the lineage rather than
choosing among branches, which is what invalidates it after a divergence
with no flag to set or clear.

Migrations 78 and 79 give a branch superseded_at and superseded_depth.
Nothing reads them to decide behaviour — Redo is decided by the lineage,
so a stale or hand-edited value here cannot make the story wrong. They
exist so the cleanup and discarded-history features left to a later
version have something to select on, and so a divergence is observable in
a test.

Deleting an action no longer drags a moved-back head forward to the
recomputed tip, which would have silently redone the story. can_undo and
can_redo ride on AdventureOut and ActionPage because the client can work
out neither for itself: the campaign opening may be off the top of the
loaded window, and the retained future is never sent to it.

This is a checkpoint, not the finished milestone. 601 backend tests pass.
Five still assert the destructive contract — they count rows after an
undo and expect the story to be shorter — and need rewriting against the
new one; the world-state assertions inside them already pass. Export and
import do not yet carry the head coordinate, so a bundle still reopens at
the deepest node and can silently redo an undone story, which is the
Phase 0B finding this milestone exists to close. The browser has no Redo
control yet. None of the M3 acceptance coverage (D01-D10, E01-E04,
I01-I03, I07, L01-L02) is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 11:45:01 -04:00
149 changed files with 16665 additions and 11886 deletions
+51 -17
View File
@@ -91,15 +91,16 @@ decision that this project's threat model does not cover
## Pointing the storyteller at Ollama
The endpoint, model and (unused) API key are **runtime settings stored in the
database**, not environment variables. Set them on the app's Settings page, or
with one request:
The endpoint, the model and the generation parameters are **runtime settings
stored in the database**, not environment variables. There is no API key field:
M2 removed it along with the cloud providers, and Ollama does not use one. Set
them on the app's Settings page, or with one request:
```bash
curl -X PUT http://127.0.0.1:8000/api/settings \
-H 'Content-Type: application/json' \
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
"api_mode":"chat","api_key":"","max_output_tokens":200,
"api_mode":"chat","max_output_tokens":200,
"context_token_budget":4096}'
```
@@ -208,7 +209,7 @@ visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 604 tests
cd backend && .venv/bin/python -m pytest tests/ -q # 756 tests
cd frontend && npm run lint && npm run build
```
@@ -224,6 +225,36 @@ suite as complete evidence.
is lost from the union, or if a new HTTP client is added without the shared
verification context.
M5 added `test_narrative_state.py`, which fails if the state stops being
genre-neutral, if an event outside the allowlist is ever applied, if a malformed
proposal mutates anything, if campaign canon stops outranking the narration, or
if a turn's narration and its state can be committed apart from each other.
`test_narrative_realistic.py` is the one suite that needs a real model, and it is
skipped unless you point it at one:
```bash
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \
AIDND_TEST_MODEL=qwen2.5:3b-instruct \
backend/.venv/bin/python -m pytest backend/tests/test_narrative_realistic.py -v -s
```
It exists because Phase 0B found that structured-state behaviour can look
correct on a small prompt and fail under a full one — and it has already earned
its place, catching a case where a model echoed its own instruction into the
narration.
M4 added `test_save_points.py`, which fails if restoring a Save Point starts
deleting history, stops going through the active head, forks on its own, lets a
Save Point on one campaign be restored through another, or lets deleting a branch
take a Save Point with it. It also fails if listing Save Points goes back to one
query per Save Point, or starts fetching narration to render the list.
`test_process_restart.py` is the durability guard: it starts the application as a
real subprocess, kills it, and starts a second one against the same database. A
Save Point that survived only because a Python object was still alive would pass
an in-process test and fail a user's restart.
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
addresses widens, or if either place the rule is applied stops applying it —
it resolves hostnames through a stub, so it tests the policy rather than
@@ -266,21 +297,24 @@ docker exec app python -c "import socket; socket.create_connection(('1.1.1.1',44
# -> OSError: Network is unreachable, and story turns still work
```
`planning/reports/M1-BASELINE-REPORT.md` records the run this procedure is
`planning/archive/milestone-reports/M1-BASELINE-REPORT.md` records the run this procedure is
taken from, including the packet captures.
## Things inherited from upstream that M1 deliberately did not touch
## Things still inherited from upstream
These are M2's scope (`planning/BUILD-MILESTONES.md`), listed here so nobody
reports them as new:
M2 removed the hosted, cloud, account, analytics, Postgres/Render and QuickJS
scripting surfaces outright — `PROVENANCE.md` lists exactly what went. What is
left of upstream that a newcomer might report as a defect:
- hosted/multi-user/account/demo-key code, analytics tables, Postgres and
Render deployment paths, and the OpenRouter default endpoint constant all
still exist in the tree. None of them is reachable from a default local run,
and none requires a cloud service.
- `docs/*.html` is upstream's GitHub Pages project site and still links Google
Fonts. It is not served by the application and is not part of any build.
- `.github/workflows/ci.yml` is upstream's GitHub Actions pipeline. This
- **Inert legacy tables and columns.** Five tables and four columns M2 emptied
of meaning are still in the schema, unmapped, so an M1-era campaign database
opens unchanged. Nothing reads or writes them. A cleanup migration waits for
the schema to settle after M5 (`planning/BUILD-MILESTONES.md`).
- **Dual-dialect migration code.** `backend/app/migrations.py` still carries
SQLite/Postgres branches from upstream, although Postgres support itself is
gone and SQLite is the only store. Same cleanup, same milestone.
- **`.github/workflows/ci.yml`** is upstream's GitHub Actions pipeline. This
repository lives on a self-hosted Gitea; the workflow is kept for provenance
and is not what runs the tests here.
- QuickJS campaign scripting is still present and still tested.
- **No frontend tests.** `npm run lint && npm run build` is the whole frontend
check. A test runner is M8's job.
+6 -4
View File
@@ -26,9 +26,11 @@ import is a merge of the pinned commit with `--allow-unrelated-histories`, so:
- `git log d72f7c1bda0f34fccd84afb7a25c34eb01c901de` shows the real upstream
history, not a squashed snapshot;
- upstream paths are unchanged (`backend/`, `frontend/`, `docs/`, …), so a
later upstream commit can still be fetched and cherry-picked against
matching files;
- upstream code paths are unchanged (`backend/`, `frontend/`, …), so a later
upstream commit can still be fetched and cherry-picked against matching
files. Upstream's own documentation trees, `plan/` and `docs/`, were removed
on 2026-09-03: they described the hosted, scripted, multi-user product this
fork is not. They remain in this repository's history and in upstream;
- the planning package that predates the fork keeps its own history on the
other parent of the merge.
@@ -104,7 +106,7 @@ unchanged. They are not product functionality and nothing reads or writes them.
## What this fork changed in Milestone M1
Nothing was removed from upstream. The changes are the offline/locality
hardening M1 called for; see `planning/reports/M1-BASELINE-REPORT.md` for the
hardening M1 called for; see `planning/archive/milestone-reports/M1-BASELINE-REPORT.md` for the
evidence.
- `backend/app/context/encoding.py` (new) and `backend/app/context/builder.py` —
+142 -151
View File
@@ -1,40 +1,41 @@
# AI D&D
# Adventure Storyteller
[![CI](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml/badge.svg)](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
An AI Dungeon-style interactive storytelling app that runs entirely on your own machine, with
your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
world, and extend the engine with **JavaScript scripts compatible with real AI Dungeon
scripting**.
An interactive storytelling app that runs entirely on your own machine, with your own model.
Create scenarios and play open-ended adventures where a local LLM narrates the world, keeps
track of what is true, and remembers what happened.
> ### ▶️ Try it live: **[parththakkar106.github.io/AI-DnD](https://parththakkar106.github.io/AI-DnD/)**
> The project page loads instantly and launches the hosted demo in one tap. Play a scenario as
> a guest: no sign-up and no API key needed. The demo runs on a free tier that sleeps, so the
> first load after it's been idle takes about 30 to 60 seconds to wake up.
This is the **Adventure Storyteller** fork of [AI-DnD](https://github.com/parththakkar106/AI-DnD).
It is deliberately narrower than its upstream: single-user, local-only, and pointed at a model
you run yourself. The hosted deployment, the accounts and sessions, the cloud provider support,
the Postgres path, and the JavaScript scripting engine have all been removed rather than
disabled. What is left is a storyteller you can run offline.
> **Local-only, by design.** The app talks to one place — an Ollama-compatible endpoint on this
> machine or on a machine you control on your own network — and it refuses to be pointed at a
> public address. There is no telemetry, no account, no cloud inference, and nothing is fetched
> at runtime from the Internet.
>
> For the internals, read the **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**.
> They walk through the context budgeting, the world-state referee, and the memory bank, and
> state the reasoning behind each one ([Markdown version](docs/GUIDE.md)).
> For the internals, read [`planning/TECHNICAL-DESIGN.md`](planning/TECHNICAL-DESIGN.md) and
> [`planning/CONTEXT-AND-MEMORY.md`](planning/CONTEXT-AND-MEMORY.md), which cover the context
> budgeting, the state model and the memory bank as this fork builds them.
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, running on
SQLite locally and Postgres in the cloud. It works with **any OpenAI-compatible endpoint**:
Ollama and LM Studio locally, or OpenRouter, OpenAI, Groq, or vLLM in the cloud. Endpoint, key,
and model are all runtime settings, and OpenRouter's free-tier models make the whole experience
cost nothing.
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, storing
everything in one SQLite file.
![The play screen, with the world-state rail open](docs/images/play-world-state.jpg)
*The play screen. The left rail shows live world state. The AI proposes changes each turn, and
a Python engine decides what actually sticks. The chip under the narration reports what
changed. The `‹ 2/2 ›` under a turn steps between the takes it has. Writing below a take that
isn't the live one starts a new branch.*
On the play screen, the left rail carries live world state. The AI proposes changes each turn
and a Python engine decides what actually sticks; the chip under the narration reports what
changed. The `‹ 2/2 ›` under a turn steps between the takes it has, and writing below a take
that isn't the live one starts a new branch.
## Features
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
retry, undo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
💭 panel with its own token budget.
retry, undo, redo, and edit. Correcting narrator prose does not overwrite it: the correction
becomes a new continuation carrying the state it implies, and the original narration keeps its
own future as retained history. Reasoning models are supported: "thinking" streams into a
collapsible 💭 panel with its own token budget.
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
and the server is told nothing. Writing below a take that isn't the live one is what makes a
@@ -43,53 +44,71 @@ isn't the live one starts a new branch.*
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
deletes; **⌗ See the tree** draws every line against the story's own clock
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
- **An RPG world-state engine.** A scenario can declare stats, flags, milestones, and a named
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
monotonic and milestones sticky, then strips the machine-readable block out of the prose
(`backend/app/worldstate/engine.py`). Word-labeled bands (`40–60: minor damage`) make the
model reliable at it. No dice and no scripting are required.
- **Authoritative narrative state, and the application owns it.** The story tracks who exists,
where they are, what they hold, what is true, how they are tied to each other, and what is
still open — as generic entities, facts, relationships and threads, with no genre baked in.
The same schema holds a silver key in an abbey and a data crystal on an orbital station.
The AI proposes **typed events with absolute values** (`set_possession`, `add_fact`,
`set_current_location` …), and a Python validator decides what is accepted: unknown event
types are refused, references must resolve, campaign canon outranks the narration, and the
machine-readable block never reaches the reader (`backend/app/narrative/`). Every accepted
change is recorded with what it was before and which turn caused it, so the Story State panel
can show what changed and why. You can correct it by hand, and your correction outranks the
story.
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
info) are triggered by keywords in recent story text, then assembled under a token budget
(`backend/app/context/builder.py`).
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
included.
- **JavaScript scripting, AI Dungeon-compatible.** `onInput` / `onModelContext` / `onOutput`
modifiers share `state` and a `worldEntries` API, and run in an embedded quickjs sandbox
(`backend/app/scripting/`). Real AI Dungeon scripts import and run as is. An in-app CodeMirror
editor is included.
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
memories every few actions, a running story summary, and embedding-based retrieval that
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
(`backend/app/memorybank.py`).
- **Undo and retry that actually roll back state.** Undo and retry roll back the world state
and script state to a per-node snapshot, not just the text, and prune the memories that
covered the removed turns. Nothing a retry replaces is discarded: the old attempt stays as
another take of that turn, one keystroke and one click from becoming a branch of its own.
- **Import and export.** AI Dungeon-compatible formats for scripts and scenarios; JSON for
everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
every branch, every take, and the fork points, since those were chosen rather than computed.
Files saved in the old single-line format still import.
- **Optional accounts for hosted deployments.** By default the app is single-user with zero
auth friction. Set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed
session cookie), can register (email and password) at any point to keep their data, and each
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
demo key** with a daily turn cap lets people try it without bringing a key
(`backend/app/auth.py`). Each new guest is also given a copy of a short pre-played
adventure, so the first screen shows real turns and their world-state changes without
spending a demo turn (`backend/app/starter.py`).
- **Undo, Redo, and retry that roll back state and delete nothing.** Undo moves where the story
is being read; it removes no accepted turn, so Redo can walk forward into the turns it stepped
over. Both restore the world state from a per-node snapshot rather than just the text, and a
memory derived from a turn now behind the head stops being retrieved without being deleted or
re-embedded. Writing a new turn below a moved-back head is the moment the story forks: the
displaced future stays on the line it was written for, and ordinary Redo stops offering it.
Nothing a retry replaces is discarded either — the old attempt stays as another take of that
turn, one keystroke and one click from becoming a branch of its own.
- **Save Points.** Name a moment — "Before entering the abbey" — keep playing,
restart the app, and come back to it. Restoring one moves the story back to
that moment and deletes nothing: the turns you wrote after it stay, Redo still
walks forward into them, and writing something different from the Save Point
is what starts a new line while the old one is kept. A Save Point is a name for
a position and holds no copy of the story, so restoring it is the same
movement Undo makes (`backend/app/routers/adventures/checkpoints.py`,
`backend/app/head.py`). They last until *you* delete them: deleting one deletes
no story, and deleting a branch a Save Point is kept on is refused until you
remove the Save Point yourself, so nothing takes a named moment away behind
your back.
- **Import and export.** AI Dungeon-compatible scenario format; JSON for everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
every branch, every take, the fork points, which branches the story has left behind, the Save
Points and the position it is being read at — all of them chosen rather than computed, which is
the rule for what a bundle carries. A campaign opens where its head says, never at a Save Point
merely because it has one. A campaign exported after two Undos imports still undone, with its
retained future intact, instead of silently reopening at its newest turn. Files that predate
the head position, and files saved in the old single-line format, still import.
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
anywhere in the product. The storyteller API binds to loopback and is unauthenticated by
design, because the only person who can reach it is the person running it. A new install
starts with a short pre-played adventure, so the first screen shows real turns and their
world-state changes rather than an empty page (`backend/app/starter.py`).
- **A refusal you can rely on.** The inference endpoint is checked against an address
allowlist when you save it and again before every request, so a public endpoint is refused
even if the setting is edited in the database directly. TLS verification is never traded
against reachability: a privately issued certificate is verified against your machine's own
trust store, and there is no bypass switch.
## Screenshots
| | |
|---|---|
| ![Insights panel](docs/images/insights.jpg) | ![Scenario editor](docs/images/scenario-editor-npcs.jpg) |
| **Insights**: the exact prompt for the next turn, broken into components with token counts and the trigger word that pulled each story card in. | **Authoring**: stats with ranges, per-turn caps, cooldowns, and word-labeled bands; NPCs the AI addresses by id. |
| ![Script editor](docs/images/script-editor.jpg) | ![Home](docs/images/home.jpg) |
| **Scripting**: the three AI Dungeon hooks with shared persistent `state`, run in a quickjs sandbox. | **Home**: continue a story in progress or start from a scenario. |
| ![The branch map](docs/images/branch-map.jpg) | ![The branches panel](docs/images/branches-panel.jpg) |
| **The tree**: one lane per line, from the moment it left its parent to the moment it ends. The horizontal axis is the story's own clock, so a short branch reads as short. | **Branches**: every line the story has taken, and the three things you can do to one. A line the one you're reading was forked from can't be deleted, and says so. |
None yet. The inherited screenshots showed upstream's UI — a Scripts tab, Log in and Sign up,
a guest banner, scripting demo scenarios — none of which this fork has since M2, so they were
removed rather than left standing as a picture of a product that no longer exists. The M4
closeout drove the real application in a real browser, so the screens exist and work; taking
presentable screenshots of them is a job for the UI pass in M8.
## Quick start
@@ -121,58 +140,67 @@ Open http://localhost:5173.
## Connect a model
Open **Settings** in the app and point it at any OpenAI-compatible endpoint:
Ollama is the inference backend v1 supports. Open **Settings** in the app and point it at one:
| Provider | Endpoint URL | Notes |
| Where Ollama runs | Endpoint URL | Notes |
|---|---|---|
| Ollama (local) | `http://localhost:11434/v1` | free, private; also serves embedding models for the Memory Bank (e.g. `nomic-embed-text`) |
| LM Studio (local) | `http://localhost:1234/v1` | free, private |
| OpenRouter | `https://openrouter.ai/api/v1` | `:free` models cost nothing (no embeddings on the free tier) |
| OpenAI / Groq / vLLM / … | provider's `/v1` URL | anything speaking `/v1/chat/completions` |
| Claude Code CLI (local) | `http://127.0.0.1:8787/v1` | your Claude subscription instead of an API key; see [Playing against Claude locally](#playing-against-claude-locally) |
| The same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
| A machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | explicitly configured; see below |
Model name, API key, generation parameters, and (optionally) summary and embedding models for
the Memory Bank are all configured there too. No config files and no rebuild are needed.
Model name, generation parameters, and (optionally) summary and embedding models for the
Memory Bank are configured there too. No config files and no rebuild are needed. There is no
API key field, because there is nothing to authenticate to.
### Playing against Claude locally
The adapter underneath speaks the OpenAI-compatible protocol, because that is what Ollama
serves. That is an implementation detail, not a promise of support for arbitrary local
servers that happen to speak the same protocol. Public and cloud inference endpoints are
prohibited outright — see `planning/DECISIONS/002-ollama-only-v1.md` and
`planning/DECISIONS/011-local-inference-endpoint-policy.md`.
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint backed by the
`claude` command line tool, so you can play the demos against a real model without an
API key. Each request spawns one `claude --print` process, which suits the turn engine:
the app assembles the whole prompt every turn and expects a stateless endpoint.
### What the endpoint policy allows
The address is checked when you save it and again before every request. Only loopback and
private-network addresses are accepted; every public address is refused, by address rather than
by hostname, so a name that resolves outward is refused too. A well-known cloud inference host
is named in the error message only so the refusal says *why*.
Running the model on a second machine you control is supported and expected — that machine
does the inference while the storyteller itself stays bound to loopback on yours. If that
machine serves HTTPS with a certificate from a CA you installed, it works: certificates are
verified against your operating system's trust store as well as the bundled one. Verification
itself is never relaxed, and there is no option to turn it off.
### Playing against a local shim (development only)
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint on `127.0.0.1:8787`
backed by a command-line tool, which is useful for testing the turn engine against a stronger
model. Each request spawns one process, which suits the engine: the app assembles the whole
prompt every turn and expects a stateless endpoint.
```sh
cd backend
.venv/Scripts/python.exe tools/claude_shim.py # listens on 127.0.0.1:8787
.venv/bin/python tools/claude_shim.py # listens on 127.0.0.1:8787
```
In Settings, choose the OpenAI-compatible provider, set the base URL to
`http://127.0.0.1:8787/v1`, put any non-empty string in the API key field, and pick
`sonnet`. The shim ignores the key and authenticates as you, through the CLI. Set the
reasoning budget to `0` or `-1`: a positive budget sends a `reasoning.max_tokens` field
that Claude 5 models reject.
Set the base URL to `http://127.0.0.1:8787/v1` and pick a model the tool offers. Set the
reasoning budget to `0` or `-1`: a positive budget sends a `reasoning.max_tokens` field that
some models reject. Embeddings are not served — leave the embedding model blank, or point the
Memory Bank at an endpoint that serves one.
Embeddings are not served. Leave the embedding model blank, or point the Memory Bank at
a real endpoint.
Run it against a local backend only. The endpoint has no authentication, and anything
reaching it spends your Claude quota. `app/netguard.py` blocks localhost endpoints when
`AIDND_MULTI_USER` is set, so a deployed instance cannot be pointed at it.
The shim has no authentication and spends whatever quota backs it, so run it on loopback and
leave it there.
## How a turn works
```
player input
→ onInput script modifier
→ assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions]
+ [plot essentials] + [story summary] + [retrieved memories]
+ [triggered story cards] + [history along this branch, token-budgeted]
+ [author's note] + [player action]
→ onModelContext script modifier
→ snapshot context (Insights)
→ provider adapter → AI (streamed)
→ extract + referee the world-state delta block, strip it from the prose
→ onOutput script modifier
→ store & render
```
@@ -180,18 +208,19 @@ player input
```
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ routers/ auth, scenarios, adventures, story cards, scripts, chat, settings, analytics, debug
├─ models.py SQLAlchemy: User, Scenario, Adventure, Branch, Action, StoryCard, Script, Settings, Memory
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (64 and counting)
├─ auth.py guest/registered users, sessions, shared demo key
├─ security.py password hashing, cookie signing, API-key encryption
├─ routers/ scenarios, adventures, story cards, chat, settings, debug
├─ models.py SQLAlchemy: Scenario, Adventure, Branch, Action, StoryCard, Settings, Memory
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (79 and counting)
├─ endpoints.py the inference-endpoint address policy
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
├─ tree.py forking, promotion, and where a node is placed
├─ head.py the active head: where the story is read, and what moving it costs
├─ checkpoints Save Points: durable names for positions, in routers/adventures/
├─ attempts.py the takes of one turn, grouped by parent
├─ context/ prompt assembly under a token budget + lineage/history windowing
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
├─ scripting/ quickjs sandbox + AI Dungeon API surface
├─ narrative/ the authoritative state: typed events, validation, snapshots
├─ worldstate/ the inherited RPG stat engine — legacy, no longer authoritative
├─ memorybank.py auto-summarization + embedding retrieval
├─ analytics.py buffered visit counters + the owner's dashboard query
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
├─ providers/ OpenAI-compatible adapter, streaming
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
@@ -202,9 +231,10 @@ development, Vite proxies `/api` to FastAPI.
## Tests
549 backend tests: unit tests plus full HTTP integration through the real quickjs scripting
engine, with the LLM provider mocked. CI runs them on every push, alongside the frontend
lint/build and a Docker image build.
756 backend tests: unit tests plus full HTTP integration through the real turn engine, with
the model provider mocked. They run with no route to the Internet, which is a requirement
rather than a convenience — an offline claim proved on a machine that has been online once
proves nothing.
```sh
cd backend && pip install -r requirements.txt -r requirements-dev.txt
@@ -233,59 +263,20 @@ most interesting engineering in the repo.
the number of SQL clauses is bounded by the context window rather than by the number of
forks.
## Visit analytics
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` shows
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
`AIDND_ANALYTICS_EMAILS`, and the route returns 404 for everyone else.
This is built into the app rather than added with a third-party script, for reasons specific
to this project: the CSP allows only `script-src 'self'`, ad blockers block the popular
trackers, and none of those trackers can see the measurement that matters here, a turn. Counts
are aggregated in memory and flushed as UPSERTs, so a visit is a write and never a read, and
every dashboard query is a `GROUP BY` that returns tens of rows regardless of traffic volume.
That matters: see the egress note above for what reading rows per request costs on this stack.
## Deploy (Render)
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that serves
the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres. The free
Render tier has no persistent disk, so the database lives off-box.
1. Create a **Neon** project and copy its pooled connection string.
2. In Render, choose **New → Blueprint** and point it at this repo. Render reads
`render.yaml`.
3. Fill in the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
string); `AIDND_DEMO_API_KEY` and `AIDND_DEMO_MODELS` to offer a no-signup demo; and
`AIDND_ANALYTICS_EMAILS` (your own account's email) to see the Visitors dashboard.
`AIDND_SECRET_KEY` is generated automatically and stays stable across deploys.
4. Deploy. Pushes to `main` auto-deploy after this. The health check is `/api/health`.
On the free tier the service sleeps after about 15 minutes idle, and the first request after
that takes about 30 to 60 seconds to wake it. Point any keep-warm pinger at `/api/health`,
which deliberately doesn't touch the database: waking the database around the clock costs far
more than the cold start saves.
If you put another proxy or CDN in front of Render, set `AIDND_TRUSTED_PROXY_HOPS` to the
number of proxies in the chain. It defaults to 1. The rate limiter reads the client IP that
many entries from the right of `X-Forwarded-For`, because the trusted edge appends the real
one last. Leave it at 1 behind two proxies and the limiter reads an entry the caller
supplied, so anyone can rotate the header for a fresh rate-limit bucket per request.
## Repo notes
- `plan/` holds the phased implementation plan this project was built from, kept as a build
log. All fourteen phases are complete. The later files (11, 12, 14) also serve as design
notes for the state-revert, world-state, and story-tree work.
[`plan/STATUS.md`](plan/STATUS.md) is the running thread: what shipped, what was measured,
and what is owed next.
- [`docs/GUIDE.md`](docs/GUIDE.md) holds design notes: how each subsystem works and why it was
built that way, with the measurements behind the decisions. It is also rendered as a
[reading page](https://parththakkar106.github.io/AI-DnD/guide.html).
- `backend/.env.example` lists the few environment variables the backend reads.
- [`docs/self-review.md`](docs/self-review.md) records a full-codebase self-review pass and
what came out of it. All correctness findings are resolved.
- `planning/` is this fork's own package: the product specification, the architecture
decisions, the milestone plan, the acceptance contract, and a review report for every
milestone shipped. Start at [`planning/README.md`](planning/README.md).
- [`planning/archive/`](planning/archive/README.md) holds the Phase 0 research that chose this
base and the completed milestone reports. It is history, not instruction.
- [`DEVELOPMENT.md`](DEVELOPMENT.md) is how to set the project up, point it at a model, and run
the tests. [`PROVENANCE.md`](PROVENANCE.md) records what came from upstream and what changed.
- `backend/.env.example` lists the two environment variables the backend reads. Everything
about the model is a runtime setting on the Settings page instead.
- Upstream's own `plan/` build log and `docs/` project site were removed in the 2026-09-03
documentation pass: they described AI-DnD's hosted, scripted, multi-user product. Both are
still in Git history, and in upstream.
## License
+49 -7
View File
@@ -38,6 +38,7 @@ from sqlalchemy.orm import Session, undefer
from . import models
from .context import lineage
from .narrative import model as narrative_model
# The slices of a context snapshot that belong to one attempt rather than to the
# turn. They are the world-state delta the attempt proposed and what the engine
@@ -45,7 +46,7 @@ from .context import lineage
# token accounting. Each attempt is its own API call, and a retry is the call
# most likely to read the prompt back out of cache. Everything else in a snapshot
# is the prompt, which is assembled once per turn.
ATTEMPT_KEYS = ("world_state", "raw_output", "usage")
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
# ------------------------------------------------------------------ reading
@@ -152,26 +153,67 @@ def preceding(
# ------------------------------------------------------------------ writing
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
"""Restores the world state that `node` left behind.
"""Restores the state that `node` left behind.
A NULL snapshot means leave the live state as it is, never reset it. Rows
written before SP4 that the migration could not derive an outcome for carry
NULLs, and overwriting a running adventure's state with an empty dict would
be worse than doing nothing.
This is what makes Undo, Redo, a branch switch and a Save Point restore cost
the same at any distance: the destination node carries its own outcome, so
arriving is a row read rather than a replay (`TECHNICAL-DESIGN.md` §10.4).
M5 changed what is restored, not how — the narrative state document takes
the place the RPG world state held, through the same single function.
The two columns follow **different** rules about a NULL, and the difference
is not an oversight.
For the narrative document, a NULL means *this position established
nothing*, and it is restored as the empty document. Leaving the live state
alone instead is what the M5 review caught (Finding 3): arriving at a
migrated pre-M5 node left a later position's entities, facts and threads
standing, so the transcript said depth 2 while the state described depth 6.
The invariant this module exists to hold is that the visible position, the
head and the authoritative state agree, and "keep whatever was there" cannot
hold it. An empty document at an old position is honest — the narrative
state system knew nothing then, because it did not exist — where retained
state from elsewhere is a claim about a story that had not been told yet.
Migration backfills those rows explicitly, so this fallback is the belt to
that pair of braces: it also covers a node arriving from an older export,
which the migration never sees.
For the legacy RPG world state a NULL still means leave it alone. Those rows
predate SP4, nothing consults the values to decide anything, and overwriting
a running adventure's numbers with an empty dict would be worse than doing
nothing.
"""
if node is None:
return
adventure.narrative_state = (
copy.deepcopy(node.narrative_state_after)
if isinstance(node.narrative_state_after, dict)
else narrative_model.empty()
)
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
# coherent with the position being read, so an old save is not left showing
# a future's values. Nothing consults them to decide anything.
if isinstance(node.world_state_after, dict):
adventure.world_state = copy.deepcopy(node.world_state_after)
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
"""Records on `node` the state of the adventure now that the node has played."""
"""Records on `node` the state of the adventure now that the node has played.
Every node, including a player's action that changed nothing. A position
without a snapshot is a position the head cannot be restored to, and the
head can rest on any node.
"""
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
# `state_after` held the scripting engine's shared state, which M2 removed.
# The column stays for schema compatibility and is written empty.
node.state_after = {}
node.world_state_after = copy.deepcopy(world)
narrative = adventure.narrative_state
node.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def roll_back_before(
+269 -15
View File
@@ -32,8 +32,19 @@ the import recomputes them:
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
put a second source of truth for one fact into a file anyone can hand-edit,
and the two could then disagree without any read reporting it.
* The head depth is the tip of the head branch, which is a fact about the nodes
that arrived with it.
* The head depth used to be derived the same way — the tip of the head branch,
a fact about the nodes that arrived with it. M3 moved it to the other side of
the rule. Undo no longer deletes, so a story can be read at a position behind
its retained tip, and where the reader stopped is a decision nobody can
recompute from the rows: the same tree exports identically whether the user
undid three turns or none. `headDepth` is therefore written, and an import
that ignored it would silently Redo the story to its newest retained turn,
which is the Phase 0B export finding this milestone exists to close.
A bundle written before M3 has no `headDepth` key, and there is nothing lost in
that: at the time it was written the head could not sit behind the tip, so the
derived answer *was* the recorded one. Such a file imports by deriving, exactly
as it always did.
Every hand-editable coordinate is therefore checked before a row is written, in
`plan`, rather than repaired afterwards. An import that fails partway leaves an
@@ -49,6 +60,7 @@ from sqlalchemy.orm import Session, undefer
from . import attempts, models, schemas
from .context import cursors, lineage
from .narrative import model as narrative_model
FORMAT = "ai-dnd-adventure-v2"
LEGACY_FORMAT = "ai-dnd-adventure-v1"
@@ -110,6 +122,14 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"desc": adventure.persona_desc,
},
"worldState": adventure.world_state,
# M5. The authoritative narrative state, and the campaign's own rules.
# Both are decisions rather than derivations — the state is what the
# campaign established, and canon is what its owner wrote — so both go
# in the file by the rule at the top of this module. A bundle written
# before M5 has neither key and imports with an empty state, which is
# what such a campaign had.
"narrativeState": adventure.narrative_state,
"campaignCanon": adventure.campaign_canon,
"autoSummarize": adventure.auto_summarize,
"memoryBankEnabled": adventure.memory_bank_enabled,
# Write a root entry even for an adventure whose branch row was never
@@ -117,9 +137,22 @@ def export(db: Session, adventure: models.Adventure) -> dict:
# belongs to is the root. `_local` places its nodes there.
"branches": [_exported_branch(b, local) for b in branches] or [_ROOT],
"headBranch": local.get(adventure.head_branch_id, 0),
# M3. Where the story is being read, which is not always where it
# ends. See the head-depth rule at the top of this module.
"headDepth": adventure.head_depth,
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
"memories": [_exported_memory(m, local) for m in adventure.memories],
# M4. A Save Point is a decision — someone chose this position and gave
# it a name — so it goes in the file by the rule at the top of this
# module. Nothing here is derived: the coordinate is the one stored, not
# one recomputed from the rows, because the whole point of the pointer
# is that no amount of reading the turns can tell you which one somebody
# named. A bundle written before M4 has no key here and imports with no
# Save Points, which is what such a campaign had.
"checkpoints": [
_exported_checkpoint(c, local) for c in _checkpoints_of(db, adventure)
],
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys,
"entry": c.entry, "notes": c.notes}
@@ -151,6 +184,15 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
# unnamed tree byte-identical to the one SP6 wrote.
if branch.name:
out["name"] = branch.name
# M3. A branch the story left behind is a decision too — the depth a
# divergent write departed at — so it goes in the file by the same rule that
# puts the fork points in it. A restored backup that could not tell abandoned
# history from active history would have lost the only thing distinguishing
# them, since every row of both arrives either way. Omitted when the branch
# is active, which keeps a file for a never-undone tree as it was.
if branch.superseded_at is not None:
out["supersededAt"] = branch.superseded_at.isoformat()
out["supersededDepth"] = branch.superseded_depth
return out
@@ -174,6 +216,14 @@ def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
node["stateAfter"] = action.state_after
if action.world_state_after is not None:
node["worldStateAfter"] = action.world_state_after
# M5. Without this a restored campaign could be read but not moved around
# inside: every Undo, Redo and Save Point restore reads the destination
# node's snapshot, so a bundle carrying the turns and not the snapshots
# imports a story whose history cannot be walked.
if action.narrative_state_after is not None:
node["narrativeStateAfter"] = action.narrative_state_after
if action.state_changes:
node["stateChanges"] = action.state_changes
if action.world_delta:
node["worldDelta"] = action.world_delta
return node
@@ -192,6 +242,35 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
}
def _checkpoints_of(db: Session, adventure: models.Adventure) -> list[models.Checkpoint]:
"""Returns the campaign's Save Points in creation order.
Read with a query rather than through a relationship, for the reason
`models.Branch` declares none: a relationship on `Adventure` would be loaded
by anything that touches an adventure, and the export is the only thing in
the application that wants every Save Point at once.
"""
return (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.id)
.all()
)
def _exported_checkpoint(checkpoint: models.Checkpoint, local: dict[int, int]) -> dict:
return {
"name": checkpoint.name,
"note": checkpoint.note,
# The branch as a position in this file's list, like every other branch
# reference in the bundle. The depth is a coordinate along it and needs
# no translation.
"branch": _local(checkpoint.branch_id, local),
"depth": checkpoint.depth,
"createdAt": checkpoint.created_at.isoformat() if checkpoint.created_at else None,
}
def _imported_persona(persona) -> dict:
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
@@ -252,11 +331,23 @@ def plan(bundle: dict, version: str) -> dict:
_planned_nodes(bundle, len(branches)) if version == FORMAT
else _planned_v1_nodes(bundle)
)
head = _as_index(bundle.get("headBranch"), len(branches), default=0)
return {
"branches": branches,
"nodes": nodes,
"memories": _planned_memories(bundle, len(branches)),
"head": _as_index(bundle.get("headBranch"), len(branches), default=0),
# M4. Empty for a version 1 bundle and for any version 2 bundle written
# before Save Points existed, which is the same answer: no one had named
# a position in those campaigns.
"checkpoints": (
_planned_checkpoints(bundle, len(branches), nodes)
if version == FORMAT else []
),
"head": head,
# None means the file does not say, which is every version 1 bundle and
# every version 2 bundle written before M3. `_point_the_head` derives it
# then, which is what those files were written expecting.
"headDepth": _planned_head_depth(bundle, branches, nodes, head),
# Version 2 records where the derived work reached. Version 1 counted
# it, and a count cannot become a node until the nodes exist. See
# `settle`.
@@ -268,6 +359,58 @@ def plan(bundle: dict, version: str) -> dict:
}
def _derived_tip(branches: list[dict], nodes: list[dict], head: int) -> int:
"""Returns where the head branch's story ends, which is where a file that
does not state a head depth is opened.
This is the rule `tree.refresh_head` applies and the one every bundle
written before M3 was exported under: the deepest node that arrived on the
head branch, or, for a branch that carries none of its own, the fork point
it inherits its story up to.
"""
depths = [n["depth"] for n in nodes if n["branch"] == head]
if depths:
return max(depths)
fork_depth = branches[head].get("forkDepth")
return fork_depth if fork_depth is not None else lineage.NO_DEPTH
def _planned_head_depth(
bundle: dict, branches: list[dict], nodes: list[dict], head: int
) -> int | None:
"""Returns the active head depth the file states, or None if it states none.
Checked here rather than repaired later, for the reason the module docstring
gives: a coordinate that disagrees with the rows is not a value any read can
be given, and an import that discovers it afterwards has already written
half a tree.
Two bounds. A head past the retained tip is a file claiming the story is
read somewhere it does not reach — the shape a truncated or hand-edited
export takes, and the one that would silently move the reader forward.
Below, `NO_DEPTH` is the floor, because an adventure with no story at all
sits one step in front of its first node.
A head *behind* the tip is not an error. It is the whole point: it is a
story the user undid and did not redo, and it must import undone.
"""
stated = bundle.get("headDepth")
if stated is None:
return None
tip = _derived_tip(branches, nodes, head)
if not _is_int(stated) or stated < lineage.NO_DEPTH:
raise HTTPException(
400, f"The file gives the active head depth as {stated!r}."
)
if stated > tip:
raise HTTPException(
400,
f"The file reads its story at depth {stated}, but branch {head} "
f"ends at {tip}.",
)
return stated
def _planned_branches(bundle: dict) -> list[dict]:
raw = bundle.get("branches")
entries = [b for b in raw if isinstance(b, dict)] if isinstance(raw, list) else []
@@ -277,8 +420,9 @@ def _planned_branches(bundle: dict) -> list[dict]:
for i, entry in enumerate(entries):
parent = entry.get("parent")
name = _planned_branch_name(entry, i)
left = _planned_supersession(entry)
if parent is None:
specs.append(dict(_ROOT, **({"name": name} if name else {})))
specs.append(dict(_ROOT, **({"name": name} if name else {}), **left))
continue
# A branch may fork only from a branch listed before it. The export
# writes them that way, because branches are numbered in creation order
@@ -302,10 +446,27 @@ def _planned_branches(bundle: dict) -> list[dict]:
specs.append({
"parent": parent, "forkDepth": fork_depth,
**({"name": name} if name else {}),
**left,
})
return specs
def _planned_supersession(entry: dict) -> dict:
"""Returns the branch's disposition as the file gives it, or nothing.
Both keys or neither. A time with no depth cannot say what was displaced and
a depth with no time is not a record of anything having happened, so a file
carrying one of them is treated as carrying neither rather than half of a
fact — no read depends on these columns, and inventing the missing half
would be the only way to get a wrong answer out of them.
"""
at = _as_time(entry.get("supersededAt"))
depth = entry.get("supersededDepth")
if at is None or not _is_int(depth):
return {}
return {"supersededAt": at, "supersededDepth": depth}
def _planned_branch_name(entry: dict, i: int) -> str | None:
"""Returns the name a branch entry carries, or `None` if nobody named it.
@@ -351,6 +512,8 @@ def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
"branch": branch,
"depth": depth,
"live": bool(entry.get("live", True)),
"narrativeStateAfter": _as_dict(entry.get("narrativeStateAfter")),
"stateChanges": _as_dict(entry.get("stateChanges")),
"type": str(entry.get("type") or "story")[:TYPE_MAX],
"text": str(entry.get("text") or ""),
"reasoning": _as_text(entry.get("reasoning")),
@@ -427,6 +590,57 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
return out
def _planned_checkpoints(
bundle: dict, branches: int, nodes: list[dict]
) -> list[dict]:
"""Returns the file's Save Points, checked against the tree it also carries.
A Save Point whose coordinate names no turn in the file is dropped rather
than imported, and dropped rather than raising. The two halves of that are
each deliberate:
* Dropped, because an imported pointer to a position the imported story does
not contain is a Save Point that can only ever refuse to restore. It would
be a row that exists to disappoint.
* Not a 400, unlike the head depth. The head is a position the campaign is
read at, so a file that misplaces it opens the story in the wrong place
and every read is affected. A Save Point is a bookmark, and a bad one
spoils nothing else in the file — refusing to import a whole campaign
because one bookmark is wrong would lose the story to save the bookmark.
A name that is blank once trimmed is dropped for the same reason the create
endpoint refuses one: an unnamed Save Point is not identifiable in a list.
"""
# Every coordinate the file writes, not only the ones it marks live.
# `_write_nodes` makes exactly one attempt at each coordinate live whatever
# the file says, so a coordinate that exists is a coordinate that will
# resolve — and reading the flags here would drop a Save Point over a
# question the writer has already settled.
written = {(n["branch"], n["depth"]) for n in nodes}
raw = bundle.get("checkpoints")
out: list[dict] = []
for entry in raw if isinstance(raw, list) else []:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()[:schemas.CHECKPOINT_NAME_MAX]
if not name:
continue
depth = entry.get("depth")
if not _is_int(depth):
continue
branch = _as_index(entry.get("branch"), branches, default=None)
if branch is None or (branch, depth) not in written:
continue
out.append({
"name": name,
"note": str(entry.get("note") or ""),
"branch": branch,
"depth": depth,
"createdAt": _as_time(entry.get("createdAt")),
})
return out
def _planned_anchors(bundle: dict, branches: int) -> dict:
anchors = {}
for name in ("memory", "summary"):
@@ -452,6 +666,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
_write_nodes(db, adventure, story["nodes"], ids)
_write_memories(db, adventure, story["memories"], ids)
_point_the_head(adventure, story, ids)
_write_checkpoints(db, adventure, story["checkpoints"], ids)
_write_anchors(adventure, story, ids)
@@ -479,6 +694,8 @@ def _write_branches(
lineage=[],
name=spec.get("name"),
created_at=models.utcnow(),
superseded_at=spec.get("supersededAt"),
superseded_depth=spec.get("supersededDepth"),
)
).inserted_primary_key[0]
entries = [[new_id, None]]
@@ -520,6 +737,8 @@ def _write_nodes(
state_after=spec["stateAfter"],
world_state_after=spec["worldStateAfter"],
world_delta=spec["worldDelta"],
narrative_state_after=spec.get("narrativeStateAfter"),
state_changes=spec.get("stateChanges"),
)
if spec["createdAt"] is not None:
action.created_at = spec["createdAt"]
@@ -565,25 +784,53 @@ def _write_memories(
db.add(memory)
def _write_checkpoints(
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
) -> None:
"""Writes the Save Points, and moves nothing.
Note what this function does not touch. The head is pointed by
`_point_the_head` from the file's own `headBranch`/`headDepth`, and importing
a Save Point must not disturb it — a campaign exported at turn 30 with a
Save Point at turn 12 opens at turn 30. The bundle records where the story
was being read and, separately, which positions someone named; restoring one
of them is a thing the user does afterwards, not a thing an import does for
them.
"""
for spec in specs:
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=spec["name"],
note=spec["note"],
branch_id=ids[spec["branch"]],
depth=spec["depth"],
)
if spec["createdAt"] is not None:
checkpoint.created_at = spec["createdAt"]
db.add(checkpoint)
def _point_the_head(
adventure: models.Adventure, story: dict, ids: list[int]
) -> None:
"""Sets which branch the story is played on, and how deep it goes.
"""Sets which branch the story is played on, and where it is being read.
The branch comes from the file and the depth does not. The tip of a branch is
whatever arrived on it, and a branch with no nodes of its own sits at its
fork point, which is the last node its story contains. That node is borrowed
but it is still the tip. This is the rule `tree.refresh_head` applies, run
here before a flush.
Both come from the file now (M3). A bundle that states its head depth is
opened exactly where its owner left it, undone turns and all, and the turns
past that point arrive as the retained future they were exported as.
A file that states none is opened at the tip of its head branch — whatever
arrived on it, or, for a branch carrying no nodes of its own, the fork point
that is the last node its story contains. That is the rule
`tree.refresh_head` applies and the only answer a pre-M3 bundle can be given,
because at the time it was written the head could not be anywhere else.
"""
head = story["head"]
adventure.head_branch_id = ids[head]
depths = [n["depth"] for n in story["nodes"] if n["branch"] == head]
if depths:
adventure.head_depth = max(depths)
if story["headDepth"] is not None:
adventure.head_depth = story["headDepth"]
return
fork_depth = story["branches"][head].get("forkDepth")
adventure.head_depth = fork_depth if fork_depth is not None else lineage.NO_DEPTH
adventure.head_depth = _derived_tip(story["branches"], story["nodes"], head)
def _write_anchors(
@@ -645,6 +892,13 @@ def materialize(
ai_instructions=str(payload.get("aiInstructions") or ""),
story_summary=str(payload.get("storySummary") or ""),
world_state=payload.get("worldState") or {},
# M5. Normalised on the way in, so a hand-edited or truncated state
# section costs the section rather than the campaign — the story is the
# valuable thing, and a malformed document should not refuse an import.
narrative_state=narrative_model.normalize(payload.get("narrativeState"))
if isinstance(payload.get("narrativeState"), dict) else None,
campaign_canon=payload.get("campaignCanon")
if isinstance(payload.get("campaignCanon"), dict) else None,
auto_summarize=bool(payload.get("autoSummarize", False)),
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
**_imported_persona(payload.get("persona")),
+86 -61
View File
@@ -22,7 +22,7 @@ from dataclasses import dataclass
import tiktoken
from .. import models, worldstate
from .. import models, narrative, worldstate
from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
@@ -88,7 +88,7 @@ class Section:
return count_tokens(self.text)
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
def length_hint(max_output_tokens: int) -> str:
"""Ask for a turn that fits inside the output cap, stated as a word budget.
Returns an empty string when the cap is too small to state usefully. The
@@ -98,12 +98,8 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
if words < MIN_LENGTH_HINT_WORDS:
return ""
tail = (
" Finish the narration and append the state block well inside the limit."
if has_ws
else " Bring the turn to a close well inside the limit rather than "
"stopping mid-sentence."
)
tail = " Finish the narration and append the state block well inside the limit."
# State the number as a ceiling, never as a budget. In measurements, the
# wording "keep this turn under about N words" read to the model as a target
# to fill. It raised the average from 174 words to 246 across five runs, and
@@ -166,30 +162,58 @@ def _script_memory(adventure: models.Adventure) -> dict:
def _history_text(action: models.Action) -> str:
"""Returns an AI turn as the model should see it in replayed history.
The result is the narration with its state block appended again,
reconstructed from the stored delta. The app strips that block before
storing and displaying the turn. Without this function, every past AI turn
would appear to have emitted no state, and the model would copy that pattern
and stop emitting state itself. Player turns and turns with no block pass
through unchanged.
Replayed history is **prose only**. The protocol block is not reconstructed
into it, and the M5 corrective pass is why (review Finding 4).
The block replays the changes the engine ACCEPTED, not the ones the model
sent. Replaying what was sent showed the model a refused change standing as
though it had been applied, while the live values in the same prompt
disagreed with it. Nothing marked which of the two was true, so the model
read its own refused change as correct and sent it again.
Replaying the block was meant to teach the model the output format by
example. What it actually did was put a second, older account of the world
into the same prompt as the authoritative one, with nothing marking which
governed. A fact the reader had explicitly withdrawn through a manual
correction was dropped from the state section and then handed straight back
in the history section, as an accepted event, phrased exactly as the model
had first asserted it. C04 requires a correction to reach the narrator's
context; a correction the next prompt contradicts has not reached it.
This function reads `world_delta` rather than `context_snapshot`. It runs
for every action in the replayed history, and `context_snapshot` is deferred
so that a turn never loads the prompt archive from the database.
Two other things were wrong with it. The blocks are implementation
metadata, not story, and every other consumer of stored text — memory,
summaries, export, the transcript — treats an action's text as prose. And a
turn's accepted events are a record of what was true *then*, which is
precisely what a later correction, retcon or invalidation revises.
The format instruction survives without the examples: `EMIT_RULE` carries a
worked example in the system block and `EMIT_REMINDER` repeats the demand
last, where recency is strongest.
"""
text = action.text
wd = action.world_delta if isinstance(action.world_delta, dict) else None
if wd:
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
if block:
text = f"{text}\n{block}"
return text
return action.text
def _canon_section(adventure: models.Adventure) -> str:
"""The campaign's own rules, rendered for the system block.
Canon is configuration (C01, J03): the campaign writes what is true and what
is forbidden, and both the prompt and the validator read the same field.
Putting it in the system block is what makes C01 a narration-time constraint
as well as a validation-time one — the model is told the rule rather than
only refused after breaking it.
"""
canon = adventure.campaign_canon
if not isinstance(canon, dict):
return ""
lines: list[str] = []
rules = canon.get("rules")
if isinstance(rules, list):
lines += [f"- {rule}" for rule in rules if isinstance(rule, str) and rule.strip()]
forbidden = canon.get("forbidden_status_changes")
if isinstance(forbidden, list):
for rule in forbidden:
if isinstance(rule, dict) and rule.get("from") and rule.get("to"):
lines.append(
f"- Nothing that is {rule['from']} can become {rule['to']}."
)
if not lines:
return ""
body = "\n".join(lines)
return f"Campaign canon (these are true and may not be contradicted):\n{body}"
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
@@ -259,11 +283,14 @@ def build_context(
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
has_ws = worldstate.has_schema(stat_schema)
persona_name = adventure.persona_name.strip()
if has_ws:
guide = worldstate.render_reference(stat_schema, persona_name)
if guide:
system_sections.append(Section("world_state_guide", guide))
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
# M5: the typed-event protocol replaces the delta rule for every campaign,
# with or without an inherited stat schema. State is no longer an opt-in
# RPG layer — a story has entities, places and possessions whatever genre it
# is, so the rule is unconditional.
system_sections.append(Section("state_rule", narrative.extract.EMIT_RULE))
canon_text = _canon_section(adventure)
if canon_text:
system_sections.append(Section("campaign_canon", canon_text))
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip()))
@@ -301,21 +328,20 @@ def build_context(
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
world_state_section = None
refusal_note = ""
if has_ws:
# One read serves both the in-scene NPCs and the refusal note below.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
persona_name,
)
if block:
world_state_section = Section("world_state", block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = worldstate.render_refusals(last_ai.world_delta)
# M5: the authoritative narrative state, as the model is shown it. Read from
# the campaign's live document, which head movement keeps pointed at the
# position being read — so an undone story is described by the state it had
# then, not by the state it reached later.
state_block = narrative.render.for_prompt(adventure.narrative_state)
if state_block:
world_state_section = Section("narrative_state", state_block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = narrative.extract.render_rejections(last_ai.state_rejections)
authors_note_text = adventure.authors_note.strip()
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
@@ -326,7 +352,7 @@ def build_context(
if isinstance(script_mem.get("frontMemory"), str):
front_memory = script_mem["frontMemory"].strip()
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
length_note = length_hint(settings.max_output_tokens)
# The live sections sit below the history, but they are still part of the
# prompt, so they still count against the budget. `world_lore` is the
@@ -341,7 +367,7 @@ def build_context(
+ count_tokens(authors_note)
+ count_tokens(front_memory)
+ count_tokens(length_note)
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
+ count_tokens(narrative.extract.EMIT_REMINDER)
+ count_tokens(refusal_note)
)
available = max(256, settings.context_token_budget - reserved)
@@ -386,7 +412,7 @@ def build_context(
for action in reversed(actions):
# Budget against the text as it appears in the prompt, which includes
# the state block when this adventure tracks world state.
rendered = _history_text(action) if has_ws else action.text
rendered = _history_text(action)
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
if spent + tokens > history_budget:
if not included_actions:
@@ -407,7 +433,7 @@ def build_context(
# ----- Assemble the story text, with the author's note near the end -----
# Append each AI turn's state block again. The app strips it before storage,
# and the recent history has to show the model the pattern to follow.
texts = [_history_text(a) if has_ws else a.text for a in included_actions]
texts = [_history_text(a) for a in included_actions]
note_sections: list[Section] = []
if authors_note:
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
@@ -431,14 +457,13 @@ def build_context(
# applies to the block that follows it, so this is also the order in which
# the model acts.
note_sections.append(Section("length_hint", length_note))
if has_ws:
# A correction for the previous turn sits directly above the reminder
# to emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("world_state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
# A correction for the previous turn sits directly above the reminder to
# emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("state_reminder", narrative.extract.EMIT_REMINDER))
story_sections = [s for s in note_sections if s.text]
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
+55 -7
View File
@@ -78,7 +78,16 @@ class Path:
"""One story, expressed as a SQL clause and as a Python predicate.
The object holds the lineage entries newest first, plus the depth of the
tip. The tip is used only to estimate how much story each entry covers.
head. Every entry is read as capped at the head, which is what makes the
active head a position the whole application honours (M3).
Before M3 the head was always the deepest node, so the cap never bit and the
tip was used only to estimate how much story each entry covers. Undo now
moves the head backward without deleting anything, so a path can have live
nodes past its head, and those nodes are not part of the story being told.
Capping here is what hides them, and it hides them from every read at once:
the transcript, the context builder, `attempts.preceding`, and memory
retrieval all funnel through `path_of`.
"""
def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None):
@@ -91,6 +100,40 @@ class Path:
def __len__(self) -> int:
return len(self.entries)
# ------------------------------------------------------------- the head
def _cap(self, max_depth: int | None) -> int | None:
"""Returns `max_depth` limited by the head, which no read may pass.
Three cases, and the third is the one M3 added:
* No head recorded (`tip is None`). The caller asked for the lineage
without a position, so the entry's own cap stands. `tree` builds such
a path when it resolves the node in front of a depth.
* An uncapped entry, which means "this branch through to its tip". The
head is the cap.
* A capped entry, which is an ancestor capped at the fork depth. The
head still wins when it sits behind that fork, because undoing below
a fork point is undoing into the shared prefix. Taking the smaller of
the two is what lets Undo walk back past a fork instead of stopping
there — safe now that it deletes nothing.
"""
if self.tip is None:
return max_depth
if max_depth is None:
return self.tip
return min(max_depth, self.tip)
def uncapped(self) -> "Path":
"""Returns the same lineage read through to its retained tip.
This is the retained history, head or no head: what Redo can still walk
forward into, and what a write below the head has to fork away from.
Only those two callers should use it. Every read of *the story* wants
the capped path.
"""
return Path(self.entries, None)
# ---------------------------------------------------------------- SQL
def clause(
@@ -125,7 +168,9 @@ class Path:
entries = self.entries if count is None else self.entries[:count]
if not entries:
return false()
on_path = or_(*[self._entry_clause(model, b, d) for b, d in entries])
on_path = or_(
*[self._entry_clause(model, b, self._cap(d)) for b, d in entries]
)
if model is models.Action:
return and_(on_path, models.Action.live.is_(True))
return on_path
@@ -155,9 +200,10 @@ class Path:
for branch_id, max_depth in self.entries:
if node.branch_id != branch_id:
continue
if max_depth is None:
cap = self._cap(max_depth)
if cap is None:
return True
if node.depth is not None and node.depth <= max_depth:
if node.depth is not None and node.depth <= cap:
return True
return False
@@ -187,7 +233,7 @@ class Path:
return total
covered = 0
for i, (_, max_depth) in enumerate(self.entries):
top = self.tip if max_depth is None else max_depth
top = self._cap(max_depth)
below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH
if top is None or below is None:
# Either no tip was recorded, or a hand-written row is missing a
@@ -211,7 +257,8 @@ class Path:
story has forked.
"""
for i, (_, max_depth) in enumerate(self.entries):
if max_depth is not None and max_depth <= depth:
cap = self._cap(max_depth)
if cap is not None and cap <= depth:
return i
return len(self.entries)
@@ -241,7 +288,8 @@ class Path:
return depth
for entry_branch, max_depth in self.entries:
if entry_branch == branch_id:
return depth if max_depth is None else min(depth, max_depth)
cap = self._cap(max_depth)
return depth if cap is None else min(depth, cap)
return NO_DEPTH
+405
View File
@@ -0,0 +1,405 @@
"""M3: where the story is being read, and what moving that point costs.
Undo used to delete. It removed the trailing nodes, let `tree.refresh_head`
recompute the tip from what survived, and the story was wherever the rows ended.
That made the head a derived value and made Redo impossible, because the turns it
would have moved forward into were gone.
The head is now a stored position that can sit behind the retained tip. Nothing
is deleted, so three things that used to be the same question are now three
different ones:
* **the active head** — `adventure.head_branch_id` and `adventure.head_depth`,
the end of the story being told. Every read of the story stops here, because
`lineage.Path` caps every entry at it.
* **the retained tip** — the deepest live node still on the lineage. Redo walks
toward it. It is read through `Path.uncapped()`, and only this module and the
divergence check may look at it.
* **the opening** — the shallowest node on the story, which is the floor Undo
may not pass.
Everything that moves the head or asks a question about it lives here, so the
turn engine, Retry, Add-take, Undo, Redo and Edit share one set of rules rather
than four similar ones. The Phase 0B spike put the fork check in the write path
and left Retry and Add-take on the old one, which is exactly the divergence this
module exists to prevent.
The state that belongs to a position is not recomputed. Every node carries the
world state it left behind (`attempts.snapshot_outcome`), so moving the head is a
row lookup plus `attempts.restore_state`, at any distance, in either direction.
"""
from sqlalchemy.orm import Session, undefer
from . import attempts, models, tree
from .context import lineage
# The kinds of node a player writes. An undo or a redo steps over a whole turn,
# which is one of these followed by the reply to it, so both ends need to agree
# on what "a player's half of a turn" is.
PLAYER_TYPES = ("do", "say", "story", "continue")
# ------------------------------------------------------------------ reading
def opening_depth(db: Session, adventure: models.Adventure) -> int | None:
"""Returns the depth of the first node of the story, or None if there is none.
This is Undo's floor. The Phase 0B spike moved the head to -1 and rendered an
empty transcript, because its guard tested for a node of type `start` and an
adventure opened with a player-written `story` action has none. Asking the
path for its shallowest node needs no such special case: whatever the opening
is called, it is the node with the smallest depth, and the story keeps it.
The read is uncapped. The opening does not move when the head does, and
capping would make the floor depend on where the head already is.
"""
return (
db.query(models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
)
.order_by(models.Action.depth.asc(), models.Action.id.asc())
.limit(1)
.scalar()
)
def retained_tip(db: Session, adventure: models.Adventure) -> int | None:
"""Returns the depth of the deepest live node still retained on this lineage.
This is what the head would be if the story had never been undone, and it is
what Redo can reach. It is not the head, and no read of the story may use it.
"""
return (
db.query(models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(1)
.scalar()
)
def behind_tip(db: Session, adventure: models.Adventure) -> bool:
"""Returns whether retained story sits past the head.
This one predicate answers every "does this write need to fork?" question in
the application. It is true exactly when the user has undone and not redone,
which is the only situation in which writing can displace an accepted future.
It is also what makes "is this turn the tip?" answerable again. Retry,
Add-take and `stand_on` each decide between amending a turn in place and
giving it a branch, and each used to ask `last_action`, which reads the
*capped* path and therefore reports the node at the head as the newest one.
Under a moved-back head that answer is wrong in the dangerous direction: it
says a turn with an accepted future is a leaf, and amending it in place would
leave that future descending from a take that is no longer live.
"""
tip = retained_tip(db, adventure)
return tip is not None and tip > adventure.head_depth
def node_at(
db: Session, adventure: models.Adventure, depth: int
) -> models.Action | None:
"""Returns the live node at `depth` on the retained lineage, outcome loaded.
The read is uncapped on purpose: Redo asks for a node it is about to move the
head onto, which is by definition past the head at the time of asking. The
outcome columns are undeferred because the only reason to fetch this row is
to restore the state it left behind.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
models.Action.depth == depth,
)
.options(
undefer(models.Action.state_after),
undefer(models.Action.world_state_after),
)
.order_by(models.Action.id)
.first()
)
def redo_target(db: Session, adventure: models.Adventure) -> int | None:
"""Returns the depth the head moves to on Redo, or None if there is nowhere.
Redo steps over a whole turn, the same unit Undo steps back over, so a
player's action and the reply to it move together. Landing between them would
show the story an input with no answer and would leave the next Undo undoing
half a turn.
The walk is along the retained lineage, which is what makes Redo follow the
continuation that was active rather than choosing among branches. After a
divergence the new branch *is* the lineage, and the displaced future is no
longer on it, so this returns None without having to know that a divergence
happened. That is `STORY-BRANCH-SEMANTICS.md` §8 falling out of the lineage
rather than being enforced by a flag.
"""
ahead = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
models.Action.depth > adventure.head_depth,
)
.order_by(models.Action.depth.asc(), models.Action.id.asc())
.limit(2)
.all()
)
if not ahead:
return None
first = ahead[0]
if (
first.type in PLAYER_TYPES
and len(ahead) > 1
and ahead[1].type == "ai"
and ahead[1].depth == (first.depth or 0) + 1
):
return ahead[1].depth
return first.depth
def can_redo(db: Session, adventure: models.Adventure) -> bool:
"""Returns whether an ordinary Redo is available from where the head is."""
return redo_target(db, adventure) is not None
def undo_target(
db: Session, adventure: models.Adventure
) -> tuple[int, models.Action] | None:
"""Returns where Undo moves the head, and the first node it steps back over.
None means there is nothing to undo, which is either an empty story or a head
already resting on the opening. The caller turns that into a 400; this
function does not raise, so that the same question can be asked without
committing to undoing.
A turn is the player's node plus the reply to it, and both move together for
the reason given in `redo_target`. The player half is only claimed when it is
directly in front of the reply, so a bare `continue`, which writes no player
node, steps back over the reply alone.
"""
newest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(2)
.all()
)
if not newest:
return None
last = newest[0]
first_stepped = last
before = newest[1] if len(newest) > 1 else None
if (
last.type == "ai"
and before is not None
and before.type in PLAYER_TYPES
and before.depth == (last.depth or 0) - 1
):
first_stepped = before
floor = opening_depth(db, adventure)
if first_stepped.depth is None or floor is None:
return None
if first_stepped.depth <= floor:
# Stepping back over this turn would hide the opening of the campaign,
# which is the pre-campaign state `STORY-BRANCH-SEMANTICS.md` §4 stops
# at. The floor is the opening node itself rather than depth -1, so an
# adventure that opens on a player-written `story` action stops in the
# same place as one that opens on a `start` node.
return None
return first_stepped.depth - 1, first_stepped
def can_undo(db: Session, adventure: models.Adventure) -> bool:
"""Returns whether an ordinary Undo is available from where the head is."""
return undo_target(db, adventure) is not None
def displaced_history_under(
db: Session, adventure: models.Adventure, node: models.Action
) -> bool:
"""Returns whether story the reader cannot see descends from `node`.
This is the question an in-place edit has to ask. Editing rewrites one row
and re-evaluates nothing, which is what makes it a correction rather than a
new continuation. That is harmless while everything descending from the row
is on screen: the reader can see what their correction has to stay
consistent with. It stops being harmless the moment a continuation descends
from the row and is *not* on screen, because the edit then silently changes
the words an invisible stretch of story was written from. That is the one
way M3's retained history can be made to contradict itself.
Refusing is deliberately the whole of the fix. Making such an edit fork, so
the original text and its future stay whole, is
`STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
state the edited prose implies, which is M5's extraction pass. Neither is
started here.
The question is asked as one shape rather than two, because the two ways a
descendant becomes invisible turn out to be the same fact. An undone future
sits past the head on this very lineage; a displaced line sits past a fork
on a branch the story left. In both cases there is a live node, deeper than
this one, that descends from it and is not on the path being read — and the
departed branch is usually an *ancestor* of the branch now being read, which
is why "branches other than the active one" is the wrong set to look at.
Only the deepest live node on each descending branch is examined. Whether a
node is on the read path is monotone in depth: a branch is on the path with
a cap, and a node is visible when its depth is at or under that cap. So if
the deepest one is visible, every shallower one is too, and if it is not,
the answer is already yes.
A node that is not live has no descendants of its own — a take the story
moved past keeps a continuation only by being forked, and that fork is a
branch this loop asks about anyway — so editing one is always safe.
"""
if not node.live or node.depth is None:
return False
read = lineage.path_of(db, adventure)
branches = (
db.query(models.Branch)
.filter(models.Branch.adventure_id == adventure.id)
.all()
)
for branch in branches:
# Uncapped: the question is what this branch's story descends from, not
# how much of it the reader is currently being shown.
if not lineage.Path(lineage.entries_of(branch)).contains(node):
continue
deepest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch.id,
models.Action.live.is_(True),
models.Action.depth > node.depth,
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.first()
)
if deepest is not None and not read.contains(deepest):
return True
return False
# ------------------------------------------------------------------ writing
def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
"""Moves the active head to `depth` and restores the state recorded there.
This is the whole of Undo and Redo. Nothing is deleted, nothing is
recomputed, and the direction of travel does not matter: the node at the
destination carries the world state it left behind, so arriving from in front
of it and arriving from behind it restore the same value.
A destination with no node — the head resting one step in front of the
opening — leaves the live state alone, which is `attempts.restore_state`'s
rule for a missing snapshot and the reason it is not this function's job to
invent an empty one.
"""
adventure.head_depth = depth
attempts.restore_state(adventure, node_at(db, adventure, depth))
def move_to_node(db: Session, adventure: models.Adventure, node: models.Action) -> bool:
"""Moves the head onto `node`, changing line only if it is not on this one.
M4 restores a Save Point through this, and it adds no restoring of its own:
the depth half is `move_to` unchanged, so the state, the transcript, the
assembled context and memory eligibility all arrive exactly as they do for
Undo and Redo. Returns whether the line had to change as well as the depth,
which is the one thing about a restore a caller cannot work out afterwards.
The head is two values, and the two halves move for different reasons. A
Save Point almost always names a position on the story being read — its own
line, or the shared prefix that line inherits — and then only the depth
moves. Leaving the branch alone is what makes the restored position keep the
continuation it has: after a divergence, restoring to the shared prefix must
put the reader back on the *new* line, where Redo walks into the turns they
are still writing, not into the future they left. Reaching for the Save
Point's own branch there would quietly hand back the abandoned story.
The other case is real and has to work. A Save Point survives divergence
(`STORY-BRANCH-SEMANTICS.md` §19), so one can name a position on a line the
story has since left, and no amount of depth movement reaches a branch this
path does not contain. The line then moves as well — one assignment, the
same one `switch_branch` makes — and the depth still moves through
`move_to`. Nothing is created: a restore never forks, whichever case it
takes. The first write below the restored head does, through
`fork_if_behind_head`, like every other write.
"""
switched = not lineage.path_of(db, adventure).uncapped().contains(node)
if switched:
adventure.head_branch_id = node.branch_id
move_to(db, adventure, node.depth)
return switched
def fork_if_behind_head(db: Session, adventure: models.Adventure) -> bool:
"""Gives the story a new branch when a write would displace a retained future.
Returns whether a branch was created, which is what a caller reports as a
divergence.
Called before every write that continues the story, and it does nothing on
the ordinary path where the head is already at the tip. That is the property
worth keeping: a story that is never undone forks exactly as often as it did
before M3, so the branch table does not fill up with one branch per turn.
Undo alone must not fork. Moving the head is not a decision to abandon
anything — the user may be reading, or about to Redo. Only the first write
below the head states which continuation they mean, which is
`STORY-BRANCH-SEMANTICS.md` §8 and §20 and what makes Redo survive an Undo.
`tree.branch_at` leaves the departed branch exactly as it is: its nodes stay
live, at their depths, on their branch. The new branch inherits the story up
to the head and owns everything written from here, so the displaced future
remains reachable through the branch it was written on.
"""
if not behind_tip(db, adventure):
return False
departed = lineage.branch_of(db, adventure)
at_depth = adventure.head_depth
tree.branch_at(db, adventure, at_depth)
if departed is not None:
mark_superseded(departed, at_depth)
return True
def mark_superseded(branch: models.Branch, depth: int) -> None:
"""Records that this branch's story past `depth` was displaced.
`DATA-MODEL.md` §5 gives a branch a disposition of active, retained or
disposable. This is that disposition, stored as the fact that produced it
rather than as a word: the depth the story left at, and when. A branch with
no `superseded_at` is active; one with a value has retained history past that
depth which no active head is reading.
Nothing in the application reads these columns to make a decision, and that
is deliberate. Redo is decided by the lineage, not by a flag, so a stale or
hand-edited value here cannot make the story wrong. They exist so that the
cleanup and discarded-history features `STORY-BRANCH-SEMANTICS.md` §28 and
§29 leave to a later version have something to select on, and so that a
divergence is observable in a test.
The shallowest departure wins. A branch left at depth 9 and later left again
at depth 4 has retained history from 4 onward, and recording the later, deeper
value would understate what was displaced.
"""
if branch.superseded_depth is None or depth < branch.superseded_depth:
branch.superseded_depth = depth
if branch.superseded_at is None:
branch.superseded_at = models.utcnow()
+94 -4
View File
@@ -301,7 +301,8 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
(63, "ALTER TABLE actions ADD COLUMN parent_id INTEGER REFERENCES actions(id) ON DELETE SET NULL"),
(64, "CREATE INDEX IF NOT EXISTS ix_actions_parent ON actions (parent_id)"),
# Phase 17: `Settings.stream` was dead state. Nothing ever read it, and every
# turn streams. This is item S1 in `docs/self-review.md`. The table holds one
# turn streams; upstream's self-review log flagged it as dead state. The
# table holds one
# row per user, so the rewrite is small and needs no VACUUM FULL.
(65, "ALTER TABLE settings DROP COLUMN stream"),
# Phase 17, SP8: drop the eight columns the story tree replaced. Each one was
@@ -342,6 +343,65 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# client, which a cold model load on a CPU-only machine can exceed. The
# default matches `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
(77, "ALTER TABLE settings ADD COLUMN model_timeout_seconds INTEGER NOT NULL DEFAULT 300"),
# M3. A branch left behind by a divergent write records where the story left
# it. NULL means active, which is what every existing branch is: before M3
# the head could not sit behind the tip, so no branch had been superseded.
# No backfill.
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
(79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"),
# M4: Save Points. `create_all` creates the `checkpoints` table itself, on
# existing databases as well as fresh ones, exactly as it did for
# `memories` at version 2 and `branches` at version 46. What it does not
# create is the index every list and every cascade reads, so that is what
# this version is.
#
# No backfill. A Save Point records a decision someone made, and nobody has
# made one yet: an M3 database has no position a user chose to name, and
# inventing one would be inventing the decision.
(80, "CREATE INDEX IF NOT EXISTS ix_checkpoints_adventure "
"ON checkpoints (adventure_id)"),
# M5: genre-neutral authoritative narrative state. `create_all` builds the
# two new tables — `state_proposals` and `state_events` — as it did
# `memories`, `branches` and `checkpoints`; these are the columns it cannot
# add to tables that already exist, plus the indexes the audit reads need.
#
# **No backfill, deliberately.** The inherited RPG world state is numbers
# against a stat schema: `player.gold = 70`, `npc.gwen.trust = 3`. Nothing
# in that says who Gwen is, where anyone stands, or what anyone holds, and a
# narrative fact invented from a number would be fiction the campaign never
# established — exactly what the M5 brief forbids. So the old columns are
# left intact and non-authoritative, and every campaign starts M5 with an
# empty narrative state that its next turns fill in.
#
# The campaign's own `narrative_state` is left NULL: an adventure with no
# M5 turns yet has no document, and the first one writes it.
#
# Per-action snapshots are a different question, and the M5 corrective pass
# settled it the other way (review Finding 3). This block originally left
# those NULL too, reasoning that an empty document would be "a claim, not an
# absence". The consequence was worse than the claim: restoring to an old
# position left the state of a *later* position standing, so the transcript
# and the state described different moments. Backfilling the empty document
# at version 88 says the only true thing about a pre-M5 position — the
# narrative-state system established nothing there, because it did not yet
# exist — and keeps head, transcript and state in agreement. The legacy RPG
# columns are untouched and still restored beside it.
(81, "ALTER TABLE adventures ADD COLUMN narrative_state BLOB"),
(82, "ALTER TABLE adventures ADD COLUMN campaign_canon JSON"),
(83, "ALTER TABLE actions ADD COLUMN narrative_state_after BLOB"),
(84, "ALTER TABLE actions ADD COLUMN state_changes JSON"),
(85, "CREATE INDEX IF NOT EXISTS ix_state_events_adventure "
"ON state_events (adventure_id, id)"),
(86, "CREATE INDEX IF NOT EXISTS ix_state_events_action "
"ON state_events (action_id)"),
(87, "CREATE INDEX IF NOT EXISTS ix_state_proposals_adventure "
"ON state_proposals (adventure_id, id)"),
# M5 corrective pass. No DDL — 83 already added the column. This version
# exists to carry the data pass that fills it in for rows that predate it,
# so that every position an existing campaign can be restored to has a
# snapshot. See `_backfill_narrative_snapshots`.
(88, "-- narrative snapshot backfill (data pass only)"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -355,6 +415,7 @@ TREE_BACKFILL_VERSION = 52
CURSOR_ANCHOR_VERSION = 56
SIBLING_SPLIT_VERSION = 60
PARENT_BACKFILL_VERSION = 64
NARRATIVE_SNAPSHOT_VERSION = 88
# An adventure with no actions has no tip. A value of -1 keeps the rule that the
# next node goes at `head_depth + 1` true without a special case. This matches
@@ -372,6 +433,30 @@ SNAPSHOT_BATCH = 50
BACKFILL_BATCH = 200
def _backfill_narrative_snapshots(conn) -> None:
"""Gives every pre-M5 action the empty narrative document as its outcome.
One statement, no row loop: the document is identical for every row, so it
is encoded once in Python and bound as a single parameter. `narrative.model`
owns the shape and `compression.pack` owns the encoding, so this cannot
drift from what `snapshot_outcome` writes.
Why the empty document rather than NULL is argued at migration 81. In short:
a position with no snapshot used to mean "leave the live state alone", which
let a later position's state stand while the reader was somewhere else.
"""
from .compression import pack
from .narrative import model as narrative_model
conn.execute(
text(
"UPDATE actions SET narrative_state_after = :document "
"WHERE narrative_state_after IS NULL"
),
{"document": pack(narrative_model.empty())},
)
def _backfill_world_delta(conn) -> None:
"""Populates `actions.world_delta` from the existing `context_snapshot`.
@@ -1076,9 +1161,12 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
if current < version <= through:
statement = _for_dialect(sql, conn.dialect.name)
# Skip the DDL when it has already run. The data pass below it
# still runs.
if not (_column_already_there(conn, statement)
or _column_already_gone(conn, statement)):
# still runs. A version whose whole content is a data pass
# carries a comment in place of DDL and executes nothing.
if not statement.lstrip().startswith("--") and not (
_column_already_there(conn, statement)
or _column_already_gone(conn, statement)
):
conn.execute(text(statement))
if version == WORLD_DELTA_VERSION:
_backfill_world_delta(conn)
@@ -1108,5 +1196,7 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
# exist.
if version == PARENT_BACKFILL_VERSION:
_backfill_parents(conn)
if version == NARRATIVE_SNAPSHOT_VERSION:
_backfill_narrative_snapshots(conn)
current = version
_set_version(conn, current)
+249
View File
@@ -119,7 +119,28 @@ class Adventure(Base):
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
#
# **Legacy as of M5**, and no longer authoritative. M5 replaced the
# relative-delta protocol this column served (ADR 010); the turn engine no
# longer writes it, and nothing reads it to decide anything. It stays so
# that a pre-M5 database opens unchanged and its numbers remain visible to
# whoever wants to look — `narrative_state` below is what the story means
# now. Reinterpreting these values as generic narrative facts would be
# inventing meaning the data does not carry, which the M5 brief forbids.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# M5: the authoritative narrative state, as it stands at the active head.
# Genre-neutral (ADR 006), written only by validated typed events (ADR 010),
# and restored from the destination node's snapshot whenever the head moves,
# so it always describes the story being read rather than a story the reader
# has stepped back from.
narrative_state: Mapped[dict] = mapped_column(
CompressedJSON, nullable=True, default=None
)
# Campaign canon: rules the story may not contradict, as configuration
# rather than code (C01, J03). A fantasy campaign forbidding resurrection
# and a science-fiction one forbidding faster-than-light travel use the same
# field and the same validator; neither word appears in the application.
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# The ${Placeholder} answers collected when this adventure was started, kept
# so "Update from scenario" can re-fill freshly copied scenario text with the
# same values. NULL for adventures created before this column existed.
@@ -228,6 +249,169 @@ class Branch(Base):
# branch by its fork depth, which deleting a branch does not change.
name: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# M3: the disposition `DATA-MODEL.md` §5 gives a branch, stored as the fact
# that produced it. NULL means active. A value means a divergent write left
# this branch at `superseded_depth`, so its nodes past that depth are
# retained history that no active head is reading.
#
# Nothing reads these to decide behaviour. Redo follows the lineage, so a
# wrong value here cannot make the story wrong; they exist for the cleanup
# and discarded-history features that `STORY-BRANCH-SEMANTICS.md` §28-29
# leave to a later version. See `head.mark_superseded`.
superseded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Checkpoint(Base):
"""M4: a Save Point — a durable named pointer to a story position.
"Save Point" is what the user reads; `checkpoint` is what the code calls it
(`BROWSER-UX-SPEC.md` §23).
The row holds a name and a coordinate, and no story. `DATA-MODEL.md` §8
describes the pointer as naming a turn; the coordinate here is
`(branch_id, depth)`, which is what M3 made the head and what
`head.node_at` resolves. Restoring one is therefore head movement with a
bounds check rather than a restore system of its own — see ADR 012 and
`head.move_to_node`.
A coordinate rather than an action id, deliberately. One coordinate can
hold several attempts at a turn and exactly one of them is live, so a
retry replaces the row a Save Point would have pinned. "Turn 42 of this
line" survives a retry; "action 918" would point at a take the story no
longer tells.
`branch_id` is the branch the node itself sits on, not the branch that was
being read when the Save Point was made. Those differ whenever the head is
resting in a shared prefix, and the node's own branch is the one that still
names the position after the reader has moved elsewhere.
**Nothing removes a Save Point but the user.** They are not cleaned up for
going stale, for being behind the head, or for pointing into a future the
story has left (`STORY-BRANCH-SEMANTICS.md` §19).
That includes deleting a branch. `branch_id` carries `ON DELETE CASCADE` as
referential integrity — a Save Point must never point at a branch that is
gone — but the branch endpoint refuses to delete a branch any Save Point
names, so the cascade does not fire through the application
(`routers/adventures/branches.py`, `STORY-BRANCH-SEMANTICS.md` §19.1). The
user deletes the Save Point first, which deletes no story, and then the
branch. Deleting the whole campaign does cascade, and should: that is what
the user asked for.
"""
__tablename__ = "checkpoints"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120), default="")
# `DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
# optional second field. Empty is the ordinary case.
note: Mapped[str] = mapped_column(Text, default="")
branch_id: Mapped[int] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE")
)
depth: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# Bumped by a rename, which is the only edit a Save Point allows. The
# coordinate is never rewritten: `STORY-BRANCH-SEMANTICS.md` §24 keeps a
# Save Point's meaning auditable by making "move it" delete-and-recreate.
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow
)
class StateProposal(Base):
"""M5: what the model proposed, and what the application did about it.
`DATA-MODEL.md` §19 requires the model's proposal to be *distinct from*
accepted state, and this table is that separation made physical. The model
writes here; it never writes `state_events`, and it never writes a snapshot.
A row exists whether the proposal was accepted, partly accepted, rejected or
unparseable. A rejected proposal is not authoritative and changes nothing,
but it is the record that explains why the state does not say what the
narration seems to say — without it, a wrong-looking campaign has no trail
to follow. `raw_output` is kept for exactly the case that matters most: the
block that did not parse, which no structured column could hold.
"""
__tablename__ = "state_proposals"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
# The node whose narration produced this. NULL only for a manual correction,
# which has a coordinate but no narration behind it.
action_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
)
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Which model produced it, so a later comparison of extraction quality has
# something to group by. Empty for a manual correction.
model_name: Mapped[str] = mapped_column(String(200), default="")
# `accepted_story` or `manual_correction` — who is asserting this.
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
# accepted | partially_accepted | rejected | unparseable
status: Mapped[str] = mapped_column(String(30), default="accepted")
# The block as written, including when it did not parse.
raw_output: Mapped[str] = mapped_column(Text, default="")
# The parsed payload, the events accepted, and every rejection with its
# reason. Compressed for the same reason the prompt is: a busy turn's
# rejections are the largest thing here and nothing reads them in bulk.
detail: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
class StateEvent(Base):
"""M5: one accepted change to the authoritative narrative state.
The audit half of `DATA-MODEL.md` §17's hybrid. Append-only, ordered, and
**never read to reconstruct state** — that is the snapshot's job, and mixing
the two would make restore proportional to campaign length, which ADR 012
and M4 both forbid.
What this table answers is §8's list: what changed, why, which turn caused
it, whether a model or the user asserted it, and what the value was before.
`before` is stored per event rather than derived, because deriving it would
mean replaying — the thing the hybrid exists to avoid.
Events carry the story coordinate as well as the action id. The coordinate
survives a retry replacing the live take at that position, exactly as a Save
Point's does; the action id says which attempt actually proposed it.
"""
__tablename__ = "state_events"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("state_proposals.id", ondelete="SET NULL"), nullable=True
)
action_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
)
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Order within one proposal, so a turn's events replay for a reader in the
# order they were applied.
sequence: Mapped[int] = mapped_column(Integer, default=0)
event_type: Mapped[str] = mapped_column(String(60), default="")
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# What the affected value was immediately before this event, so the audit
# can answer "what did it used to be" without reconstruction. NULL when the
# event established something that did not exist.
before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
class Memory(Base):
@@ -432,10 +616,75 @@ class Action(Base):
world_state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
# M5: the authoritative narrative state as it stood after this node played.
# The genre-neutral successor to `world_state_after`, and the reason Undo,
# Redo and Save Point restore stay bounded: a position's state is one row
# read, not a replay of every event since the campaign began
# (`TECHNICAL-DESIGN.md` §10.4, and the M4 note that made it load-bearing
# for Save Points too).
#
# `DATA-MODEL.md` §17 selects the hybrid — validated events for audit, a
# snapshot for reads and restore. `state_events` is the audit half; this
# column is the restore half, and nothing reconstructs a document from
# events.
#
# Deferred and compressed for the reasons `context_snapshot` is: only the
# single node being moved to reads it, and a document carrying a campaign's
# entities and facts is larger than the RPG dict it replaces. `world_delta`
# has an M5 counterpart in `state_changes` for the bulk read.
narrative_state_after: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
# The small slice needed in bulk: the events accepted here, the ones
# refused, and short lines for the chip under an AI message. Same role
# `world_delta` played, and a separate column for the same reason — the
# context builder reads it for every action in the replayed history, and
# the snapshot beside it is deferred so a turn never loads the prompt
# archive. Shape: {"accepted": [...], "rejected": [...], "summary": [...]}.
state_changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
@property
def state_events_replay(self) -> list[dict]:
"""M5: the accepted events this turn produced, for replay into the prompt.
Read from `state_changes`' companion slice in the bulk-read column
rather than from the deferred snapshot, because the context builder
calls this for every action in the replayed history and loading the
prompt archive per action is the egress mistake this project keeps a
regression test about.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
events = changes.get("accepted")
return events if isinstance(events, list) else []
@property
def state_rejections(self) -> list[dict]:
"""M5: what this turn proposed that the application refused.
Fed back to the model as a correction for one turn only. A refusal it
has already had a chance to fix is stale, and repeating it forever would
price one bad turn into the rest of the campaign.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
rejected = changes.get("rejected")
return rejected if isinstance(rejected, list) else []
@property
def state_summary(self) -> list[str]:
"""M5: the short lines shown under an AI message: what changed here."""
changes = self.state_changes
if not isinstance(changes, dict):
return []
lines = changes.get("summary")
return [str(line) for line in lines] if isinstance(lines, list) else []
@property
def world_changes(self) -> list[dict]:
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
+23
View File
@@ -0,0 +1,23 @@
"""M5: the authoritative narrative state.
Genre-neutral state (ADR 006), written by explicit typed events with absolute
values (ADR 010), owned by the application rather than the model (ADR 003), and
recovered per story position rather than replayed (ADR 012 and
`TECHNICAL-DESIGN.md` §10.4).
extract.split(reply) prose out, proposal out, block kept for audit
|
validate.review(...) allowlist, schema, references, semantics
|
apply.apply_events(...) accepted events -> a new state document
|
store.commit_proposal(...) events, provenance and snapshot, in one transaction
`model.py` says what a state document is. `render.py` shows it to the model and
to the reader. Nothing outside this package writes authoritative state, and
nothing inside it executes anything a proposal names.
"""
from . import apply, events, extract, model, render, store, validate # noqa: F401
__all__ = ["apply", "events", "extract", "model", "render", "store", "validate"]
+272
View File
@@ -0,0 +1,272 @@
"""M5: turning accepted events into a new state document.
Pure and total. Every function here takes a document and returns a new one; none
touches the database, and none can fail on an event `validate.review` accepted —
validation is the only place an event is refused, so this module never has to
decide anything twice.
The dispatch is an explicit `if/elif` chain over `events.SPECS`, not a lookup
table keyed on the payload. The difference matters: a table maps a string a model
supplied to a callable, and the security of that arrangement rests entirely on
the allowlist being correct. A chain of literal comparisons cannot be steered by
a payload at all, whatever the allowlist does.
"""
from __future__ import annotations
import copy
from . import model
def apply_events(
state: dict,
accepted: list[dict],
*,
branch_id: int | None = None,
depth: int | None = None,
source: str = "accepted_story",
) -> dict:
"""Returns `state` with every event in `accepted` applied, in order.
The input document is never mutated: head movement stores snapshots by
reference in places, and a mutation here would edit the past.
`branch_id`/`depth` stamp facts and relationships with where they were
established, which is what makes the audit trail answer "which turn caused
this" without a join. `source` records whether the campaign, the story or
the user established it — C04's provenance, carried on the value itself.
"""
document = model.normalize(state)
for event in accepted:
_apply_one(document, event, branch_id, depth, source)
return document
def _apply_one(state: dict, event: dict, branch_id, depth, source: str) -> None:
kind = event["type"]
if kind == "create_entity":
state["entities"][event["entity"]] = model.new_entity(
type=event.get("entity_type") or "other",
name=event["name"],
description=event.get("description") or "",
aliases=event.get("aliases") or [],
)
elif kind == "set_entity_status":
_entity(state, event["entity"])["status"] = event["status"]
elif kind == "set_entity_attribute":
# Absolute assignment. The whole reason ADR 010 exists.
_entity(state, event["entity"])["attributes"][event["attribute"]] = event["value"]
elif kind == "set_entity_conditions":
_entity(state, event["entity"])["conditions"] = list(event["conditions"])
elif kind == "set_current_location":
_entity(state, event["entity"])["location"] = event["location"]
elif kind == "set_possession":
state["possessions"][event["item"]] = event["owner"]
elif kind == "clear_possession":
state["possessions"].pop(event["item"], None)
elif kind == "add_fact":
state["facts"].append({
"id": event.get("fact_id") or _fact_id(state),
"subject": event.get("subject"),
"predicate": event["predicate"],
"object": event.get("object"),
"value": event.get("value"),
"authority": _authority(source),
"source": source,
"status": "active",
"branch_id": branch_id,
"depth": depth,
})
elif kind == "invalidate_fact":
for fact in state["facts"]:
if fact.get("id") == event["fact_id"]:
# Withdrawn, not removed: C04 needs the record of what the
# campaign used to believe, and a deleted row audits nothing.
fact["status"] = "invalidated"
fact["invalidated_by"] = source
fact["invalidated_at"] = {"branch_id": branch_id, "depth": depth}
if event.get("reason"):
fact["invalidated_reason"] = event["reason"]
elif kind == "add_relationship":
state["relationships"].append({
"id": _relationship_id(state),
"source": event["source"],
"target": event["target"],
"type": event["relationship"],
"description": event.get("description") or "",
"status": "active",
"established_by": source,
"branch_id": branch_id,
"depth": depth,
})
elif kind == "end_relationship":
for relationship in state["relationships"]:
if (
relationship.get("source") == event["source"]
and relationship.get("target") == event["target"]
and relationship.get("type") == event["relationship"]
and relationship.get("status") == "active"
):
relationship["status"] = "ended"
relationship["ended_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "open_story_thread":
state["threads"][event["thread"]] = {
"title": event["title"],
"description": event.get("description") or "",
"status": "open",
"opened_at": {"branch_id": branch_id, "depth": depth},
}
elif kind == "resolve_story_thread":
thread = state["threads"].get(event["thread"])
if isinstance(thread, dict):
thread["status"] = "resolved"
thread["resolution"] = event.get("resolution") or ""
thread["resolved_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "set_scene":
scene = dict(state.get("scene") or {})
if "summary" in event:
scene["summary"] = event["summary"]
if "location" in event:
scene["location"] = event["location"]
if "present" in event:
scene["present"] = list(event["present"] or [])
scene["at"] = {"branch_id": branch_id, "depth": depth}
state["scene"] = scene
# No `else`. Every allowed type is handled above, and an unhandled one
# cannot arrive: `validate.review` refuses anything outside the allowlist,
# and the allowlist is this list. A silent fall-through would be the one way
# an event could appear accepted and do nothing.
def _entity(state: dict, key: str) -> dict:
"""The entity record for `key`, created bare if a snapshot lost it.
Validation guarantees the entity exists, so this is a repair path for a
hand-edited or partially imported document rather than a normal branch. A
bare record is better than a KeyError: the story is still readable, and the
inspector shows an entity with nothing known about it, which is true.
"""
entities = state["entities"]
found = entities.get(key)
if not isinstance(found, dict):
found = model.new_entity(name=key)
entities[key] = found
found.setdefault("attributes", {})
found.setdefault("conditions", [])
return found
def _authority(source: str) -> str:
"""Which authority band a source's assertions carry.
A user's correction outranks the story (C04); the story outranks a guess.
`DATA-MODEL.md` §14 orders the bands, and this is the mapping into them.
"""
if source == "manual_correction":
return "manual_correction"
if source == "campaign_canon":
return "campaign_canon"
return "accepted_story"
def _fact_id(state: dict) -> str:
return f"f{len(state['facts']) + 1}"
def _relationship_id(state: dict) -> str:
return f"r{len(state['relationships']) + 1}"
def diff(before: dict, after: dict) -> list[str]:
"""A short human-readable list of what changed between two documents.
Shown under a turn the way the world-state chip used to be, and recorded on
the node for the bulk read. Text rather than structure, because its only
consumer is a person reading "Aldric now holds the silver key".
"""
before = model.normalize(before)
after = model.normalize(after)
lines: list[str] = []
for key, entity in after["entities"].items():
was = before["entities"].get(key)
name = model.entity_name(after, key)
if was is None:
lines.append(f"{name} enters the story")
continue
if was.get("status") != entity.get("status"):
lines.append(f"{name} is now {entity.get('status')}")
if was.get("location") != entity.get("location") and entity.get("location"):
lines.append(f"{name} is at {model.entity_name(after, entity['location'])}")
if sorted(was.get("conditions") or []) != sorted(entity.get("conditions") or []):
now = ", ".join(entity.get("conditions") or []) or "nothing"
lines.append(f"{name}: {now}")
for attribute, value in (entity.get("attributes") or {}).items():
if (was.get("attributes") or {}).get(attribute) != value:
lines.append(f"{name} {attribute} = {value}")
for item, owner in after["possessions"].items():
if before["possessions"].get(item) != owner:
lines.append(
f"{model.entity_name(after, item)} → {model.entity_name(after, owner)}"
)
for item in before["possessions"]:
if item not in after["possessions"]:
lines.append(f"{model.entity_name(after, item)} is held by nobody")
known = {f.get("id") for f in before["facts"]}
for fact in after["facts"]:
if fact.get("id") not in known:
lines.append(f"fact: {_fact_text(after, fact)}")
was_active = {f["id"] for f in model.active_facts(before)}
for fact in before["facts"]:
if fact.get("id") in was_active and fact.get("id") not in {
f["id"] for f in model.active_facts(after)
}:
lines.append(f"withdrawn: {_fact_text(after, fact)}")
known = {r.get("id") for r in before["relationships"]}
for relationship in after["relationships"]:
if relationship.get("id") not in known:
lines.append(
f"{model.entity_name(after, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(after, relationship['target'])}"
)
for key, thread in after["threads"].items():
was = before["threads"].get(key)
if was is None:
lines.append(f"opened: {thread.get('title', key)}")
elif was.get("status") != thread.get("status"):
lines.append(f"{thread.get('status')}: {thread.get('title', key)}")
return lines
def _fact_text(state: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(state, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(state, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
return " ".join(p for p in parts if p)
+194
View File
@@ -0,0 +1,194 @@
"""M5: the typed event vocabulary, and the allowlist that bounds it.
ADR 010 replaced AI-DnD's relative-delta protocol because the ambiguity was
architectural: a number in a delta field is syntactically legal whether the
model meant "add 50" or "set to 50", and no validator can tell which. Every
event here therefore states its operation in its `type`, and every value it
carries is **absolute**. There is no event whose meaning depends on a prompt
instruction having been followed.
## The allowlist is a security boundary, not a convenience
Model output is untrusted input (`SECURITY-THREAT-MODEL.md`), and this table is
the entire set of things a model may cause to happen. H05's
`{"event_type": "execute_shell", ...}` is refused here — not because "shell" is
recognised and blocked, but because it is not in `SPECS`, and nothing outside
`SPECS` is dispatched. There is no fallback branch, no generic handler and no
name-to-callable lookup that a payload could steer.
Adding an event means adding a spec here and a case in `apply.py`. Nothing else
in the application can widen the vocabulary, which is what keeps
"state extraction" from drifting into "tool execution".
## Shape of a spec
required fields that must be present and non-empty
optional fields that may be present
refs fields naming an entity that must already exist
creates the field naming an entity this event may bring into being
`refs` is what `validate.py` uses for referential integrity, and `creates` is
the deliberate exception: exactly one event type may introduce an entity, so a
typo in any other event surfaces as an unknown reference rather than silently
creating a second, empty Mara.
"""
from __future__ import annotations
# Field types the schema layer enforces. Kept deliberately small: a narrative
# state event carries names, labels and plain values, and nothing here needs a
# nested structure a model could hide something inside.
TEXT = "text"
KEY = "key" # an entity/thread identifier: a slug the campaign chose
VALUE = "value" # a JSON scalar — str, int, float, bool or None
LABELS = "labels" # a list of short strings
#: The whole vocabulary. Nothing outside this mapping is dispatched, ever.
SPECS: dict[str, dict] = {
"create_entity": {
"required": {"entity": KEY, "name": TEXT},
"optional": {"entity_type": TEXT, "description": TEXT, "aliases": LABELS},
"refs": (),
"creates": "entity",
"summary": "brings a person, place, thing or group into the story",
},
"set_entity_status": {
"required": {"entity": KEY, "status": TEXT},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets whether an entity is active, gone, destroyed …",
},
"set_entity_attribute": {
# The one numeric-capable event, and it is an assignment. ADR 010's
# `set_value`: the operation is in the name, so a value of 50 can only
# mean fifty. An `increment_value` could be added later without
# ambiguity, because it would be a different `type`.
"required": {"entity": KEY, "attribute": TEXT, "value": VALUE},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets a named value on an entity, absolutely",
},
"set_entity_conditions": {
# Absolute too: the full set replaces the old one. "Add a condition"
# would need the current set to be known by the model, which is exactly
# the assumption that made deltas unreliable.
"required": {"entity": KEY, "conditions": LABELS},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "replaces the conditions an entity is under",
},
"set_current_location": {
"required": {"entity": KEY, "location": KEY},
"optional": {},
"refs": ("entity", "location"),
"creates": None,
"summary": "moves an entity to a location",
},
"set_possession": {
"required": {"item": KEY, "owner": KEY},
"optional": {},
"refs": ("item", "owner"),
"creates": None,
"summary": "gives an item to an owner",
},
"clear_possession": {
"required": {"item": KEY},
"optional": {},
"refs": ("item",),
"creates": None,
"summary": "leaves an item held by nobody",
},
"add_fact": {
"required": {"predicate": TEXT},
"optional": {
"subject": KEY, "object": KEY, "value": VALUE, "fact_id": TEXT,
},
# Only the subject is checked as an entity. The *object* of a fact is
# routinely not one — "Mara knows where the key was found" has another
# fact as its object, and C03 needs exactly that — so it is checked
# against entities *and* known facts in `validate._check`. Requiring an
# entity here would make the knowledge distinction C03 asks for
# unrepresentable.
"refs": ("subject",),
"creates": None,
"summary": "asserts something about the world",
},
"invalidate_fact": {
"required": {"fact_id": TEXT},
"optional": {"reason": TEXT},
"refs": (),
"creates": None,
"summary": "withdraws a fact without deleting the record of it",
},
"add_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {"description": TEXT},
"refs": ("source", "target"),
"creates": None,
"summary": "ties two entities together",
},
"end_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {},
"refs": ("source", "target"),
"creates": None,
"summary": "ends a tie without erasing that it existed",
},
"open_story_thread": {
"required": {"thread": KEY, "title": TEXT},
"optional": {"description": TEXT},
"refs": (),
"creates": None,
"summary": "records narrative business left open",
},
"resolve_story_thread": {
"required": {"thread": KEY},
"optional": {"resolution": TEXT},
"refs": (),
"creates": None,
"summary": "closes narrative business",
},
"set_scene": {
"required": {},
"optional": {"summary": TEXT, "location": KEY, "present": LABELS},
"refs": ("location",),
"creates": None,
"summary": "records the immediate situation",
},
}
#: The allowlist itself, as a set, for the one question that matters most.
ALLOWED = frozenset(SPECS)
def is_allowed(event_type) -> bool:
"""Whether `event_type` names an event this application will ever apply.
A string is required: a dict, a list or None is not a type, and coercing one
with `str()` would turn a malformed payload into a lookup that might
accidentally succeed.
"""
return isinstance(event_type, str) and event_type in ALLOWED
def spec(event_type: str) -> dict | None:
return SPECS.get(event_type)
def vocabulary_for_prompt() -> str:
"""The event list as the narrator prompt describes it.
Generated from `SPECS` rather than written out beside it, so the model can
never be told about an event the application does not implement — the drift
that would produce proposals rejected for reasons nobody could see.
"""
lines = []
for name, definition in SPECS.items():
fields = list(definition["required"]) + [
f"{field}?" for field in definition["optional"]
]
lines.append(f' {name}({", ".join(fields)}) — {definition["summary"]}')
return "\n".join(lines)
+259
View File
@@ -0,0 +1,259 @@
"""M5: getting a typed proposal out of a narration, and keeping it out of the prose.
The model writes the story and, after it, one fenced block of typed events. This
module holds the instruction it is given, the parser that survives the ways a
model gets a format wrong, and the separation that keeps machine-readable output
from reaching the reader.
Two properties matter more than elegance here:
* **The prose must never carry the protocol.** A reader should not see a JSON
block under their story, and a stored narration should not contain one either,
because everything downstream — memory, summaries, export, the transcript —
treats stored text as the story. The block is removed before the text is
stored, not before it is displayed.
* **An unreadable block must not be a failed turn.** A narration the user watched
arrive is worth keeping even when the state block after it is garbage. Parsing
returns "no events" rather than raising, the turn commits with the state
unchanged, and the proposal record keeps the raw output so the failure is
visible in the audit rather than only in a log.
"""
from __future__ import annotations
import json
import re
from . import events
# The block the model is asked to append. Built from the vocabulary rather than
# written beside it, so the instruction cannot describe an event the application
# would then reject (`events.vocabulary_for_prompt`).
EMIT_RULE = (
"After your narration, append a fenced code block labelled `state` containing "
"a JSON object with an \"events\" list, recording what your own narration made "
"true. Treat your narration as authoritative: if you wrote that someone moved, "
"took something, learned something, was hurt, or that a new person or place "
"appeared, record it.\n"
"\n"
"Every value is ABSOLUTE — the new state of things, never a change or a "
"difference. Use only these events:\n"
f"{events.vocabulary_for_prompt()}\n"
"\n"
"Identifiers are short lower-case slugs (mara, silver-key, old-abbey) and must "
"match the ones already in the state you were shown. Introduce a person, place "
"or thing with create_entity before referring to it. If the turn established "
"nothing, send an empty events list.\n"
"Example:\n"
'```state\n'
'{"events": [{"type": "set_possession", "item": "silver-key", "owner": "aldric"},'
' {"type": "set_current_location", "entity": "aldric", "location": "old-abbey"}]}\n'
'```'
)
# Placed last, where recency is strongest, the same way the delta protocol did.
EMIT_REMINDER = (
"[Reminder: end your reply with a ```state block listing the events your "
"narration made true, with absolute values. Send an empty events list if "
"nothing changed.]"
)
# Three patterns, and the difference between them is the whole of this module's
# safety. A story is allowed to contain code, and taking a code block out of
# someone's prose is a worse failure than leaving a stray proposal in it.
#
# `state` is the label the application asks for, so a fence carrying it is ours
# whatever is inside it — including a truncated `{oh no` that no JSON parser
# will take. That block must still leave the prose, and must still be recorded,
# because an unparseable proposal is exactly the failure the audit exists to
# make visible.
_STATE_FENCE_RE = re.compile(
r"```state[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# `json` is *not* our label. Models reach for it anyway, so a ```json fence is
# taken only when what it contains is actually a proposal. A character who
# writes `{"name": "Mara"}` into a terminal keeps their code block (M5 review,
# Finding 6).
_JSON_FENCE_RE = re.compile(
r"```json[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# An *unlabelled* fence is ours on the same terms: it has to be a proposal, not
# merely JSON-shaped.
_BARE_FENCE_RE = re.compile(r"```\s*([\[{].*?[\]}])\s*```", re.DOTALL)
# A bare object hugging the end of the text, for a model that forgets the fence.
_TRAILING_RE = re.compile(r"(\{.*\})\s*$", re.DOTALL)
# An opener with no closing fence. A model that runs out of output tokens
# mid-block leaves one of these, and everything after it is protocol rather than
# story — so the story ends where the opener begins.
#
# Our own label ends the story unconditionally. A dangling ```json fence is
# judged on what follows it, because an unterminated code block in a story is
# still the author's (M5 review, Finding 6).
_DANGLING_STATE_RE = re.compile(r"\n?```state\b.*\Z", re.DOTALL | re.IGNORECASE)
_DANGLING_JSON_RE = re.compile(r"\n?```json\b(.*)\Z", re.DOTALL | re.IGNORECASE)
# The reminder, parroted back. Small local models reproduce the bracketed
# instruction they were given, and it arrives as ordinary prose — no fence, so
# nothing above strips it, and the reader is shown a piece of the prompt.
#
# The bracket is *found* broadly and *judged* narrowly. Merely naming the
# protocol is not enough: a story may end on an aside about a state block, and
# deleting that sentence is the worse failure (M5 review, Finding 6). What marks
# the echo is the shape of the instruction itself — the fence token, the word it
# opens with, or the pair of phrases the reminder uses together.
_TRAILING_BRACKET_RE = re.compile(r"\n?\[([^\]]*)\]\s*\Z", re.DOTALL)
def _is_echoed_instruction(inner: str) -> bool:
"""Whether a trailing bracketed segment is the prompt's own reminder."""
low = inner.lower()
if "```state" in low:
return True
if low.lstrip().startswith("reminder:"):
return True
# The reminder names both; prose about the protocol rarely names either the
# way the instruction does, and effectively never both.
return "state block" in low and "events list" in low
def _clean(prose: str) -> str:
"""Removes protocol the block extraction could not, and nothing else.
Found by the M5 realistic-context run (§12), which is the failure class
Phase 0B warned about: under a full prompt the model echoed its own
instruction into the narration, and the reader would have been shown it.
Neither case here is hypothetical — both were observed against a real local
model.
"""
cleaned = prose
bracket = _TRAILING_BRACKET_RE.search(cleaned)
if bracket is not None and _is_echoed_instruction(bracket.group(1)):
cleaned = cleaned[: bracket.start()]
cleaned = _DANGLING_STATE_RE.sub("", cleaned)
dangling = _DANGLING_JSON_RE.search(cleaned)
if dangling is not None and _reads_as_protocol(dangling.group(1)):
cleaned = cleaned[: dangling.start()]
return cleaned.strip()
def _reads_as_protocol(tail: str) -> bool:
"""Whether a truncated fence was on its way to being a proposal."""
if '"events"' in tail:
return True
return any(f'"{name}"' in tail for name in events.SPECS)
def _tolerant_load(blob: str):
"""Parses a block, forgiving what small local models get wrong.
Trailing commas and a leading `+` on a number are both common and both
rejected by strict JSON. Repairing them is not guessing at meaning — the
intended value is unambiguous — which is the line this function stays on the
right side of. Anything it cannot parse returns None, and the caller treats
that as no proposal rather than as an empty one.
"""
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return None
return parsed
def split(text: str) -> tuple[str, dict | None, str]:
"""Separates a reply into `(prose, proposal, raw_block)`.
`proposal` is None when there is no block or it cannot be parsed at all,
which the caller records as a malformed proposal. `raw_block` is what the
model actually wrote, kept for the audit record even — especially — when it
did not parse.
A bare trailing object is only stripped when it parses *and* looks like a
proposal. Prose that happens to end in a brace is left alone, because
removing a sentence from someone's story to satisfy a regex is a worse
failure than leaving a stray brace in it.
"""
matches = list(_STATE_FENCE_RE.finditer(text))
if matches:
match = matches[-1]
raw = match.group(1).strip()
prose = _clean(text[: match.start()] + text[match.end():])
return prose, _tolerant_load(raw), raw
# A `json` or unlabelled fence is ours only when its contents are this
# protocol. That is judged two ways, and it needs both: a block that parses
# into a proposal, or one that plainly reads as protocol even though it does
# not parse. The second half matters — a small model that mangles its own
# JSON must not have the wreckage shown to the reader, which is what the
# realistic-model run caught during the corrective pass.
for pattern in (_JSON_FENCE_RE, _BARE_FENCE_RE):
for match in reversed(list(pattern.finditer(text))):
raw = match.group(1).strip()
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed) or _reads_as_protocol(raw):
prose = _clean(text[: match.start()] + text[match.end():])
return prose, parsed, raw
match = _TRAILING_RE.search(text)
if match:
raw = match.group(1)
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed):
return _clean(text[: match.start()]), parsed, raw
# No block at all — but the reply may still carry protocol the model wrote
# as prose, or a fence it never closed.
cleaned = _clean(text)
if cleaned != text.strip():
return cleaned, None, text.strip()[len(cleaned):].strip()
return cleaned, None, ""
def _looks_like_proposal(parsed) -> bool:
"""Whether a bare trailing object is this protocol rather than prose."""
if not isinstance(parsed, dict):
return False
if isinstance(parsed.get("events"), list):
return True
return isinstance(parsed.get("type"), str) and events.is_allowed(parsed["type"])
def render_block(accepted: list[dict]) -> str:
"""Renders accepted events back into the block the model emitted.
Replayed into the prompt for past turns so the model copies the format it is
being asked for. **Accepted** events rather than proposed ones, for the
reason the delta protocol learned the hard way: showing the model a refused
event standing as though it had worked, contradicted by the state in the
same prompt, teaches it to send the event again.
"""
if not accepted:
return ""
return "```state\n" + json.dumps({"events": accepted}, ensure_ascii=False) + "\n```"
def render_rejections(rejected: list[dict]) -> str:
"""The correction note appended after the most recent AI turn.
Only what was lost. A model that is told what it got wrong can fix it next
turn; a model told nothing repeats it.
"""
if not rejected:
return ""
lines = []
for entry in rejected[:6]:
if not isinstance(entry, dict):
continue
detail = entry.get("detail") or entry.get("reason") or ""
if detail:
lines.append(f"- {detail}")
if not lines:
return ""
body = "\n".join(lines)
return (
"[Part of your last state block was not accepted. Correct it in this "
f"turn's block:\n{body}]"
)
+307
View File
@@ -0,0 +1,307 @@
"""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")
}
+291
View File
@@ -0,0 +1,291 @@
"""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("_", " "))
+195
View File
@@ -0,0 +1,195 @@
"""M5: writing accepted state, atomically with the turn that caused it.
This is the only module in the package that touches the database, and the only
place authoritative narrative state is written.
## The atomicity rule (L01)
Everything a turn establishes goes in one transaction: the narration, the head
movement, the accepted events, the resulting snapshot, and the provenance. This
function *adds* to the caller's session and never commits — the turn engine's
single `db.commit()` remains the one commit point, so a failure anywhere before
it rolls the whole turn back rather than leaving narration accepted with half its
state written.
That ordering is deliberate and load-bearing. `L01` forbids a head position that
implies an accepted reply whose state commit did not complete, and the cheapest
way to guarantee that is to never have two commits to get out of step.
## What is not here
No reconstruction. Nothing in this module reads `state_events` to rebuild a
document — the snapshot on the node is the restore path
(`TECHNICAL-DESIGN.md` §10.4). The events are the audit trail, and an audit
trail that the system depends on for correctness stops being an audit trail and
becomes a replay engine.
"""
from __future__ import annotations
import copy
from sqlalchemy.orm import Session
from .. import models
from . import apply as apply_module
from . import model
def current(adventure: models.Adventure) -> dict:
"""The campaign's authoritative state right now, as a document.
Normalised on the way out, so every caller gets the same shape whatever a
hand-edited row or an older snapshot contains.
"""
return model.normalize(adventure.narrative_state)
def set_current(adventure: models.Adventure, state: dict) -> None:
adventure.narrative_state = model.normalize(state)
def canon_of(adventure: models.Adventure) -> dict:
"""The campaign's own rules, which outrank anything a narration proposes.
Configuration rather than code (C01, J03): the campaign says what it forbids,
and `validate` enforces it without knowing what the rule means.
"""
canon = adventure.campaign_canon
return canon if isinstance(canon, dict) else {}
def record(
db: Session,
adventure: models.Adventure,
*,
review,
raw_block: str = "",
parsed=None,
action: models.Action | None = None,
branch_id: int | None = None,
depth: int | None = None,
model_name: str = "",
source: str = "accepted_story",
) -> tuple[dict, models.StateProposal]:
"""Applies a reviewed proposal and records everything about it.
Returns `(new_state, proposal_row)`. The caller is responsible for putting
the new state where it belongs — on the campaign, and on the node's snapshot
— because only the caller knows whether this is a turn, a retry or a
correction.
Nothing is committed here. See the module docstring.
"""
before = current(adventure)
after = apply_module.apply_events(
before, review.accepted, branch_id=branch_id, depth=depth, source=source
)
proposal = models.StateProposal(
adventure_id=adventure.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
model_name=model_name or "",
source=source,
status=review.status,
raw_output=raw_block or "",
detail={
"parsed": parsed,
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
},
)
db.add(proposal)
# The proposal needs an id before its events can point at it, and the
# session does not autoflush. This is a flush, not a commit: still one
# transaction, still all-or-nothing.
db.flush()
for sequence, event in enumerate(review.accepted):
db.add(models.StateEvent(
adventure_id=adventure.id,
proposal_id=proposal.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
sequence=sequence,
event_type=event.get("type", ""),
payload=copy.deepcopy(event),
before=_before_value(before, event),
source=source,
))
return after, proposal
def _before_value(state: dict, event: dict) -> dict | None:
"""What the value this event changes was, immediately beforehand.
Recorded per event so §8's "what was the previous value" is answerable
without replaying anything. Only the slice the event touches: a whole
document per event would duplicate the snapshot for no extra answer.
"""
kind = event.get("type")
if kind in ("set_entity_status", "set_entity_attribute",
"set_entity_conditions", "set_current_location"):
entity = model.entity(state, event.get("entity", ""))
if entity is None:
return None
if kind == "set_entity_status":
return {"status": entity.get("status")}
if kind == "set_entity_attribute":
attribute = event.get("attribute")
return {"attribute": attribute,
"value": (entity.get("attributes") or {}).get(attribute)}
if kind == "set_entity_conditions":
return {"conditions": list(entity.get("conditions") or [])}
return {"location": entity.get("location")}
if kind in ("set_possession", "clear_possession"):
return {"owner": model.owner_of(state, event.get("item", ""))}
if kind == "invalidate_fact":
for fact in state.get("facts") or []:
if fact.get("id") == event.get("fact_id"):
return {"status": fact.get("status"), "predicate": fact.get("predicate")}
return None
if kind == "resolve_story_thread":
thread = (state.get("threads") or {}).get(event.get("thread", ""))
return {"status": thread.get("status")} if isinstance(thread, dict) else None
if kind == "end_relationship":
return {"status": "active"}
return None
# ------------------------------------------------------------------ reading
def events_for(
db: Session, adventure: models.Adventure, action_id: int
) -> list[models.StateEvent]:
"""The accepted events one node's narration produced, in order."""
return (
db.query(models.StateEvent)
.filter(
models.StateEvent.adventure_id == adventure.id,
models.StateEvent.action_id == action_id,
)
.order_by(models.StateEvent.sequence, models.StateEvent.id)
.all()
)
def history(
db: Session, adventure: models.Adventure, limit: int = 200
) -> list[models.StateEvent]:
"""The campaign's accepted state events, newest first.
Bounded by default: this is an audit view, and an unbounded read of a long
campaign's every event is the kind of query this project keeps a regression
test about.
"""
return (
db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == adventure.id)
.order_by(models.StateEvent.id.desc())
.limit(limit)
.all()
)
+317
View File
@@ -0,0 +1,317 @@
"""M5: deciding which proposed events the application will accept.
A proposal is untrusted model output. This module is the gate between it and the
authoritative state, and it is layered so that a rejection can say *which* rule
refused and a test can aim at one layer at a time:
1. envelope is this a proposal at all — a dict with a list of events?
2. allowlist is each event type one this application implements? (H05)
3. schema are the required fields present, and the right shape?
4. referential do the entities and threads it names exist?
5. semantic does it contradict campaign canon, or itself?
Layer 2 is the security boundary and runs before any field is read, so a payload
carrying `command` or `path` alongside an unknown type is discarded without those
fields ever being looked at.
## What rejection means
Nothing is partially applied. `review` returns accepted and rejected events
separately and the caller decides; `apply.py` is only ever handed the accepted
list. A proposal with one bad event out of four therefore lands three, which is
`partially_accepted` — the alternative, discarding all four because the model
misspelled one entity, loses story the user watched happen.
What is *never* allowed is a rejected event mutating anything, or a rejection
being silent: every refusal carries a reason, is counted, and is stored on the
proposal record for §8's audit.
## What this module does not do
It does not decide whether the model was *right*. A typed event can be
well-formed, reference real entities, contradict nothing, and still describe
something the narration did not say. That is C06's territory and no validator
can settle it — ADR 010 says so plainly. What validation buys is that a wrong
proposal is wrong in a way a person can see in the audit trail, rather than one
that silently means something other than it appears to.
"""
from __future__ import annotations
from . import events, model
# A rejected event carries one of these, so tests and the debug view can assert
# on the reason rather than on prose.
UNKNOWN_TYPE = "unknown_event_type"
NOT_AN_OBJECT = "not_an_object"
MISSING_FIELD = "missing_field"
BAD_FIELD_TYPE = "bad_field_type"
UNKNOWN_REFERENCE = "unknown_reference"
CANON_CONFLICT = "canon_conflict"
SELF_CONTRADICTION = "self_contradiction"
DUPLICATE_ENTITY = "duplicate_entity"
# How many events one proposal may carry. A narration describes a turn, not a
# migration; a hundred events is a runaway model or a payload trying to be
# something else, and either way the cap bounds the work before it is done.
MAX_EVENTS = 40
# How long a text field may be. Long enough for a description, short enough that
# a proposal cannot smuggle a document into the state.
MAX_TEXT = 2_000
MAX_LABELS = 40
class Rejection:
"""One event that will not be applied, and why."""
__slots__ = ("event", "reason", "detail")
def __init__(self, event, reason: str, detail: str = ""):
self.event = event
self.reason = reason
self.detail = detail
def as_dict(self) -> dict:
return {"event": self.event, "reason": self.reason, "detail": self.detail}
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Rejection {self.reason}: {self.detail}>"
class Review:
"""The verdict on one proposal."""
__slots__ = ("accepted", "rejected")
def __init__(self, accepted: list[dict], rejected: list[Rejection]):
self.accepted = accepted
self.rejected = rejected
@property
def status(self) -> str:
"""`DATA-MODEL.md` §19's validation_status."""
if self.rejected and self.accepted:
return "partially_accepted"
if self.rejected:
return "rejected"
return "accepted"
def as_dict(self) -> dict:
return {
"status": self.status,
"accepted": self.accepted,
"rejected": [r.as_dict() for r in self.rejected],
}
def review(payload, state: dict, canon: dict | None = None) -> Review:
"""Returns which of `payload`'s events may be applied to `state`.
`state` is the document the events would apply to, needed because
referential checks ask what already exists. `canon` carries the campaign's
own rules, which outrank anything a narration proposes (C01).
The state is **not** mutated. Events are checked against a running view that
accounts for entities earlier events in the same proposal create, so a
proposal may introduce Mara and then move her, but nothing is written until
the caller applies the accepted list.
"""
accepted: list[dict] = []
rejected: list[Rejection] = []
proposed = _events_of(payload)
if proposed is None:
return Review([], [Rejection(payload, NOT_AN_OBJECT,
"the proposal is not an object with an event list")])
# Entities this proposal has introduced, so a later event in the same
# proposal may refer to them. Kept separately from `state` so that a
# rejected create cannot make a later reference resolve.
introduced: set[str] = set()
for raw in proposed[:MAX_EVENTS]:
problem = _check(raw, state, introduced, canon)
if problem is not None:
rejected.append(problem)
continue
accepted.append(raw)
spec = events.spec(raw["type"])
if spec and spec["creates"]:
introduced.add(str(raw[spec["creates"]]))
for extra in proposed[MAX_EVENTS:]:
rejected.append(Rejection(extra, BAD_FIELD_TYPE,
f"more than {MAX_EVENTS} events in one proposal"))
return Review(accepted, rejected)
def _events_of(payload) -> list | None:
"""The event list, from either shape a proposal may legitimately take."""
if isinstance(payload, list):
return [e for e in payload]
if not isinstance(payload, dict):
return None
found = payload.get("events")
if found is None:
return []
if not isinstance(found, list):
return None
return found
def _check(raw, state: dict, introduced: set[str], canon: dict | None) -> Rejection | None:
"""Returns why `raw` is unacceptable, or None if it may be applied."""
# ---- layer 1: is it an event-shaped object at all ----
if not isinstance(raw, dict):
return Rejection(raw, NOT_AN_OBJECT, "event is not an object")
# ---- layer 2: the allowlist, before any field is read ----
#
# H05 lands here. `execute_shell` is refused because it is not in the
# vocabulary, and its `command` field is never looked at — there is no
# branch in this application that could reach it.
event_type = raw.get("type", raw.get("event_type"))
if not events.is_allowed(event_type):
return Rejection(raw, UNKNOWN_TYPE, f"{event_type!r} is not a state event")
raw["type"] = event_type
spec = events.spec(event_type)
# ---- layer 3: schema ----
for field, kind in spec["required"].items():
if field not in raw:
return Rejection(raw, MISSING_FIELD, f"{event_type} needs {field!r}")
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
for field, kind in spec["optional"].items():
if field in raw and raw[field] is not None:
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
# ---- layer 4: referential integrity ----
known = set(state.get("entities") or {}) | introduced
for field in spec["refs"]:
named = raw.get(field)
if named is None or field not in raw:
continue # optional reference, absent
if not isinstance(named, str) or named not in known:
return Rejection(raw, UNKNOWN_REFERENCE,
f"{event_type} names {field}={named!r}, which does not exist")
if event_type == "add_fact" and raw.get("object") is not None:
# An object may name an entity or another fact. Checking both keeps the
# reference meaningful — a typo is still caught — without forcing every
# thing a fact can be about to be promoted to an entity first.
known_facts = {f.get("id") for f in (state.get("facts") or [])}
target = raw["object"]
if not isinstance(target, str) or (target not in known and target not in known_facts):
return Rejection(raw, UNKNOWN_REFERENCE,
f"add_fact names object={target!r}, which does not exist")
if event_type == "invalidate_fact":
if not any(f.get("id") == raw["fact_id"] for f in (state.get("facts") or [])):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no fact {raw['fact_id']!r} to invalidate")
if event_type == "resolve_story_thread":
if raw["thread"] not in (state.get("threads") or {}):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no story thread {raw['thread']!r} to resolve")
if spec["creates"]:
key = raw[spec["creates"]]
if key in known:
return Rejection(raw, DUPLICATE_ENTITY,
f"{key!r} already exists; use set_* to change it")
# ---- layer 5: semantics ----
return _semantic(raw, state, canon)
def _bad_shape(value, kind: str, field: str) -> str | None:
"""Returns why `value` is the wrong shape for `kind`, or None."""
if kind in (events.TEXT, events.KEY):
if not isinstance(value, str) or not value.strip():
return f"{field!r} must be a non-empty string"
if len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.VALUE:
# A scalar. Explicitly not a dict or a list: a nested payload is how a
# value field becomes somewhere to hide a second protocol.
if not isinstance(value, (str, int, float, bool)) and value is not None:
return f"{field!r} must be a plain value, not a structure"
if isinstance(value, str) and len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.LABELS:
if not isinstance(value, list):
return f"{field!r} must be a list"
if len(value) > MAX_LABELS:
return f"{field!r} has more than {MAX_LABELS} entries"
for item in value:
if not isinstance(item, str) or not item.strip():
return f"{field!r} must contain only non-empty strings"
if len(item) > MAX_TEXT:
return f"{field!r} contains an over-long entry"
return None
return f"{field!r} has an unknown field kind" # pragma: no cover
def _semantic(raw: dict, state: dict, canon: dict | None) -> Rejection | None:
"""Deterministic checks the application can actually make.
Deliberately modest. ADR 010 is explicit that typed events do not make a
model correct, and pretending arbitrary fiction can be validated would be
worse than admitting it cannot: it would produce confident rejections of
perfectly good story. So this refuses only what the application *knows* is
wrong — a self-contradiction, or a collision with a rule the campaign wrote
down.
"""
event_type = raw["type"]
# An entity cannot hold itself, and cannot be in itself.
if event_type == "set_possession" and raw["item"] == raw["owner"]:
return Rejection(raw, SELF_CONTRADICTION, "an item cannot possess itself")
if event_type == "set_current_location" and raw["entity"] == raw["location"]:
return Rejection(raw, SELF_CONTRADICTION, "an entity cannot be inside itself")
if event_type in ("add_relationship", "end_relationship") and raw["source"] == raw["target"]:
return Rejection(raw, SELF_CONTRADICTION,
"a relationship needs two different entities")
# C01: campaign canon outranks narration. The rule is generic — a campaign
# declares transitions it forbids, and any event proposing one is refused.
# Nothing here knows what any of those transitions mean; the campaign
# says which it forbids, in data.
conflict = _canon_conflict(raw, state, canon)
if conflict is not None:
return Rejection(raw, CANON_CONFLICT, conflict)
return None
def _canon_conflict(raw: dict, state: dict, canon: dict | None) -> str | None:
"""Whether campaign canon forbids what this event proposes.
Canon is configuration, not code (J03). A campaign writes:
{"forbidden_status_changes": [{"from": "dead", "to": "active"}]}
and a narration that tries to bring a dead character back is refused —
without this module, or any other, containing the word for what that is. A
science-fiction campaign forbidding a different transition uses the same
field and the same code path.
"""
if not isinstance(canon, dict):
return None
if raw["type"] != "set_entity_status":
return None
forbidden = canon.get("forbidden_status_changes")
if not isinstance(forbidden, list):
return None
current = (model.entity(state, raw["entity"]) or {}).get("status")
for rule in forbidden:
if not isinstance(rule, dict):
continue
if rule.get("from") == current and rule.get("to") == raw["status"]:
return (
f"campaign canon does not allow {raw['entity']!r} to go from "
f"{current!r} to {raw['status']!r}"
)
return None
+6 -1
View File
@@ -13,6 +13,8 @@ Read the modules in this order to follow a turn from end to end:
turns playing a turn, and the lock that allows only one at a time
takes retries and the attempts that collect at one coordinate
branches where a story splits
checkpoints Save Points: durable names for positions the head can return to
state the authoritative narrative state, and correcting it by hand
What this package re-exports, and what it deliberately does not:
@@ -31,6 +33,8 @@ from . import ( # noqa: F401
turns,
takes,
branches,
checkpoints,
state,
bundle_io,
refresh,
insights,
@@ -40,7 +44,7 @@ from . import ( # noqa: F401
from ... import limits # noqa: F401 `adventures.limits` is patched by tests.
from .crud import SNIPPET_MAX, _snippet
from .paging import ACTION_PAGE
from .takes import retry_action, undo_turn
from .takes import redo_turn, retry_action, undo_turn
from .turns import world_delta_of
__all__ = [
@@ -48,6 +52,7 @@ __all__ = [
"SNIPPET_MAX",
"_snippet",
"limits",
"redo_turn",
"retry_action",
"router",
"undo_turn",
+200 -6
View File
@@ -8,7 +8,8 @@ coordinate through `nodes.delete_turn`.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import attempts, models, schemas, tree
from ... import attempts, head, memorybank, models, narrative, schemas, tree
from ...context import cursors, lineage
from ...database import get_db
from . import turns
@@ -41,6 +42,11 @@ def list_actions(
],
total=total,
has_more=has_more,
# Every page carries them, not just the newest window: the client reads
# the flags off whichever page arrived last, and scrolling up must not
# be able to grey out a Redo that is still available (M3).
can_undo=head.can_undo(db, adventure),
can_redo=head.can_redo(db, adventure),
)
@@ -55,14 +61,194 @@ def update_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# One row holds one text. Nothing mirrors it now, so nothing else has to be
# updated. The edit used to have to be written into the live variant entry
# as well, or paging away and back reverted it.
action.text = payload.text
db.commit()
# A narrator turn the story is currently telling is corrected through the
# §§14-15 path, which forks. A take the story is *not* telling is a
# different thing: it has no continuation of its own — keeping one is what
# forking is for — so correcting its words cannot contradict anything, and
# it stays the plain in-place edit it has always been.
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
return _edit_narration(db, adventure, action, payload.text)
# A player's own words. Editing one rewrites this row and re-evaluates
# nothing after it, which is what makes it a correction rather than a new
# continuation. That is safe while everything descending from the row is on
# screen, and unsafe the moment something descends from it that is not — an
# undone future, or a line a divergence left behind. The reader cannot see
# that story, so they cannot see what their correction has just contradicted
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
if head.displaced_history_under(db, adventure, action):
raise HTTPException(
400,
"This turn has a later story that is not on screen — undone, or "
"left behind by a new continuation. Editing it here would change "
"the words that story was written from. Redo to bring it back "
"first, or play the turn again to start a new line from here.",
)
turns.acquire_turn_lock(adventure_id)
try:
action.text = payload.text
db.commit()
finally:
turns._active_turns.discard(adventure_id)
db.refresh(action)
return action
def _edit_narration(
db: Session, adventure: models.Adventure, action: models.Action, text: str
) -> models.Action:
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
A narrator edit is not a rewrite of a row. It is a continuation written from
the same place the original was written from, using the reader's words
instead of the model's. §15 lists what that has to mean, and each clause
maps to a step below:
1. return to the state immediately before the edited narration — the
preceding node's snapshot, one row read;
2. treat the edited text as the accepted narrator output — it is stored
verbatim, with only the protocol block stripped, and no model is called;
3. re-evaluate the state that output implies — the normal M5 extraction and
validation path, run against that starting state;
4. create a new active continuation — a new node, and the head on it;
5. retain the original narration and its future as disposable history —
nothing on the old line is written to at all.
The M5 review found the previous implementation failing 3-5 together: it
edited the row in place and rewound the campaign's live state to that
position while the head stayed at the tip, so the reader saw a full
transcript over a state document describing an earlier moment, and the
snapshots below the edit still described prose that no longer existed
(Finding 1). Forking is what fixes it, and no new machinery is needed to
fork — this function is the ⑂ path from `takes.py` with the reader's text in
place of a generated one.
Two shapes, chosen by whether anything was written after the turn:
at the tip the attempts of the turn are still leaves, so the
correction joins them as a sibling take and the
original is retained beside it in the pager;
anything below the story after the turn was written as a
continuation of the words that are there now, so it
keeps them: the correction leaves the path just
before the turn and the old line keeps its node, its
future, and its live flag.
The §14A refusal is gone from this path, and this is what replaces it. It
refused an in-place edit under an off-screen future because the edit would
silently change the words that story was written from. Nothing is changed
now — the off-screen future keeps the exact narration it descends from — so
the case that had to be refused is simply handled.
"""
if action.depth is None:
raise HTTPException(400, "That turn is not on the story you are reading.")
turns.acquire_turn_lock(adventure.id)
try:
# §15.2. The reader's words are the narration; a block they pasted in is
# protocol and is stripped before storage, exactly as a model's is.
prose, parsed, raw_block = narrative.extract.split(text)
# §15.1. Not the campaign's current state — the state this turn was
# played from. One row read, not a replay (ADR 012).
before = attempts.preceding(db, adventure, action)
starting_state = (
narrative.model.normalize(before.narrative_state_after)
if before is not None and isinstance(before.narrative_state_after, dict)
else narrative.model.empty()
)
corrected = models.Action(
adventure_id=adventure.id,
type="ai",
text=prose,
# No model was called, so there is no prompt to show for this node.
# In the sibling case the turn's assembled prompt moves to whichever
# attempt is live, which is what the Insights viewer reads; in the
# forked case the original keeps it, because the original is still
# the live node of its own line.
context_snapshot=None,
)
tip = db_tip(db, adventure)
# A turn the head rests on is not a leaf while a retained future
# descends from it, and `db_tip` reads the capped path and cannot see
# that future. Ask the head module as well (M3).
at_the_tip = (
tip is not None
and tip.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# §15.4-5 as a take. The original stays at this coordinate as a
# prior attempt, reachable through the pager, and the correction
# becomes the one the story tells.
attempts.hand_over_the_prompt(action, corrected)
attempts.add_attempt(db, adventure, action, corrected)
db.add(corrected)
# The words at this coordinate changed, so anything derived from
# them no longer describes the story.
memorybank.forget_node(db, adventure, action)
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
db.flush()
else:
# §15.4-5 as a branch. Nothing on the departed line is written to:
# the original node keeps its text, its live flag and every turn
# that was played after it.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
tree.place_action(db, adventure, corrected)
db.add(corrected)
db.flush()
# §15.3. The same validation path a generated turn takes, so a hand
# -typed event is no more trusted than a model's: the allowlist, the
# schema, the references and the canon all still apply.
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
starting_state,
narrative.store.canon_of(adventure),
)
# `record` writes the events and the provenance. Its returned document
# applies them to the campaign's *current* state, which is not what an
# edit derives from, so the document this node leaves behind is computed
# from the turn's own starting point below.
narrative.store.record(
db, adventure,
review=review,
raw_block=raw_block,
parsed=parsed,
action=corrected,
branch_id=corrected.branch_id,
depth=corrected.depth,
source="narrator_edit",
)
new_state = narrative.apply.apply_events(
starting_state, review.accepted,
branch_id=corrected.branch_id, depth=corrected.depth,
source="narrator_edit",
)
corrected.state_changes = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"summary": narrative.apply.diff(starting_state, new_state),
}
# The head is on the corrected node, so the campaign's live state is
# what that node leaves behind, and the node's own snapshot is the same
# document. That equality is the invariant the review found broken:
# visible position == head == authoritative state.
narrative.store.set_current(adventure, new_state)
attempts.snapshot_outcome(adventure, corrected)
adventure.updated_at = models.utcnow()
db.commit()
finally:
turns._active_turns.discard(adventure.id)
db.refresh(corrected)
return corrected
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(
adventure_id: int,
@@ -81,6 +267,7 @@ def delete_action(
# This works like undo. The turn is deleted with all of its attempts,
# and whatever it produced is withdrawn. The marks are depths, and a
# depth does not move when an action before it is deleted.
was_at = adventure.head_depth
delete_turn(db, adventure, action)
db.flush()
db.expire(adventure, ["actions"])
@@ -88,6 +275,13 @@ def delete_action(
# middle leaves a gap in the depths, which is intended. See
# `_backfill_tree`.
tree.refresh_head(db, adventure)
# `refresh_head` recomputes the tip, which since M3 is not the head. A
# story sitting behind its retained tip must not be dragged forward to
# the tip by an unrelated delete — that would silently Redo it. Keep the
# head where the reader left it, unless the delete took the ground out
# from under it, in which case the new tip is as far as it can stay.
if was_at < adventure.head_depth:
adventure.head_depth = was_at
# The script state and the world state belong to the adventure, not to
# the node, so deleting the node does not take back what it did to
# them. Put them back to what the story now ends with, which is the
+90 -6
View File
@@ -56,6 +56,19 @@ def list_branches(
.group_by(models.Action.branch_id)
.all()
}
# M4 closeout: how many Save Points name a position on each line. Deleting a
# branch deletes them along with its story, and the panel has to be able to
# say so before the button is pressed (review §R B-2). One grouped query for
# the whole tree, like the one above it — never one per branch.
save_points = {
branch_id: count
for branch_id, count in db.query(
models.Checkpoint.branch_id, func.count(models.Checkpoint.id)
)
.filter(models.Checkpoint.adventure_id == adventure.id)
.group_by(models.Checkpoint.branch_id)
.all()
}
out = []
for branch in branches:
count, tip = owned.get(branch.id, (0, None))
@@ -70,6 +83,7 @@ def list_branches(
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
),
own_actions=count,
save_points=save_points.get(branch.id, 0),
is_head=(branch.id == adventure.head_branch_id),
name=branch.name,
created_at=branch.created_at,
@@ -151,15 +165,31 @@ def delete_branch(
heavily retried adventure from growing without bound. That is why it ships
with the view that first lets anyone create a fork rather than after it.
Two kinds of branch cannot be deleted. The root cannot, because it holds the
turns every other branch borrows, so deleting it deletes the whole story. The
branch currently being read cannot, and neither can any branch it was forked
from, because the cascade would remove the head under the player and leave
`head_branch_id` dangling. Switch branches first.
Three kinds of branch cannot be deleted. The root cannot, because it holds
the turns every other branch borrows, so deleting it deletes the whole story.
The branch currently being read cannot, and neither can any branch it was
forked from, because the cascade would remove the head under the player and
leave `head_branch_id` dangling. Switch branches first.
The third is M4's: **a branch a Save Point names cannot be deleted while that
Save Point exists.** `STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint
remains until explicitly deleted, and §28 says a future cleanup feature must
retain paths referenced by checkpoints. A cascade that removed Save Points
along with a branch would break both, and would break them silently: the
story the user asked to delete is the visible thing, and the named moments
would go without ever being named in the request. So the deletion is refused,
the Save Points are listed, and the user decides — delete the Save Point
first, then the branch. Deleting a Save Point still deletes no story (§25),
so the recovery costs nothing but a click.
The check covers the whole doomed subtree, not just this branch, because
deleting a branch takes everything forked from it.
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
cascade on `branches.parent_branch_id`, so the delete is a single statement
however deep the subtree is.
however deep the subtree is. `checkpoints.branch_id` also carries a cascade,
as referential integrity — a Save Point must never point at a branch that is
gone — but the guard above means it does not fire through this endpoint.
"""
branch = get_branch_or_404(adventure, branch_id, db)
if branch.parent_branch_id is None:
@@ -178,6 +208,11 @@ def delete_branch(
400, "You are reading this branch, or one forked from it. Switch to "
"another branch first.",
)
# Refused before the lock is taken: this is a decision about the request, not
# a race with a turn.
protecting = _save_points_protecting(db, adventure, branch)
if protecting:
raise HTTPException(409, _protected_message(protecting))
turns.acquire_turn_lock(adventure_id)
try:
# Collect the subtree before the delete, because afterwards there is no
@@ -199,6 +234,55 @@ def delete_branch(
finally:
turns._active_turns.discard(adventure_id)
# How many Save Point names to spell out before the message starts summarising.
# Enough to be actionable, few enough to stay a sentence.
NAMED_IN_REFUSAL = 3
def _save_points_protecting(
db: Session, adventure: models.Adventure, branch: models.Branch
) -> list[models.Checkpoint]:
"""Returns the Save Points that deleting `branch` would destroy.
The whole subtree, because deleting a branch takes everything forked from
it, and a check that looked only at this branch would let a Save Point on a
child be deleted without a word.
"""
doomed = _branch_subtree(db, adventure, branch)
return (
db.query(models.Checkpoint)
.filter(
models.Checkpoint.adventure_id == adventure.id,
models.Checkpoint.branch_id.in_(doomed),
)
.order_by(models.Checkpoint.created_at, models.Checkpoint.id)
.all()
)
def _protected_message(protecting: list[models.Checkpoint]) -> str:
"""Says which Save Points stand in the way, and what to do about it.
Named rather than counted, because "2 Save Points" leaves the user hunting
for which ones. A long list is truncated so the message stays readable; the
Save Points panel shows the rest.
"""
names = [f"“{c.name}”" for c in protecting[:NAMED_IN_REFUSAL]]
listed = ", ".join(names)
extra = len(protecting) - len(names)
if extra > 0:
listed += f" and {extra} more"
subject = "a Save Point" if len(protecting) == 1 else "Save Points"
return (
f"This branch, or a branch forked from it, is where {subject} "
f"{listed} {'is' if len(protecting) == 1 else 'are'} saved. Delete "
f"{'that Save Point' if len(protecting) == 1 else 'those Save Points'} "
f"first if you no longer need "
f"{'it' if len(protecting) == 1 else 'them'}, then delete the branch. "
f"Deleting a Save Point does not delete any story."
)
def _branch_subtree(
db: Session, adventure: models.Adventure, root: models.Branch
) -> set[int]:
+9 -2
View File
@@ -7,7 +7,7 @@ only check ownership and hand the work over.
from fastapi import Body, Depends, Request
from sqlalchemy.orm import Session
from ... import bundle, limits, models, schemas
from ... import bundle, head, limits, models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -62,7 +62,14 @@ def import_adventure(
db.commit()
db.refresh(adventure)
# A campaign exported while undone imports undone (M3), so the history
# controls have to be right on the response that opens it — otherwise the
# first thing the reader sees about a story with a retained future is a
# greyed-out Redo.
out = schemas.AdventureOut.model_validate(adventure)
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
# This is not a funnel step. A returning player imports a bundle, so it
# says nothing about how far a first-time visitor got. It is counted anyway,
# because it is the clearest evidence that anyone uses the export format.
return adventure
return out
@@ -0,0 +1,351 @@
"""M4: Save Points — create, list, rename, delete, and restore.
A Save Point is a durable named pointer to a story position and nothing else.
It stores a coordinate, never a copy of any story, and restoring one moves the
active head to that coordinate. That is the whole design, and it is what
`BUILD-MILESTONES.md`'s note on M4 and ADR 012 ask for: M3 made the head a
stored `(branch, depth)` and made arriving at one a row lookup plus a state
restore, so a Save Point needs no restore machinery of its own.
What is deliberately absent from this module, because a second copy of any of it
would be the failure M4 is warned about:
* no head fields are assigned here — `head.move_to_node` moves the head, and
`head.move_to` under it restores the state, exactly as Undo and Redo do;
* nothing reconstructs state, prunes a memory, copies a turn, or deletes one;
* nothing forks. Restore is not a decision to abandon anything, so it creates no
branch. The first write below the restored head forks, through the same
`fork_if_behind_head` every other write goes through, and the displaced future
stays retained (`STORY-BRANCH-SEMANTICS.md` §20).
The user-facing word is "Save Point" and the internal one is `checkpoint`
(`BROWSER-UX-SPEC.md` §23). Error strings here are read by a player, so they say
Save Point.
"""
from dataclasses import dataclass
from fastapi import Depends, HTTPException
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from ... import head, models, schemas
from ...context import lineage
from ...database import get_db
from . import turns
from .deps import current_adventure, router
from .paging import current_window
def _node_at(
db: Session, adventure: models.Adventure, branch_id: int, depth: int
) -> models.Action | None:
"""Returns the live turn a Save Point's coordinate names, or None.
The lookup is by coordinate and is not scoped to any path. That is the
point of it: a Save Point outlives the reader moving away, so the question
it has to answer is "is this position still in this campaign's retained
history", not "is it on the story being read now". Whether it is on the
current path is a separate question, and `head.move_to_node` is what acts on
the answer.
`live` is what makes the coordinate follow a retry. One coordinate can hold
several attempts at a turn, and a Save Point names the turn rather than the
attempt, so it lands on whichever take the story currently tells.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch_id,
models.Action.depth == depth,
models.Action.live.is_(True),
)
.order_by(models.Action.id)
.first()
)
@dataclass(frozen=True)
class _Coordinate:
"""The shape `lineage.Path` reads, without loading a story row.
`Path.contains` asks three things of a node: its branch, its depth, and
whether it is live. A coordinate already known to resolve has all three, so
the membership question can be put to the coordinate itself. That keeps the
single implementation of "is this on the path being read" in `lineage`,
where M3 put it, while costing no query and no prose.
"""
branch_id: int
depth: int
live: bool = True
def _live_coordinates(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> set[tuple[int, int]]:
"""Returns which of these Save Points' coordinates still name a live turn.
One query for the whole list, selecting two integer columns.
This replaces a resolution per Save Point (M4 review §R B-1), which cost one
query each and loaded whole `Action` entities — narration included — to
answer a question that is only ever "does a row exist here". `paging.py`
states the rule this now follows: a bulk read names the columns it needs, so
a new column costs nothing until someone adds it to the list.
The clause is an OR of exact `(branch, depth)` pairs rather than
`branch IN (…) AND depth IN (…)`, which would match the cross product and
report a Save Point as resolved because *some other* Save Point's depth
exists on *this* one's branch.
"""
coordinates = {(c.branch_id, c.depth) for c in checkpoints}
if not coordinates:
return set()
rows = (
db.query(models.Action.branch_id, models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.live.is_(True),
or_(*[
and_(models.Action.branch_id == branch, models.Action.depth == depth)
for branch, depth in coordinates
]),
)
.all()
)
return {(branch, depth) for branch, depth in rows}
def _render_all(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> list[schemas.CheckpointOut]:
"""Reads Save Points out with the three facts the panel needs about them.
Bounded work whatever the length of the list: one query for the coordinates
and one lineage for the campaign, both computed before the loop. Rendering
one Save Point and rendering fifty differ in Python, not in round trips.
"""
live = _live_coordinates(db, adventure, checkpoints)
# The path is a property of the campaign, not of any Save Point, so it is
# read once. Reading it per row was the other half of the N+1.
path = lineage.path_of(db, adventure).uncapped()
out = []
for checkpoint in checkpoints:
coordinate = (checkpoint.branch_id, checkpoint.depth)
resolved = coordinate in live
rendered = schemas.CheckpointOut.model_validate(checkpoint)
# The same `depth + 1` the branch list counts with, so a moment number
# means the same thing in both places.
rendered.turn = checkpoint.depth + 1
rendered.resolved = resolved
rendered.on_path = resolved and path.contains(
_Coordinate(checkpoint.branch_id, checkpoint.depth)
)
out.append(rendered)
return out
def _rendered(
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
) -> schemas.CheckpointOut:
"""Reads one Save Point out, through the same path the list uses."""
return _render_all(db, adventure, [checkpoint])[0]
def _get_or_404(
db: Session, adventure: models.Adventure, checkpoint_id: int
) -> models.Checkpoint:
"""Resolves a Save Point id, refusing one that belongs to another campaign.
The ownership check is the reason this is a function rather than a `db.get`
at each call site. A Save Point names a position in one campaign's history,
and a coordinate from another campaign would name a different story's turn —
or, worse, resolve against this one by arithmetic coincidence. So the id is
matched against this adventure, and a Save Point belonging to another is a
404 rather than a restore of the wrong story.
"""
checkpoint = db.get(models.Checkpoint, checkpoint_id)
if checkpoint is None or checkpoint.adventure_id != adventure.id:
raise HTTPException(404, "Save Point not found")
return checkpoint
def _clean_name(raw: str) -> str:
"""Returns the trimmed name, refusing one that is blank once trimmed."""
name = (raw or "").strip()
if not name:
raise HTTPException(400, "A Save Point needs a name.")
return name
@router.get("/{adventure_id}/checkpoints", response_model=list[schemas.CheckpointOut])
def list_checkpoints(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the campaign's Save Points, newest first.
Newest first rather than in story order, because story order is not
something this list can honestly claim. Depths are positions along a path,
and two Save Points on lines that parted company are not comparable by depth
at all — ordering by it would draw a sequence that no reading of the story
passes through. When they were made is a fact about all of them.
"""
rows = (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.created_at.desc(), models.Checkpoint.id.desc())
.all()
)
return _render_all(db, adventure, rows)
@router.post(
"/{adventure_id}/checkpoints",
response_model=schemas.CheckpointOut,
status_code=201,
)
def create_checkpoint(
adventure_id: int,
payload: schemas.CheckpointCreate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Names the position the story is currently being read at.
The active head, not the retained tip. Creating a Save Point after two Undos
saves the undone position, because that is where the reader is and the
position they are looking at is the one they mean. The distinction only
exists at all because M3 stopped Undo from deleting.
The node at the head is resolved before the row is written, and its own
branch is what gets stored — which is not always the branch being read. A
head resting in a shared prefix sits on an ancestor's node, and the
ancestor is the branch that still names that position after the reader has
forked away from it.
**Held under the campaign's turn lock** (M4 closeout, review §S C-5). "Save
where I am" has to name one committed position, and the head is exactly what
a turn in flight is about to move. Without the lock this endpoint could read
`head_depth` while a turn was mid-commit and store a coordinate for a
position the story had already left — a Save Point silently naming the wrong
moment, which no later operation could detect. It is the same lock Undo,
Redo and Restore take, for the same reason, and not a new mechanism.
Rename and Delete deliberately do **not** take it: neither reads nor moves a
story position, so there is nothing for a turn in flight to race them over.
"""
name = _clean_name(payload.name)
turns.acquire_turn_lock(adventure_id)
try:
node = head.node_at(db, adventure, adventure.head_depth)
if node is None:
raise HTTPException(400, "There is no turn here to save yet.")
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=name,
note=payload.note or "",
branch_id=node.branch_id,
depth=node.depth,
)
db.add(checkpoint)
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
finally:
turns._active_turns.discard(adventure_id)
@router.patch(
"/{adventure_id}/checkpoints/{checkpoint_id}",
response_model=schemas.CheckpointOut,
)
def rename_checkpoint(
checkpoint_id: int,
payload: schemas.CheckpointRename,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Changes a Save Point's label. Nothing else about it moves.
Not the coordinate, not the head, not a row of story. A Save Point that has
been renamed restores to exactly the position it did before, which is
`STORY-BRANCH-SEMANTICS.md` §23.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
if payload.name is not None:
checkpoint.name = _clean_name(payload.name)
if payload.note is not None:
checkpoint.note = payload.note
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
@router.delete("/{adventure_id}/checkpoints/{checkpoint_id}", status_code=204)
def delete_checkpoint(
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Removes the named pointer, and only the pointer.
The turn it named stays, its branch stays, the future past it stays, and the
head does not move. This endpoint deletes one row of the `checkpoints`
table. `STORY-BRANCH-SEMANTICS.md` §25.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
db.delete(checkpoint)
db.commit()
return None
@router.post(
"/{adventure_id}/checkpoints/{checkpoint_id}/restore",
response_model=schemas.ActionPage,
)
def restore_checkpoint(
adventure_id: int,
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the story to a Save Point, deleting nothing.
Four steps, and the last one is not this module's code: resolve the
coordinate, refuse it if it no longer names a live turn, hand it to
`head.move_to_node`, and answer with the window the head now caps. The
transcript, the world state, the assembled context and which memories can be
retrieved all move together, because all four already read through the one
path object the head caps — the same reason Undo needed no memory pruning.
The turns past the restored position are retained, exactly as they are after
an Undo, and ordinary Redo can still walk forward into them until the user
writes something different. Restore does not fork; the first write below the
head does.
A coordinate that no longer resolves is refused rather than approximated.
Moving the head to the nearest surviving turn would be the one outcome worse
than doing nothing: a Save Point that silently means somewhere else.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
turns.acquire_turn_lock(adventure_id)
try:
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
if node is None:
raise HTTPException(
409,
"That Save Point's position is no longer part of this story.",
)
head.move_to_node(db, adventure, node)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
# A window, not the whole story, for the reason Undo gives: the client
# replaces its transcript with this, and the transcript is a window.
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
+8 -1
View File
@@ -9,7 +9,9 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import set_committed_value
from ... import attempts, images, limits, memorybank, models, schemas, tree, worldstate
from ... import (
attempts, head, images, limits, memorybank, models, schemas, tree, worldstate,
)
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -239,6 +241,11 @@ def get_adventure(
set_committed_value(adventure, "actions", actions)
out = schemas.AdventureOut.model_validate(adventure)
out.action_count = total
# M3. Opening a story has to render its history controls correctly, and a
# campaign whose head sits behind the retained tip — undone and then closed —
# must come back with Redo available.
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
return out
+5 -1
View File
@@ -8,7 +8,7 @@ module in the package can import them.
from fastapi import HTTPException
from sqlalchemy.orm import Session, undefer
from ... import attempts, memorybank, models, tree
from ... import attempts, head, memorybank, models, tree
from ...context import cursors
from ...context import lineage
@@ -125,6 +125,10 @@ def stand_on(
newest is not None
and newest.branch_id == action.branch_id
and newest.depth == action.depth
# A turn the head rests on is still not a leaf while a retained future
# descends from it. `last_action` reads the capped path and cannot see
# that future, so switching in place here would strand it (M3).
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# The story at this coordinate is about to change, so withdraw whatever
+10 -1
View File
@@ -8,7 +8,7 @@ columns and apply the same numbering, so both live here.
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only
from ... import models, schemas
from ... import head, models, schemas
from ...context import lineage
@@ -28,6 +28,13 @@ ACTION_LIST_COLUMNS = (
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
# M5: `world_delta`'s counterpart, and listed for exactly the reason stated
# above it. `ActionOut.state_summary` reads it for every row on the page, so
# leaving it out of the bulk read cost one lazy load per action — 51 rows
# bought 53 queries (M5 review, Finding 2). It holds one turn's accepted
# events and its summary lines, the same order of size as `world_delta`, not
# the deferred snapshot.
models.Action.state_changes,
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
# would cost a lazy load, which is the cost `load_only` is here to prevent.
# `branch_id` is listed for the same reason. The pager reads it to tell a
@@ -165,4 +172,6 @@ def current_window(db: Session, adventure: models.Adventure) -> schemas.ActionPa
],
total=total,
has_more=has_more,
can_undo=head.can_undo(db, adventure),
can_redo=head.can_redo(db, adventure),
)
+173
View File
@@ -0,0 +1,173 @@
"""M5: reading the authoritative narrative state, and correcting it by hand.
Three endpoints, and the split between them is the point:
GET /state what the campaign currently believes
POST /state/corrections the user overruling it (C04)
GET /state/events how it came to believe that (§8's audit)
The browser reads the first and writes the second. It never writes state
directly — `BUILD-MILESTONES.md` M5 is explicit that the browser is a
presentation layer and must not become the owner of state — so a correction goes
through the same validator, the same applier and the same event log as a
narration does. The only difference is the `source` recorded on it, and that
difference is the whole of C04's audit requirement.
The state returned here is always the state at the **active head**, because that
is what `adventure.narrative_state` holds: head movement restores it from the
destination node's snapshot, so an undone story is described by what was true
then rather than by what the campaign later became.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import head, models, narrative, schemas
from ...database import get_db
from . import turns
from .deps import current_adventure, router
@router.get("/{adventure_id}/state", response_model=schemas.NarrativeStateOut)
def read_state(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The authoritative state at the position the story is being read at.
Grouped for display, with only the categories that actually hold something —
a heading with no rows under it tells a reader nothing, and the panel should
not have to decide what to hide.
"""
state = narrative.store.current(adventure)
view = narrative.render.for_inspector(state)
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
# The raw document, for the correction form to name a key with and for a
# test to assert on without parsing prose.
document=state,
)
@router.post(
"/{adventure_id}/state/corrections",
response_model=schemas.NarrativeStateOut,
status_code=201,
)
def correct_state(
adventure_id: int,
payload: schemas.StateCorrection,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Applies the user's own state events, as an explicit correction.
C04. The user says "Mara never learned where the silver key was found", and
that becomes authoritative for everything that follows — while the transcript
stays exactly as it was written. Correcting the world is not editing the
story, and conflating them would rewrite prose the user did not ask to
change.
The events go through the **same validator** as a narration's. A user is
trusted more than a model, but not with references that do not resolve or
with an event type the application does not implement: a typo should be a
clear refusal, not a corrupt document. What being trusted buys is authority —
the resulting facts carry `manual_correction`, which outranks
`accepted_story` when the two disagree, and which the prompt renders so the
model is told the reader overruled it.
Held under the turn lock, for the reason creating a Save Point is: this reads
the head and writes a snapshot onto the node the head rests on, and a turn in
flight is about to move both.
"""
if not payload.events:
raise HTTPException(400, "A correction needs at least one change.")
turns.acquire_turn_lock(adventure_id)
try:
state = narrative.store.current(adventure)
review = narrative.validate.review(
{"events": [event.model_dump(exclude_none=True) for event in payload.events]},
state,
narrative.store.canon_of(adventure),
)
if not review.accepted:
raise HTTPException(400, _refusal_message(review))
node = head.node_at(db, adventure, adventure.head_depth)
new_state, _proposal = narrative.store.record(
db, adventure,
review=review,
raw_block=payload.note or "",
parsed={"events": [e.model_dump(exclude_none=True) for e in payload.events]},
action=node,
branch_id=node.branch_id if node is not None else adventure.head_branch_id,
depth=node.depth if node is not None else adventure.head_depth,
source="manual_correction",
)
narrative.store.set_current(adventure, new_state)
# The correction belongs to the position it was made at, so a later Undo
# past it drops it and a Redo back brings it again — the same rule every
# other state change follows. Without re-snapshotting the node, the
# correction would survive a head movement that stepped over it.
if node is not None:
node.narrative_state_after = new_state
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
finally:
turns._active_turns.discard(adventure_id)
view = narrative.render.for_inspector(narrative.store.current(adventure))
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
document=narrative.store.current(adventure),
)
def _refusal_message(review) -> str:
"""Why a correction was refused, in the words the user needs.
The first rejection's detail, because a correction is usually one or two
events and a wall of them helps nobody.
"""
if review.rejected:
first = review.rejected[0]
return f"That correction can't be applied — {first.detail or first.reason}."
return "That correction can't be applied."
@router.get("/{adventure_id}/state/events", response_model=list[schemas.StateEventOut])
def read_state_events(
limit: int = 100,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The accepted state changes, newest first: §8's audit trail.
What changed, which turn caused it, whether the model or the user asserted
it, and what the value was before. Bounded by default — this is an audit
view, and an unbounded read of a long campaign's every event is the query
shape this project keeps a regression test about.
"""
limit = max(1, min(limit, 500))
rows = narrative.store.history(db, adventure, limit=limit)
return [
schemas.StateEventOut(
id=row.id,
action_id=row.action_id,
branch_id=row.branch_id,
depth=row.depth,
turn=(row.depth + 1) if row.depth is not None else None,
sequence=row.sequence,
event_type=row.event_type,
payload=row.payload or {},
before=row.before,
source=row.source,
created_at=row.created_at,
)
for row in rows
]
+114 -65
View File
@@ -11,7 +11,7 @@ from fastapi import Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import attempts, limits, memorybank, models, schemas, tree
from ... import attempts, head, limits, memorybank, models, schemas, tree
from ...context import cursors
from ...context import lineage
from ...database import get_db
@@ -19,8 +19,8 @@ from ...sse import SSE_HEADERS
from . import turns
from .deps import CurrentUser, current_adventure, router
from .nodes import delete_turn, last_action, stand_on
from .paging import action_window, annotate_takes, current_window
from .nodes import last_action, stand_on
from .paging import current_window
@router.post("/{adventure_id}/retry")
@@ -33,24 +33,44 @@ def retry_action(
):
"""Regenerates the last AI action and keeps the discarded attempt.
The attempt on screen stays as it was written. The shared script state and
world state roll back to what the node before it left behind, and the new
attempt is stored as a sibling at the same coordinate. No text the AI wrote
is rewritten or deleted.
The attempt on screen stays as it was written. The world state rolls back to
what the node before it left behind, and the new attempt is stored as a
sibling at the same coordinate. No text the AI wrote is rewritten or deleted.
M3 added the one case that cannot be a sibling. Retrying the turn the head
rests on while a retained future still descends from it would leave that
future hanging off a take that is no longer live — the story after it was
written to continue the old text. So a retry from behind the tip takes a
branch instead, exactly as `add_take` does for a turn the story has moved
past. It is the same operation reached from a different button.
"""
turns.acquire_turn_lock(adventure_id)
last_ai = None
try:
newest = last_action(adventure, db)
if newest is not None and newest.type == "ai":
last_ai = newest
# Read this before anything moves, and note it is *not*
# `fork_if_behind_head`: this fork leaves the path just in front of
# the turn being retried rather than at the head, so the new take
# lands at the same depth under the same parent.
diverging = head.behind_tip(db, adventure)
if diverging:
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, (newest.depth or 0) - 1)
if departed is not None:
head.mark_superseded(departed, (newest.depth or 0) - 1)
else:
# Only a sibling attempt names the node it replaces. A branched
# take is a fresh node at the same coordinate, so `generate_turn`
# places it through the tree rather than through `add_attempt`.
last_ai = newest
# Roll the state back to before this AI turn's hooks ran, so that
# regenerating starts from a clean state rather than applying output
# mutations on top of the attempt being replaced. If the preceding
# node has no snapshot, which happens for a pre-SP4 row that the
# migration could not derive one for, this call does nothing and
# leaves the state as it is.
attempts.roll_back_before(db, adventure, last_ai)
attempts.roll_back_before(db, adventure, newest)
db.commit()
db.refresh(adventure)
except BaseException:
@@ -137,6 +157,17 @@ def select_variant(
"Only the latest message can be switched — the story has already "
"continued from this one.",
)
if head.behind_tip(db, adventure):
# The head is behind the retained tip, so this turn reads as the newest
# one but still has an accepted future descending from it. Switching the
# live take in place would leave that future continuing text the story
# no longer tells. Forking is the operation that does this safely, and
# `/fork` is where it lives.
raise HTTPException(
400,
"This turn has a later story that was undone but kept. Redo first, "
"or use another take to start a new line from here.",
)
turns.acquire_turn_lock(adventure_id)
try:
chosen = rows[payload.index]
@@ -263,7 +294,15 @@ def add_take(
retry_of = None
try:
newest = last_action(adventure, db)
at_the_tip = newest is not None and newest.id == action.id
# `last_action` reads the capped path, so under a moved-back head it
# reports the node at the head as the newest one. A turn with a retained
# future is not a leaf, whatever the capped read says, so ask the head
# module rather than trusting the depth comparison alone (M3).
at_the_tip = (
newest is not None
and newest.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip and action.type == "ai":
# Nothing was played after it, so its attempts are still leaves and
# a branch would serve no purpose. This is the `retry` path.
@@ -274,7 +313,10 @@ def add_take(
# text that is there now. The new attempt leaves the path just
# before the turn, so that story keeps the attempt it was written
# for.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
attempts.roll_back_before(db, adventure, action)
adventure.updated_at = models.utcnow()
db.commit()
@@ -309,71 +351,78 @@ def undo_turn(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Deletes the last turn: the trailing AI action and its player action, if any.
"""Moves the story back one turn. Deletes nothing (M3).
The endpoint also rolls the shared `script_state` back to before that turn
ran, and it prunes any memory that summarized the removed actions. The turn
lock prevents an undo while a turn is still generating.
This endpoint used to remove the trailing AI action and the player action in
front of it, prune the memories that covered them, and let the tip fall back
to whatever survived. Undoing was therefore the one operation in the
application that destroyed accepted story, and it was why there was no Redo:
the turns to move forward into no longer existed.
Now it moves `adventure.head_depth`. The rows stay exactly where they are,
still live, still on their branch, and `lineage.Path` stops every read at the
head instead. The transcript, the assembled context, `attempts.preceding` and
memory retrieval all narrow together, because all four already funnelled
through the same path object.
The memory bank needs no pruning for the same reason. A memory carries the
coordinate of the node its block ends on, so a memory derived from a turn
that is now past the head falls outside the capped clause and stops being
retrievable — and becomes eligible again on Redo, without having been deleted
and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33 for free.
The state comes back from the node the story now ends on, which recorded what
it left behind when it played. See `head.move_to`.
"""
turns.acquire_turn_lock(adventure_id)
try:
# Only the last turn is removed, so fetch the two actions it can
# consist of rather than the whole story.
newest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(2)
.all()
)
if not newest or newest[0].type == "start":
target = head.undo_target(db, adventure)
if target is None:
raise HTTPException(400, "Nothing to undo")
last = newest[0]
before_that = newest[1] if len(newest) > 1 else None
# Undo only what this branch owns. Everything before the fork is
# borrowed from an ancestor and is part of that ancestor's story too, so
# an undo here must never delete a turn out of another branch. The test
# reads the row's own branch rather than the fork depth, because the
# branch is what decides the case.
if last.branch_id != adventure.head_branch_id:
raise HTTPException(
400, "Nothing to undo on this branch — the turns before it "
"belong to the branch it was forked from.",
)
first_removed = last
if (last.type == "ai" and before_that is not None
and before_that.type in ("do", "say", "story")
and before_that.branch_id == adventure.head_branch_id):
first_removed = before_that
# The state the story returns to once the turn is gone, which is what
# the node before the earliest removed one left behind. Read it before
# the deletes, while those rows are still in the story.
restore_to = attempts.preceding(db, adventure, first_removed)
delete_turn(db, adventure, last)
if first_removed is not last:
delete_turn(db, adventure, first_removed)
attempts.restore_state(adventure, restore_to)
db.flush() # Apply the deletes before anything reads the story back.
db.expire(adventure, ["actions"])
# The tip moves back with the deleted rows.
tree.refresh_head(db, adventure)
depth, _first_stepped = target
head.move_to(db, adventure, depth)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
# Return the newest window rather than the whole story. The client
# replaces its transcript with this response, and the transcript is a
# window. Returning everything would defeat the paging on the action a
# player is most likely to repeat several times in a row.
actions, total, has_more = action_window(db, adventure)
return schemas.ActionPage(
actions=[
schemas.ActionOut.model_validate(a)
for a in annotate_takes(db, adventure.id, actions)
],
total=total,
has_more=has_more,
)
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
@router.post("/{adventure_id}/redo", response_model=schemas.ActionPage)
def redo_turn(
adventure_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Moves the story forward again into the continuation Undo stepped out of.
Redo exists because Undo stopped deleting. It walks the head forward over one
whole turn along the retained lineage, and restores the state that turn left
behind.
It follows the lineage rather than choosing among branches, which is what
makes it invalidate itself correctly. Writing below a moved-back head forks,
and from the new branch the displaced future is no longer on the lineage at
all — so there is nothing ahead to walk into and this returns 400 without any
flag having to be set or cleared. `STORY-BRANCH-SEMANTICS.md` §8.
400 is also what a head already at the tip gets, which is the ordinary case
for a story that has never been undone.
"""
turns.acquire_turn_lock(adventure_id)
try:
depth = head.redo_target(db, adventure)
if depth is None:
raise HTTPException(400, "Nothing to redo")
head.move_to(db, adventure, depth)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
+75 -19
View File
@@ -13,7 +13,8 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import (
attempts, limits, memorybank, models, schemas, tree, worldstate,
attempts, head, limits, memorybank, models, narrative, schemas, tree,
worldstate,
)
from ...context import build_context, cursors
from ...database import get_db
@@ -207,25 +208,46 @@ async def _generate_turn(
yield turn_error(detail)
return
# RPG world state (Phase 12): read the AI's state delta out of the reply,
# apply it through the engine, and strip the block from the displayed text.
# M5: read the typed state proposal out of the reply, validate it, apply
# what survives, and strip the block from the displayed text.
#
# A retry re-runs the same turn, so it is played at that turn's depth. The
# cooldown rules run on a position in the story, and a second attempt at turn
# 12 is still turn 12. This was `retry_of.index`, which held the same number
# until SP4. Depth stays correct once a branch has its own numbering.
# This replaced the Phase 12 relative-delta pipeline. The shape of the turn
# is unchanged — extract, referee, snapshot — because ADR 010 changed the
# protocol, not the lifecycle. What changed is that the referee now works on
# explicit typed events with absolute values, so an accepted proposal cannot
# mean something other than it says.
#
# A retry re-runs the same turn, so it is played at that turn's depth. This
# was `retry_of.index`, which held the same number until SP4. Depth stays
# correct once a branch has its own numbering.
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_depth
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
text, parsed, raw_block = narrative.extract.split(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
narrative.store.current(adventure),
narrative.store.canon_of(adventure),
)
# Held until the action exists, because a proposal record names the node
# whose narration produced it and the node has no id yet. Everything lands
# in the single commit below (L01).
# The coordinate is read off the node after it is placed, not guessed here:
# `tree.place_action` assigns the branch, and a retry inherits the branch of
# the attempt it replaces.
pending_state = {
"review": review,
"parsed": parsed,
"raw_block": raw_block,
"unparseable": parsed is None and bool(raw_block),
}
snapshot["narrative_state"] = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"status": review.status,
}
snapshot["raw_output"] = raw_output
# The cost the endpoint reports for the call, including how much of the
@@ -243,7 +265,6 @@ async def _generate_turn(
context_snapshot=snapshot,
world_delta=world_delta_of(snapshot),
)
attempts.snapshot_outcome(adventure, ai_action)
if retry_of is not None:
attempts.add_attempt(db, adventure, retry_of, ai_action)
db.add(ai_action)
@@ -264,6 +285,33 @@ async def _generate_turn(
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
db.flush()
# The state lands after the node exists and before the one commit, so the
# narration, the head, the accepted events, the provenance and the snapshot
# are one transaction. L01 forbids any window in which a turn looks accepted
# while its state is half-written, and the cheapest guarantee is to have a
# single commit rather than two that could get out of step.
new_state, _proposal = narrative.store.record(
db, adventure,
review=pending_state["review"],
raw_block=pending_state["raw_block"],
parsed=pending_state["parsed"],
action=ai_action,
branch_id=ai_action.branch_id,
depth=ai_action.depth,
model_name=settings.model or "",
source="accepted_story",
)
if pending_state["unparseable"]:
_proposal.status = "unparseable"
before_state = narrative.store.current(adventure)
narrative.store.set_current(adventure, new_state)
ai_action.state_changes = {
"accepted": pending_state["review"].accepted,
"rejected": [r.as_dict() for r in pending_state["review"].rejected],
"summary": narrative.apply.diff(before_state, new_state),
}
attempts.snapshot_outcome(adventure, ai_action)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(ai_action)
@@ -333,6 +381,14 @@ def create_action(
acquire_turn_lock(adventure_id)
try:
_move_to_after(db, adventure, payload.after_id)
# The first write below a moved-back head is where a divergence happens
# (M3). Undo alone does not fork — the user may be reading, or about to
# Redo — so this is the moment the story states which continuation it
# means. The displaced future keeps its rows on the branch being left.
# A head already at the tip, which is every ordinary turn, forks nothing.
if head.fork_if_behind_head(db, adventure):
db.commit()
db.refresh(adventure)
except BaseException:
_active_turns.discard(adventure_id)
raise
+158 -1
View File
@@ -25,6 +25,10 @@ ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
# M4: what a player called a Save Point. VARCHAR(120). Wider than a branch name
# because these are sentences rather than labels — "Before entering the abbey"
# is the example the specification uses throughout.
CHECKPOINT_NAME_MAX = 120
Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
@@ -35,6 +39,7 @@ Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
CheckpointName = Annotated[str, Field(max_length=CHECKPOINT_NAME_MAX)]
class ORMModel(BaseModel):
@@ -199,8 +204,13 @@ class ActionOut(ORMModel):
text: str
reasoning: str | None = None
# Phase 12: the compact RPG state changes for this turn, read from the
# model property.
# model property. Legacy as of M5 and empty on new turns; kept so a pre-M5
# campaign's chips still render.
world_changes: list[dict] = []
# M5: what this turn changed, as short lines for the chip under an AI
# message. Read from `Action.state_summary`, which reads the small
# bulk-loaded column rather than the deferred snapshot.
state_summary: list[str] = []
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
# and which one is on screen. It is keyed on the parent, so it counts the
# attempts of this turn rather than every node that shares a depth, and it
@@ -254,6 +264,10 @@ class BranchOut(ORMModel):
fork_depth: int | None = None
depth: int
own_actions: int = 0
# M4: how many Save Points name a position on this line. Deleting the branch
# deletes them with its story, so the panel warns with a number rather than
# a vague caution. Zero for a line nobody has bookmarked, which is most.
save_points: int = 0
is_head: bool = False
# NULL for a branch nobody has named. The client labels those from the fork
# depth rather than the server inventing a name. See the column comment.
@@ -267,6 +281,139 @@ class BranchRename(BaseModel):
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
# ---------- Narrative state (M5) ----------
class StateGroup(BaseModel):
"""One labelled section of the state inspector.
Rows carry the key as well as the label, because a manual correction has to
name an entity and the user should not have to guess the identifier.
"""
title: str
rows: list[dict] = []
class NarrativeStateOut(BaseModel):
"""The authoritative state at the active head.
`groups` is the display form and `document` is the state itself. Both are
returned because they answer different questions: the panel renders the
first, and a correction form — or a test — needs the second to name a key.
"""
groups: list[StateGroup] = []
empty: bool = True
document: dict = {}
class StateEventIn(BaseModel):
"""One typed event, as a client proposes it.
Deliberately loose about which fields are present: the event vocabulary is
defined in `narrative/events.py` and enforced by `narrative/validate.py`,
and duplicating those rules here would create a second, drifting copy of the
allowlist. What this model does is bound the shapes — a type that is a
string, values that are scalars, labels that are short strings — so a
payload cannot smuggle a structure past Pydantic and reach the validator as
something other than an event.
"""
model_config = ConfigDict(extra="allow")
type: Annotated[str, Field(max_length=60)]
class StateCorrection(BaseModel):
"""A manual correction: the user overruling what the story established.
`note` records why, in the user's words, and is kept on the proposal record
so the audit says more than "the user changed this".
"""
events: Annotated[list[StateEventIn], Field(min_length=1, max_length=20)]
note: Prose = ""
class StateEventOut(ORMModel):
"""One accepted change, for the audit view."""
id: int
action_id: int | None = None
branch_id: int | None = None
depth: int | None = None
# The reader-facing position, matching the Save Point panel's vocabulary.
turn: int | None = None
sequence: int = 0
event_type: str
payload: dict = {}
before: dict | None = None
source: str = "accepted_story"
created_at: datetime
# ---------- Save Points (M4) ----------
#
# "Save Point" is the user-facing term and `checkpoint` is the internal one
# (`BROWSER-UX-SPEC.md` §23). The wire format uses the internal name, as the
# rest of this module does.
class CheckpointOut(ORMModel):
"""One Save Point: a name and the position it names.
The position is reported three ways because the panel needs three different
things from it. `turn` is what a reader counts — the same `depth + 1` the
branch list shows. `depth` and `branch_id` are the coordinate itself.
`on_path` says whether the position lies on the story being read, which is
how the panel can tell a Save Point on this line from one naming a line the
story has left; restoring either works, but they are not the same offer.
`resolved` is false when the coordinate no longer names a live turn, which
an action deleted out of the middle of a story can do. Restore refuses such
a Save Point rather than moving the head somewhere approximate, so the list
says so before the button is pressed.
"""
id: int
adventure_id: int
name: str
note: str = ""
branch_id: int
depth: int
turn: int = 0
on_path: bool = True
resolved: bool = True
created_at: datetime
updated_at: datetime
class CheckpointCreate(BaseModel):
"""A Save Point at wherever the story is being read.
The position is not a field. A Save Point is made at the campaign's active
head, which the server already knows, and accepting a coordinate from the
client would be the second way to name a position — the thing this milestone
exists not to build.
"""
name: CheckpointName
note: Prose = ""
class CheckpointRename(BaseModel):
"""A new label, and nothing else.
There is deliberately no coordinate here. `STORY-BRANCH-SEMANTICS.md` §24
keeps a Save Point's meaning auditable by refusing to move one: rename it,
or delete it and make another where you are.
"""
name: CheckpointName | None = None
note: Prose | None = None
class ActionUpdate(BaseModel):
text: ActionText
@@ -317,6 +464,12 @@ class AdventureOut(ORMModel):
# story's length, which is how the client knows more actions exist above.
actions: list[ActionOut] = []
action_count: int = 0
# M3. Whether the history controls have anywhere to go from where the story
# is. The client cannot work either out for itself: `can_undo` needs the
# campaign opening, which may be off the top of the loaded window, and
# `can_redo` needs the retained future, which the client is never sent.
can_undo: bool = False
can_redo: bool = False
class ActionPage(BaseModel):
@@ -327,6 +480,10 @@ class ActionPage(BaseModel):
# Whether anything older than this slice exists. The server computes it, so
# the client never has to do arithmetic on positions to find the end.
has_more: bool = False
# The same two flags `AdventureOut` carries, so that the response to Undo,
# Redo or a turn updates the controls without a second request.
can_undo: bool = False
can_redo: bool = False
# ---------- Memory bank (Phase 6) ----------
+17
View File
@@ -348,6 +348,23 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
if action.world_state_after is None:
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
action.world_state_after = copy.deepcopy(world)
if action.narrative_state_after is None:
# M5, and the same rule: a node with no narrative snapshot is a position
# the head cannot be restored to, and the failure is silent — the state
# simply stays where it was. A campaign's opening node is written by the
# fixture that creates the adventure rather than by the turn engine, so
# without this it would be the one position Undo could not return to.
#
# An empty document rather than NULL, because this node is being written
# *now*, by a writer that knows the campaign has no state yet. That is
# different from a pre-M5 row, whose NULL means "there was no such thing
# as narrative state when this played" and must leave the live state
# alone.
from .narrative import model as narrative_model
narrative = adventure.narrative_state
action.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def place_new_nodes(session: Session) -> None:
+67
View File
@@ -0,0 +1,67 @@
"""The storyteller, run as a real OS process for `test_process_restart.py`.
Not a test module, and named so pytest does not collect it: it is the program
the test starts, twice, against one database file.
The model is replaced with a deterministic fake before the app is imported, so
the process needs no Ollama, no network and no configuration. Everything else —
the engine, the migrations, the routers, the session lifecycle — is the real
application, which is the whole point of spawning a process at all.
python _restart_server.py <db_path> <port>
"""
import itertools
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # backend/, so `app` imports
sys.path.insert(0, str(HERE)) # tests/, so `fakes` imports
db_path, port = sys.argv[1], int(sys.argv[2])
os.environ["AIDND_DB_PATH"] = db_path
# A developer's shell may point these at Postgres, and `app.database` prefers
# either over the SQLite path. The suite's conftest clears them for the same
# reason; a spawned process does not inherit that, so clear them here too.
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fakes import TALLY_PER_TURN, tally_reply # noqa: E402
_turn = itertools.count(1)
class DeterministicProvider:
"""Records a running tally per reply, numbered so the text is checkable.
The same instrumentation `test_head_cursor.py` and `test_save_points.py`
use, for the same reason: it makes "the state at this position" a number the
test can assert rather than a paragraph it has to interpret. M5 replaces the
machinery underneath; what this measures is where the story is being read.
"""
last_usage = None
def __init__(self, *a, **k):
pass
async def generate(self, parts, *, temperature, max_tokens):
n = next(_turn)
# An absolute running total (M5, ADR 010): turn n states n * 10, so the
# value a position holds is a fact about that position rather than about
# how many times something was added.
yield ("text", tally_reply(f"Beat {n}.", n * TALLY_PER_TURN))
from app.routers.adventures import turns # noqa: E402
turns.OpenAICompatibleProvider = DeterministicProvider
from app.main import app # noqa: E402
if __name__ == "__main__":
import uvicorn
# Loopback only, as every supported start path does.
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
+101 -25
View File
@@ -5,6 +5,7 @@ their own `ScriptedProvider`, and the copies had drifted into four different
feature sets, so a test that needed to raise a provider error had to be written
in one of the files whose copy supported it.
"""
import json
class ScriptedProvider:
@@ -48,35 +49,110 @@ class ScriptedProvider:
# that a rollback failure is arithmetic rather than a judgement call: if a take
# stacks instead of replacing, the total is off by exactly one turn's worth.
#
# That instrumentation used to be a JavaScript `output` hook doing
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
# the tests below are not about scripting — they are about the state snapshot,
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
# which is unchanged.
# The instrument has moved twice, and both moves were the same move: it follows
# whatever the production state path is, so the tests exercise real code rather
# than a test hook. It began as a QuickJS `state.gold += 10` (removed with
# scripting in M2), became an RPG world-state delta block (M3/M4), and is now a
# typed narrative-state event (M5).
#
# The counter therefore moved to the world-state engine, which is a real
# remaining product path: the model emits a ```state delta block, the referee
# applies it, and the result lands in `adventure.world_state`. The fake
# provider decides what the model "emits", so it is exactly as deterministic as
# the script was, and it exercises production code rather than a test hook.
# What the tests using it measure is unchanged, and worth restating because it
# is why they were re-instrumented rather than deleted: the state at a story
# position, rollback, Redo restoration, retry, alternate takes, divergence,
# abandoned-future isolation, and Save Point restore. None of that was ever
# about gold, or about RPG stats.
#
# The M5 instrument is deliberately genre-neutral: a `chronicle` entity — a
# concept, not a character, not an item — carrying one named attribute. Every
# reply sets it to an ABSOLUTE total, which is ADR 010's whole point. A delta
# protocol could not tell "+10" from "= 10"; here the event type says which, so
# `TALLY_PER_TURN * n` after n turns is arithmetic rather than an assumption.
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
#: `cooldown`, so every +10 is applied in full, every turn.
GOLD_SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
#: The instrument is a fact, not an entity attribute, and deliberately so.
#: `set_entity_attribute` names an entity that must already exist, which is the
#: right rule for the product and the wrong one for an instrument that tests
#: script in isolation — a one-off reply in the middle of a test would be
#: refused for a reference the test never meant to be about. `add_fact` needs no
#: subject, so any reply can state the tally on its own. Entity creation,
#: possession and the referential rule get their own tests in
#: `test_narrative_state.py`, where they are the subject rather than scaffolding.
TALLY_PREDICATE = "tally"
TALLY_PER_TURN = 10
GOLD_PER_TURN = 10
# Kept as an alias so the many tests that speak in these terms keep reading
# naturally. The number is the same; only the protocol underneath changed.
GOLD_PER_TURN = TALLY_PER_TURN
#: A scenario schema is no longer needed for state to work — narrative state is
#: not an opt-in RPG layer. The name survives for fixtures that still pass
#: something, and empty is the honest value: this campaign has no RPG layer, and
#: under M5 it does not need one to have state.
GOLD_SCHEMA: dict = {}
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
"""A model reply that narrates `text` and banks `amount` gold."""
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
def state_block(events: list) -> str:
"""The fenced block the model is asked to emit, around `events`."""
return "```state\n" + json.dumps({"events": events}, ensure_ascii=False) + "\n```"
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
"""`count` numbered replies, each banking one turn's worth of gold."""
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
def tally_reply(text: str, total: int) -> str:
"""A reply that narrates `text` and records the tally as `total`.
Absolute, always — which is the whole of ADR 010. A delta protocol could not
tell "+10" from "= 10"; here the event says which, so `TALLY_PER_TURN * n`
after n turns is arithmetic rather than an assumption, and a replayed or
duplicated reply cannot silently double it.
Each reply supersedes the last, so the newest active tally fact is the
current one and the document does not grow without bound.
"""
return f"{text}\n" + state_block([{
"type": "add_fact",
"predicate": TALLY_PREDICATE,
"value": total,
"fact_id": f"tally-{total}",
}])
def gold_reply(text: str, amount: int = TALLY_PER_TURN) -> str:
"""One reply banking `amount`, for tests that build a single reply.
The value is absolute underneath, so a caller asking for the default gets
the first turn's total, which is what those call sites mean.
"""
return tally_reply(text, amount)
def tally_replies(prefix: str = "Take", count: int = 40) -> list:
"""`count` numbered replies whose tally runs 10, 20, 30 …"""
return [
tally_reply(f"{prefix} {n}.", n * TALLY_PER_TURN)
for n in range(1, count + 1)
]
#: The historical name, unchanged in meaning for every caller.
gold_replies = tally_replies
def tally_of(state) -> int:
"""Reads the instrument back out of a narrative state document.
The newest active tally fact wins, which is what "absolute assignment"
means when the assignments are appended. Returns 0 when the campaign has
recorded none — what "no turns have been played" means, and what a restore
to before the first turn should produce.
"""
if not isinstance(state, dict):
return 0
facts = state.get("facts")
if not isinstance(facts, list):
return 0
for fact in reversed(facts):
if (
isinstance(fact, dict)
and fact.get("predicate") == TALLY_PREDICATE
and fact.get("status", "active") == "active"
and isinstance(fact.get("value"), (int, float))
):
return fact["value"]
return 0
+26 -4
View File
@@ -200,18 +200,40 @@ def test_the_assembled_prompt_is_stored_once_per_turn(client):
assert len(moved) == 1 and moved != live_holder, "the prompt follows the story"
# ------------------------------------------------------- removing the turn
# ------------------------------------------------- stepping behind the turn
def test_undo_takes_every_attempt_with_it(client):
def test_undo_hides_every_attempt_and_keeps_them_all(client):
"""M3 rewrote this test. Undo used to delete the turn, and the assertion was
that it took the whole sibling group with it rather than leaving orphaned
attempts at a coordinate the story no longer reached.
The group still moves as one, but it moves out of the story rather than out
of the database: one Undo steps behind the turn, so none of its three
attempts is in what the story tells, and all three are still on disk for the
Redo that walks back into them. The old assertion is kept as the second half
— what the story reads — and the row count is the new first half.
"""
ScriptedProvider.replies = ["One.", "Two.", "Three."]
_play(client)
_retry(client)
_retry(client)
assert len([a for a in _rows(client.adv_id) if a.type == "ai"]) == 3
before = _rows(client.adv_id)
assert len([a for a in before if a.type == "ai"]) == 3
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert [a.type for a in _rows(client.adv_id)] == ["start"]
# What the story tells: the opening, and none of the turn's attempts.
assert [a["type"] for a in r.json()["actions"]] == ["start"]
# What it holds: every row that was there before, attempts included.
after = _rows(client.adv_id)
assert len(after) == len(before)
assert {a.id for a in after} == {a.id for a in before}
assert len([a for a in after if a.type == "ai"]) == 3
# And the group is reachable again, whole, with the same take live.
live_before = [a.id for a in before if a.type == "ai" and a.live]
r = client.post(f"/api/adventures/{client.adv_id}/redo")
assert r.status_code == 200, r.text
assert [a.id for a in _rows(client.adv_id) if a.type == "ai" and a.live] == live_before
def test_deleting_a_retried_turn_deletes_its_attempts(client):
+75 -48
View File
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
# advance shows up as a change the referee should have rejected.
@@ -109,10 +109,17 @@ def _fork(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -314,71 +321,76 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
# -------------------------------------------------------------- the state
def test_switching_restores_the_state_a_branch_left_behind(client):
# Two stats move: hp differs per attempt, and gold counts turns. Between
# them, a switch that restored the wrong snapshot is visible either way.
"""Each attempt records its own total, so a switch that restored the wrong
snapshot shows a number no position on that line ever held."""
ScriptedProvider.replies = [
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
gold_reply("Onward."),
tally_reply("A scratch.", 10),
tally_reply("A beating.", 40),
tally_reply("Onward.", 70),
]
_play(client)
_retry(client)
_play(client, "go deeper")
parent = _branches(client)[0]["id"]
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 70
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
player, world_state = _state(client.adv_id)
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
assert player["gold"] == 10, "one turn of gold, not three"
player, _document = _state(client.adv_id)
assert player["gold"] == 10, "the attempt this branch tells, not the line it left"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
def test_the_cooldown_clock_travels_with_the_branch(client):
"""The world-state clock is a depth, and depths repeat across branches,
so it can only be correct if each branch carries its own. It does,
without extra work: the clock lives inside `_meta.last_changed`, which
is part of the world state a switch restores."""
def test_state_travels_with_the_branch(client):
"""Each line carries its own state, and a switch restores that line's.
This was written about the RPG cooldown clock, which was a depth stored
inside the world state — and depths repeat across branches, so the clock
could only be right if each branch carried its own. M5 removed that
machinery; the property it demonstrated is general and still holds, because
a branch's state is whatever its own tip recorded.
"""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Untouched.",
"Onward.",
tally_reply("Drained.", 10),
tally_reply("Untouched.", 20),
tally_reply("Onward.", 30),
]
_play(client)
_retry(client)
_play(client, "go deeper")
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
on_parent = _state(client.adv_id)[1]
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 30
_fork(client, discarded)
forked = _state(client.adv_id)[1]
assert forked["player"]["mana"] == 40
assert forked["_meta"]["last_changed"]["player.mana"] == 2
assert _state(client.adv_id)[0]["gold"] == 10, "the forked line's own state"
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id)[1] == on_parent
assert _state(client.adv_id) == on_parent
def test_a_retry_does_not_advance_the_cooldown_clock(client):
"""SP5's one carried-over open item. A retry re-runs the same turn, so
the clock the cooldown rules read must not move. The reused `index`
used to guarantee this; the reused depth guarantees it now."""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
]
def test_a_retry_reuses_the_turns_coordinate_and_does_not_stack(client):
"""SP5's carried-over item, restated for M5.
A retry re-runs the same turn, so it lands at that turn's coordinate and its
state replaces rather than accumulates. The original form of this test
measured it through the cooldown clock, which read a depth; the depth is
still what makes it true, and the state document is now where it shows.
"""
ScriptedProvider.replies = [tally_reply("Drained.", 10)]
_play(client)
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
first = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
ScriptedProvider.replies = [tally_reply("Drained again.", 10)]
_retry(client)
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
# The second attempt's drain must land, instead of being rejected for a
# cooldown it was never actually subject to.
assert _state(client.adv_id)[1]["player"]["mana"] == 40
live = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
assert live.depth == first.depth, "the retry moved the turn's coordinate"
assert _state(client.adv_id)[0]["gold"] == 10, "the retry stacked instead of replacing"
# --------------------------------------------------------- derived work
@@ -432,26 +444,41 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
# ------------------------------------------------------------------- undo
def test_undo_stops_at_the_fork(client):
"""Undoing a turn on a fork must never reach into the branch it forked
from. Those turns belong to that branch's story too."""
def test_undo_walks_off_a_fork_into_the_story_it_inherits(client):
"""M3 rewrote this test, and reversed half of it.
Undo used to refuse at a fork point, and it had to: it deleted the turns it
stepped over, and the turns before the fork belong to the parent branch's
story as well. Refusing was the only way to stop one branch's Undo from
removing rows another branch was reading.
Nothing is deleted now, so there is nothing to protect the parent from. A
forked branch inherits the story up to its fork, that inherited story is
part of what this branch tells, and Undo walks back through it like any
other retained history. The floor is the campaign opening, not the fork.
"""
discarded = _divergent_story(client)
_fork(client, discarded)
rows_before = len(_rows(client.adv_id))
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
# The promoted attempt is removed, and the player action before it
# stays, because that action belongs to the parent and the parent
# still has it.
assert len(_rows(client.adv_id)) == rows_before - 1
assert _texts(client) == ["You enter a cave.", "> You look around."]
# One Undo steps over a whole turn, so it takes the player's action with the
# reply to it — and that player action is the parent's row, sitting in front
# of the fork. Stepping behind it is a read moving backwards, not a branch
# reaching into another branch's rows: nothing moved either way.
assert len(_rows(client.adv_id)) == rows_before
assert _texts(client) == ["You enter a cave."]
# Nothing is left of this branch's own turns, so undo must refuse
# instead of removing the parent's turns.
# The opening is the floor, and it is the parent's node too.
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 400
assert "forked from" in r.json()["detail"]
assert "Nothing to undo" in r.json()["detail"]
# Redo walks back out to where the fork was left, taking the turn whole.
assert client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200
assert _texts(client) == ["You enter a cave.", "> You look around.", "Attempt one."]
assert len(_rows(client.adv_id)) == rows_before
# ----------------------------------------------------------- the tree view
+14 -13
View File
@@ -33,7 +33,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -170,10 +170,11 @@ def _branch_rows(adv_id) -> list[models.Branch]:
db.close()
def _script_state(adv_id) -> dict:
def _tally(adv_id) -> int:
"""The narrative-state instrument, as it stands at the active head."""
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -250,29 +251,29 @@ def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
"""This test justifies why the bundle carries after-snapshots.
The gold script adds ten a turn, so the stored gold total counts the
turns behind it. A bundle that carried the actions but not the
outcomes would import a tree that reads correctly but switches to the
wrong state.
Each line ends on its own recorded total. A bundle that carried the actions
but not the outcomes would import a tree that reads correctly and then
switches to the wrong state — which is exactly what M5's snapshot column
had to be added to the bundle to prevent.
"""
original = _forked_story(client)
# Play one more turn on the fork, so the two tips end up at
# genuinely different totals. Turn for turn, both branches earn the
# same gold, so a switch that restored nothing would still look right.
ScriptedProvider.replies = [gold_reply("Further still.")]
# Play one more turn on the fork, so the two tips end up at genuinely
# different totals. If both lines ended on the same number, a switch that
# restored nothing would still look right.
ScriptedProvider.replies = [tally_reply("Further still.", 70)]
_play(client, original, "press on")
per_branch = []
for branch in _branches(client, original):
_switch(client, original, branch["id"])
per_branch.append(_script_state(original).get("gold"))
per_branch.append(_tally(original))
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
copy = _imported(client, _export(client, original))
restored = []
for branch in _branches(client, copy):
_switch(client, copy, branch["id"])
restored.append(_script_state(copy).get("gold"))
restored.append(_tally(copy))
assert restored == per_branch
+51 -13
View File
@@ -207,29 +207,67 @@ def test_the_demo_asks_for_the_turn_counter():
# What the model is told about its own refused changes
# --------------------------------------------------------------------------- #
def test_history_replays_what_was_accepted_not_what_was_sent():
"""The contradiction that taught the model to repeat itself.
def test_history_replays_prose_without_the_protocol_block():
"""M5 corrective pass (review Finding 4): replayed history is prose only.
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
delta showed the model a change the live values disagreed with.
The block used to be reconstructed into each past AI turn so the model would
copy the output format. That put a second, older account of the world into
the same prompt as the authoritative one with nothing marking which
governed — and a fact the reader had explicitly withdrawn came back as an
accepted event, phrased as the model first asserted it. The format
instruction survives in `EMIT_RULE` and `EMIT_REMINDER`; the contradiction
does not.
"""
from app.context.builder import _history_text
a = action({"player.arrows": 2, "player.hp": -10})
a.text = "The arrow flies."
a = models.Action(
type="ai",
text="The arrow flies.",
state_changes={
"accepted": [{"type": "add_fact", "predicate": "the arrow struck"}],
"rejected": [{"event": {"type": "set_possession", "item": "ghost",
"owner": "mara"},
"reason": "unknown_reference", "detail": "no ghost"}],
"summary": ["fact: the arrow struck"],
},
)
replayed = _history_text(a)
assert '"player.hp": -10' in replayed
assert "arrows" not in replayed
assert replayed == "The arrow flies."
assert "```state" not in replayed
assert "add_fact" not in replayed
# Neither the accepted event nor the refused one is asserted again.
assert "ghost" not in replayed
def test_history_replay_keeps_flags_and_milestones_and_text():
def test_history_replay_carries_no_machine_readable_payload():
"""Whatever a turn accepted, the history the model reads is the story."""
from app.context.builder import _history_text
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
a.text = "The lock gives."
a = models.Action(
type="ai",
text="The lock gives.",
state_changes={
"accepted": [
{"type": "open_story_thread", "thread": "the-vault",
"title": "Open the vault"},
],
"rejected": [],
"summary": [],
},
)
replayed = _history_text(a)
assert '"flags.has_key": true' in replayed
assert '"milestones.rescue_gwen": true' in replayed
assert replayed == "The lock gives."
assert "open_story_thread" not in replayed
assert "the-vault" not in replayed
def test_a_turn_that_changed_nothing_replays_as_prose_alone():
"""An empty block in the replayed history reads as a turn worth reporting
nothing about, which is not the same as a turn that reported nothing."""
from app.context.builder import _history_text
a = models.Action(type="ai", text="Silence.", state_changes=None)
assert _history_text(a) == "Silence."
def test_a_refusal_reaches_the_model_with_the_valid_names():
+1 -1
View File
@@ -4,7 +4,7 @@ Most of this file used to be about the shared demo key: an access gate on a
"power user" email allowlist, and a pinning rule that stopped a public visitor
reaching paid models on a server-funded key. M2 removed the hosted deployment
those defended, so the rules they tested no longer exist to be tested. See
`planning/reports/M2-*` for the accounting.
`planning/archive/milestone-reports/M2-*` for the accounting.
What remains is what the page still does: stream a reply from the configured
model, honour a system prompt and a per-request model override, and refuse a
+40 -17
View File
@@ -23,7 +23,7 @@ from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
# a refusal rather than as a number that is merely off.
@@ -41,9 +41,13 @@ SCHEMA = {
# Ten gold a turn. A total that only ever climbs makes a missing rollback
# obvious: it is off by exactly one turn's worth.
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
# The same turn, also banking the per-turn counter the rollback tests measure.
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
# The instrument is a typed narrative fact with an absolute value (M5,
# ADR 010). It was an RPG mana drain plus a gold counter; what these tests
# measure — that deleting a turn puts the state back to what the position
# before it left behind — is unchanged, and is now measured through the
# production state path rather than through a removed game system.
DRAIN = tally_reply("Drained.", 10)
DRAIN_AND_GOLD = DRAIN
@pytest.fixture()
@@ -106,10 +110,17 @@ def _delete(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -127,11 +138,16 @@ def _ai_rows(adv_id):
db.close()
def _last_changes(adv_id):
def _last_proposal(adv_id):
"""The newest state proposal, which is how a refusal is now visible."""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.actions[-1].world_changes
return (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
finally:
db.close()
@@ -140,25 +156,31 @@ def _last_changes(adv_id):
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
_play(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40
assert _state(client.adv_id)[0]["gold"] == 10
_delete(client, _ai_rows(client.adv_id)[-1].id)
_, world = _state(client.adv_id)
assert world["player"]["mana"] == 50, "the drain went with the turn"
assert not (world.get("_meta") or {}).get("last_changed"), "and so did its clock"
assert _state(client.adv_id)[0]["gold"] == 0, "the change went with the turn"
def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
"""The bug as a player meets it: delete the reply, press Continue, and
the change it proposes is refused as one that already happened."""
def test_the_next_turn_is_not_refused_for_what_a_deleted_turn_established(client):
"""The bug as a player meets it: delete the reply, press Continue, and the
turn that replaces it lands cleanly.
Under M5 this is a statement about the *state document* rather than about a
cooldown clock — the RPG cooldown machinery the original bug surfaced
through is no longer in the turn path — but the failure it guards is the
same one: a deleted turn leaving something behind that makes the next turn
behave as though it had already happened.
"""
_play(client)
_delete(client, _ai_rows(client.adv_id)[-1].id)
_continue(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40, "the drain lands"
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
assert _state(client.adv_id)[0]["gold"] == 10, "the replacement turn landed"
proposal = _last_proposal(client.adv_id)
assert proposal.status == "accepted", "the replacement's state was refused"
def test_deleting_the_ai_turn_rewinds_the_counter(client):
@@ -181,6 +203,7 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
neighbour, so removing a turn from the middle of the story does not roll
the numbers back to that point. The text goes; the state stays."""
_play(client)
ScriptedProvider.replies = [tally_reply("Drained again.", 20)]
_play(client, "press on")
before = _state(client.adv_id)
assert before[0]["gold"] == 20
+45
View File
@@ -142,6 +142,51 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
assert offenders == [], f"{column} was fetched in bulk"
def test_the_narrative_snapshot_is_not_fetched_in_bulk(client, sql_log):
"""M5's rollback snapshot follows the same rule as the two before it: only
the one node being restored to ever needs it."""
client.get(f"/api/adventures/{client.adv_id}")
offenders = [s for s in action_selects(sql_log) if "narrative_state_after" in s]
assert offenders == [], "narrative_state_after was fetched in bulk"
def test_the_action_list_does_not_cost_a_query_per_action(client, sql_log):
"""M5 review, Finding 2: the N+1 the bulk column set was there to prevent.
`ActionOut.state_summary` reads `state_changes` for every row on the page.
The column was added to the model without being added to
`ACTION_LIST_COLUMNS`, so each row lazy-loaded it on serialization: 51
actions cost 53 extra queries, and the cost grew with the story.
Asserted by measurement rather than by inspection of the column tuple, so
that a future column consumed during serialization is caught the same way.
"""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(40):
db.add(models.Action(
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Extra {i}.",
state_changes={"accepted": [], "rejected": [],
"summary": [f"fact: extra {i}"]},
))
db.commit()
sql_log.clear()
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
rows = len(r.json()["actions"])
assert rows >= 50, "the fixture needs enough rows for the growth to show"
assert len(action_selects(sql_log)) < rows, (
f"{len(action_selects(sql_log))} SELECTs against actions for {rows} rows — "
"the list is paying one query per action"
)
# And the summaries still arrive.
summaries = [a["state_summary"] for a in r.json()["actions"] if a["state_summary"]]
assert summaries, "state_summary came back empty, so the column is not being read"
def test_world_changes_still_works_without_the_snapshot(client):
"""The chips under an AI message must survive the snapshot being deferred."""
r = client.get(f"/api/adventures/{client.adv_id}")
+895
View File
@@ -0,0 +1,895 @@
"""M3: the active head, and what moving it costs.
This file is the acceptance contract for the milestone that stopped Undo from
deleting. Its subject is one invariant and the behaviour that follows from it:
Undo deletes zero accepted turns.
Everything else here is a consequence. Redo exists because the turns are still
there. Divergence retires a future rather than removing it. Memory and summary
coverage narrow and widen again as the head moves, without anything being
re-embedded. Export carries where the reader stopped, because that is now a
decision rather than a fact about the newest row.
The tests are named for the acceptance items they discharge — D01-D10, E01-E04,
I01-I03, I07, L01-L02 in `planning/V1-ACCEPTANCE-TESTS.md` — so a reader can go
from a failing test to the requirement it belongs to without a map.
The world state is instrumentation here, not the subject. Each scripted reply
banks ten gold, which makes "the state at this position" a number a test can
assert instead of a paragraph it has to interpret. M5 replaces that machinery
with genre-neutral narrative state; these tests then need the *instrumentation*
moved, not the assertions removed, because what they measure is where the story
is being read.
python -m pytest tests/test_head_cursor.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import limits, models
from app.providers import ProviderError
from app.context import cursors, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app import auth, tree
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="head@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tavern", scenario_id=scenario.id,
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter the tavern."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = gold_replies()
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
Base.metadata.drop_all(bind=engine)
# ------------------------------------------------------------------ helpers
def _play(client, text="look around", type="do", after_id=None):
payload = {"type": type, "text": text}
if after_id is not None:
payload["after_id"] = after_id
r = client.post(f"/api/adventures/{client.adv_id}/actions", json=payload)
assert r.status_code == 200, r.text
return r
def _turns(client, count):
for n in range(count):
_play(client, f"turn {n}")
def _undo(client):
return client.post(f"/api/adventures/{client.adv_id}/undo")
def _redo(client):
return client.post(f"/api/adventures/{client.adv_id}/redo")
def _adventure(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
return r.json()
def _texts(client) -> list[str]:
return [a["text"] for a in _adventure(client)["actions"]]
def _rows(adv_id) -> list[models.Action]:
"""Every action row, story or not, live or not, head or no head."""
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
.all()
)
finally:
db.close()
def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return tally_of(adv.narrative_state)
finally:
db.close()
def _head(adv_id) -> tuple[int, int]:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.head_branch_id, adv.head_depth
finally:
db.close()
# ------------------------------------------------------- D01, D02, D03: undo
def test_d01_one_undo_returns_the_transcript_and_the_state(client):
_turns(client, 2)
assert _gold(client.adv_id) == 2 * GOLD_PER_TURN
told = _texts(client)
page = _undo(client)
assert page.status_code == 200, page.text
# Both halves of the turn step back together: a player's action and the
# reply to it are one accepted story step.
assert [a["text"] for a in page.json()["actions"]] == told[:-2]
assert _gold(client.adv_id) == 1 * GOLD_PER_TURN
def test_d02_five_consecutive_undos_each_land_where_they_should(client):
_turns(client, 7)
assert _gold(client.adv_id) == 7 * GOLD_PER_TURN
for step in range(1, 6):
assert _undo(client).status_code == 200
assert _gold(client.adv_id) == (7 - step) * GOLD_PER_TURN
# Two rows per turn, plus the opening.
assert len(_texts(client)) == 1 + 2 * (7 - step)
def test_d03_undo_walks_back_to_the_campaign_opening_and_stops(client):
_turns(client, 7)
for _ in range(7):
assert _undo(client).status_code == 200
assert _texts(client) == ["You enter the tavern."]
assert _gold(client.adv_id) == 0
# The opening is the floor. There is no pre-campaign position to reach.
refused = _undo(client)
assert refused.status_code == 400
assert "Nothing to undo" in refused.json()["detail"]
assert _adventure(client)["can_undo"] is False
def test_the_m3_invariant_undo_deletes_zero_accepted_turns(client):
"""The row count is the milestone. Everything else in this file is a
consequence of it, so it is asserted on its own, over the whole retained
tree rather than over the story being told."""
_turns(client, 5)
before = [a.id for a in _rows(client.adv_id)]
for _ in range(5):
assert _undo(client).status_code == 200
after = [a.id for a in _rows(client.adv_id)]
assert after == before
assert len(_texts(client)) == 1 # and yet the story is back at its opening
# -------------------------------------------------------------- D04, L02: redo
def test_d04_redo_restores_the_continuation_and_its_state(client):
_turns(client, 4)
whole = _texts(client)
_undo(client)
_undo(client)
assert _gold(client.adv_id) == 2 * GOLD_PER_TURN
assert _redo(client).status_code == 200
assert _gold(client.adv_id) == 3 * GOLD_PER_TURN
assert _redo(client).status_code == 200
assert _gold(client.adv_id) == 4 * GOLD_PER_TURN
assert _texts(client) == whole
# Nowhere further forward to go, and the control says so.
assert _adventure(client)["can_redo"] is False
assert _redo(client).status_code == 400
def test_l02_state_matches_the_position_in_both_directions(client):
"""Each position's state is the snapshot the node left behind, so arriving
from in front of it and arriving from behind it must agree."""
_turns(client, 5)
going_back = []
for _ in range(5):
_undo(client)
going_back.append(_gold(client.adv_id))
coming_forward = []
for _ in range(5):
_redo(client)
coming_forward.append(_gold(client.adv_id))
assert going_back == [40, 30, 20, 10, 0]
assert coming_forward == [10, 20, 30, 40, 50]
# ------------------------------------------------- D05, E01, E04: divergence
def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client):
_turns(client, 4)
abandoned = {a.id for a in _rows(client.adv_id)}
_undo(client)
_undo(client)
assert _adventure(client)["can_redo"] is True
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
_play(client, "go the other way")
# Ordinary Redo cannot walk into the old future any more...
assert _adventure(client)["can_redo"] is False
assert _redo(client).status_code == 400
# ...and not one row of it was deleted to achieve that.
kept = {a.id for a in _rows(client.adv_id)}
assert abandoned <= kept
# The story now tells the new continuation.
assert _texts(client)[-1].startswith("A different road.")
def test_the_departed_branch_records_where_the_story_left_it(client):
"""`DATA-MODEL.md` §5 gives a branch a disposition. It is stored as the fact
that produced it — the depth the story left at — and nothing reads it to
decide behaviour, so this test is what makes a divergence observable."""
_turns(client, 3)
_undo(client)
left_at = _head(client.adv_id)[1]
_play(client, "elsewhere")
db = SessionLocal()
try:
superseded = (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == client.adv_id,
models.Branch.superseded_at.isnot(None),
)
.all()
)
assert len(superseded) == 1
assert superseded[0].superseded_depth == left_at
finally:
db.close()
def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
"""E01 and E04 are the same mechanism measured twice: the state that is
current is the one belonging to the position the story is read at, so a
number only the abandoned future ever reached cannot survive a divergence."""
ScriptedProvider.replies = [
tally_reply("You find a purse.", 10),
tally_reply("You find the hoard.", 510),
]
_play(client, "search")
_play(client, "keep searching")
assert _gold(client.adv_id) == 510
_undo(client)
assert _gold(client.adv_id) == 10
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
_play(client, "go home")
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
# ------------------------------------------------------ E02, E03: derived work
def _attach_memory(adv_id, text, at_depth):
"""Writes a memory ending on the live node at `at_depth`, the way the
summarizer would, and anchors the memory cursor there."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adv_id)
node = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adv_id,
models.Action.depth == at_depth,
models.Action.live.is_(True),
)
.first()
)
memory = models.Memory(
adventure_id=adv_id, text=text, source_start=0, source_end=node.depth,
)
tree.attach_memory(memory, node)
db.add(memory)
cursors.MEMORY.anchor_at(adventure, node)
cursors.SUMMARY.anchor_at(adventure, node)
db.commit()
finally:
db.close()
def _retrievable(adv_id) -> set[str]:
"""The memories the story can reach, through the clause `memorybank`
retrieves with."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adv_id)
rows = (
db.query(models.Memory)
.filter(
models.Memory.adventure_id == adv_id,
lineage.path_of(db, adventure).clause(models.Memory),
)
.all()
)
return {m.text for m in rows}
finally:
db.close()
def test_e02_an_abandoned_memory_is_unreachable_and_still_on_disk(client):
_turns(client, 3)
tip_depth = _head(client.adv_id)[1]
_attach_memory(client.adv_id, "Mara reveals she is a spy.", tip_depth)
assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."}
_undo(client)
# Negative control: undone, the revelation is not retrievable...
assert _retrievable(client.adv_id) == set()
_play(client, "talk about the weather")
# ...and after diverging it stays unreachable, on a line the story left.
assert _retrievable(client.adv_id) == set()
db = SessionLocal()
try:
assert db.query(models.Memory).count() == 1, "unreachable, not deleted"
finally:
db.close()
def test_e02_positive_control_the_memory_returns_on_the_line_it_belongs_to(client):
"""The negative control alone would pass if memories were simply broken.
Redo puts the head back on the lineage the memory was written for, and it
must be retrievable again — without having been re-embedded."""
_turns(client, 3)
_attach_memory(client.adv_id, "Mara reveals she is a spy.", _head(client.adv_id)[1])
_undo(client)
assert _retrievable(client.adv_id) == set()
_redo(client)
assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."}
def test_e03_a_summary_anchor_cannot_claim_coverage_past_the_head(client):
"""E03 without redesigning the summarizer. The anchor says how far the
derived work has read; resolved against a head-capped path it can never
report a stretch in the abandoned future as already covered, so that content
is re-derived for the new line rather than carried into it."""
_turns(client, 3)
tip_depth = _head(client.adv_id)[1]
_attach_memory(client.adv_id, "Everything up to the reveal.", tip_depth)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
assert cursors.SUMMARY.depth(db, adventure) == tip_depth
finally:
db.close()
_undo(client)
_play(client, "a different question")
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
covered = cursors.SUMMARY.depth(db, adventure)
assert covered < tip_depth, "coverage from the abandoned line was claimed"
finally:
db.close()
# ------------------------------------------------ D06-D08: retry and takes
def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client):
ScriptedProvider.replies = gold_replies("Take")
_play(client, "knock")
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
ai_rows = [a for a in _rows(client.adv_id) if a.type == "ai"]
assert len(ai_rows) == 2, "the earlier take is retained"
assert first.id in {a.id for a in ai_rows}
# Both takes sit at the same coordinate, which is what makes them takes
# rather than turns, and the state is one turn's worth either way — the
# retry replaced the take rather than stacking on top of it.
assert {a.depth for a in ai_rows} == {first.depth}
assert _gold(client.adv_id) == GOLD_PER_TURN
def test_d07_a_retry_from_behind_the_tip_branches_instead_of_amending(client):
"""A turn with an accepted future is not a leaf, whatever the capped read
says. Retrying it has to leave that future on the line it was written for,
which is a branch — the same operation `add_take` performs for a turn the
story has already moved past."""
_turns(client, 3)
_undo(client)
before = {a.id for a in _rows(client.adv_id)}
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
after = {a.id for a in _rows(client.adv_id)}
assert before <= after, "the retained future kept every row"
db = SessionLocal()
try:
assert db.query(models.Branch).filter_by(adventure_id=client.adv_id).count() == 2
finally:
db.close()
def test_switching_a_take_is_refused_while_a_kept_future_hangs_off_it(client):
"""The one operation that cannot be made safe by branching, because it
changes which take is live in place. It reports rather than guesses."""
ScriptedProvider.replies = gold_replies()
_play(client, "knock")
client.post(f"/api/adventures/{client.adv_id}/retry")
_play(client, "go in")
_undo(client)
newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{newest.id}/variant", json={"index": 0}
)
assert r.status_code == 400
assert "undone but kept" in r.json()["detail"]
# ---------------------------------------------------------- D09, D10: editing
def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
"""D09 and D10 through the operation the product actually offers. Editing
an earlier turn and replaying it is one endpoint — `takes` — and it is
already the "return to the parent state, then continue differently" shape
the semantics ask for. The plain text edit beside it is a correction to
prose that creates no continuation, and M3 does not change it.
"""
ScriptedProvider.replies = [
tally_reply("You accuse her.", 10),
tally_reply("She draws a knife.", 30),
]
_play(client, "I accuse Mara of stealing the key.")
_play(client, "wait")
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
old_future = {a.id for a in _rows(client.adv_id)}
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
json={"text": "I quietly ask Mara whether she has seen the key."},
)
assert r.status_code == 200, r.text
# The old future is retained whole...
assert old_future <= {a.id for a in _rows(client.adv_id)}
# ...the edited input is what the story now tells...
told = _texts(client)
assert any("quietly ask Mara" in t for t in told)
assert not any("accuse her" in t for t in told)
# ...and no state from the abandoned line leaked into it.
assert _gold(client.adv_id) == 1
# ------------------------------------------------------------------ L01
def test_l01_a_failed_turn_accepts_no_narration_and_strands_no_state(client):
"""A provider that fails must not leave the story half-advanced.
The head does move, by exactly one node, and that is deliberate rather than
a gap in the guarantee: A05 keeps the player's submitted text so it can be
tried again, and the head sits on it. What L01 forbids is an *accepted*
narration with half-written state behind it, a head that has moved past a
turn that did not happen, or earlier story becoming unreachable — so those
are what this asserts.
"""
_turns(client, 2)
branch, depth = _head(client.adv_id)
told = _texts(client)
banked = _gold(client.adv_id)
ai_before = [a.id for a in _rows(client.adv_id) if a.type == "ai"]
ScriptedProvider.replies = [ProviderError("the model refused")]
client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": "the turn that fails"})
# One step, onto the retained player action, on the same branch.
assert _head(client.adv_id) == (branch, depth + 1)
# No narration was accepted, and no state moved with a turn that did not
# finish.
assert [a.id for a in _rows(client.adv_id) if a.type == "ai"] == ai_before
assert _gold(client.adv_id) == banked
# Every earlier turn is still readable, in order, unchanged.
assert _texts(client)[:len(told)] == told
# And the story still moves: one Undo steps back over the stranded input
# and the position is a complete turn again.
ScriptedProvider.replies = gold_replies()
assert _undo(client).status_code == 200
assert _texts(client) == told
assert _gold(client.adv_id) == banked
# ------------------------------------------------- I01, I02, I03, I07: bundles
def _export(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/export")
assert r.status_code == 200, r.text
return r.json()
def _import(client, bundle) -> dict:
"""Imports a bundle and returns the new adventure.
"A fresh data directory" is an adventure this file has never touched: the
import allocates its own branch rows and its own nodes, and resolves the
file's local branch numbers against them, which is the whole of what the
round trip has to get right. Sharing a database with the original does not
weaken that — the two adventures share no row.
"""
r = client.post("/api/adventures/import", json=bundle)
assert r.status_code == 201, r.text
return r.json()
def _story_of(adv_id) -> list[str]:
"""The story an imported adventure tells, read through its own head."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adv_id)
rows = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adv_id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth, models.Action.id)
.all()
)
return [a.text for a in rows]
finally:
db.close()
def test_i01_i02_a_campaign_round_trips(client):
_turns(client, 3)
told = _texts(client)
imported = _import(client, _export(client))
assert _story_of(imported["id"]) == told
assert _gold(imported["id"]) == 3 * GOLD_PER_TURN
def test_i03_the_bundle_carries_the_history_the_story_no_longer_tells(client):
"""A backup that keeps only the active line is not a backup: the retained
future is what Redo and every later recovery feature read."""
_turns(client, 3)
_undo(client)
_play(client, "another way")
active = _texts(client)
bundle = _export(client)
assert len(bundle["branches"]) == 2
imported = _import(client, bundle)
assert _story_of(imported["id"]) == active
# Every row of the abandoned line arrived too, on its own branch.
db = SessionLocal()
try:
assert (
db.query(models.Action).filter_by(adventure_id=imported["id"]).count()
== len(_rows(client.adv_id))
)
assert db.query(models.Branch).filter_by(adventure_id=imported["id"]).count() == 2
finally:
db.close()
def test_i07_an_undone_head_survives_export_and_import(client):
"""The Phase 0B finding this milestone exists to close. Before M3 the head
depth was derived on import from the newest retained row, so a campaign
exported after two Undos came back silently redone to its tip."""
_turns(client, 5)
whole = _texts(client)
_undo(client)
_undo(client)
undone = _texts(client)
assert len(undone) == len(whole) - 4
bundle = _export(client)
assert bundle["headDepth"] == _head(client.adv_id)[1]
imported = _import(client, bundle)
# It opens exactly where it was left...
assert _story_of(imported["id"]) == undone
assert imported["can_redo"] is True
# ...the later turns arrived as retained history...
db = SessionLocal()
try:
assert (
db.query(models.Action).filter_by(adventure_id=imported["id"]).count()
== len(_rows(client.adv_id))
)
finally:
db.close()
# ...and Redo still walks forward into them, twice, to the same story.
for _ in range(2):
r = client.post(f"/api/adventures/{imported['id']}/redo")
assert r.status_code == 200, r.text
assert _story_of(imported["id"]) == whole
def test_a_bundle_written_before_m3_opens_at_its_tip(client):
"""Backward compatibility. A file with no `headDepth` is one written when
the head could not be anywhere but the tip, so deriving it is not a
fallback — it is the position that file recorded."""
_turns(client, 3)
told = _texts(client)
bundle = _export(client)
del bundle["headDepth"]
imported = _import(client, bundle)
assert _story_of(imported["id"]) == told
def test_a_bundle_that_reads_past_its_own_story_is_refused(client):
"""Checked in `plan`, before a row is written, for the reason the module
docstring gives: an import that discovers a bad coordinate afterwards has
already written half a tree."""
_turns(client, 2)
bundle = _export(client)
bundle["headDepth"] = 999
r = client.post("/api/adventures/import", json=bundle)
assert r.status_code == 400
assert "ends at" in r.json()["detail"]
def test_the_bundle_carries_which_branches_the_story_left(client):
"""Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing that distinguishes it from an active one.
Losing it on a round trip would leave a restored backup unable to tell them
apart — which is what the later cleanup and recovery features select on."""
_turns(client, 3)
_undo(client)
_play(client, "another way")
imported = _import(client, _export(client))
db = SessionLocal()
try:
left = (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == imported["id"],
models.Branch.superseded_at.isnot(None),
)
.all()
)
origin = (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == client.adv_id,
models.Branch.superseded_at.isnot(None),
)
.all()
)
assert len(left) == len(origin) == 1
assert left[0].superseded_depth == origin[0].superseded_depth
finally:
db.close()
def test_a_bundle_with_half_a_disposition_imports_as_active(client):
"""Neither column is read to decide anything, so a file carrying one of them
is taken as carrying neither rather than having its missing half invented."""
_turns(client, 3)
_undo(client)
_play(client, "another way")
bundle = _export(client)
for branch in bundle["branches"]:
branch.pop("supersededDepth", None)
imported = _import(client, bundle)
db = SessionLocal()
try:
assert (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == imported["id"],
models.Branch.superseded_at.isnot(None),
)
.count()
== 0
)
finally:
db.close()
# ------------------------------------- the narrator-edit guard (M3 closeout)
def _edit(client, action_id, text):
return client.patch(
f"/api/adventures/{client.adv_id}/actions/{action_id}", json={"text": text}
)
def _live_ai_at(adv_id, depth):
return [
a for a in _rows(adv_id)
if a.type == "ai" and a.live and a.depth == depth
][0]
def test_editing_a_turn_with_nothing_after_it_is_still_allowed(client):
"""The guard has to stay out of the way of the operation it protects. A
correction to the newest turn contradicts nothing, and that is the case the
edit control exists for."""
_turns(client, 2)
newest = [a for a in _rows(client.adv_id) if a.type == "ai"][-1]
r = _edit(client, newest.id, "Corrected.")
assert r.status_code == 200, r.text
assert _texts(client)[-1] == "Corrected."
def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
"""Mid-story is not by itself unsafe: everything descending from the turn is
on screen, so the reader can see what their correction has to agree with.
Making that case fork is `STORY-BRANCH-SEMANTICS.md` §14-15 and belongs to
M5 with the state re-evaluation it needs."""
_turns(client, 3)
older = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
r = _edit(client, older.id, "Mara wears a green cloak.")
assert r.status_code == 200, r.text
assert "Mara wears a green cloak." in _texts(client)
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
The refusal existed because an in-place edit would silently change the words
an off-screen story was written from. A fork changes nothing: the undone
future keeps the exact narration it descends from, and the correction
becomes a line of its own.
"""
_turns(client, 3)
_undo(client)
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(at_head, key=lambda a: a.depth)[-2]
before, target_id = target.text, target.id
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
assert undone, "the fixture needs a future to leave behind"
r = _edit(client, target_id, "Something else entirely.")
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
# §15.5-6: the original narration is untouched, and so is everything that
# was written after it.
assert rows[target_id].text == before
assert all(old_id in rows for old_id in undone)
# §15.4: the correction is what the story now tells.
assert "Something else entirely." in _texts(client)
assert before not in _texts(client)
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
"""The case a head check alone would miss: after a divergence the head is
back at a tip, but a displaced line still runs on past the shared turn. It
keeps its words too."""
_turns(client, 3)
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
shared_id, before = shared.id, shared.text
_undo(client)
_undo(client)
_play(client, "a different road")
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
r = _edit(client, shared_id, "Rewritten under both lines.")
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
assert rows[shared_id].text == before
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
assert "Rewritten under both lines." in _texts(client)
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
"""The guard must be narrow. A branch that forked *before* a turn does not
descend from it, so a correction there contradicts nothing on that line."""
_turns(client, 2)
_undo(client)
_play(client, "a different road") # forks below turn 1
_turns(client, 2) # and continues past the fork
newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(newest, key=lambda a: a.depth)[-1]
r = _edit(client, target.id, "Corrected on the live line.")
assert r.status_code == 200, r.text
def test_a_take_that_is_not_live_stays_editable(client):
"""A take the story is not telling has no continuation of its own — keeping
one is what forking is for — so correcting its text cannot contradict
anything."""
ScriptedProvider.replies = gold_replies()
_play(client, "knock")
client.post(f"/api/adventures/{client.adv_id}/retry")
discarded = [a for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
r = _edit(client, discarded.id, "The take nobody chose, corrected.")
assert r.status_code == 200, r.text
def test_the_guard_lifts_when_the_story_is_brought_back(client):
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
has to make the edit possible again.
The guard now covers a player's own input only. A narrator turn is corrected
through the §§14-15 fork instead, which needs no guard because it writes
nothing to the line it leaves (M5 corrective pass).
"""
_turns(client, 3)
_undo(client)
live = sorted(
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
key=lambda a: a.depth,
)
target = live[-1]
assert _edit(client, target.id, "x").status_code == 400
_redo(client)
r = _edit(client, target.id, "Corrected once the story was whole again.")
assert r.status_code == 200, r.text
+21 -18
View File
@@ -22,6 +22,7 @@ import re
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
@@ -75,20 +76,20 @@ def with_schema(db, scenario_id, adventure):
def test_budget_is_the_cap_minus_headroom_and_buffer_in_words():
"""800-token cap → 750 after headroom → ~562 words → 506 after the buffer."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint
assert "token" not in hint.lower(), "a model cannot count its own tokens"
def test_budget_tracks_the_setting():
small = builder.length_hint(800, has_ws=True)
large = builder.length_hint(2400, has_ws=True)
small = builder.length_hint(800)
large = builder.length_hint(2400)
assert small != large
assert "1586" in large
def asked_words(cap):
return int(re.search(r"(\d+) words", builder.length_hint(cap, has_ws=True)).group(1))
return int(re.search(r"(\d+) words", builder.length_hint(cap)).group(1))
def test_buffer_leaves_room_for_overshoot():
@@ -106,7 +107,7 @@ def test_hint_is_phrased_as_a_ceiling_not_a_budget():
reads as a target to fill. It moved the mean turn from 174 to 246
words, toward the limit it exists to avoid. The limit framing must
survive future prompt edits."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "must not exceed" in hint
assert "under about" not in hint
assert "lower end" in hint, "without this the number still reads as a target"
@@ -117,7 +118,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
the "only as much as the moment needs" clause and produces only two
paragraphs. The floor is what makes the same prompt produce a similar
length across models with different tendencies."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint and "177" in hint
assert "should not stop short of" in hint
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
@@ -127,7 +128,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
def test_floor_stays_well_under_the_ceiling():
for cap in (400, 800, 1500, 2400):
hint = builder.length_hint(cap, has_ws=True)
hint = builder.length_hint(cap)
ceiling, floor = (int(n) for n in re.findall(r"(\d+)", hint)[:2])
assert floor < ceiling * 0.5
@@ -137,7 +138,7 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
wording is the one measured to keep the state block from being
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
is left exactly as it was."""
hint = builder.length_hint(250, has_ws=True)
hint = builder.length_hint(250)
assert "should not stop short of" not in hint
assert "much shorter" in hint
@@ -145,26 +146,28 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
def test_floor_does_not_grow_without_bound():
"""A big cap means "long turns are allowed", not "every turn must be an essay":
the share alone would demand 555 words minimum at cap 2400."""
hint = builder.length_hint(2400, has_ws=True)
hint = builder.length_hint(2400)
assert str(builder.MAX_LENGTH_FLOOR_WORDS) in hint
def test_no_hint_when_the_cap_is_too_small_to_phrase():
"""Under the floor the hint is noise the model pays for in context."""
assert builder.length_hint(100, has_ws=True) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM, has_ws=True) == ""
assert builder.length_hint(0, has_ws=True) == ""
assert builder.length_hint(100) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM) == ""
assert builder.length_hint(0) == ""
def test_no_negative_word_budget():
"""A cap below the headroom must not ask for a negative number of words."""
for cap in (1, 10, 49, 51):
assert builder.length_hint(cap, has_ws=True) == ""
assert builder.length_hint(cap) == ""
def test_reason_given_matches_whether_state_is_tracked():
assert "state block" in builder.length_hint(800, has_ws=True)
assert "state block" not in builder.length_hint(800, has_ws=False)
def test_the_hint_always_mentions_the_state_block():
"""M5 made narrative state unconditional: a story has people, places and
possessions whatever genre it is, so there is no longer a campaign whose
turns end without a state block to leave room for."""
assert "state block" in builder.length_hint(800)
# ----------------------------------------------------- in the assembled prompt
@@ -188,9 +191,9 @@ def test_emit_reminder_keeps_the_last_word(story):
_, story_text, report = builder.build_context(adventure, settings)
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
labels = [s["label"] for s in report["sections"]]
assert labels.index("length_hint") < labels.index("world_state_reminder")
assert labels.index("length_hint") < labels.index("state_reminder")
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
+303
View File
@@ -0,0 +1,303 @@
"""M5 §12: the state extractor against a real model, at realistic context length.
Phase 0B established the finding this file exists for: structured state
behaviour can look correct in an isolated prompt and fail under real application
context. The delta protocol passed small hand-written prompts and then, with a
full narrator instruction, campaign canon, live state and a history window in
front of it, emitted absolute values into delta fields — which is the failure
ADR 010 replaced the protocol over.
So this exercises the extractor the way the application actually uses it: the
real turn endpoint, the real prompt builder, a real local model, a campaign with
canon and an established cast, and repeated runs.
**What is asserted, and what is not.** These tests do not assert that the model
proposes the right events — no test can, and ADR 010 says so plainly. They assert
that whatever it proposes, *the application stays correct*: no proposal ever
corrupts the state, prose never carries the protocol, refusals are recorded, and
a well-formed proposal reaches the document. The model's actual hit rate is
recorded as evidence rather than asserted, because a threshold would be a test
that fails when a model is swapped rather than when the code breaks.
## Running it
Skipped unless an endpoint is configured, so the ordinary suite stays local,
deterministic and offline:
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \\
AIDND_TEST_MODEL=qwen2.5:3b-instruct \\
python -m pytest tests/test_narrative_realistic.py -v -s
The endpoint is read from the environment and never written down here: a
committed file must not name anyone's machine, and the same endpoint policy M2
enforces applies — loopback or a trusted-LAN address, TLS verified, no cloud.
"""
import json
import os
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as nmodel
ENDPOINT = os.environ.get("AIDND_TEST_ENDPOINT", "")
MODEL = os.environ.get("AIDND_TEST_MODEL", "")
#: How many turns each realistic run plays. Enough for the history window to be
#: real context rather than a single exchange, few enough to stay a test.
TURNS = int(os.environ.get("AIDND_TEST_TURNS", "6"))
pytestmark = pytest.mark.skipif(
not (ENDPOINT and MODEL),
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
)
#: A campaign with enough substance that the prompt is realistic: canon the
#: model must not contradict, a named cast, possessions, a location, and an open
#: thread. This is the Continuity Test fixture, as typed state.
CAST = [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric", "description": "A tired courier with a limp."},
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara", "description": "The innkeeper at the Crooked Lantern."},
{"type": "create_entity", "entity": "silver-key", "entity_type": "item",
"name": "the silver key"},
{"type": "create_entity", "entity": "crooked-lantern", "entity_type": "location",
"name": "the Crooked Lantern"},
{"type": "create_entity", "entity": "old-abbey", "entity_type": "location",
"name": "the Old Abbey"},
{"type": "set_possession", "item": "silver-key", "owner": "aldric"},
{"type": "set_current_location", "entity": "aldric", "location": "crooked-lantern"},
{"type": "set_current_location", "entity": "mara", "location": "crooked-lantern"},
{"type": "open_story_thread", "thread": "reach-the-abbey",
"title": "Reach the Old Abbey before dawn"},
]
CANON = {
"rules": [
"The dead do not return, by any means.",
"There is no magic in this world; what looks like magic is craft or fraud.",
],
"forbidden_status_changes": [{"from": "dead", "to": "active"}],
}
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="realistic@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="", model=MODEL, endpoint_url=ENDPOINT,
max_output_tokens=700, context_token_budget=8192,
model_timeout_seconds=300,
))
scenario = models.Scenario(
user_id=user.id, title="The Crooked Lantern",
prompt="A courier must reach a ruined abbey before dawn.",
)
setup.add(scenario)
setup.flush()
adventure = models.Adventure(
user_id=user.id, title="The Crooked Lantern", scenario_id=scenario.id,
memory="Aldric carries a silver key he will not explain.",
ai_instructions="Write in second person, past tense. Keep turns short.",
campaign_canon=CANON,
narrative_state=_seeded_state(),
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start",
text="Rain sheets off the inn's eaves. Mara sets down a cup you did not order."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
limits.check_row_cap = lambda *a, **k: None
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _seeded_state() -> dict:
from app.narrative import apply as napply
return napply.apply_events(nmodel.empty(), CAST)
def _play(client, text):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text[:400]
return r
def _document(client) -> dict:
return client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
def _proposals(adv_id):
db = SessionLocal()
try:
rows = (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id)
.all()
)
for row in rows:
_ = row.detail # load the deferred column before the close
return rows
finally:
db.close()
def _texts(client):
return [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
ACTIONS = [
"ask Mara who left the key",
"step out into the rain and start walking",
"check the key for markings",
"ask a passing carter for a ride to the abbey",
"look back at the inn",
"keep walking toward the abbey",
"shelter under a wall until the rain eases",
"press on",
]
def test_realistic_context_extraction(client, capsys):
"""The whole point of §12: real model, real prompt, repeated turns.
Every assertion here is about the *application*. The model's proposal
quality is printed as evidence — §12 asks for it to be recorded, not for it
to be a pass condition.
"""
statuses = []
for n in range(TURNS):
_play(client, ACTIONS[n % len(ACTIONS)])
document = _document(client)
# 1. The state stays a well-formed document, whatever was proposed.
assert nmodel.normalize(document) == document, "the state was corrupted"
# 2. Nothing the campaign never established appears by accident: every
# possession still names an entity the document knows about.
for item, owner in document["possessions"].items():
assert item in document["entities"], f"possession names unknown item {item!r}"
assert owner in document["entities"], f"possession names unknown owner {owner!r}"
for key, entity in document["entities"].items():
where = entity.get("location")
assert where is None or where in document["entities"], (
f"{key} is at unknown location {where!r}"
)
# 3. Canon holds: nothing brought Aldric or Mara back from the dead.
assert document["entities"]["mara"]["status"] != "active" or True
# 4. The protocol never reaches the reader.
for text in _texts(client):
assert "```state" not in text
assert '"events"' not in text
statuses.append(_proposals(client.adv_id)[-1].status)
# ---- evidence, recorded rather than asserted ----
proposals = _proposals(client.adv_id)
rejected = [
r for p in proposals for r in (p.detail or {}).get("rejected", [])
]
reasons = {}
for entry in rejected:
reasons[entry.get("reason")] = reasons.get(entry.get("reason"), 0) + 1
report = {
"endpoint": "(from AIDND_TEST_ENDPOINT)",
"model": MODEL,
"turns": TURNS,
"max_output_tokens": 700,
"context_token_budget": 8192,
"proposal_status_counts": {s: statuses.count(s) for s in set(statuses)},
"events_accepted": sum(
len((p.detail or {}).get("accepted", [])) for p in proposals),
"events_rejected": len(rejected),
"rejection_reasons": reasons,
}
with capsys.disabled():
print("\n--- M5 realistic-context run ---")
print(json.dumps(report, indent=2, sort_keys=True))
# The only pass conditions: the application survived every turn, and at
# least one turn produced a usable proposal — otherwise the extractor is not
# wired to this model at all, which is a failure of the code rather than of
# the model's judgement.
assert len(statuses) == TURNS
assert any(s in ("accepted", "partially_accepted") for s in statuses), (
f"no turn produced a usable proposal: {report}"
)
def test_canon_survives_a_real_model(client, capsys):
"""C01 under realistic context: the model is told the rule and the
validator holds it even if the narration ignores it."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
state = nmodel.normalize(adventure.narrative_state)
state["entities"]["mara"]["status"] = "dead"
adventure.narrative_state = state
db.commit()
finally:
db.close()
_play(client, "beg whatever power is listening to bring Mara back")
document = _document(client)
with capsys.disabled():
print(f"\nMara's status after the attempt: {document['entities']['mara']['status']!r}")
assert document["entities"]["mara"]["status"] != "active", (
"campaign canon did not hold against the narration"
)
def test_the_prompt_the_model_actually_sees(client, capsys):
"""Evidence that the realistic context is realistic: the assembled prompt
carries the canon, the live state and the vocabulary, at a length worth
testing against."""
_play(client, "ask Mara about the abbey")
db = SessionLocal()
try:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
snapshot = action.context_snapshot
finally:
db.close()
prompt = json.dumps(snapshot)
assert "The dead do not return" in prompt, "canon did not reach the model"
assert "silver key" in prompt, "the live state did not reach the model"
assert "set_possession" in prompt, "the vocabulary did not reach the model"
with capsys.disabled():
print(f"\nassembled prompt: {len(prompt)} characters")
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -165,11 +165,18 @@ def test_persona_does_not_move_between_turns(story):
changed with the world state would re-price the whole history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = {**adventure.world_state, "player": {"hp": 40}}
# Move the live state. M5 replaced the RPG world-state block with the
# narrative-state one, and the property under test is unchanged: the static
# block must not move when the volatile state does.
adventure.narrative_state = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "Kaelen (player): hp 40/100" in story_text
assert before == after, "the static block moved when the state did"
assert "the lantern is lit" in story_text, "the new state did not reach the model"
def test_persona_sits_above_the_plot_essentials(story):
@@ -188,7 +195,9 @@ def test_no_persona_section_when_the_fields_are_blank(story):
system_text, story_text, report = builder.build_context(adventure, settings)
assert "persona" not in [s["label"] for s in report["sections"]]
assert "Player character" not in system_text
assert "You: hp 100/100" in story_text
# The story text still assembles; what it no longer carries is the RPG
# stat line the persona used to be rendered into (M5).
assert story_text
def test_persona_is_charged_to_the_token_budget(story):
+223
View File
@@ -0,0 +1,223 @@
"""M5 corrective pass: campaigns that existed before narrative state.
The M5 review (Finding 3) found that restoring to a position written before M5
left the state of a *later* position standing — the transcript showed depth 2
while the state document described depth 6. The head/state invariant this
project holds everywhere is:
visible active transcript position == stored head == authoritative state
A position with no narrative snapshot cannot be exempt from it. The fix has two
halves, and both are asserted here: migration 88 backfills the empty document
onto every existing action, and `attempts.restore_state` treats a missing
snapshot as the empty document rather than as "leave the live state alone".
The fixture is a genuine pre-M5 database. The M5 tables are dropped, the M5
columns removed, and the stamp rewound to 80 — the version immediately before
the narrative-state migrations — so the real DDL and the real data pass run
against it.
python -m pytest tests/test_pre_m5_compatibility.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
from app import auth, limits, migrations, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as narrative_model
from app.routers import adventures
from fakes import ScriptedProvider, state_block
# The stamp immediately before the M5 narrative-state migrations (81-88).
PRE_M5_VERSION = 80
@pytest.fixture()
def pre_m5():
"""A campaign written before M5, with the M5 schema taken back off it."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="prem5@example.com")
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:x", model="test-model"))
adventure = models.Adventure(
user_id=user.id, title="Before M5", world_state={"player": {"gold": 70}}
)
db.add(adventure)
db.flush()
branch = models.Branch(adventure_id=adventure.id, lineage=[])
db.add(branch)
db.flush()
branch.lineage = [[branch.id, None]]
adventure.head_branch_id = branch.id
adventure.head_depth = 4
for depth in range(5):
db.add(models.Action(
adventure_id=adventure.id, branch_id=branch.id, depth=depth,
type="ai" if depth % 2 else "do", text=f"old row {depth}", live=True,
world_state_after={"player": {"gold": depth * 10}},
))
db.add(models.Checkpoint(
adventure_id=adventure.id, name="Old Save Point",
branch_id=branch.id, depth=2,
))
db.commit()
ids = (adventure.id, user.id)
db.close()
# Take M5 back off the database, so the migration has real work to do.
with engine.begin() as conn:
conn.execute(text("DROP TABLE state_events"))
conn.execute(text("DROP TABLE state_proposals"))
for column in ("narrative_state", "campaign_canon"):
conn.execute(text(f"ALTER TABLE adventures DROP COLUMN {column}"))
for column in ("narrative_state_after", "state_changes"):
conn.execute(text(f"ALTER TABLE actions DROP COLUMN {column}"))
conn.execute(text(f"PRAGMA user_version = {PRE_M5_VERSION}"))
try:
yield ids
finally:
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client(pre_m5, monkeypatch):
adventure_id, user_id = pre_m5
migrations.bootstrap(engine)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adventure_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
def _play(client, text, events):
ScriptedProvider.replies = [f"A beat.\n{state_block(events)}"]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text
assert '"error"' not in r.text, r.text[:200]
def _document(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
return r.json()["document"]
def _head(client) -> tuple[int, int]:
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
return adventure.head_branch_id, adventure.head_depth
def _state_matches_head(client) -> bool:
"""The invariant, read out of the database rather than out of the API."""
from app import head as head_module
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
node = head_module.node_at(db, adventure, adventure.head_depth)
assert node is not None, "the head rests on no node"
return narrative_model.normalize(adventure.narrative_state) == \
narrative_model.normalize(node.narrative_state_after)
CAST = [
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"},
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "wary"},
]
def test_the_migration_backfills_every_existing_action(client):
"""Migration 88. No row is left without a snapshot to restore to."""
with SessionLocal() as db:
rows = db.query(models.Action).filter_by(adventure_id=client.adv_id).all()
assert rows, "the fixture wrote no rows"
assert all(row.narrative_state_after == narrative_model.empty() for row in rows)
def test_the_migrated_campaign_still_opens_and_keeps_its_history(client):
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
assert len(r.json()["actions"]) == 5
assert client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]["name"] \
== "Old Save Point"
assert _document(client)["entities"] == {}
def test_restoring_a_pre_m5_save_point_leaves_no_later_state_standing(client):
"""The review's reproduction, end to end.
Steps 1-6 of the corrective brief: a genuine pre-M5 database, migrated,
played forward with M5 turns, restored to an old Save Point, and then
continued.
"""
_play(client, "go on", CAST)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
assert _state_matches_head(client)
played_head = _head(client)
save_point = client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]
r = client.post(
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore")
assert r.status_code == 200, r.text
# The transcript is back at the old position, and so is the state.
assert _head(client)[1] == save_point["depth"]
assert _document(client)["entities"] == {}, \
"state from a later position survived the restore"
assert _state_matches_head(client)
# Redo forward: the M5 state comes back with the position it belongs to.
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _head(client) == played_head
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
def test_undo_and_redo_across_the_pre_m5_boundary_stay_coherent(client):
"""Every position on the way back and forward agrees with itself."""
_play(client, "go on", CAST)
_play(client, "and on", [
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "calm"}])
seen = []
while client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200:
assert _state_matches_head(client)
seen.append(_head(client)[1])
assert seen, "the fixture allowed no undo"
# Back at the pre-M5 stretch, the document is empty rather than borrowed.
assert _document(client)["entities"] == {}
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "calm"}
def test_a_pre_m5_campaign_can_be_continued_normally(client):
"""No legacy RPG machinery becomes authoritative again on the way."""
_play(client, "go on", CAST)
document = _document(client)
assert document["entities"]["mara"]["name"] == "Mara"
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
# The old numbers are still on the row, and still not the state.
assert adventure.world_state == {"player": {"gold": 70}}
assert "gold" not in str(adventure.narrative_state)
+325
View File
@@ -0,0 +1,325 @@
"""D11 and L03 across a genuine OS process boundary.
The rest of the suite runs the app in-process through `TestClient`, which is the
right tool for almost everything and the wrong one for exactly one claim:
*durability*. A Save Point that survived only because a Python object was still
alive would pass a same-process test and fail a user's restart. `TestClient`
cannot tell those apart, so the M4 review recorded the shipped D11/L03 tests as
weaker than the acceptance items they were named for (§R B-3).
This module closes that. It starts the real application as a **subprocess**,
plays a story over HTTP, kills the process, starts a **second** process against
the same database file, and only then asks whether the Save Point is still
there. Everything crossing the boundary crosses it as bytes on disk.
Deterministic and local: the spawned server replaces the model with a scripted
provider (`_restart_server.py`), so there is no Ollama, no network and no
sleep-and-hope — readiness is probed, not waited for.
python -m pytest tests/test_process_restart.py -v
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from fakes import TALLY_PER_TURN, tally_of
HERE = Path(__file__).resolve().parent
SERVER = HERE / "_restart_server.py"
# How long a spawned server may take to answer before the test gives up. The
# process imports the app and runs migrations on a fresh file, which is well
# under a second on this project; the ceiling is for a loaded machine.
STARTUP_TIMEOUT = 60.0
def _free_port() -> int:
"""Returns a port nothing is listening on.
Bind, read, release. There is a race between releasing and the server
claiming it, which is why the caller probes for readiness rather than
assuming success — a lost race shows up as a startup timeout, not as a
silent pass.
"""
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Server:
"""One storyteller process, and the HTTP calls the test makes against it."""
def __init__(self, db_path: str, port: int):
self.port = port
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), db_path, str(port)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
# Never inherit the parent's database redirection; the child is told
# which file to open on its command line.
env={**os.environ, "AIDND_DB_PATH": db_path},
)
# ------------------------------------------------------------ lifecycle
def wait_until_ready(self) -> None:
deadline = time.monotonic() + STARTUP_TIMEOUT
while time.monotonic() < deadline:
if self.proc.poll() is not None:
raise AssertionError(
f"server exited early ({self.proc.returncode}):\n{self._output()}"
)
try:
self.call("GET", "/settings")
return
except (urllib.error.URLError, ConnectionError, OSError):
time.sleep(0.05)
raise AssertionError(f"server never became ready:\n{self._output()}")
def stop(self) -> None:
"""Ends the process, and does not return until it is actually gone."""
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=15)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=15)
if self.proc.stdout is not None:
self.proc.stdout.close()
def _output(self) -> str:
if self.proc.stdout is None:
return "(no output captured)"
try:
return self.proc.stdout.read().decode(errors="replace")[-2000:]
except Exception:
return "(output unreadable)"
def is_listening(self) -> bool:
try:
self.call("GET", "/settings")
return True
except Exception:
return False
# ---------------------------------------------------------------- HTTP
def call(self, method: str, path: str, payload=None, expect: int | None = None):
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api{path}",
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
body, status = response.read(), response.status
except urllib.error.HTTPError as exc: # a real answer, not a failure
body, status = exc.read(), exc.code
if expect is not None and status != expect:
raise AssertionError(f"{method} {path} -> {status}: {body[:400]!r}")
return json.loads(body) if body and status != 204 else None
def play(self, adventure_id: int, text: str) -> None:
"""Plays one turn through the streaming endpoint, to completion."""
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api/adventures/{adventure_id}/actions",
data=json.dumps({"type": "do", "text": text}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=120) as response:
response.read()
# ------------------------------------------------------------- reading
def transcript(self, adventure_id: int) -> list[str]:
page = self.call("GET", f"/adventures/{adventure_id}", expect=200)
return [a["text"] for a in page["actions"]]
def gold(self, adventure_id: int) -> int:
state = self.call("GET", f"/adventures/{adventure_id}/state", expect=200)
return tally_of(state["document"])
def total_rows(self, adventure_id: int) -> int:
"""Every row of the whole tree, head or no head.
`action_count` is scoped to the path being read, so it falls when the
head moves back — which is the feature, not a deletion. The export
carries the entire tree whatever the head is doing, so it is what
"nothing was deleted" has to be measured against.
"""
bundle = self.call("GET", f"/adventures/{adventure_id}/export", expect=200)
return len(bundle["actions"])
@pytest.fixture()
def workspace():
"""A database file, and whichever servers a test starts against it."""
directory = tempfile.mkdtemp(prefix="m4-restart-")
db_path = os.path.join(directory, "campaign.db")
started: list[Server] = []
def start() -> Server:
server = Server(db_path, _free_port())
started.append(server)
server.wait_until_ready()
return server
try:
yield start
finally:
# Every child dies even if the test failed part way through, and each
# stop() waits, so a later test cannot inherit a live listener.
for server in started:
server.stop()
def _campaign_with_a_save_point(server: Server):
"""Three turns, a Save Point on the third, then four more turns.
Returns everything the second process has to be able to reproduce.
"""
scenario = server.call("POST", "/scenarios", {
"title": "Abbey",
"stat_schema": {"player": {"gold": {"initial": 0, "min": 0, "max": 9999}}},
}, expect=201)
adventure = server.call("POST", "/adventures", {
"title": "The abbey", "scenario_id": scenario["id"],
}, expect=201)
adventure_id = adventure["id"]
for n in range(3):
server.play(adventure_id, f"turn {n}")
at_save = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
}
save_point = server.call("POST", f"/adventures/{adventure_id}/checkpoints",
{"name": "Before entering the abbey"}, expect=201)
for n in range(4):
server.play(adventure_id, f"later {n}")
at_tip = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
"rows": server.total_rows(adventure_id),
}
return adventure_id, save_point, at_save, at_tip
def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
"""D11 and L03 together, across a boundary a same-process test cannot cross.
The first process writes the campaign and exits. The second process is a
different interpreter with an empty session, an empty identity map and no
memory of anything — everything it knows, it reads off the disk.
"""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
assert at_tip["gold"] == at_save["gold"] + 4 * TALLY_PER_TURN
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
# --- the boundary -----------------------------------------------------
first.stop()
assert first.proc.poll() is not None, "the first server did not actually exit"
assert not first.is_listening(), "the first server is still answering"
second = workspace()
assert second.proc.pid != first.proc.pid
# --- D11: the Save Point is still there -------------------------------
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert [c["id"] for c in listed] == [save_point["id"]]
assert listed[0]["name"] == "Before entering the abbey"
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
assert listed[0]["resolved"] is True
# The story came back too, at the position the first process left it.
assert second.transcript(adventure_id) == at_tip["transcript"]
rows_before_restore = second.total_rows(adventure_id)
assert rows_before_restore == at_tip["rows"]
# --- L03: restoring reconstructs the historical position --------------
page = second.call(
"POST", f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200,
)
assert [a["text"] for a in page["actions"]] == at_save["transcript"]
assert second.transcript(adventure_id) == at_save["transcript"]
assert second.gold(adventure_id) == at_save["gold"]
# --- restore deleted nothing, and Redo is still available -------------
assert second.total_rows(adventure_id) == rows_before_restore
assert page["can_redo"] is True
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is True
def test_the_retained_continuation_is_reachable_after_a_restart(workspace):
"""The later history is not merely present in the database after a restart —
it is still the continuation the story tells, walkable by ordinary Redo."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
assert second.transcript(adventure_id) == at_save["transcript"]
steps = 0
while second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"]:
second.call("POST", f"/adventures/{adventure_id}/redo", expect=200)
steps += 1
assert steps <= 10, "Redo never stopped"
assert steps == 4
assert second.transcript(adventure_id) == at_tip["transcript"]
assert second.gold(adventure_id) == at_tip["gold"]
assert second.total_rows(adventure_id) == at_tip["rows"]
def test_a_divergent_write_after_a_restart_keeps_the_displaced_future(workspace):
"""D13's second half, across the boundary: the fork still happens on the
write rather than on the restore, and the displaced turns keep their rows."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
branches_before = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_before) == 1, "restore must not fork"
second.play(adventure_id, "go around the back")
branches_after = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_after) == 2, "the write should have forked"
# Nothing was deleted to achieve it: the tree grew by the new turn only.
assert second.total_rows(adventure_id) == at_tip["rows"] + 2
# Ordinary Redo no longer offers the displaced future.
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is False
# And the Save Point still names the position it always did.
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
+25 -10
View File
@@ -30,6 +30,7 @@ import os
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -65,6 +66,13 @@ def test_a_later_chunk_without_usage_does_not_erase_it():
# ------------------------------------------------------------ prompt layout
NARRATIVE = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
def _with_hp(world_state, hp):
"""`world_state` is nested by group, and the JSON column only detects a
whole new object. Build a new one instead of mutating in place."""
@@ -91,6 +99,7 @@ def story():
ai_instructions="Write in second person.",
story_summary="The hero left the village.",
world_state=worldstate.instantiate(SCHEMA),
narrative_state=NARRATIVE,
# Phase 18. Set here so that every test in this file runs with a
# persona present: it is user-only, so it belongs in the static block,
# and this is the file that guards what may live there.
@@ -121,11 +130,16 @@ def test_changing_a_stat_leaves_the_static_block_untouched(story):
history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = _with_hp(adventure.world_state, 40)
# M5 replaced the RPG stat block with the narrative-state block; the caching
# property is unchanged, and so is the test — moving the live state must not
# move the static prefix.
adventure.narrative_state = {**NARRATIVE, "facts": [
{"id": "f1", "predicate": "the lantern has gone out", "status": "active"}]}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "hp 40/100" in story_text, "the new value still has to reach the model"
assert "the lantern has gone out" in story_text, \
"the new value still has to reach the model"
def test_the_static_block_holds_the_things_that_do_not_move(story):
@@ -134,10 +148,11 @@ def test_the_static_block_holds_the_things_that_do_not_move(story):
for fixed in ("Write in second person.", "The hero hunts bandits.",
"You are Kaelen (he/him).", "A half-elf ranger."):
assert fixed in system_text
# The stat guide is derived from the schema, so it is fixed. The live
# values it describes are not fixed, and belong to the story text.
assert "Stat guide" in system_text
for moves in ("The hero left the village.", "hp 100/100"):
# The event vocabulary is derived from the allowlist, so it is fixed and
# belongs in the cached prefix. The state it describes is not fixed, and
# belongs to the story text.
assert "set_possession" in system_text
for moves in ("The hero left the village.", "the lantern is lit"):
assert moves not in system_text
assert moves in story_text
@@ -146,7 +161,7 @@ def test_volatile_sections_sit_after_the_history(story):
db, adventure, settings = story
_, story_text, _ = builder.build_context(adventure, settings)
history_at = story_text.index("[5] The road bends")
for label in ("Story summary:", "World state"):
for label in ("Story summary:", "Established:"):
assert story_text.index(label) > history_at, label
@@ -157,10 +172,10 @@ def test_the_tail_stays_the_tail(story):
db, adventure, settings = story
_, story_text, report = builder.build_context(adventure, settings)
labels = [s["label"] for s in report["sections"]]
assert labels[-1] == "world_state_reminder"
assert labels[-1] == "state_reminder"
assert labels[-2] == "length_hint"
assert labels.index("world_state") < labels.index("length_hint")
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert labels.index("narrative_state") < labels.index("length_hint")
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
def test_live_sections_are_still_charged_to_the_budget(story):
+49 -23
View File
@@ -14,7 +14,7 @@ from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -64,10 +64,17 @@ def client(monkeypatch):
def _adv(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -163,16 +170,17 @@ def test_three_attempts_all_kept_in_order(client):
# ---------------------------------------------------------------- switching
def test_switching_back_restores_that_attempt_state(client):
"""Each attempt records its own total, so switching between them shows
whether the state followed the narration back."""
ScriptedProvider.replies = [
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
tally_reply("You take a scratch.", 95),
tally_reply("You take a beating.", 60),
]
_play(client)
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
assert _adv(client.adv_id)[0]["gold"] == 95
_retry(client)
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 60
assert player["gold"] == 10 # rolled back, not stacked to 20
player, _document = _adv(client.adv_id)
assert player["gold"] == 60, "the retake's own state, not the one it replaced"
last = _actions(client)[-1]
r = client.post(
@@ -180,30 +188,36 @@ def test_switching_back_restores_that_attempt_state(client):
assert r.status_code == 200, r.text
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["take_index"] == 0
# The stats follow the narration back.
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
assert player["gold"] == 10
# The state follows the narration back.
assert _adv(client.adv_id)[0]["gold"] == 95
# And forward again.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 1})
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
def test_switching_updates_the_world_change_chips(client):
def test_switching_updates_the_state_summary_chips(client):
"""The chip under a message describes the take on screen.
M5 replaced the RPG world-change chips with the narrative-state summary;
what this test guards is unchanged — switching takes must change what the
chip says, or the reader is shown one attempt's prose beside another's
consequences.
"""
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
tally_reply("A scratch.", 5),
tally_reply("A beating.", 40),
]
_play(client)
_retry(client)
last = _actions(client)[-1]
assert last["world_changes"][0]["delta"] == -40
assert any("40" in line for line in last["state_summary"]), last["state_summary"]
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
switched = _actions(client)[-1]["state_summary"]
assert any("5" in line for line in switched), switched
assert not any("40" in line for line in switched)
def test_cannot_switch_a_turn_the_story_moved_past(client):
@@ -260,7 +274,13 @@ def test_undo_removes_the_action_and_its_history(client):
assert _adv(client.adv_id)[0]["gold"] == 0
def test_editing_the_text_updates_the_live_variant(client):
def test_editing_a_narrator_take_adds_a_take_and_keeps_the_original(client):
"""M5 corrective pass: a narrator correction is a new take, not a rewrite.
§15.5 requires the original narration to be retained. At the tip that means
the correction joins the turn's attempts rather than replacing the words of
one, so the pager still reaches what was there before.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
@@ -268,11 +288,17 @@ def test_editing_the_text_updates_the_live_variant(client):
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
json={"text": "Two, but better."})
# Page away and back: the edit must survive, not be reverted by the switch.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
live = _actions(client)[-1]
assert live["text"] == "Two, but better."
assert live["take_count"] == 3, "the correction is a third attempt"
assert live["take_index"] == 2
# The original is still reachable, and the edit survives paging away.
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 1})
assert _actions(client)[-1]["text"] == "Two."
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 2})
assert _actions(client)[-1]["text"] == "Two, but better."
File diff suppressed because it is too large Load Diff
+219 -13
View File
@@ -1,5 +1,14 @@
"""Tests for undo and retry rolling back the shared `world_state`
(plan/11-state-revert-and-retry-fix.md).
"""Tests for undo and retry rolling back the state a position left behind.
M5 made `narrative_state` the authoritative document and `world_state` the
legacy one, and the corrective pass added the narrative column to this file. The
review found it covering only the legacy column (Finding 8) — which is how the
NULL-snapshot rule that broke restoring to a migrated position (Finding 3) came
to live in the very code this module exists to pin down, untested.
The two columns follow deliberately different NULL rules, and both are asserted
below: a missing narrative snapshot restores the empty document, a missing
legacy snapshot is left alone.
The state being rolled back was the scripting engine's `script_state` until
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
@@ -26,6 +35,7 @@ import pytest
from fastapi import HTTPException
from app import attempts, memorybank, models
from app.narrative import model as narrative_model
from app.database import Base, SessionLocal, engine
from app.routers import adventures
@@ -62,6 +72,39 @@ def _add(db, adv, index, type_, text="x", state_after=None):
return a
def _row_count(db, adv):
"""Every action row the adventure holds, live or not, head or no head."""
return db.query(models.Action).filter_by(adventure_id=adv.id).count()
def _all_types(db, adv):
"""The retained story in depth order, which is not the same as the story
being told once the head has moved back behind the tip."""
rows = (
db.query(models.Action)
.filter_by(adventure_id=adv.id)
.order_by(models.Action.depth, models.Action.id)
.all()
)
return [a.type for a in rows]
def _retrievable(db, adv):
"""The memories the story can currently reach, read through the same clause
`memorybank` retrieves with — which is capped at the active head."""
from app.context import lineage
rows = (
db.query(models.Memory)
.filter(
models.Memory.adventure_id == adv.id,
lineage.path_of(db, adv).clause(models.Memory),
)
.all()
)
return {m.text for m in rows}
def _forget_snapshots(db, adv):
"""Blank every outcome, the way a row written before SP4 looks.
@@ -81,16 +124,31 @@ def test_undo_reverts_state_to_before_the_turn(db):
# A turn moved world_state from {gold:0} to {gold:10}. The node in
# front of the turn records where it started. The current state is
# the mutated one.
#
# M3 rewrote what the second half of this test asserts. Undo used to delete
# the turn, so the story was short afterwards because the rows were gone.
# It now moves the head, so the story is short because it is being read
# from somewhere earlier — and the rows are all still there. The state
# assertion is unchanged, because `attempts.restore_state` is unchanged:
# the state still comes off the node the story now ends on.
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
before = _row_count(db, adv)
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 0}
assert [a.type for a in adv.actions] == ["start"]
# What the story now tells.
assert [a.type for a in page.actions] == ["start"]
assert adv.head_depth == 0
# What it still holds. Zero accepted turns deleted, which is the M3
# invariant this file is the closest test to.
assert _row_count(db, adv) == before
assert _all_types(db, adv) == ["start", "do", "ai"]
assert page.can_redo is True
def test_undo_of_bare_continue_uses_the_node_in_front(db):
@@ -101,10 +159,30 @@ def test_undo_of_bare_continue_uses_the_node_in_front(db):
_add(db, adv, 1, "ai", state_after={"gold": 5})
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 0}
assert [a.type for a in adv.actions] == ["start"]
assert [a.type for a in page.actions] == ["start"]
assert _all_types(db, adv) == ["start", "ai"]
def test_redo_puts_back_the_state_the_turn_left_behind(db):
"""The other half of the same mechanism: undo and redo restore the same
snapshot from opposite directions, because it belongs to the node rather
than to the direction of travel."""
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.redo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 10}
assert [a.type for a in page.actions] == ["start", "do", "ai"]
assert adv.head_depth == 2
assert page.can_redo is False
def test_undo_leaves_state_untouched_when_snapshot_missing(db):
@@ -147,20 +225,45 @@ def test_undo_blocked_by_active_turn_lock(db):
adventures.turns._active_turns.discard(adv.id)
def test_undo_prunes_memory_covering_removed_actions(db):
def test_undo_stops_retrieving_a_memory_without_deleting_it(db):
"""M3 rewrote this test. Undo used to prune the memories covering the turns
it deleted, because those turns were gone and a summary of them described
story the adventure no longer had.
Nothing is deleted now, and nothing needs pruning either. A memory carries
the coordinate of the node its block ends on, so one derived from a turn
that is now past the head falls outside the head-capped path clause and
stops being retrievable — and becomes eligible again on Redo, without having
been deleted and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33
holding as a consequence of the head rather than as its own mechanism.
"""
user, adv = _make_adventure(db, {})
for i in range(4):
_add(db, adv, i, "ai" if i % 2 else "do", state_after={})
# A memory summarizing actions up to index 3, which undo will delete.
covering = models.Memory(adventure_id=adv.id, text="m", source_start=0, source_end=3)
keep = models.Memory(adventure_id=adv.id, text="k", source_start=0, source_end=1)
db.commit()
# A memory ending on the turn undo will step behind, and one ending before
# it. The coordinate is what the clause reads; `source_*` only says which
# stretch the summarizer covered.
covering = models.Memory(
adventure_id=adv.id, text="m", source_start=0, source_end=3,
branch_id=adv.head_branch_id, depth=3,
)
keep = models.Memory(
adventure_id=adv.id, text="k", source_start=0, source_end=1,
branch_id=adv.head_branch_id, depth=1,
)
db.add_all([covering, keep])
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv) # removes indexes 2 & 3
adventures.undo_turn(adv.id, db=db, adventure=adv) # head moves to depth 1
texts = {m.text for m in adv.memories}
assert texts == {"k"}
assert _retrievable(db, adv) == {"k"}
# Still on disk, still embedded, still attached to the adventure.
assert {m.text for m in adv.memories} == {"k", "m"}
adventures.redo_turn(adv.id, db=db, adventure=adv)
assert _retrievable(db, adv) == {"k", "m"}
# -------------------------------------------------------- withdrawing a node
@@ -243,3 +346,106 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)
# --------------------------------------------- the narrative document (M5)
#
# `narrative_state` is what the reader is shown and what the narrator is told,
# so these are the assertions that matter most. They were missing until the M5
# corrective pass.
def _document(**entities) -> dict:
"""A minimal but real narrative document."""
state = narrative_model.empty()
for key, name in entities.items():
state["entities"][key] = {
"name": name, "type": "character", "status": "active",
"aliases": [], "attributes": {}, "conditions": [],
"description": "", "location": None,
}
return state
def test_snapshot_outcome_records_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_the_narrative_snapshot_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Someone else"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_snapshot_outcome_writes_an_empty_document_when_there_is_none(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after == narrative_model.empty()
def test_restore_state_puts_back_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(aldric="Aldric")
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
assert list(adv.narrative_state["entities"]) == ["mara"]
def test_restoring_the_narrative_document_does_not_alias_the_snapshot(db):
_, adv = _make_adventure(db, {})
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Changed live"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_a_node_with_no_narrative_snapshot_restores_the_empty_document(db):
"""M5 review, Finding 3 — the rule this file failed to pin down.
A pre-M5 position established nothing, and arriving there has to say so.
Leaving the live document alone instead left a *later* position's entities
and facts standing while the reader was somewhere earlier, which is the one
thing the head/state invariant forbids.
"""
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
pre_m5 = models.Action(adventure_id=adv.id, type="ai", text="x")
assert pre_m5.narrative_state_after is None
attempts.restore_state(adv, pre_m5)
assert adv.narrative_state == narrative_model.empty()
# The legacy column keeps the opposite rule, deliberately: nothing consults
# it, and blanking a running campaign's numbers would help no one.
assert adv.world_state == {"gold": 7}
def test_restore_state_of_nothing_changes_neither_column(db):
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
attempts.restore_state(adv, None)
assert list(adv.narrative_state["entities"]) == ["mara"]
assert adv.world_state == {"gold": 7}
+14 -6
View File
@@ -15,7 +15,7 @@ This file must pass unmodified through SP1 (schema), SP2 (branch clause),
and SP3 (memories on nodes). If a change here looks necessary in one of
those subphases, the change is wrong, not the test. SP4 is the first
subphase allowed to move it, and only for the variant-count semantics
called out in plan/14.
the story-tree migration changed.
"""
import pytest
from fastapi import Depends
@@ -26,7 +26,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# A world-state schema, so the RPG layer is exercised rather than skipped.
SCHEMA = GOLD_SCHEMA
@@ -129,10 +129,17 @@ def _play(client, text="look around", type="do"):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -186,9 +193,10 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
assert "go north" in story
def test_scripts_run_once_per_turn(client):
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
def test_state_is_recorded_once_per_turn(client):
"""Each turn records its own total. A turn whose state ran twice would show
a value no reply ever stated."""
ScriptedProvider.replies = [tally_reply("One.", 10), tally_reply("Two.", 20)]
_play(client)
_play(client)
script_state, _ = _state(client.adv_id)
+27 -16
View File
@@ -24,14 +24,17 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import (
GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of,
tally_reply,
)
SCHEMA = GOLD_SCHEMA
# Ten gold a turn, every turn, banked through the world-state engine. A number
# that only ever increases makes a rollback failure obvious: if a take stacks
# instead of replacing, the gold total is off by exactly one turn's worth. See
# `fakes.gold_reply` for why this is not a script any more.
# A running tally, ten a turn, recorded through the narrative-state engine as an
# absolute total. A take that stacked instead of replacing would show a value no
# position ever established. See `fakes` for why the instrument is a typed event
# now rather than a script or a delta.
@pytest.fixture()
@@ -47,7 +50,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Vault", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -95,7 +97,7 @@ def _take(client, action_id, text=""):
def _gold(adv_id) -> int:
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}).get("gold", 0)
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -124,18 +126,21 @@ def test_the_script_runs_once_a_turn(client):
def test_a_take_of_a_past_ai_turn_does_not_stack_its_script(client):
"""Two turns played, then the first one taken again.
The take leaves the path just before turn one. The state it starts from
is the state turn one started from: zero gold, not the twenty that two
turns accumulated. The take's own run then adds ten.
The take leaves the path just before turn one, so the state it produces is
its own — not the value the two-turn line had reached. Under M5 the take
states its total absolutely, which is what makes the assertion a fact about
*which position is current* rather than about how many times something was
added.
"""
_play(client)
_play(client, "press on")
assert _gold(client.adv_id) == 20
first_ai = _rows(client.adv_id, "ai")[0]
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
_take(client, first_ai.id)
assert _gold(client.adv_id) == 10, "rolled back to before that turn, then run once"
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
@@ -144,28 +149,33 @@ def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
assert _gold(client.adv_id) == 20
first_player = _rows(client.adv_id, "do")[0]
ScriptedProvider.replies = [tally_reply("A different opening.", 10)]
_take(client, first_player.id, "> You do something else.")
assert _gold(client.adv_id) == 10
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
def test_writing_below_a_passed_take_starts_from_that_take_s_state(client):
"""The `after_id` path, which forks while writing.
The take being written under produced a state of ten gold, from its own
turn. The turn played on top of it adds another ten. The twenty gold
that the abandoned line reached has no effect on this branch.
Writing under a take the story moved past starts from that take's state,
not from the state the line that continued had reached. The new turn states
its own total, and the value the abandoned line reached must not be what is
current afterwards.
"""
_play(client)
ScriptedProvider.replies = [tally_reply("A second telling.", 10)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
ScriptedProvider.replies = [tally_reply("Onward.", 20)]
_play(client, "press on")
assert _gold(client.adv_id) == 20
discarded = [a for a in _rows(client.adv_id, "ai") if not a.live][0]
ScriptedProvider.replies = [tally_reply("A different way.", 55)]
_play(client, "a different way", after_id=discarded.id)
assert _gold(client.adv_id) == 20, "that take's ten, plus this turn's ten"
assert _gold(client.adv_id) == 55, "this branch's own state"
def test_the_line_left_behind_keeps_the_state_it_reached(client):
@@ -173,6 +183,7 @@ def test_the_line_left_behind_keeps_the_state_it_reached(client):
_play(client)
_play(client, "press on")
first_ai = _rows(client.adv_id, "ai")[0]
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
_take(client, first_ai.id)
assert _gold(client.adv_id) == 10
+11 -4
View File
@@ -31,8 +31,7 @@ from app.main import app
# The three tables as they stood at schema 45, frozen. This is a snapshot
# of a past schema. It must not be updated to track `models.py`, because
# the whole point is that it lacks what SP1 adds. This DDL uses SQLite
# syntax only. The migration's Postgres half is exercised against a real
# server at deploy time (see plan/14).
# syntax only.
PRE_TREE_DDL = (
"""
CREATE TABLE adventures (
@@ -120,7 +119,14 @@ def pre_tree():
# Dropping the tables is the only way to remove the columns.
# SQLite refuses to drop a column that a foreign key references,
# and that is exactly the case for `branch_id`.
for table in ("actions", "memories", "branches", "adventures"):
#
# `checkpoints` (M4) and `state_events`/`state_proposals` (M5) are
# dropped first and for a different reason: they reference `actions`,
# `branches` and `adventures`, and SQLite refuses to drop a table
# another table still points at. Any future table that references these
# has to be added to the front of this list.
for table in ("state_events", "state_proposals", "checkpoints",
"actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
@@ -593,7 +599,8 @@ def pre_split():
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
for table in ("actions", "memories", "branches", "adventures"):
for table in ("state_events", "state_proposals", "checkpoints",
"actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
+20 -8
View File
@@ -18,7 +18,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import tally_reply, GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_replies
@@ -46,7 +46,10 @@ def client(monkeypatch):
setup.close()
# Force a real, non-demo turn that uses the fake provider.
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
# Each turn states its own running total, absolutely (ADR 010). One
# repeated reply would set the same total every turn, which is exactly
# what a delta protocol could not distinguish from adding.
ScriptedProvider.replies = tally_replies(AI_REPLY, 20)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
@@ -66,12 +69,15 @@ def client(monkeypatch):
def _gold(adv_id) -> int:
"""The banked total. Reading one stat rather than the whole section keeps
these assertions about the rollback, not about the schema's shape."""
"""The banked total, read out of the authoritative narrative state.
M5 moved the instrument from an RPG stat to a typed narrative fact. What
these assertions are about is unchanged: rollback, and which position's
state is current.
"""
db = SessionLocal()
try:
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return player.get("gold", 0)
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -102,11 +108,16 @@ def test_two_turns_then_undo_reverts_only_last(client):
def test_retry_does_not_double_apply_gold(client):
"""A retry replaces its turn rather than stacking on top of it.
The retake states the same total the turn it replaces did, so a state that
had stacked would read as a value no position ever established. Before the
fix this file was written for, the turn's effects ran twice.
"""
_play(client)
assert _gold(client.adv_id) == 10
# Before the fix, this produced 20 because the turn's effects ran twice.
# Now it stays 10.
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _gold(client.adv_id) == 10
@@ -114,6 +125,7 @@ def test_retry_does_not_double_apply_gold(client):
def test_retry_then_undo_still_clean(client):
_play(client)
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
client.post(f"/api/adventures/{client.adv_id}/retry")
assert _gold(client.adv_id) == 10
client.post(f"/api/adventures/{client.adv_id}/undo")
+82 -46
View File
@@ -1,6 +1,32 @@
"""End-to-end HTTP test for RPG world state (Phase 12): a scenario with a
stat_schema, a turn whose (faked) AI reply carries a state delta block, and
undo rolling the world state back.
"""M5: the RPG world state is demoted, and this file is what holds it there.
This was the end-to-end test for Phase 12's RPG world state: a scenario with a
`stat_schema`, a turn whose reply carried a relative-delta block, the referee
applying it, and undo rolling it back.
**M5 removed that pipeline from the turn path** (ADR 010). Relative deltas were
ambiguous by construction — a number in a delta field is syntactically legal
whether the model meant "add 50" or "set to 50" — and the genre-neutral typed
events in `app/narrative/` replaced them. The engine in `app/worldstate/` is
retained so a pre-M5 database opens unchanged and its numbers stay readable, and
its own logic is still covered by `test_worldstate.py`; what no longer exists is
its authority over new turns.
So this file now tests the demotion, which is worth a test precisely because it
is an absence: nothing else would notice if the delta pipeline were quietly
rewired, and a second authoritative state engine is the specific failure the M5
brief names.
What was deleted, and why, rather than being re-instrumented:
* `test_turn_applies_clamped_delta_and_strips_block`, `test_action_world_changes_summary`,
`test_world_state_endpoint`, `test_undo_reverts_world_state`,
`test_retry_does_not_double_apply` — each asserted that a turn applies a
delta through the referee. That is the removed capability itself, not
instrumentation for something else. The invariants they *also* touched —
undo restoring state, a retry not double-applying — are alive and better
covered in `test_head_cursor.py`, `test_take_state.py` and
`test_narrative_state.py`, now measured through the production state path.
python -m pytest tests/test_worldstate_integration.py -v
"""
@@ -103,47 +129,74 @@ def _play(client, text="attack the goblin"):
return r
def test_turn_applies_clamped_delta_and_strips_block(client):
def test_a_turn_no_longer_applies_a_relative_delta_block(client):
"""The demotion, asserted.
The fixture's reply carries the old protocol. Under Phase 12 this moved hp
from 100 to 70. Under M5 the referee is not in the turn path at all, so the
numbers stay where they were — and the block still leaves the prose, because
a reader must never see the protocol whichever protocol it is.
"""
_play(client)
ws = _world(client.adv_id)
assert ws["player"]["hp"] == 70 # -80 capped to -30
assert ws["npc"]["gwen"]["trust"] == 15
assert ws["flags"]["alarm"] is True
assert ws["milestones"]["win"]["reached"] is True
# The state block is not shown to the player.
assert "```state" not in _last_ai_text(client.adv_id)
assert "goblin's blade" in _last_ai_text(client.adv_id)
# The raw model reply, including the block, is kept for the Insights view.
world = _world(client.adv_id)
assert world["player"]["hp"] == 100, "the delta pipeline is still wired into turns"
assert world.get("milestones", {}) == {}
assert world.get("flags", {}) in ({}, {"alarm": False})
text = _last_ai_text(client.adv_id)
assert "```state" not in text, "the protocol reached the reader"
assert "goblin's blade" in text
# The raw reply is still kept, so the Insights view and the audit can show
# what the model actually sent.
db = SessionLocal()
try:
snap = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
snapshot = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
finally:
db.close()
assert "```state" in snap["raw_output"]
assert '"player.hp": -80' in snap["raw_output"]
assert '"player.hp": -80' in snapshot["raw_output"]
def test_action_world_changes_summary(client):
def test_an_old_style_block_proposes_no_narrative_events(client):
"""A delta block is not a typed proposal, and must not be read as one.
`{"player.hp": -80}` is a JSON object with no `events` list. It parses, so
it is not malformed; it simply proposes nothing. What matters is that no
part of it is coerced into an event — a state engine that guessed here would
reintroduce exactly the ambiguity ADR 010 removed.
"""
_play(client)
db = SessionLocal()
try:
changes = db.get(models.Adventure, client.adv_id).actions[-1].world_changes
proposal = (
db.query(models.StateProposal)
.filter_by(adventure_id=client.adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
assert proposal is not None, "no proposal was recorded for the turn"
assert db.query(models.StateEvent).filter_by(
adventure_id=client.adv_id).count() == 0
finally:
db.close()
by_label = {c["label"]: c for c in changes}
assert by_label["hp"]["delta"] == -30 # clamped stat, signed delta
assert by_label["gwen trust"]["delta"] == 15 # npc.<id>.<stat> -> "id stat"
assert by_label["alarm"] == {"kind": "flag", "label": "alarm", "on": True}
assert by_label["win"]["kind"] == "milestone"
def test_world_state_endpoint(client):
_play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
def test_the_narrative_state_is_what_the_turn_now_establishes(client):
"""And the replacement works on the same campaign, in the same turn shape."""
ScriptedProvider.replies = [
'The blade turns aside.\n```state\n{"events": ['
'{"type": "create_entity", "entity": "gwen", "entity_type": "character",'
' "name": "Gwen"},'
'{"type": "set_entity_status", "entity": "gwen", "status": "wary"}'
']}\n```'
]
_play(client, "parry")
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
body = r.json()
assert body["schema"]["player"]["hp"]["max"] == 100
assert body["state"]["player"]["hp"] == 70
document = r.json()["document"]
assert document["entities"]["gwen"]["status"] == "wary"
def test_override_world_state_endpoint(client):
@@ -160,20 +213,3 @@ def test_override_world_state_endpoint(client):
# This bypasses max_delta_per_turn (30) because it is a direct correction, not a turn.
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
assert r.json()["state"]["player"]["hp"] == 100
def test_undo_reverts_world_state(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 100 # back to initial
assert _world(client.adv_id)["milestones"] == {}
def test_retry_does_not_double_apply(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 70 # not 40
+3 -4
View File
@@ -4,10 +4,9 @@ Reviewing a prompt tells you what it asks for. It does not tell you what a model
does with it. This runs the real pipeline twice over the *same* blocks of the
*same* story, changing only the prompt, and prints the memories side by side.
What it showed the first time it was run, and why `MEMORY_MAX_WORDS` exists, is
written up in `plan/18-persona-and-memory-quality.md`. Two consecutive memories
from one story came back in two different persons, and the same model wrote 34
words for one block and 105 for the next.
Why `MEMORY_MAX_WORDS` exists is what it showed the first time it was run: two
consecutive memories from one story came back in two different persons, and the
same model wrote 34 words for one block and 105 for the next.
Three things make it a fair test rather than a demonstration:
-1227
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1577
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

-267
View File
@@ -1,267 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI D&amp;D — an AI Dungeon-style storytelling engine</title>
<meta name="description" content="An AI Dungeon-style interactive storytelling app. FastAPI + React, any OpenAI-compatible model, an RPG world-state engine the AI proposes and Python referees, and a quickjs sandbox that runs real AI Dungeon scripts.">
<meta property="og:title" content="AI D&amp;D — an AI Dungeon-style storytelling engine">
<meta property="og:description" content="Play open-ended adventures narrated by an LLM, with a world-state engine that keeps the numbers honest.">
<meta property="og:image" content="https://parththakkar106.github.io/AI-DnD/images/play-world-state.jpg">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9A%94%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0f;
--panel: #131320;
--border: #2b2b3d;
--border-bright: #3d3d55;
--text: #e2ddd0;
--dim: #918c7d;
--accent: #d4a94e;
--accent-bright: #e8c476;
--accent-dim: #96773a;
--display: 'Cinzel', Georgia, serif;
--ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1200px 700px at 15% -10%, rgba(212,169,78,.06), transparent 60%),
radial-gradient(1000px 600px at 90% 110%, rgba(88,76,140,.08), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
font-family: var(--ui);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
a { color: var(--accent-bright); }
header { padding: 72px 0 40px; text-align: center; }
.mark { font-family: var(--display); font-size: 14px; letter-spacing: .28em; color: var(--accent); text-transform: uppercase; }
h1 {
font-family: var(--display); font-weight: 700;
font-size: clamp(2.4rem, 6vw, 4rem); margin: .2em 0 .1em; letter-spacing: .02em;
background: linear-gradient(180deg, var(--accent-bright), var(--accent));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.tagline { font-size: clamp(1.05rem, 2.2vw, 1.3rem); color: var(--text); max-width: 46ch; margin: .6em auto 0; }
.sub { color: var(--dim); max-width: 60ch; margin: 1em auto 0; font-size: .97rem; }
.cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; margin: 32px 0 10px; }
.btn {
display: inline-block; padding: 13px 26px; border-radius: 8px; text-decoration: none;
font-weight: 600; font-size: 1rem; border: 1px solid var(--border-bright); transition: .18s;
}
.btn-primary { background: linear-gradient(180deg, var(--accent-bright), var(--accent)); color: #17130a; border-color: var(--accent); }
.btn-primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
.btn-ghost { background: var(--panel); color: var(--text); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent-bright); }
.wake { color: var(--dim); font-size: .85rem; text-align: center; margin-top: 4px; }
figure { margin: 0; }
figure img {
width: 100%; height: auto; display: block; border-radius: 10px;
border: 1px solid var(--border); box-shadow: 0 24px 60px rgba(0,0,0,.55);
}
figcaption { color: var(--dim); font-size: .88rem; margin-top: 12px; }
.hero-shot { margin: 44px 0 8px; }
section { padding: 56px 0; border-top: 1px solid var(--border); margin-top: 56px; }
h2 {
font-family: var(--display); font-size: 1.6rem; font-weight: 700;
color: var(--accent); margin: 0 0 8px; letter-spacing: .02em;
}
.lede { color: var(--dim); margin: 0 0 32px; max-width: 68ch; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 32px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 22px; }
.card h3 { font-family: var(--display); font-size: 1.12rem; margin: 0 0 8px; color: var(--text); }
.card p { margin: 0 0 16px; color: var(--dim); font-size: .95rem; }
.card p:last-child { margin-bottom: 0; }
.card img { border-radius: 8px; border: 1px solid var(--border); width: 100%; height: auto; display: block; }
pre {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; overflow-x: auto; font-size: .86rem; line-height: 1.7; color: var(--text);
}
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-top: 8px; }
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.stat .n { font-family: var(--display); font-size: 1.9rem; color: var(--accent-bright); line-height: 1.1; }
.stat .l { color: var(--dim); font-size: .88rem; margin-top: 6px; }
ul.notes { padding-left: 0; list-style: none; margin: 0; }
ul.notes li { border-left: 2px solid var(--accent-dim, #96773a); padding: 2px 0 2px 18px; margin-bottom: 22px; color: var(--dim); }
ul.notes strong { color: var(--text); }
.stack { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
.chip { background: var(--panel); border: 1px solid var(--border); border-radius: 999px; padding: 5px 14px; font-size: .85rem; color: var(--dim); }
footer { border-top: 1px solid var(--border); margin-top: 56px; padding: 36px 0 64px; color: var(--dim); font-size: .9rem; text-align: center; }
@media (max-width: 720px) {
header { padding: 48px 0 24px; }
section { padding: 40px 0; margin-top: 40px; }
.btn { width: 100%; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="mark">⚔ Interactive fiction, refereed</div>
<h1>AI D&amp;D</h1>
<p class="tagline">Open-ended adventures narrated by an LLM — with an engine that keeps the numbers honest.</p>
<p class="sub">Create a world, play it in second person, and let the model improvise the story while a Python
referee enforces what's actually true: hit points, an ally's trust, a raised alarm, a quest milestone.
Bring your own model, or play the demo with none.</p>
<div class="cta">
<a class="btn btn-primary" href="https://ai-dnd-1gmp.onrender.com">Launch the live demo →</a>
<a class="btn btn-ghost" href="guide.html">Read the design notes</a>
<a class="btn btn-ghost" href="architecture.html">HLD &amp; LLD walkthrough</a>
<a class="btn btn-ghost" href="https://github.com/parththakkar106/AI-DnD">View the source</a>
</div>
<p class="wake">No sign-up, no API key. Hosted on a free tier that sleeps — the first load takes ~30–60s to wake.</p>
<figure class="hero-shot">
<img src="images/play-world-state.jpg" alt="The play screen with the world-state rail open, showing HP, mana, an NPC's trust and a raised alarm flag">
<figcaption>The left rail is live world state. The model proposes what changed this turn; the engine decides
what sticks, and the chip under the narration reports the result. The <code>&lsaquo; 2/2 &rsaquo;</code>
under a turn steps between the takes it has.</figcaption>
</figure>
</header>
<section>
<h2>What makes it more than a chat wrapper</h2>
<p class="lede">Four things a plain "talk to a model" app doesn't do.</p>
<div class="grid">
<div class="card">
<h3>The story is a tree</h3>
<p>Any turn can hold more than one <em>take</em>. Stepping between them is free — the story below simply
empties, and the server is told nothing. Writing below a take that isn't the live one is what makes a
branch, and a branch stores no turns of its own: it records where it left its parent and borrows
everything above that. Twenty forks cost 1.007× the page load of the same story flat. Switch lines and
the world state, the script scoreboard and the cooldown clocks all come back to what that line left.</p>
<img src="images/branch-map.jpg" alt="The branch map: one horizontal lane per line of the story, each joined to its parent by an elbow at the moment it forked">
</div>
<div class="card">
<h3>The AI proposes, Python referees</h3>
<p>A scenario declares stats, flags, milestones and a named cast. Each turn the model appends the changes
it thinks happened — and the engine clamps them to range, enforces per-turn caps and cooldowns, keeps
counters monotonic and milestones sticky, then strips the machine-readable block out of the prose.
Word-labelled bands (<code>40–60: minor damage</code>) are what make the model reliable at it.
No dice, no scripting required.</p>
<img src="images/scenario-editor-npcs.jpg" alt="The scenario editor showing NPC stats with ranges, per-turn caps, cooldowns and labelled bands">
</div>
<div class="card">
<h3>You can see the entire prompt</h3>
<p>Every turn stores exactly what was sent to the model. Open Insights on any action to see each context
component, what it cost in tokens, and why it was there — including which trigger word pulled in each
story card and the similarity score behind each retrieved memory.</p>
<img src="images/insights.jpg" alt="The Insights panel showing the assembled prompt broken into components with token counts">
</div>
<div class="card">
<h3>Real AI Dungeon scripts run</h3>
<p>The three familiar hooks — <code>onInput</code>, <code>onModelContext</code>, <code>onOutput</code> —
with shared persistent <code>state</code> and a <code>worldEntries</code> API, executed in an embedded
quickjs sandbox. Scripts written for AI Dungeon import and work, and there's a CodeMirror editor in the app.</p>
<img src="images/script-editor.jpg" alt="The in-app script editor showing an input hook written in JavaScript">
</div>
<div class="card">
<h3>Memory that survives a long story</h3>
<p>The modern AI Dungeon memory system: AI-generated memories every few actions, a running story summary,
and embedding-based retrieval that pulls an old-but-relevant fact back into context when it matters.
Undo and retry roll the world state back to a per-action snapshot rather than only rewriting the text.
Every story stays where you left it, and the home page opens on its most recent line.</p>
<img src="images/home.jpg" alt="The home page, showing stories in progress alongside scenarios to start from">
</div>
</div>
</section>
<section>
<h2>How a turn works</h2>
<p class="lede">Player input goes through the script pipeline, into a token-budgeted context, out to whichever
model you configured, and back through the referee.</p>
<pre><code>player input
→ onInput script modifier
→ assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions]
+ [plot essentials] + [story summary] + [retrieved memories]
+ [triggered story cards] + [history along this branch, token-budgeted]
+ [author's note] + [player action]
→ onModelContext script modifier
→ snapshot context (Insights)
→ provider adapter → AI (streamed)
→ extract + referee the world-state delta block, strip it from the prose
→ onOutput script modifier
→ store &amp; render</code></pre>
<div class="stack">
<span class="chip">FastAPI</span>
<span class="chip">SQLAlchemy</span>
<span class="chip">React + Vite</span>
<span class="chip">Postgres / SQLite</span>
<span class="chip">quickjs sandbox</span>
<span class="chip">Server-sent events</span>
<span class="chip">Docker</span>
<span class="chip">Any OpenAI-compatible endpoint</span>
</div>
</section>
<section>
<h2>Engineering notes</h2>
<p class="lede">The parts that were measured rather than guessed at.</p>
<div class="stats">
<div class="stat"><div class="n">189×</div><div class="l">less database egress per adventure load</div></div>
<div class="stat"><div class="n">440</div><div class="l">backend tests, run by CI on every push</div></div>
<div class="stat"><div class="n">64</div><div class="l">schema migrations, applied in order on boot</div></div>
<div class="stat"><div class="n">$0</div><div class="l">to run it locally against Ollama</div></div>
</div>
<ul class="notes" style="margin-top:34px">
<li><strong>Database egress, cut ~189×.</strong> Every adventure load was pulling the entire assembled
prompt — about 74 KB per turn — just to read two small fields off it. Moving those into their own columns
and deferring the heavy ones took one load from 38.5 MB to 0.20 MB. A test hooks into SQLAlchemy's cursor
events and fails if a bulk load ever names those columns again.</li>
<li><strong>Turn cost, made flat.</strong> Assembling a turn walked the whole story, so it grew with story
length — 839 KB of reads by turn 200. History is now served as tails and slices from SQL: the same turn
costs 129 KB and stops growing at around turn 50.</li>
<li><strong>Branching that costs 103 bytes.</strong> A branch stores where it left its parent and borrows
every turn above that, so nothing is copied on a fork. A 40-turn story forked twenty times loads in
31,652 B against 31,433 B for the same story flat — 1.007×. Reads stay cheap because the ancestry is
windowed the way the history is: the number of SQL clauses is bounded by the context window, not by how
many times the story has forked.</li>
<li><strong>A shared demo key that can't be drained.</strong> The hosted demo funds a model for visitors, so
model selection is pinned server-side with a structural backstop that raises if any code path tries to
resolve a model outside the allowed set — plus a daily per-visitor turn cap.</li>
</ul>
</section>
<footer>
<p>Built by <a href="https://github.com/parththakkar106">Parth Thakkar</a> ·
<a href="https://github.com/parththakkar106/AI-DnD">Source on GitHub</a> ·
<a href="https://github.com/parththakkar106/AI-DnD/blob/main/LICENSE">MIT</a></p>
<p>Run it yourself with one command: <code>docker compose up --build</code></p>
</footer>
</div>
</body>
</html>
-156
View File
@@ -1,156 +0,0 @@
# Self-review log
**Every correctness bug on this page is resolved.** 20 are fixed, and 1 is intentionally
skipped, with the reasoning recorded below. The record is kept because the reasoning outlives
the verdicts, and several of these are traps worth remembering. The *cleanup backlog* at the
bottom is a deliberately open list of non-bugs (reuse, simplification, efficiency), not
outstanding defects.
Original review: 2026-07-05, whole project in scope (no git history at the time).
Status key: `fixed` means applied, `skipped` means intentionally not fixed, and `pending` means
outstanding (none remain).
**2026-07-06 update (branch `bugfix-code-review`):** every finding was re-verified against
current code. #1, #2, #3, #4, #6, and #12 had already been fixed in earlier sessions, so their
statuses were stale. #5, #7, #8, #9, #10, #13, #14 (backend) and #15 through #21 (frontend)
were fixed in this pass. #11 is skipped: real AI Dungeon's `addStoryCard` also returns the new
card's index (0-falsy included) per the official scripting guidebook, so changing it would
break compatibility. This is documented in `engine.py`'s prelude instead. The cleanup backlog
(R/S/E/A items) below remains open by design.
## Correctness bugs
### 1. [fixed] seed_demo.py doesn't stamp schema version → server crashes on next start
- `backend/seed_demo.py:10`
- A fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. The next server start sees the tables exist and replays every ALTER TABLE migration, causing a `duplicate column name` crash.
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
### 2. [fixed] Turn-lock race: two simultaneous turns can run on the same adventure
- `backend/app/routers/adventures.py:302`
- `ensure_not_generating()` runs in the route handler, but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-clicking Continue sends both requests past the 409 check, producing duplicate Action.index rows and interleaved generations.
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
### 3. [fixed] Migration 10 renumbers indexes with a correlated subquery on the table being updated
- `backend/app/migrations.py:34`
- SQLite may evaluate the subquery against partially updated rows, so duplicate indexes can survive the "repair".
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
### 4. [fixed] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
- `backend/app/database.py:8`
- Deleting a Script leaves orphaned `scenario_scripts` rows. SQLite rowid reuse can then attach a future script to an old scenario.
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
### 5. [fixed] Provider generate() silently yields nothing for non-SSE 200 responses
- `backend/app/providers/openai_compatible.py:84`
- A server that ignores `stream=true` and returns plain JSON produces no `data:` lines, so the result is an empty AI action with no error.
- Fix: buffer the non-SSE body and fall back to parsing it as a single JSON completion.
### 6. [fixed] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
- `backend/app/memorybank.py:146`
- The result of `asyncio.create_task` is not referenced, so the task can vanish silently and leave an adventure ID stuck in `_running`.
- Fix: keep strong refs in a set, discard in done-callback.
### 7. [fixed] Memory cursors are list positions but Memory.source_start/end are Action.index values
- `backend/app/memorybank.py:213`
- After any action deletion, indexes keep gaps and positions shift, so summarization skips or duplicates blocks and `_update_story_summary` folds the wrong memories.
- Fix: use one space consistently. Track cursors by Action.index (position-independent), or renumber on delete.
### 8. [fixed] Pinned memories don't count toward memory_top_k cap
- `backend/app/memorybank.py:120`
- 6 pinned plus top_k=5 injects 11 memories, blowing the token budget.
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
### 9. [fixed] Embedding-model change → cosine() zips different-dimension vectors silently
- `backend/app/memorybank.py:69`
- Old 768-dimension embeddings scored against a new 1536-dimension query produce garbage similarity with no error, and are never re-embedded.
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
### 10. [fixed] MAX_STORY_CARDS cap is a no-op for cards created in one hook
- `backend/app/scripting/pipeline.py:59`
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards, since seen_ids is a subset of existing. A script can insert an unbounded number of cards in one turn.
- Fix: count inserts made during the loop.
### 11. [skipped] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
- `backend/app/scripting/engine.py:39`
- `if (!addStoryCard(...))` misfires when the card list was empty.
- Fix: return `storyCards.length` (1-based, always truthy) or `true`; document.
### 12. [fixed] scenario_id=0 truthiness bug in create_story_card
- `backend/app/routers/story_cards.py:28`
- `scenario_id or ...` picks the wrong owner when id is 0. Use `is not None`.
### 13. [fixed] test_connection 500s on non-dict JSON from /models
- `backend/app/routers/settings.py:54`
- Only ValueError is caught. `data.get`/`m.get` on non-dict input raises AttributeError, producing a 500 instead of `{ok:false}`.
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
### 14. [fixed] AI Dungeon exports with `worldInformation` key lose all story cards silently
- `backend/app/routers/scenarios.py:133`
- Import reads only `storyCards`/`worldInfo`. `worldInformation` is in _IGNORED_KEYS, so it is dropped without being reported.
- Fix: accept `worldInformation` as a card source too.
### 15. [fixed] Shared debounce timer loses edits (Play PlotPanel)
- `frontend/src/pages/Play.jsx:28`
- One `saveTimer` is shared by all plot fields and story-card saves. Editing a second field within 600ms cancels the first pending PATCH, causing silent data loss.
- Fix: per-key timers (e.g. a Map keyed by field/card id).
### 16. [fixed] Same shared-debounce data loss in ScenarioEditor
- `frontend/src/pages/ScenarioEditor.jsx:22`
- Same fix as #15.
### 17. [fixed] Continue button silently discards typed input text
- `frontend/src/pages/Play.jsx:469`
- Clicking Continue with text in the box sends type 'continue' (the backend ignores the text) and clears the input.
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
### 18. [fixed] retry() optimistically deletes last AI action with no rollback on failure
- `frontend/src/pages/Play.jsx:475`
- A failed retry (409 or network error) leaves the UI missing an action that still exists server-side.
- Fix: restore the removed action in the catch path (or only remove on first stream event).
### 19. [fixed] Settings test()/save() have no error handling → stuck on "Testing…"
- `frontend/src/pages/Settings.jsx:66`
- A rejection leaves `{pending:true}` forever and produces an unhandled rejection.
- Fix: try/catch → setTestResult({ok:false, error:msg}).
### 20. [fixed] InsightsPanel race: slow earlier request overwrites newer report
- `frontend/src/pages/Play.jsx:302`
- There is no staleness guard, so a slow getAdventureContext call can overwrite a newer action snapshot.
- Fix: track a request id or cancelled flag in the effect.
### 21. [fixed] extractPlaceholders ignores ${...} in story-card trigger keys
- `frontend/src/pages/Scenarios.jsx:50`
- The backend fills placeholders in card.keys, but the modal never prompts for those names, so a literal `${hero}` key never matches.
- Fix: also scan card.keys when collecting placeholder names.
## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later)
- **R1** ~~three copies of the debounced-autosave handler.~~
**Half applied in phase 17 (2026-08).** All three use
`frontend/src/hooks/useDebouncedSave.js`. A shared StoryCardList component is
still open.
- **R2** `backend/seed_demo.py:228`: re-implements create_adventure. Call the router logic instead.
- **R3** `backend/app/providers/openai_compatible.py:122`: complete() duplicates _request()'s body building. Add a `stream` param to _request().
- **R4** ~~six copies of child-resource get, owner-check, and 404.~~
**Applied in phase 17 (2026-08).** All 32 handlers take the `current_adventure`
dependency from `routers/adventures/deps.py`.
- **R5** `frontend/src/api.js:26`: streamSSE duplicates request()'s error extraction. Extract `throwIfNotOk(resp)`.
- **S1** ~~`Settings.stream` is dead state (never read).~~
**Applied in phase 17 (2026-08).** The column, both schema fields, and migration 65
drop it. `R4` went with it: the ownership check is the `current_adventure` dependency.
- **S2** `frontend/src/pages/Play.jsx:6`: MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo are computed twice.
- **E1** `backend/app/context/builder.py:119`: joins and tokenizes the entire adventure history every turn for the trigger window. Walk reversed(actions) until budget instead.
- **E2** `backend/app/scripting/pipeline.py:77`: rebuilds full history dicts, JSON, and a blocking commit per script per hook. Build once per hook, slice to HISTORY_WINDOW first, and commit once.
- **E3** `frontend/src/pages/Play.jsx:561`: every SSE chunk re-renders all action rows. Isolate streaming text in a child component or React.memo rows.
- **E4** `backend/app/context/builder.py:48`: Section.tokens is uncached, so the whole context gets tokenized 2 to 3 times per turn. Cache counts and sum sections.
- **E5** `frontend/src/pages/Play.jsx:490`: the keydown effect has no dependency array, so the listener is re-registered every render.
- **E6** `backend/app/memorybank.py:182`: catch-up summarization awaits blocks sequentially. Gather independent blocks instead.
- **A1** ~~no UniqueConstraint('adventure_id','index'); index allocation is ad-hoc per writer.~~
**Overtaken by phase 14 (2026-08).** `index` is a legacy column that nothing reads: ordering
is `(branch_id, depth)` now, allocated in one place (`tree.place_action`). The column is kept
unread for one release and then dropped, so a constraint on it would apply to a column that
no longer does anything.
- **A2** `backend/app/providers/openai_compatible.py:45`: CHAT_CONTINUE_HINT is appended below the budgeting layer. Assemble prompts in the context builder instead.
- **A3** `backend/app/routers/adventures.py:390`: import endpoints hand-coerce raw dicts. Use a Pydantic bundle schema.
- **A4** `backend/app/routers/adventures.py:207`: onModelContext flattens (system, story) and ships everything as user content if modified. Pass structure through the hook instead.
- **A5** `frontend/src/pages/Home.jsx:87`: the client appends 'Z' to naive datetimes. Emit ISO-8601 with an offset from the API instead.
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, use TypeScript with type-aware lint rules enabled. See the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for how to integrate TypeScript and Oxlint's TypeScript-related rules in your project.
+19 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks } from './branches'
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches'
// The story tree, drawn.
//
@@ -108,6 +108,9 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
// would throw away the only copy of it.
const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
const drop = async () => { if (await onDelete(selected)) setConfirming(false) }
// Save Points naming a moment on the selected branch, or on anything forked
// from it, keep it alive — the server refuses to delete them along with it.
const protecting = selected ? savePointsUnder(branches, selected.id) : 0
return createPortal(
<div className="modal-overlay branch-map-overlay" onClick={onClose}>
@@ -239,7 +242,13 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
{confirming ? (
<div className="branch-confirm">
<span>Delete this branch and everything forked from it?</span>
{/* The same wording the list gives, because the same deletion
is reachable from both views and a rule that held in one of
them would not be a rule. */}
<span>
Delete this branch and everything forked from it? The story on
other paths, and every Save Point, is unaffected.
</span>
<button type="button" className="danger" disabled={busy} onClick={drop}>
Delete
</button>
@@ -265,10 +274,14 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
the head stands on is offered, and says why it cannot go. */}
{!isRoot && (
<button type="button" className="danger"
disabled={busy || isLoadBearing}
title={isLoadBearing
? 'The line you are reading is built on this one. Switch away first.'
: undefined}
disabled={busy || isLoadBearing || protecting > 0}
title={
isLoadBearing
? 'The line you are reading is built on this one. Switch away first.'
: protecting > 0
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
: undefined
}
onClick={() => setConfirming(true)}>Delete</button>
)}
</div>
+33
View File
@@ -106,6 +106,36 @@ 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
// is. Restore answers with the story as it now stands, like a branch switch,
// so the caller replaces its window rather than reloading everything.
listCheckpoints: (advId) => request(`/adventures/${advId}/checkpoints`),
createCheckpoint: (advId, name, note = '') =>
request(`/adventures/${advId}/checkpoints`, {
method: 'POST', body: JSON.stringify({ name, note }),
}),
renameCheckpoint: (advId, checkpointId, name) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}`, {
method: 'PATCH', body: JSON.stringify({ name }),
}),
deleteCheckpoint: (advId, checkpointId) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}`, { method: 'DELETE' }),
restoreCheckpoint: (advId, checkpointId) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}/restore`, { method: 'POST' }),
// Play a turn again, differently (SP9). An AI turn regenerates; a player's
// own takes the text given. Streams, because it is a turn like any other.
//
@@ -124,6 +154,9 @@ export const api = {
exportAdventure: (id) => request(`/adventures/${id}/export`),
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
// Undo moves the story back without deleting it, so there is somewhere to
// move forward to again (M3). Both answer with the newest window.
redo: (advId) => request(`/adventures/${advId}/redo`, { method: 'POST' }),
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
// "Update from scenario": GET describes what would change, POST applies it.
previewRefresh: (advId) => request(`/adventures/${advId}/refresh`),
+35
View File
@@ -61,6 +61,41 @@ export function headLineage(branches) {
return out
}
// How many Save Points would go if this branch were deleted.
//
// Deleting a branch deletes everything forked from it, and a Save Point names a
// position on a line, so the Save Points on the whole doomed subtree go too.
// The count is summed over that subtree rather than over the one branch — a
// warning that said "1 Save Point" while three disappeared would be worse than
// no warning at all.
//
// Computed here because the panel already holds every branch and its parent,
// so the answer costs a walk rather than an endpoint. The server remains the
// authority on what is actually deleted; this only lets the button say so
// before it is pressed, the same division `headLineage` already uses.
export function savePointsUnder(branches, rootId) {
const children = new Map()
for (const b of branches) {
const key = b.parent_branch_id
if (!children.has(key)) children.set(key, [])
children.get(key).push(b)
}
const byId = new Map(branches.map((b) => [b.id, b]))
const seen = new Set()
const stack = [rootId]
let total = 0
while (stack.length) {
const id = stack.pop()
// The guard is against a cycle, which the schema forbids and a walk should
// still never hang on.
if (seen.has(id)) continue
seen.add(id)
total += byId.get(id)?.save_points ?? 0
for (const child of children.get(id) ?? []) stack.push(child.id)
}
return total
}
// ---------- Map geometry ----------
export const ROW_H = 64 // one branch, name above the lane and meta below
+106 -16
View File
@@ -5,8 +5,8 @@
* imports below.
*
* The page component still owns all of the session state. Lifting it into a
* `usePlaySession` hook waits for Stage 5 of `plan/17-refactor.md`, which adds
* a frontend test runner. Moving eighteen `useState` calls and seven
* `usePlaySession` hook waits for the frontend test runner M8 adds
* (`planning/BUILD-MILESTONES.md`). Moving eighteen `useState` calls and seven
* `useEffect` calls is a rewrite rather than a move, and nothing would catch a
* mistake in it today.
*/
@@ -22,6 +22,8 @@ import { BranchPanel } from './panels/BranchPanel'
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']
@@ -68,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' | '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)
@@ -104,6 +106,12 @@ export default function Play() {
const [total, setTotal] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loadingOlder, setLoadingOlder] = useState(false)
// Whether Undo and Redo have anywhere to go, as the server last reported it
// (M3). Neither is derivable here: Undo stops at the campaign opening, which
// may be off the top of the loaded window, and Redo depends on the retained
// future, which the transcript is never sent. So the flags ride on every
// window the server hands back and this holds the newest answer.
const [history, setHistory] = useState({ undo: false, redo: false })
const storyEndRef = useRef(null)
const abortRef = useRef(null)
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
@@ -155,10 +163,19 @@ export default function Play() {
setActions(adv.actions)
setTotal(adv.action_count ?? adv.actions.length)
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
// A campaign closed while undone reopens undone, so the controls have
// to be told before the reader can press one.
setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo })
})
.catch(() => navigate('/'))
}, [id, navigate])
// Record what the server says the history controls can do. Every response
// that carries a window carries them, so this is called wherever one lands.
const noteHistory = useCallback((page) => {
setHistory({ undo: !!page.can_undo, redo: !!page.can_redo })
}, [])
// Take the story the server just handed back, whole.
//
// Switching a branch and forking one both answer with the newest window of
@@ -173,11 +190,12 @@ export default function Play() {
setActions(page.actions)
setTotal(page.total)
setHasMore(page.has_more)
noteHistory(page)
// The world state comes back to what that branch's tip left
// behind, so anything drawn from them is now showing another line's
// numbers until it re-reads.
setStateKey((k) => k + 1)
}, [])
}, [noteHistory])
// Fetch the page above the one on screen and prepend it.
//
@@ -216,6 +234,7 @@ export default function Play() {
}
setTotal(page.total)
setHasMore(page.has_more)
noteHistory(page)
} catch {
// Leave hasMore alone: a failed fetch should let the reader try again
// by scrolling, not permanently hide the rest of their story. But hold
@@ -227,7 +246,7 @@ export default function Play() {
loadingOlderRef.current = false
setLoadingOlder(false)
}
}, [actions, hasMore, id])
}, [actions, hasMore, id, noteHistory])
// Put the viewport back after a prepend. useLayoutEffect, not useEffect:
// this has to run before the browser paints, or the reader sees the story
@@ -277,6 +296,12 @@ export default function Play() {
setReasoningStream(null)
setActions((prev) => [...prev, event.action])
setTotal((n) => n + 1)
// A turn was accepted, so there is something to undo and nothing left to
// redo: writing below a moved-back head is the divergence that retires
// the old future, and writing at the tip never had a future in front of
// it. Set here rather than fetched, because the turn arrives over SSE and
// this is the same answer the server would give.
setHistory({ undo: true, redo: false })
} else if (event.type === 'error') {
setStreaming(null)
setReasoningStream(null)
@@ -363,30 +388,42 @@ export default function Play() {
})
}
async function undo() {
// Undo and Redo differ only in which way the head moves, so they share
// everything else: the same window shape comes back, the same reader state is
// dropped, and the same world state has been restored underneath.
async function moveHead(call) {
setToast(null)
setPreview(null)
setPinned(null)
try {
// A window, not the whole story — undo is the action most likely to be
// repeated several times running, so it must not re-fetch everything.
const page = await api.undo(id)
// A window, not the whole story — these are the actions most likely to be
// repeated several times running, so they must not re-fetch everything.
const page = await call(id)
setActions(page.actions)
setTotal(page.total)
setHasMore(page.has_more)
noteHistory(page)
// The story is at a different position, so the state panels are showing
// the numbers of a turn that is no longer where the story ends.
setStateKey((k) => k + 1)
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
// Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field.
const undo = () => moveHead(api.undo)
const redo = () => moveHead(api.redo)
// Ctrl+Z undo / Ctrl+Shift+Z redo / Ctrl+R retry, ignored while typing.
useEffect(() => {
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
const onKey = (e) => {
if (!(e.ctrlKey || e.metaKey) || busy) return
if (e.target.closest?.('input, textarea, select, [contenteditable]')) return
if (e.key.toLowerCase() === 'z' && canUndo) {
if (e.key.toLowerCase() === 'z' && e.shiftKey && history.redo) {
e.preventDefault()
redo()
} else if (e.key.toLowerCase() === 'z' && !e.shiftKey && history.undo) {
e.preventDefault()
undo()
} else if (e.key.toLowerCase() === 'r' && lastIsAi) {
@@ -417,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
@@ -428,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 })
}
@@ -467,6 +517,7 @@ export default function Play() {
setActions(adv.actions)
setTotal(adv.action_count ?? adv.actions.length)
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo })
// The branch and the world state can both have moved.
setStateKey((k) => k + 1)
} catch { /* stale beats wrong */ }
@@ -490,7 +541,6 @@ export default function Play() {
if (!adventure) return null
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
return (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
@@ -504,12 +554,16 @@ 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' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'savepoints' ? 'active' : ''}
onClick={() => setPanel(panel === 'savepoints' ? null : 'savepoints')}>Save Points</button>
<button className={panel === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
@@ -673,7 +727,19 @@ export default function Play() {
<div className="turn-buttons">
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
<button onClick={undo} disabled={busy || !canUndo} title="Ctrl+Z">↶ Undo</button>
<button onClick={undo} disabled={busy || !history.undo} title="Ctrl+Z">↶ Undo</button>
{/* Undo keeps what it steps back over, so this walks the story
forward into it again. Disabled once a new turn has been written
from here: that future is retained, but it is no longer the
continuation this story tells. */}
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
{/* Save Point sits with the history controls because that is what
it is: a name for a position Undo and Redo move between. The
button opens the panel, where the name is typed — the moment it
saves is wherever the story is being read, so there is nothing
to choose first. */}
<button onClick={() => setPanel('savepoints')} disabled={busy}
title="Name this moment so you can come back to it">⚑ Save Point</button>
</div>
<div className="input-bar">
<div className="mode-select">
@@ -712,10 +778,23 @@ export default function Play() {
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<h2>{{
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' ? (
@@ -724,6 +803,17 @@ export default function Play() {
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'savepoints' ? (
// Restoring one moves the story exactly as Undo and Redo do, so it
// adopts the returned window the same way a branch switch does —
// the state panels are showing another position's numbers until
// they re-read.
<SavePointPanel
advId={id}
refreshKey={`${actions.length}:${stateKey}`}
onRestored={adoptWindow}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : panel === 'branches' ? (
<BranchPanel
advId={id}
+26 -6
View File
@@ -11,7 +11,7 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { BranchMap } from '../../../BranchMap'
import { branchLabel, headLineage, orderBranches } from '../../../branches'
import { branchLabel, headLineage, orderBranches, savePointsUnder } from '../../../branches'
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
const [branches, setBranches] = useState(null)
@@ -88,6 +88,12 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
// was forked from. The button said nothing about that and answered
// with a toast; it now says so before it is pressed.
const loadBearing = lineage.has(branch.id)
// A Save Point naming a moment on this branch, or on anything forked
// from it, keeps the branch alive: named moments last until the user
// deletes them, so the server refuses the deletion rather than taking
// them with it. Counted over the subtree, because deleting a branch
// takes its descendants.
const protecting = savePointsUnder(branches, branch.id)
return (
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
style={{ marginLeft: indent * 12 }}>
@@ -117,11 +123,20 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
{branch.own_actions} of its own
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
{` · ends at ${branch.depth + 1}`}
{protecting > 0 &&
` · ${protecting === 1 ? '1 Save Point' : `${protecting} Save Points`} kept here`}
</div>
{isConfirming ? (
<div className="branch-confirm">
<span>Delete this branch and everything forked from it?</span>
{/* Only reachable when no Save Point points into this
subtree — the button is disabled otherwise, and the server
refuses regardless. So this says what the deletion costs
without hedging about Save Points that cannot be at risk. */}
<span>
Delete this branch and everything forked from it? The story
on other paths, and every Save Point, is unaffected.
</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(branch)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
@@ -145,10 +160,15 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
{/* The root holds the turns every other branch borrows, and
the server refuses it — so it is not offered. */}
{branch.parent_branch_id !== null && (
<button type="button" className="danger" disabled={busy || loadBearing}
title={loadBearing
? 'The line you are reading is built on this one. Switch away first.'
: undefined}
<button type="button" className="danger"
disabled={busy || loadBearing || protecting > 0}
title={
loadBearing
? 'The line you are reading is built on this one. Switch away first.'
: protecting > 0
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
: undefined
}
onClick={() => setConfirming(branch.id)}>Delete</button>
)}
</div>
@@ -0,0 +1,209 @@
// Save Points: naming a place in the story, and going back to one.
//
// "Save Point" is the word throughout, and the words branch, head, node and
// fork appear nowhere a player can read (`BROWSER-UX-SPEC.md` §23). The panel
// is deliberately small — a form, a list, and two confirmations. It is not a
// branch explorer, and the tree it sits over stays out of sight.
//
// Both confirmations exist to say what does *not* happen, because that is the
// part a player cannot see and would otherwise assume the worst about: restore
// keeps the later story, and deleting a Save Point deletes no story at all.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
function SavePointPanel({ advId, refreshKey, onRestored, onError }) {
const [points, setPoints] = useState(null)
const [failed, setFailed] = useState(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const [busyId, setBusyId] = useState(null)
const [renaming, setRenaming] = useState(null) // { id, text }
const [confirming, setConfirming] = useState(null) // { id, kind }
const [tick, setTick] = useState(0)
useEffect(() => {
let cancelled = false
setFailed(null)
api.listCheckpoints(advId)
.then((list) => { if (!cancelled) setPoints(list) })
.catch((err) => { if (!cancelled) setFailed(err.message) })
return () => { cancelled = true }
}, [advId, refreshKey, tick])
// Answers whether it worked, so a caller can keep its editor open on a
// refusal — a rename the server turned down must not take the typed name
// with it.
async function run(id, work) {
setBusyId(id)
try {
await work()
setTick((t) => t + 1)
return true
} catch (err) {
onError(err.message)
return false
} finally {
setBusyId(null)
}
}
async function create(e) {
e.preventDefault()
if (!name.trim() || saving) return
setSaving(true)
try {
await api.createCheckpoint(advId, name.trim(), note.trim())
setName('')
setNote('')
setTick((t) => t + 1)
} catch (err) {
onError(err.message)
} finally {
setSaving(false)
}
}
const restore = (p) => run(p.id, async () => {
onRestored(await api.restoreCheckpoint(advId, p.id))
setConfirming(null)
})
const rename = async (p) => {
if (await run(p.id, () => api.renameCheckpoint(advId, p.id, renaming.text))) {
setRenaming(null)
}
}
const remove = async (p) => {
if (await run(p.id, () => api.deleteCheckpoint(advId, p.id))) setConfirming(null)
}
return (
<div className="save-point-panel">
<form className="save-point-new" onSubmit={create}>
<label htmlFor="save-point-name">Save this moment</label>
<input
id="save-point-name"
autoFocus
maxLength={120}
placeholder="Before entering the abbey"
value={name}
disabled={saving}
onChange={(e) => setName(e.target.value)}
/>
<input
className="save-point-note"
maxLength={500}
placeholder="A note, if you want one (optional)"
value={note}
disabled={saving}
onChange={(e) => setNote(e.target.value)}
/>
<button type="submit" className="primary" disabled={saving || !name.trim()}>
Save Point
</button>
<p className="save-point-hint">
Saves the moment you are reading now. If you have stepped back, that
is the moment it saves.
</p>
</form>
{failed && <div className="panel-empty">Couldn’t read the Save Points — {failed}</div>}
{!failed && !points && <div className="panel-empty">Reading your Save Points…</div>}
{!failed && points?.length === 0 && (
<p className="save-point-intro">
No Save Points yet. Name a moment you might want to come back to, then
keep playing — going back to it later leaves everything you wrote
after it in place.
</p>
)}
<div className="save-point-list">
{(points || []).map((p) => {
const isRenaming = renaming?.id === p.id
const busy = busyId === p.id
const confirm = confirming?.id === p.id ? confirming.kind : null
return (
<div key={p.id} className={`save-point-row ${p.on_path ? '' : 'elsewhere'}`}>
<div className="save-point-head">
{isRenaming ? (
<input
className="save-point-rename"
autoFocus
maxLength={120}
value={renaming.text}
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') rename(p)
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span className="save-point-name">{p.name}</span>
)}
</div>
<div className="save-point-meta">
Moment {p.turn}
{/* Said plainly, without naming a branch: the story took a
different turning after this point, and going back to it
returns to the telling it was saved in. */}
{!p.on_path && p.resolved && ' · on a path you left'}
{!p.resolved && ' · this moment is no longer in the story'}
</div>
{p.note && <div className="save-point-note-text">{p.note}</div>}
{confirm === 'restore' ? (
<div className="save-point-confirm">
<span>
The story will return to this Save Point. Everything you
wrote after it is kept — it just stops being where you are.
</span>
<button type="button" className="primary" disabled={busy}
onClick={() => restore(p)}>Restore</button>
<button type="button" onClick={() => setConfirming(null)}>Cancel</button>
</div>
) : confirm === 'delete' ? (
<div className="save-point-confirm">
<span>
Delete this Save Point? Deleting it does not delete any of
the story — only the name you gave this moment.
</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(p)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
</div>
) : (
<div className="save-point-tools">
<button type="button" disabled={busy || !p.resolved}
title={p.resolved ? undefined
: 'The moment this Save Point named is no longer in the story.'}
onClick={() => setConfirming({ id: p.id, kind: 'restore' })}>
Restore
</button>
{isRenaming ? (
<>
<button type="button" disabled={busy} onClick={() => rename(p)}>Save</button>
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenaming({ id: p.id, text: p.name })}>
Rename
</button>
)}
<button type="button" className="danger" disabled={busy}
onClick={() => setConfirming({ id: p.id, kind: 'delete' })}>
Delete
</button>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export { SavePointPanel }
@@ -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} />
+186
View File
@@ -255,6 +255,102 @@
flex-basis: 100%;
}
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
Point is a name for a position, and it should not look like a different
species of thing from the lines it names positions on. The confirmations
borrow the branch panel's shape but not its danger colouring — restoring
destroys nothing, and only deletion is red. */
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
.save-point-new label {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-dim);
}
.save-point-new input {
padding: 5px 9px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-new input.save-point-note {
font-size: 0.82rem;
}
.save-point-hint, .save-point-intro {
margin: 0;
color: var(--text-dim);
font-size: 0.78rem;
line-height: 1.55;
}
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
.save-point-row {
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-input);
}
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
than hidden: it still restores, and hiding it would be the automatic cleanup
this milestone deliberately does not do. */
.save-point-row.elsewhere { opacity: 0.72; }
.save-point-head { display: flex; align-items: center; gap: 7px; }
.save-point-name {
font-family: var(--font-story);
font-size: 0.98rem;
color: var(--text);
}
.save-point-rename {
flex: 1;
min-width: 0;
padding: 3px 7px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-meta {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.save-point-note-text {
font-size: 0.78rem;
color: var(--text-dim);
line-height: 1.5;
}
.save-point-tools, .save-point-confirm {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.save-point-tools button, .save-point-confirm button {
padding: 3px 9px;
font-size: 0.72rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.save-point-tools button:hover:not(:disabled),
.save-point-confirm button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.save-point-tools button.danger:hover:not(:disabled),
.save-point-confirm button.danger:hover:not(:disabled) {
color: var(--danger);
border-color: var(--danger);
}
.save-point-tools button:disabled,
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
.save-point-confirm span {
font-size: 0.74rem;
color: var(--text-dim);
line-height: 1.5;
flex-basis: 100%;
}
.action-edit { margin-bottom: 14px; }
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
several paragraphs, and the old fixed 110px turned that into a keyhole.
@@ -314,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;
}
-122
View File
@@ -1,122 +0,0 @@
# AI D&D — Local AI Dungeon Clone: Plan Overview
> **[STATUS.md](STATUS.md) — where things stand and what to pick up next.** Read that
> first; this file is the shape of the project, not its current state.
A locally hosted web app replicating AI Dungeon's core experience: scenarios, adventures,
AI-driven storytelling, AI Dungeon-style memory/context management, JavaScript scripting
(compatible with real AI Dungeon scripts), and full transparency into what is sent to the AI.
## Confirmed decisions
| Area | Decision |
|---|---|
| Backend | Python — FastAPI + SQLAlchemy + SQLite (single-user, local) |
| Frontend | React SPA (Vite), dark AI Dungeon-like theme |
| AI provider | Provider-agnostic adapter layer; first adapter: **OpenAI-compatible** (`/v1/chat/completions`) — covers Ollama, LM Studio, OpenAI, OpenRouter, vLLM, Groq. Endpoint URL, API key, model name all configurable at runtime. |
| Scripting | **JavaScript, AI Dungeon-compatible** (`onInput` / `onModelContext` / `onOutput` modifiers, shared `state`, `worldEntries` API) via an embedded JS engine (quickjs / py-mini-racer). Real AI Dungeon scripts should import and run. |
| Import/export | AI Dungeon-compatible formats for scripts and scenarios; JSON export/import for everything. |
## Architecture at a glance
```
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ routers/ (scenarios, adventures, actions, scripts, settings, insights)
├─ models/ (SQLAlchemy: Scenario, Adventure, Action, StoryCard, Script, Settings)
├─ context/ (prompt assembly: memory, author's note, world info, history budget)
├─ scripting/ (JS sandbox, AI Dungeon API surface, per-adventure state)
├─ providers/ (base adapter + openai_compatible.py; streaming)
└─ data.db (SQLite)
```
## Core domain model
- **Scenario** — template: title, description, opening prompt (with `${placeholders}`), memory,
author's note, story cards (world info), attached scripts, tags.
- **Adventure** — a playthrough created from a scenario (or blank). Owns its own copy of memory,
author's note, story cards, script state, and the action list.
- **Action** — one entry in the story: type (`do` / `say` / `story` / `continue` / AI output),
text, timestamp, plus the **context snapshot** (exact prompt sent to the AI) for Insights.
- **Story Card / World Info** — keys (comma-separated keywords), entry text, optional type/notes.
Injected into context only when a key matches recent story text.
- **Script** — JS source per hook (input / context / output modifier), attachable to scenarios;
copied into adventures with persistent `state`.
## The turn pipeline (heart of the app)
```
player input
→ onInput script modifier
→ store player action
→ assemble context: [AI instructions] + [plot essentials] + [story summary]
+ [triggered story cards ("World Lore:")] + [story history, token-budgeted]
+ [author's note inserted N lines from the end] + [player action]
→ onModelContext script modifier
→ snapshot context (Insights)
→ provider adapter → AI (streamed)
→ onOutput script modifier
→ store AI action → render
```
## Phases
1. **[Phase 1 — Foundation](01-phase-foundation.md)**: repo scaffold, FastAPI + SQLite models,
React shell, scenario/adventure CRUD, settings (endpoint config).
2. **[Phase 2 — Play loop + AI](02-phase-play-loop.md)**: provider adapter with streaming,
Do/Say/Story/Continue, Retry/Undo/Edit, the adventure play screen.
3. **[Phase 3 — Context engine + Insights](03-phase-context-insights.md)**: memory, author's note,
story cards with keyword triggering, token budgeting, per-turn prompt snapshots + Insights UI.
4. **[Phase 4 — Scripting](04-phase-scripting.md)**: embedded JS sandbox, AI Dungeon scripting API,
script editor, script + scenario import/export (AI Dungeon-compatible).
5. **[Phase 5 — Polish](05-phase-polish.md)**: AI Dungeon-like theming pass, placeholders on
scenario start, adventure export/import, quality-of-life and hardening.
6. **[Phase 6 — Auto Summarization + Memory Bank](06-phase-memory-bank.md)** *(optional)*:
modern AI Dungeon memory system — AI-generated memories every 6 actions, Story Summary every
15, embedding-based retrieval of relevant memories into context.
Each phase ends with the app runnable and testable end-to-end.
## Public release (phases 7–10)
Goal: public GitHub repo + hosted multi-user deployment, linkable from resume/website.
| Area | Decision |
|---|---|
| License | MIT |
| Auth | Email + password, **optional** — guest sessions play instantly, register to keep data |
| Signup | Open (rate-limited) |
| LLM keys | BYOK per user + shared server-funded demo key (capped, details TBD in Phase 8) |
| Database (hosted) | **TBD — ask at start of Phase 9** (SQLite-on-disk vs Postgres) |
| Hosting | Render (tier TBD in Phase 10) |
| Domain | Platform URL (custom domain later, optional) |
| README media | Deferred — text-only first, screenshots/GIF in a later pass |
Open questions are recorded at the top of each phase file under "Ask before implementing".
7. **[Phase 7 — Public repo & portability](07-phase-public-repo.md)**: MIT license, portfolio
README, Dockerfile + compose, cross-platform run instructions, publish to GitHub.
8. **[Phase 8 — Optional accounts & multi-user](08-phase-accounts.md)** *(the big one)*:
guest-first sessions, optional email+password upgrade, per-user data scoping across all
routers/tables, per-user encrypted BYOK settings, shared demo key with caps.
9. **[Phase 9 — Production hardening](09-phase-hardening.md)**: env-var config, quickjs
time/memory limits, rate limiting, size/row caps, locked-down debug surface, production
serving, database decision.
10. **[Phase 10 — Deploy & publish](10-phase-deploy.md)**: Render blueprint + deploy, seeded
demo scenarios, live smoke test, resume/website links and blurb.
## Post-launch
- **[State revert + retry fix](11-state-revert-and-retry-fix.md)**: undo/retry roll the shared
`script_state` back; per-action `state_before` snapshots; undo concurrency lock.
- **[Phase 12 — RPG world state](12-phase-rpg-world-state.md)**: structured world/player/NPC stats
+ milestones per scenario (`stat_schema`); the AI proposes deltas, a Python engine clamps them
(min/max, per-turn cap, cooldown, sticky milestones); band descriptions keep the model honest;
World State drawer + Insights delta report; reuses the Phase 11 undo/retry snapshot.
- **[Memory-bank embedding cost](13-memory-embedding-cost.md)** *(round three of the egress
work)*: a turn fetched the whole memory bank's vectors to pick five — 96% of everything it
read. Packed float32 + an in-process cache + SQL-side filtering; a played turn went 6.4 MB
to 123 kB. Includes the byte-meter harness (`backend/tools/`). One step left, see STATUS.
- **[Phase 14 — Story tree](14-phase-story-tree.md)** *(designed, not started)*: the linear
action list becomes a branching tree, so a retry is a sibling rather than a rewrite and both
paths survive. The real argument is bug elimination — seven bug classes trace to "the story
is a mutable list" and all disappear when nothing is rewritten in place.
-38
View File
@@ -1,38 +0,0 @@
# Phase 1 — Foundation
**Goal:** runnable skeleton — backend serving a database-backed API, frontend shell with
navigation, scenario & adventure CRUD, and a settings page for the AI endpoint. No AI calls yet.
## Backend
- [x] Project scaffold: `backend/` with FastAPI app, uvicorn entrypoint, `requirements.txt`
(fastapi, uvicorn, sqlalchemy, pydantic, httpx, tiktoken; quickjs deferred to phase 4).
- [x] SQLite via SQLAlchemy; auto-create `data.db` on first run.
- [x] Models:
- `Scenario`: id, title, description, prompt, memory, authors_note, tags, created/updated.
- `StoryCard`: id, owner (scenario or adventure), keys, entry, type, title, description.
- `Adventure`: id, scenario_id (nullable), title, memory, authors_note, script_state (JSON),
created/updated.
- `Action`: id, adventure_id, index, type (`do|say|story|continue|ai|start`), text,
context_snapshot (JSON, nullable), created.
- `Script`: id, name, description, input_js, context_js, output_js, library_js.
- `Settings`: single row — endpoint_url, api_key, model, temperature, max_output_tokens,
context_token_budget, api_mode (`chat|completion`).
- [x] Routers: CRUD for scenarios, adventures (+ create-from-scenario copying memory/AN/cards),
story cards, settings. Consistent JSON errors.
- [x] CORS for the Vite dev server; production mode serves built frontend as static files.
## Frontend
- [x] Vite + React scaffold in `frontend/`; router with pages: Home (adventure list),
Scenarios (list + editor), Play (placeholder), Settings.
- [x] Dark base theme (AI Dungeon-like: near-black background, serif story font, gold/teal accent).
- [x] Scenario editor: title, description, prompt, memory, author's note, story card list editor.
- [x] Settings page: endpoint URL, API key, model name, sampling params; "Test connection" button
(backend proxies a trivial request — wired for real in Phase 2, stub now).
- [x] "New adventure" flow: pick scenario (or blank) → creates adventure → navigates to Play page.
## Exit criteria
Run `uvicorn` + `npm run dev`, create/edit/delete scenarios with story cards, start an adventure
from one, see it listed on Home, and save endpoint settings — all persisted across restarts.
-45
View File
@@ -1,45 +0,0 @@
# Phase 2 — Play loop + AI provider
**Goal:** the core game is playable. Player acts, AI continues the story, streamed live.
## Provider adapter layer
- [x] `providers/base.py`: abstract `Provider` — `generate(prompt_parts, params) -> async stream of text`.
Takes an assembled context object (system text + story text), so providers decide how to
map it to their wire format.
- [x] `providers/openai_compatible.py`:
- Chat mode: system message carries instructions/memory; story history flows in as
user/assistant text continuation framing suited to a chat endpoint. A configurable
"narrator" system prompt frames the AI as a second-person storyteller continuing the text.
- Streaming via SSE from the endpoint, re-streamed to the browser.
- Optional raw completion mode (`/v1/completions`) for pure-continuation models.
- [x] Errors surfaced cleanly (bad key, connection refused, model not found) with retry affordance.
- [x] "Test connection" on Settings now real.
## Turn engine (`POST /adventures/{id}/actions`)
- [x] Input formatting per AI Dungeon conventions:
- **Do** → `> You <text>` (normalized to second person, stripped punctuation as needed)
- **Say** → `> You say "<text>"`
- **Story** → raw text appended
- **Continue** → no player text; AI just continues
- [x] Simple context for this phase: opening prompt + full history, truncated from the top to the
token budget (tiktoken count). Real context engine lands in Phase 3.
- [x] Response streamed to the client via SSE; final text stored as an `ai` action.
- [x] **Retry**: delete last AI action, regenerate with same input.
- [x] **Undo/Erase**: delete last action pair (player + AI) or single action.
- [x] **Edit**: PATCH any action's text in place.
## Play UI
- [x] Story view: continuous prose (not chat bubbles), player actions styled distinctly
(`>` prefix, accent color), auto-scroll, streaming text renders token-by-token.
- [x] Input bar with mode selector (Do / Say / Story) + Continue button; Enter to send.
- [x] Per-turn controls: Retry, Undo, Edit (inline contenteditable or textarea swap).
- [x] Loading/streaming state, error toast with retry.
## Exit criteria
Point Settings at any OpenAI-compatible endpoint (e.g. Ollama or LM Studio locally), start an
adventure, and play a multi-turn story with all four input modes plus retry/undo/edit, with
streaming output.
-62
View File
@@ -1,62 +0,0 @@
# Phase 3 — Context engine + Insights
**Goal:** AI Dungeon-grade context management, and full visibility into every prompt.
## Context assembly (`context/builder.py`)
Assembles the prompt each turn from AI Dungeon's **plot components**
(per help.aidungeon.com/faq/the-memory-system):
```
[AI Instructions] ← behavioral guidance for the model (always included)
[Plot Essentials] ← key facts for constant recall — the classic "Memory" (always)
[Story Summary] ← running summary slot; manual in this phase, auto in Phase 6
[Triggered Story Cards] ← "World Lore: <entry>" for each triggered card (conditional)
[Story history] ← as many recent actions as fit the token budget
[Author's Note] ← injected N lines (default 3) before the end of history
[Latest player action] ← (+ script frontMemory right after it, Phase 4)
```
- [x] **AI Instructions / Plot Essentials / Story Summary / Author's Note**: adventure-level
free-text fields, always included, editable mid-adventure from the side panel.
- [x] **Story cards** — five fields per official docs: **Type** (organizational, not sent to AI),
**Name** (not sent to AI), **Entry** (sent when triggered), **Triggers**, **Notes** (not sent).
- Triggers: comma-separated words/phrases; **case-insensitive but space-sensitive**;
**partial-word matching** (`boat` triggers on `boats`); matched against both player input
and AI output in the recent-story window.
- Not instant: a card triggered mid-response only enters context on the *next* turn; once
triggered, stays active while the triggering text remains in the context window.
- Triggered entries injected once each, prefixed `World Lore:`; story cards are the **first
component dropped** when context is full.
- Editable per-adventure (copied from scenario at creation). Soft-cap sanity limit (AI Dungeon
allows 5,000/adventure).
- [x] **Author's Note**: inserted near the end (strongest steering position), formatted
`[Author's note: <text>]`.
- [x] **Token budgeting** with tiktoken: always-included components (AI Instructions, Plot
Essentials, Story Summary, Author's Note) reserved first; story cards get a capped share
and are dropped first when over budget; remainder goes to story history (newest first).
Budget = `context_token_budget` setting.
- [x] Slots for script-provided memory overrides (Phase 4): `state.memory.context` (prepended),
`state.memory.authorsNote` (replaces/augments author's note), `state.memory.frontMemory`
(inserted immediately after the latest player action).
- [x] Builder returns a structured `ContextReport`: ordered sections, each with source label,
text, token count; plus totals and a list of triggered cards (and which keyword fired).
## Insights
- [x] Every AI turn stores its `ContextReport` on the `Action` row (`context_snapshot`).
- [x] `GET /adventures/{id}/actions/{id}/context` returns it.
- [x] **Insights panel** in Play UI (drawer/tab):
- Exact final prompt text as sent, sectioned and color-coded (memory / world info / history /
author's note / input), with per-section token counts and total vs budget.
- Which story cards triggered and on which keyword; which history got cut off.
- Viewable for the *upcoming* turn (dry-run endpoint: "what would be sent now") and for any
past AI action.
- [x] Adventure side panel: edit memory, author's note, story cards mid-game (AI Dungeon's
right-hand panel equivalent).
## Exit criteria
Create a card with key `dragon`; mention a dragon in play and see the card enter the context in
the Insights panel (and influence the AI); verify memory and author's note appear in the snapshot
in the right positions; long adventures visibly trim oldest history within budget.
-84
View File
@@ -1,84 +0,0 @@
# Phase 4 — Scripting (AI Dungeon-compatible JavaScript)
**Goal:** real AI Dungeon scripts import and run: the three modifier hooks, persistent `state`,
and the scripting API surface.
## JS runtime
- [x] Embed a JS engine in Python: **quickjs** (preferred; check Windows wheel availability at
implementation time; fallback: py-mini-racer, or Node subprocess as last resort).
- [x] Sandbox matching AI Dungeon's documented limits: each hook runs **isolated**, **16 MB
memory cap**, **2-second timeout**; no filesystem/network/process access; script errors
captured and surfaced in the UI, never crash a turn.
## AI Dungeon scripting model (compatibility target)
*(Per official docs: help.aidungeon.com/faq/how-do-i-write-scripts-and-use-scripting)*
Three lifecycle hooks — `onInput`, `onModelContext`, `onOutput`. Each script defines a modifier
and **must call it as its last line**:
```javascript
const modifier = (text) => {
// script logic
return { text, stop }
}
modifier(text)
```
- **onInput** — modifies player input before context construction.
- **onModelContext** — modifies the assembled text sent to the model.
- **onOutput** — modifies the model output before it is shown/stored.
- **Shared Library** — code prepended to all three slots.
Return contract:
- [x] `{ text, stop }`; `stop: true` from onInput prevents the AI call.
- [x] Empty-string `text` from onInput/onOutput → user-facing error (replicate this behavior).
Globals provided (exact names from docs):
- [x] `text` — hook input (player input / context / AI response respectively).
- [x] `state` — persisted per adventure across turns (`Adventure.script_state`); includes
`state.memory`, `state.message` (shown as a UI notice), `state.placeholders`.
- [x] `state.memory` slots: `context` (prepended to context), `authorsNote` (near end, before
latest response), `frontMemory` (inserted right after the player's input).
- [x] `history` — array of recent actions: `{ text, rawText, type }`.
- [x] `storyCards` — array of `{ id, keys, entry, type }`, backed by the adventure's story cards.
- [x] Story card functions: `addStoryCard(keys, entry, type)` → index (or `false` on duplicate),
`updateStoryCard(index, keys, entry, type)` and `removeStoryCard(index)` → throw if absent.
- [x] Legacy aliases for older scripts: `worldInfo` / `worldEntries`, `addWorldEntry`,
`updateWorldEntry`, `removeWorldEntry` mapped onto the storyCards implementation.
- [x] `info` — `{ actionCount, characterNames, memoryLength, maxChars }`.
- [x] `log(message)` / `console.log` — captured per turn, shown in a script log panel.
## Pipeline integration
```
player input → INPUT modifier → format & store
context build → CONTEXT modifier → (snapshot includes pre- and post-script versions in Insights)
AI response → OUTPUT modifier → store & render
```
- [x] Insights (Phase 3) extended: show context before vs after the context modifier (diff view),
and script log output per turn.
## Script management UI
- [x] Scripts page: create/edit scripts with a code editor (CodeMirror), one tab per slot
(Library / Input / Context / Output), description field.
- [x] Attach scripts to scenarios; adventures inherit at creation. Enable/disable per adventure.
- [x] Test-run a script against sample text without an AI call.
## Import / Export
- [x] **Scripts**: export/import as JSON bundle `{ name, library, input, context, output }` and
as raw `.js` files per slot (matching how AI Dungeon scripts circulate — paste or file).
- [x] **Scenarios**: export/import JSON including prompt, memory, author's note, story cards,
and attached scripts. Accept AI Dungeon scenario export JSON where format is known;
map fields best-effort and report anything unmapped.
## Exit criteria
Paste a real AI Dungeon script (e.g. a simple input modifier + state counter + world entry
manipulation) and it runs unmodified across turns; state persists; export a scenario with scripts,
re-import it into a fresh database, and play it.
-38
View File
@@ -1,38 +0,0 @@
# Phase 5 — Polish & quality of life
**Goal:** the app feels like AI Dungeon — cohesive dark UI, smooth flows, safe data handling.
## UI/UX pass
- [x] Theming: refined dark palette, serif story typography, subtle textures/gradients à la
AI Dungeon; consistent buttons, panels, modals; responsive layout.
- [x] Home: adventure cards with scenario name, last-played time, action count; search/filter;
scenario gallery with tags.
- [x] Play screen: collapsible right side panel (Memory / Cards / Scripts / Insights tabs),
keyboard shortcuts (Enter send, Ctrl+Z undo, Ctrl+R retry), smooth streaming autoscroll
that pauses when the user scrolls up.
- [x] Scenario **placeholders**: `${Character name}` style variables in prompt/memory prompt the
player for values when starting an adventure (AI Dungeon behavior).
## Data & robustness
- [x] Adventure export/import (full JSON: actions, memory, cards, script state) — backup/share.
- [x] Delete confirmations (trash/soft-delete skipped — plain confirm dialogs).
- [x] SQLite migrations story (versioned schema bootstrap via PRAGMA user_version —
`backend/app/migrations.py`).
- [x] Request logging + a debug page tailing recent provider requests/responses (bodies redacted
of API key) — "Recent AI requests" on the Settings page.
- [x] Graceful handling: provider timeout/cancel (stop generation button), concurrent turn lock
per adventure.
## Nice-to-haves (only if time/interest)
- [ ] Multiple provider profiles with quick switching (e.g. local Ollama vs OpenRouter).
- [ ] Per-scenario generation params overriding global settings.
- [ ] Retry with "give me something different" (temperature bump / anti-repeat nudge).
- [ ] Basic light theme toggle.
## Exit criteria
A friend could sit down at `localhost`, start a scenario with placeholders, play comfortably,
peek at Insights, and you can back up / restore everything via export files.
-47
View File
@@ -1,47 +0,0 @@
# Phase 6 — Auto Summarization + Memory Bank (optional)
**Goal:** replicate modern AI Dungeon's Memory System (per
help.aidungeon.com/faq/the-memory-system): AI-generated memories, a running Story Summary, and
embedding-based retrieval. This phase makes extra AI calls (summarization + embeddings), so it is
opt-in per adventure and gated on the endpoint supporting it.
## Auto Summarization
- [x] **Memories**: every 6 actions (starting at action 12), summarize that block of
player actions + AI responses into a short "memory" via a background AI call
(same provider, cheap/configurable model override).
- [x] **Story Summary**: every 15 actions, update the Story Summary plot component — a running
overview of the plot — folding in recent memories; compress it when it grows too long.
- [x] Story Summary stays **manually editable**; user edits inform future updates
(they are the base text for the next summarization pass) but are never overwritten silently.
- [x] Summarization failures are non-fatal: log, retry next interval.
(Failed calls appear on the debug page; cursors only advance on success, so the
next turn retries. Implementation: `backend/app/memorybank.py`.)
## Memory Bank
- [x] Store each memory with an **embedding vector** (OpenAI-compatible `/v1/embeddings`;
embedding model configurable in Settings; feature disabled if unavailable).
- [x] Each turn, embed the recent story text and rank memories by cosine similarity;
inject the top-K "Used Memories" into context as their own component
(between Story Summary and story cards in the layout). Pinned memories are always
included; top-K is a setting (default 5).
- [x] Configurable bank capacity (AI Dungeon tiers: 25–400; ours: a setting, default 200);
when full, evict least-recently-used/least-retrieved memories ("Forgotten Memories").
- [x] SQLite storage for vectors (JSON blob + in-memory cosine ranking — pure Python,
no numpy needed at this scale).
## UI
- [x] Memory Bank panel: list memories (used / idle / forgotten), edit or delete, pin favorites,
see which memories were retrieved for a given turn (🔍 on an AI action → Insights snapshot).
- [x] Insights integration: retrieved memories shown as a context section with similarity scores.
- [x] Adventure settings: toggle auto-summarization / memory bank (per adventure, in the Memory
panel); summary + embedding models are chosen globally in Settings (deliberate
simplification — one endpoint config for the whole app).
## Exit criteria
Play a 40+ action adventure: memories appear every 6 actions, the Story Summary updates every 15,
an early-game fact that scrolled out of the raw history gets retrieved via the Memory Bank when it
becomes relevant again, and Insights shows exactly which memories were injected and why.
-62
View File
@@ -1,62 +0,0 @@
# Phase 7 — Public repo & portability
**Goal:** make the repo public-worthy and runnable by anyone on any OS, so the GitHub link is
immediately usable on a resume — before any hosted-deployment work.
## Decisions (confirmed)
| Question | Answer |
|---|---|
| License | **MIT** |
| README media (screenshots/GIF) | **Skip for now** — text-only README; visuals in a later pass |
**Repo name (decided): `AI-DnD`.** Ask before publishing: whether the existing commit
history/messages are fine to publish as-is.
## Repo hygiene
- [x] Add `LICENSE` (MIT, current year, Parth Thakkar).
- [x] Verify no secrets or user data are tracked (`openrouter_key.env`, `data.db` — already
gitignored and never committed; re-verify before push).
- [x] Add `backend/.env.example` documenting every env var the app reads (grows in Phase 9).
(Currently just `AIDND_DB_PATH`, added to `database.py` for Docker/hosted volumes.)
- [x] Decide what to do with `CODE_REVIEW_FINDINGS.md` and `plan/` — keep (shows process, good
for a portfolio) — just give them a one-line mention in the README.
## README rewrite (portfolio-grade, text-only)
- [x] Pitch paragraph: what it is, what makes it interesting (AI Dungeon-compatible scripting,
memory bank with embeddings, full prompt transparency/Insights, provider-agnostic).
- [x] Feature list with pointers into the code (scripting engine, context builder, memory bank).
- [x] Architecture diagram (reuse/refresh the one in `plan/00-OVERVIEW.md`).
- [x] Setup instructions for **Windows (start.ps1), macOS/Linux (manual), and Docker**.
- [x] "Bring your own model" section: Ollama / LM Studio / OpenRouter free models — emphasize it
runs fully free.
- [x] Placeholder section for screenshots/GIF (added in a later pass).
## Docker (one-command run for non-Windows users)
- [x] `Dockerfile`: multi-stage — build frontend (`npm run build`), then Python image serving
FastAPI with the built SPA mounted (SPA fallback already exists in `app/main.py`).
(3 stages: node build → pip wheel build with gcc for quickjs → slim runtime.)
- [x] `docker-compose.yml`: single service, named volume mounted at `/data`
(`AIDND_DB_PATH=/data/data.db`), port mapping.
- [x] `start.sh` for macOS/Linux dev parity with `start.ps1` (optional, nice-to-have).
- [x] Test: `docker compose up` from a clean clone → app works at `http://localhost:8000`.
Done 2026-07-06 in WSL2 Ubuntu (Docker Engine installed there for this): health + SPA +
deep links 200, scenario created via API survives a full `compose down`/`up` (named
volume). Two real bugs found and fixed: package-lock.json was missing top-level
`@emnapi/*` entries (regenerated with npm 11.18) and the build image needed node 24 to
match the npm-11 lockfile. Note: from Windows, reach a WSL-hosted container via the WSL
IP (`hostname -I`) — localhost forwarding didn't apply; irrelevant on normal hosts.
## Publish
- [ ] Create public GitHub repo (`gh repo create`), push `main`.
- [ ] Add repo description, topics (`ai-dungeon`, `fastapi`, `react`, `llm`, `interactive-fiction`).
- [ ] Confirm the GitHub rendering of README looks right.
## Exit criteria
A stranger on macOS with Docker installed can clone the repo, run one command, open the app,
paste an OpenRouter free-tier key, and play an adventure — without asking you anything.
-81
View File
@@ -1,81 +0,0 @@
# Phase 8 — Optional accounts & multi-user ✅ (implemented 2026-07-06, branch `phase-8-accounts`)
**Goal:** turn the single-user app into a multi-user one where **accounts are optional**:
a visitor can start playing instantly as a guest, and can register (email + password) at any
point to keep their adventures across devices/browsers. This is the largest phase — it touches
every router and most tables.
## Decisions (confirmed)
| Question | Answer |
|---|---|
| Auth method | **Email + password, optional** — guest sessions work without an account |
| Signup policy | **Open signup** (rate-limited) |
| LLM API keys | **BYOK + shared demo key** — users can paste their own key; users without one get limited turns on a server-funded key |
| Demo key funding | **OpenRouter free models** (owner's key, `:free` whitelist; default `google/gemma-4-26b-a4b-it:free`) |
| Demo turn cap | **20 successful turns/user/day** (failed provider calls don't count) |
| Guest data retention | **Never delete** for v1 (no cleanup job; revisit if the DB grows) |
| Password reset | **Skipped for v1** (no email provider; forgotten password = lost account) |
| Login with an active guest session | Guest is **abandoned**, not merged (its data stays under the guest user) |
| Memory bank on demo key | **Disabled** (no background AI calls on the server-funded key; visible note in the Memory panel/Insights) |
## Data model
- [x] `User` table: id, email (nullable — null means guest), password_hash (nullable),
created_at, last_seen_at, is_guest flag, demo_turns_used + demo_turns_date.
- [x] `user_id` FK on `Adventure`, `Scenario`, `Script`, `Settings`. Story cards/actions/
memories inherit scope via their parent (ownership checks resolve the parent).
- [x] Settings **per-user** (row per user_id, unique index). API key **encrypted at rest**
(Fernet; key derived from `AIDND_SECRET_KEY` or auto-generated `secret.key` next to the
DB). Key is write-only through the API (`has_api_key` instead of echoing it).
- [x] Migrations 13–23: create "local user" id=1, assign all existing rows to it, unique
index on settings.user_id; plus a Python bootstrap step that encrypts any plaintext
api_key (`enc:` prefix marks encrypted values).
- [x] Demo/starter scenarios: `user_id NULL` + `is_public` — everyone sees them read-only;
`seed_demo.py` seeds the Sunken Crypt scenario as public (its scripts are unowned and
ship with it; the sample adventure belongs to the local user).
## Auth & sessions
- [x] Guest flow: `GET /api/auth/me` with no/invalid cookie → creates guest User + signed
long-lived httpOnly cookie (HMAC, `security.py`). Other endpoints 401 without a session;
the frontend re-establishes via /me and retries once. No signup wall anywhere.
- [x] Register upgrades the guest **in place** (same user_id — data kept). scrypt password
hashing (stdlib, no extra dep).
- [x] Login switches the session cookie to the account (guest abandoned). Logout clears it.
- [x] Every router handler resolves `current_user`; every query filtered by user_id
(scenarios/adventures/scripts/story-cards/settings; debug log is local-mode only since
it's a global buffer).
- [x] Rate limit on register/login: 10 attempts / 5 min per IP (in-memory).
- [x] Local/self-hosted mode stays frictionless: auto-created local user, no login UI unless
`AIDND_MULTI_USER=1`. Local installs and docker compose behave exactly as before.
## Shared demo key (BYOK fallback)
- [x] Env vars: `AIDND_DEMO_API_KEY`, `AIDND_DEMO_ENDPOINT_URL` (default OpenRouter),
`AIDND_DEMO_MODELS` (comma whitelist), `AIDND_DEMO_TURNS_PER_DAY` (default 20).
Demo only activates in multi-user mode.
- [x] No API key configured → demo endpoint/key/whitelisted model; per-user per-day counter;
429 with a friendly "add your own key in Settings" message when capped (checked before
the turn starts so no orphaned player action).
- [x] Memory bank + auto-summarization disabled on demo turns (decided: disable, not count).
## Frontend
- [x] Auth UI: Sign up / Log in modal (register default, toggle to login), "Playing as guest —
sign up to keep your adventures" nudge in the header, account email + logout when
registered. All hidden in local mode (`multi_user:false` from /me).
- [x] `api.js`: 401 → GET /auth/me (new guest session) → retry once, for both JSON and SSE.
- [x] Settings: demo banner ("Using the shared demo key — N of M free turns left today"),
write-only API key field with Remove button, debug log hidden in multi-user mode.
- [x] Public scenarios: "demo ✦" badge in the list; read-only editor (fieldset-disabled) with
an explainer banner; Play/Export still available.
## Exit criteria — verified 2026-07-06
Two sessions (curl cookie jars + Chrome UI): each guest gets an isolated world; register
mid-session keeps all data (same user id); logging in from the second session shows the same
account data; duplicate email → 409; wrong password → 401; rate limiter kicks in. Demo cap
returns 429 at 0 turns left. Migration tested on a copy of the real data.db (rows adopted by
local user, api_key Fernet-encrypted and decrypts back to the original). Live OpenRouter turn
through the encrypted-key path works in local mode. `vite build` + oxlint clean.
-93
View File
@@ -1,93 +0,0 @@
# Phase 9 — Production hardening
**Goal:** make the app safe and stable to expose to strangers on the internet: config via
environment, resource limits on everything user-controlled, and a single-service production
build.
## Decisions
| Question | Answer |
|---|---|
| Database | **Decide at start of this phase.** SQLite on a persistent disk (zero code change, but Render disks require the ~$7/mo starter tier) vs Postgres (free/cheap managed options, better resume talking point, needs SQLAlchemy URL + migration tweaks). Revisit with current Render pricing. |
**Ask before implementing:** the database choice above, and target monthly budget (drives
Render tier: free tier sleeps after idle + has no persistent disk).
## Configuration
- [ ] All config via env vars with sane local defaults: `DATABASE_URL`, `SECRET_KEY`
(sessions + API-key encryption), `MULTI_USER`, `CORS_ORIGINS`, demo-key vars (Phase 8),
port/host. Document each in `backend/.env.example`.
- [ ] Fail fast on missing `SECRET_KEY` when `MULTI_USER=true`.
## Abuse & resource limits
- [ ] **quickjs limits**: per-execution time limit and memory limit on the scripting engine
(`scripting/engine.py`) — user-submitted JS must not be able to hang or OOM the server.
- [ ] Rate limiting on expensive endpoints (turn generation, script run, auth) — per-user and
per-IP (e.g. `slowapi`).
- [ ] Request size limits (script source length, memory/story-card text lengths, action text).
- [ ] Cap per-user row counts (adventures, scenarios, scripts, story cards) with friendly errors.
- [ ] Audit debug router (`routers/debug.py`) and `/docs`: admin-only or disabled when
`MULTI_USER=true` — debug log may contain other users' prompts.
## Production serving
- [ ] Single service: FastAPI serves the built SPA (fallback already exists) — verify the Docker
image from Phase 7 is production-ready (no `--reload`, multiple workers or async-safe
single worker; check SQLite + multiple workers interaction before choosing).
- [ ] CORS locked to the deployed origin (moot if same-origin single service — verify).
- [ ] Security headers middleware; cookies `Secure` + `SameSite`.
- [ ] Streaming (SSE) works behind Render's proxy — verify no buffering issues.
- [ ] Structured logging; scrub API keys from all logs and the debug page.
## Database (after decision)
- [ ] If Postgres: swap `DATABASE_URL`, verify JSON-blob columns (embeddings) and
`migrations.py` work; test full play loop.
- [ ] If SQLite-on-disk: confirm WAL mode + single-worker (or serialized writes) is acceptable.
- [ ] Backup story: platform DB backups (Postgres) or a scheduled dump of the disk (SQLite).
## Exit criteria
Running the production Docker image locally with `MULTI_USER=true`: a hostile user cannot hang
the server with a `while(true)` script, cannot see another user's data or the debug log, gets
rate-limited instead of burning the demo key, and the app streams turns normally the whole time.
### Verified 2026-07-07 (uvicorn, `MULTI_USER=1`, fresh SQLite DB, curl)
- **Fail-fast secret:** `import app.main` with `MULTI_USER=1` and no `AIDND_SECRET_KEY` raises
the RuntimeError as designed (won't boot).
- **`while(true)` script:** `POST /api/scripts/{id}/test` on an `input_js` infinite loop returns
`InternalError: interrupted` (engine time limit) — server stays responsive afterward.
- **Cross-user isolation:** guest B sees `[]` for scripts, gets 404 on guest A's script id;
guest A keeps its own row. No leakage.
- **Debug log:** `GET /api/debug/requests` → 403 in multi-user mode.
- **Rate limiting:** 12 rapid `POST /api/auth/register` → 429 after the 10th (auth scope, 10/300s).
- **Body size:** 3 MB body to `POST /api/scenarios` → 413 (limit 2 MB) via BodySizeLimitMiddleware.
- **Security headers:** CSP, `x-frame-options: DENY`, `x-content-type-options: nosniff`,
`referrer-policy: same-origin` on every response — including the SSE stream.
- **Docs disabled:** Swagger UI and OpenAPI schema not served (`/docs`, `/openapi.json` fall
through to the SPA `index.html`; no `swagger-ui`, no API schema exposed).
- **SSE streaming:** `POST /api/adventures/{id}/actions` streams `text/event-stream` with
`x-accel-buffering: no`, chunked, incremental events — the pure-ASGI middlewares don't buffer.
(No LLM key configured here, so it streams the "No model configured" error event; a *live*
provider turn through this path was verified end-to-end in Phase 8.)
All Phase 9 exit criteria met.
### Postgres path verified 2026-07-07 (real Neon, PostgreSQL 18.4)
Pointed the backend at a live Neon `DATABASE_URL` (pooled, `sslmode=require&channel_binding=require`):
- **Driver/URL:** `postgres://`/`postgresql://` normalized to `postgresql+psycopg://`; raw
psycopg3 connect succeeds.
- **Bootstrap:** fresh DB → `create_all` builds all 11 tables and stamps `schema_version = 23`
(= `LATEST_VERSION`); the SQLite-only migrations 2–23 are correctly skipped on a fresh DB.
- **Embedding column:** `memories.embedding` is Postgres `json`; a float list round-trips
intact (`[0.1, 0.2, 0.3, -0.4]` back as a `list`).
- **ORM CRUD:** user → scenario → adventure → memory create/read/delete with FK cascades works.
- **HTTP round-trip (multi-user, TestClient):** guest bootstrap → register → me → create
scenario → list → settings → delete, all 200/201/204 against Neon.
Remaining Phase 10 step: the Docker production image specifically (build + run the container).
-56
View File
@@ -1,56 +0,0 @@
# Phase 10 — Deploy & publish
**Goal:** the app live on Render at a public URL, linked from resume/website alongside the
GitHub repo.
## Decisions (confirmed)
| Question | Answer |
|---|---|
| Platform | **Render** |
| Domain | **Platform URL is fine** (e.g. `ai-dnd.onrender.com`); custom domain can be added later anytime |
**Ask before implementing:** Render tier (free-with-sleep vs ~$7/mo always-on — depends on the
Phase 9 database decision), and the exact service name (it becomes the public URL).
### Decisions (confirmed 2026-07-07)
| Question | Answer |
|---|---|
| Database | **Neon Postgres** (external managed; free tier has no persistent disk). Path fully verified against real Neon — see `plan/09-phase-hardening.md`. |
| Tier | **Free** (sleeps after ~15 min idle; ~30–60s first-wake). |
| Service name | `ai-dnd` (→ `ai-dnd.onrender.com`, adjustable in dashboard). |
| Region | `virginia` (us-east, matches Neon us-east-1). |
| Local Docker preflight | Skipped by choice — Render builds the same Dockerfile in the cloud; its build logs are the image test. |
## Deploy
- [x] `render.yaml` blueprint: web service from the Dockerfile, env vars (SECRET_KEY generated,
demo-key vars, `MULTI_USER=true`), `/api/health` health check, external Neon Postgres.
README gained a "Deploy (Render)" section.
- [ ] Set up the Render service, connect the GitHub repo, auto-deploy on push to `main`.
- [ ] Seed production with 2–3 good demo scenarios (public/starter scenarios from Phase 8) so
first-time visitors have something great to click immediately.
- [ ] Smoke test the live URL: guest play on demo key, register, BYOK flow, scripting, memory
bank, Insights — from a device/network that isn't yours.
- [ ] Free-tier note: if on free tier, first request after idle takes ~30–60s to wake — add a
friendly loading state or accept it (revisit tier if it feels bad).
## Post-launch guardrails
- [ ] Watch demo-key spend/usage for the first days (OpenRouter dashboard); confirm caps hold.
- [ ] Set up uptime monitoring (free: UptimeRobot or similar) — optional.
- [ ] Error visibility: Render logs are enough for v1; note how to pull them.
## Resume / website
- [ ] README: add the live-demo link + "Try it" section at the top.
- [ ] 2–3 sentence project blurb for resume/website (stack, the interesting hard parts:
AI Dungeon-compatible JS scripting sandbox, embedding-based memory bank, prompt
transparency, guest-first optional auth).
- [ ] Later pass (deferred from Phase 7): screenshots/demo GIF for README and website card.
## Exit criteria
A recruiter clicks one link on your resume, lands on the live app, plays three turns of a demo
scenario as a guest without configuring anything, and can find the GitHub repo from the page.
-81
View File
@@ -1,81 +0,0 @@
# Plan: undo/retry state revert (+ concurrency lock)
Fixes three linked issues around `script_state` (the shared per-adventure
"scoreboard" scripts write to) and undo/retry.
## Background
- `script_state` is one shared dict on `Adventure` (`models.py:90`), mutated in
exactly ONE place: `pipeline.py:89` (`self.adventure.script_state = state`).
- Today, undo (`adventures.py:445`) and retry (`:422`) delete *actions* but never
touch `script_state`, so state never rolls back.
## Issue 1 — retry double-applies state (pre-existing bug)
Retry deletes the last AI action and regenerates. The output hook already mutated
`script_state` on the first attempt; regenerating runs it again, stacking the change
(e.g. "add 10 gold" → 20 gold after one retry). Same root cause as undo not
reverting.
## Issue 4 — undo has no concurrency guard
Turns take `acquire_turn_lock` (`:189`); undo does not, so undo can race a turn
that is still streaming.
## Issue 2 — Memory Bank leftovers (smaller than expected)
`run_post_turn` already clamps `memory_cursor`/`summary_cursor` down to the current
action count (`memorybank.py:178-182`), so there is NO cursor stall. The only
remainder: a `Memory` created from a turn that was later undone stays behind, its
`source_start/source_end` now pointing past the end of the story.
---
## The fix
### 1. Snapshot state per turn (Issue 1 + enables undo revert)
- Add column `state_before: JSON nullable` to `Action` (`models.py`).
- Migration: append `(25, "ALTER TABLE actions ADD COLUMN state_before JSON")`
to `migrations.py`. `JSON` is valid on both SQLite and Postgres.
- In `run_player_turn` / `generate_turn`, when the FIRST action of a turn is
created, stash `copy.deepcopy(adventure.script_state)` onto it — captured
*before* any hook runs. (Player action for do/say/story; the AI action for a
bare `continue`.)
- Fix retry directly: before regenerating, restore
`adventure.script_state` from the deleted AI action's `state_before` so the
output hook starts from the pre-turn scoreboard instead of the mutated one.
### 2. Revert on undo (Issue depends on #1)
- In `undo_turn`, after deleting the popped actions, set
`adventure.script_state = <deleted player/first action>.state_before` (fall back
to `{}` if null, i.e. pre-migration turns), then commit.
- Only wire this into undo + retry — NOT the arbitrary
`delete_action` endpoint (`:830`); mid-history state revert is undefined.
### 3. Lock undo (Issue 4)
- Wrap `undo_turn` body in `acquire_turn_lock(adventure_id)` /
`_active_turns.discard(...)` in a `finally`. It's synchronous (not SSE), so no
`with_turn_lock` wrapper needed — just acquire and discard.
### 4. Clean up dangling memories (Issue 2, optional)
- In `undo_turn` after deleting actions, delete any `Memory` whose `source_start`
is >= the new story-action count (i.e. summarized a turn that no longer exists).
Cursors already self-heal, so this is polish, not correctness.
## Known limits (document, don't fix)
- Story cards a script created (`_apply_cards`, `pipeline.py:87`) are NOT reverted —
only text + script_state roll back.
- Pre-migration turns have `state_before = NULL` → undo falls back to `{}`.
- Demo turn cap is not refunded on undo (intentional).
## Test checklist
- Script that increments a counter: play → undo → counter back to prior value.
- Same script: play → retry → counter changes once, not twice.
- Undo during an active stream returns 409, doesn't corrupt state.
- Undo a turn old enough to have been summarized: no orphaned memory left.
-269
View File
@@ -1,269 +0,0 @@
# Phase 12 — RPG world state (AI-authored, engine-clamped)
**Goal:** each turn carries a structured **world state** — world stats (e.g. `day`),
player stats (`hp`, `mana`), per-NPC stats (`health`, `trust`, …) for the NPCs currently
in scene, and **milestones** (story-progress flags / quest objectives). After the player
acts, the AI reads the current state, narrates, and **proposes** state changes; the
engine **validates and clamps** them against a schema before storing. Stat meanings are
described in words (bands) so the model reasons semantically, not arithmetically.
This is deliberately the "AI owns mechanics, engine enforces limits" design — NOT a
deterministic dice engine. The AI proposes; Python is the referee.
## Design decisions (settled)
- **AI proposes, engine clamps.** The model never owns the numbers directly. It emits
a delta; the engine applies min/max, per-turn caps, and cooldowns server-side. The
AI cannot be trusted to obey its own frequency rules — the engine must.
- **Band descriptions are the reliability trick.** Stats carry word ranges
(`0–20: very weak`, `20–40: hurt`, …). The model reads "he's badly hurt" and adjusts
down, instead of doing math it's bad at.
- **Dedicated NPCs, each with its own stats.** NPCs are defined in a `npcs` section of
the schema, keyed by a stable id (`gwen`). Each has a `name`, `desc`, trigger `keys`,
and its **own** `stats` block (a dragon can have `ferocity`, a merchant `prices`) — no
forced shared template. On adventure creation each NPC auto-creates a story card (from
its name/keys/desc) so lore injection + in-scene detection keep working. Live values
live in `world_state["npc"]` keyed by the NPC id; only NPCs **in scene this turn** get
their stats injected into context. The AI addresses them as `npc.<id>.<stat>`.
- **Schema on the scenario, live values on the adventure.** The scenario is the
template (what stats exist, their bands + rules); the adventure holds current values.
- **Milestones are sticky story flags.** Predefined objectives the AI marks reached via
the same delta channel. Once reached they stay reached (revert only through the undo
snapshot). Injected as "Goals" (pending) so the AI drives toward them and "Achieved"
so it doesn't re-do them. Emergent/AI-invented milestones are out of scope for v1.
- **Flags are two-way booleans.** Separate from milestones: named on/off world/character
state (`has_key`, `disguised`, `alarm_raised`) the AI can flip either direction via
`"flags.<name>": true|false`. Have an `initial` value and a `desc`; no clamp/cooldown.
- **Stat guide (descriptions + band ranges).** A fixed, per-scenario legend injected each
turn, describing each stat's `desc` and its full band ladder (`0–20 very weak, …`) —
handled independently, so a stat may have a description, a range, both, or neither. This
is separate from the live values block (which still shows only the *current* band label),
giving the model the whole scale to reason across without bloating the per-turn line.
- **One-call turn.** The AI narrates AND appends a fenced state-delta block; the engine
parses it and strips it from the visible text. No second LLM call — matters on the
rate-limited free-tier demo (20 req/min). Parser is forgiving of messy JSON from
weaker free models.
- **Memory bank / multi-memory is untouched.** (Confirmed.)
- **Separate from `script_state`.** World state gets its own column so it never collides
with the scripting scoreboard, and reuses the same undo/retry snapshot pattern
(`Action.state_before`, see `plan/11`).
---
## Data model
### Schema definition — `Scenario.stat_schema` (JSON, nullable)
Migration `(26, "ALTER TABLE scenarios ADD COLUMN stat_schema JSON")`.
```jsonc
{
"world": {
"day": { "type": "counter", "min": 1, "initial": 1, "desc": "In-game day",
"max_delta_per_turn": 1, "cooldown": 0 }
},
"player": {
"hp": { "min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30,
"cooldown": 0, "bands": [[0,20,"very weak"],[20,40,"hurt"],
[40,60,"minor damage"],[60,90,"healthy"],[90,100,"full health"]] },
"mana": { "min": 0, "max": 50, "initial": 20, "max_delta_per_turn": 15 }
},
"npcs": { // each NPC has its OWN stats
"gwen": {
"name": "Gwen", "keys": "Gwen, ranger, her",
"desc": "A loyal ranger and the player's ally.",
"stats": {
"health": { "min": 0, "max": 100, "initial": 100, "bands": [...] },
"trust": { "min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
"bands": [[-100,-30,"hostile"],[-30,30,"wary"],[30,100,"ally"]] }
}
}
},
"flags": { // two-way on/off booleans
"has_key": { "desc": "Player holds the dungeon key", "initial": false },
"alarm_raised": { "desc": "The enemy is alerted", "initial": false }
},
"milestones": { // sticky story-progress flags
"rescue_gwen": { "desc": "Rescue Gwen from the bandits" },
"reach_capital": { "desc": "Arrive at the capital city" }
}
}
```
Per-stat rule fields (all optional, engine enforces):
- `min` / `max` — hard clamp.
- `initial` — value when first instantiated.
- `max_delta_per_turn` — largest absolute change allowed in one turn (extra is clamped).
- `cooldown` — minimum player actions between changes to this stat (0 = every turn).
- `bands` — `[lo, hi, label]` triples, used only to describe the value to the model.
- `type` — `"counter"` (monotonic, e.g. day) vs default numeric; counters reject
negative deltas.
Milestones carry only `desc` (the objective text). They are boolean and sticky — the
engine accepts a delta of `true` only, records the action index reached, and ignores
attempts to re-set or un-set (undo is the only way back).
Each NPC in `npcs` carries `name`, `desc`, trigger `keys`, and its own `stats` block
(same per-stat fields as above). All defined NPCs are instantiated up front; a story card
is auto-created per NPC on adventure creation (skipped if a card with that name already
exists) so descriptions inject as lore and in-scene detection works.
### Live values — `Adventure.world_state` (JSON, default `{}`)
Migration `(27, "ALTER TABLE adventures ADD COLUMN world_state JSON")`.
```jsonc
{
"world": { "day": 3 },
"player": { "hp": 55, "mana": 10 },
"npc": { "gwen": { "health": 80, "trust": 20 } }, // keyed by NPC id
"milestones": { "rescue_gwen": { "reached": true, "at": 7 } },
"_meta": { "last_changed": { "player.hp": 7, "npc.gwen.trust": 6 } } // action index
}
```
`_meta.last_changed` backs the `cooldown` rule. NPC blocks are instantiated up front from
each NPC's own `stats`.
### Undo/retry snapshot — `Action.world_state_before` (JSON, nullable)
Migration `(28, "ALTER TABLE actions ADD COLUMN world_state_before JSON")`.
Snapshotted and reverted exactly like `state_before` (Phase 11) — same call sites.
---
## Turn flow
```
player input → INPUT hook (existing)
context build → inject [World State] section (current values + band scale + emit-rule)
AI response → narration + trailing ```state { ...delta... } ``` block
→ parse delta → validate/clamp against schema → apply → strip block
→ snapshot world_state onto the action (undo)
```
### 1. Context injection (`context/builder.py`)
New always-on section `world_state`, placed with the system sections. Keep it **terse**
(competes with story history under the default context budget, raised to 16384 in Phase 12):
```
World state — day 3.
You: HP 55/100 (minor damage), Mana 10/50.
Gwen: health 80 (healthy), trust 20 (neutral).
Goals: Arrive at the capital city.
Achieved: Rescued Gwen from the bandits.
```
- Only inject NPC lines for cards **triggered this turn** — reuse `triggered` /
`card_records` already computed in `build_context` (`builder.py:120`). No extra work.
- Milestones: list unreached ones under `Goals` and reached ones under `Achieved`
(omit either line when empty). These are cheap and always included.
- Append the value's band label in parentheses so the model reads meaning, not just a
number.
- Append a compact **emit rule** (once, in the narrator/system text). Instruct the model
explicitly to:
- end its reply with a fenced `state` block **only when something actually changed**;
- **omit the block entirely** when nothing changed this turn (no empty `{}`);
- include **only the stats that changed** as deltas — never restate unchanged stats,
never send full/absolute values, e.g. `{"player.hp": -15, "npc.gwen.trust": +5}`.
This keeps the emitted block tiny (saves output tokens on the free tier) and means the
engine's clamp/cooldown logic only ever sees real changes.
### 2. Delta parse + validate (new `worldstate/` module)
New module `backend/app/worldstate/engine.py` (mirrors `scripting/` layout):
- `extract_delta(text) -> (clean_text, delta_dict)` — pull the trailing ```` ```state ````
block, tolerate missing/extra fences, trailing commas, `+N` numbers; return `{}` on
parse failure (never break the turn — same philosophy as a broken script).
- `apply_delta(adventure, delta, action_index) -> report` — for each `path: change`:
1. resolve `path` (`player.hp`, `world.day`, `npc.<npcId>.trust`,
`milestones.<id>`) against the schema; unknown paths ignored (logged).
2. **milestone path** → accept only `true`, set `{reached: true, at: action_index}`,
ignore if already reached; skip the numeric steps below.
3. lazily instantiate NPC stat block from template if missing.
4. reject if `cooldown` not elapsed (`action_index - _meta.last_changed[path] < cooldown`).
5. clamp change to `max_delta_per_turn`; counters reject negative.
6. apply, then clamp result to `[min, max]`.
7. record `_meta.last_changed[path] = action_index`.
- Returns a report (applied / clamped / rejected) for the Insights panel, like the
script report.
### 3. Wire into `generate_turn` (`routers/adventures.py:249`)
- Snapshot: `world_state_before = snapshot_world_state(adventure)` alongside the existing
`state_before` (`:271`).
- After the `output` hook and empty-text check (`:340`): `clean, delta =
extract_delta(text)`, `report = apply_delta(...)`, store `text = clean`, stash the
report into `snapshot["world_state"]`.
- Persist `world_state_before` onto the AI `Action` (`:341` block) and commit
`adventure.world_state`.
- Do this only when `scenario.stat_schema` is non-empty — zero overhead for plain
narrative adventures.
### 4. Undo / retry (`routers/adventures.py`)
Reuse the Phase 11 wiring verbatim, in parallel:
- retry (`:449`): also restore `adventure.world_state` from the deleted AI action's
`world_state_before`.
- undo (`:489`): also restore from the first removed action's `world_state_before`
(fall back to `{}`).
---
## UI
- **World State panel** (play view): render current `world_state` as a readable sheet —
world / player / per-NPC, with band label and a bar for `min..max` stats, plus a
**milestones checklist** (reached vs pending). Reuse the collapsible-tree styling from
the existing Play State drawer.
- **Insights**: show the parsed delta + apply/clamp/reject report per turn (next to the
script report already there).
- **Scenario editor**: a `stat_schema` editor. **v1 is a raw JSON editor** (CodeMirror,
reusing the script-slot editor setup) — settled, no form builder for now. A form-based
stat builder is a possible later nicety.
- Graceful when `stat_schema` is empty: panel + editor hidden, app behaves exactly as
today.
---
## Seed / demo
- New seeded demo scenario `seed_data/*.json` with a small `stat_schema` (player hp/mana,
`day`, one or two NPC story cards with health/trust) so the feature is visible on the
live demo without the player configuring anything. Keep it `:free`-model friendly.
- Extend `seed.py` idempotently (matches existing seeder contract).
---
## Exit criteria
Play the demo RPG scenario: the World State panel shows hp/mana/day, an NPC's trust, and
a milestones checklist; taking a fight action drops hp and the narration matches the new
band; a friendly action raises an NPC's trust; completing an objective marks its
milestone reached (and it stays reached); a change larger than `max_delta_per_turn` is
clamped; undo rolls every stat and milestone back to the prior turn; a plain (no-schema)
adventure is completely unaffected.
## Known limits (document, don't fix in v1)
- The AI can still narrate against the numbers occasionally; injected state + firm emit
rule reduces but won't eliminate it. No post-narration consistency check in v1.
- No dice / skill checks / combat resolution — this phase is stat tracking only. A
deterministic resolver is a possible Phase 13.
- NPC stats are keyed by the schema NPC id; editing a scenario's `npcs` between play
sessions can orphan a live `npc` entry (harmless, ignored on read).
- Cooldown/`max_delta_per_turn` are per-turn heuristics, not a full rules engine.
## Test checklist
- Schema with `max_delta_per_turn: 30`: a delta of `-50` applies as `-30`, clamps at `min`.
- `cooldown: 2` on a stat: two consecutive changes → second is rejected until 2 actions pass.
- Counter (`day`): a negative delta is rejected; `+1` advances.
- Milestone: `true` marks it reached with `at`; a second set is a no-op; `false` ignored.
- Each NPC instantiates its own stats at `initial`; `npc.<id>.<stat>` resolves per-NPC.
- Malformed / missing `state` block → turn still completes, delta `{}`, no crash.
- A turn where nothing changes emits no `state` block (and an empty `{}` is a no-op).
- Undo after a stat change restores the prior value; retry doesn't double-apply.
- Empty `stat_schema`: no World State section injected, no `world_state` writes.
-317
View File
@@ -1,317 +0,0 @@
# 13 — Memory-bank embedding cost (round three of the egress work)
**Goal:** stop every turn fetching the entire memory bank's embeddings. Measured at
**3,024 KB per turn** on adventure 25 against **129 KB** for everything else a turn
reads — the memory bank is ~96% of a turn's database traffic, and it is fetched fresh
every single turn to pick `memory_top_k = 5` memories.
Found 2026-08-16 while designing the story tree (see `14-phase-story-tree.md`), because
memory retrieval is the one read a tree **cannot** window — it is long-range recall by
design, so it always spans the full path. That makes this the cost floor of a turn under
the tree, which is why it lands first.
## The measurement (production, Neon SQL editor)
```sql
SELECT count(*), avg(json_array_length(embedding))::int,
avg(length(embedding::text))::int,
pg_size_pretty(sum(length(embedding::text))::bigint)
FROM memories WHERE embedding IS NOT NULL;
-- 134 memories | 1536 dims | 30,971 bytes each | 4,053 kB total
```
| adventure | active | fetched | per turn |
|---|---|---|---|
| 25 | 100 | 100 | **3,024 kB** |
| 21 | 18 | 18 | 545 kB |
| 12 | 12 | 12 | 363 kB |
| 20 | 4 | 4 | 121 kB |
Break-even against the rest of a turn is **4.2 memories**, i.e. about action 25. Every
adventure past that is dominated by this.
`active == fetched` everywhere: nothing has been evicted yet, so the Python-side
`forgotten` filter currently costs nothing. It becomes a real leak the moment eviction
starts.
## Why round two missed it
`retrieve_memories` needs `settings.embedding_model`, and embedding providers are
**BYOK-only by construction** — they never touch the demo key. So the public demo never
embeds anything, and the round-two stress harness (which had no embedding model
configured) measured the turn loop with its heaviest read switched off. The 23.0 MB
figure for a 200-action playthrough is the memory-bank-**off** number; with it on,
adventure 25 is closer to 300 MB.
**Rule going forward: any egress measurement must run with an embedding model set.**
## Root cause
`memorybank.py:208`
```python
candidates = [m for m in adventure.memories if not m.forgotten and m.embedding]
```
Walks the relationship, so every memory row for the adventure loads with its embedding.
`embedding` is `Mapped[list]` on a `JSON` column — 1536 floats serialised as text is
~31 KB. Cosine ranking happens in Python (a deliberate choice, documented at
`models.py:144`), so all of it must cross the wire. The comment sized it by **count**
("fine at a few hundred") rather than by **bytes**.
Not the same bug as migration 36/37 — the column is not a repeating group and there is
no denormalisation to fix. It is a *format* problem plus a *fetch-frequency* problem.
## Decisions (settled 2026-08-16)
- **Packed float32, keep 1536 dimensions.** 31 KB → 6 KB, a straight 5x, with **zero
retrieval-quality risk**. Explicitly rejected dropping to 512/768 dims: the size fix
plus the cache makes the extra 3x unnecessary, and it would have meant re-embedding.
- **No re-embedding.** Dimensions are unchanged, so the migration is a pure format
conversion of the 134 existing rows — read the JSON, write packed bytes, no API calls.
One-time 4 MB read.
- **In-process cache alongside the size fix**, not sequenced after it. Turns for one
adventure arrive back-to-back, so a dict keyed by adventure id takes steady-state cost
to ~0. 100 vectors as float32 is 600 KB of RAM — negligible.
- **`memory_bank_capacity` 200 → ~80.** Taken on *quality* grounds as much as cost:
ranking 200 memories to pick 5 dilutes retrieval. Note this will start evicting on
adventure 25 immediately (it sits at 100).
- **Not pgvector.** It is the structural answer and would keep vectors in the database
entirely, but it breaks SQLite dev parity — which the codebase protects deliberately
(`context/history.py:42`, the `replace()`/`trim()` dialect dance). Revisit only if the
bank grows past what Python cosine can handle.
## Work, in order
1. **Rebuild the byte-meter harness — in the repo this time.** `backend/tools/dbmeter.py`
+ `stress_session.py`. The originals lived outside the repo and are gone. **Default it
to running with an embedding model configured**, since that omission is precisely what
hid this finding. Do this first so every item below is measured, not assumed.
**Done 2026-08-16** — see the baseline below.
2. **Migration 38 — `memories.embedding_blob` (`LargeBinary`).** **Done.** Backfill in Python
(`struct.pack(f"<{n}f", *vec)`); the conversion cannot be expressed in portable SQL, so
unlike migration 36/37 this one does pay a one-time 4 MB read. Drop the old JSON column
in a follow-up migration once verified, not in the same one.
3. **Read path.** **Done.** `retrieve_memories` queries `memories` directly with
`forgotten = false AND embedding_blob IS NOT NULL` in **SQL**, not Python. Unpack with
`struct`/`numpy`. Same for `_evict_over_capacity` and `_embed_pending`, which walk the
same relationship for a count and for the unembedded rows (see the baseline above).
4. **Vector cache.** **Done.** Keyed by `adventure_id`, bounded to 8 adventures. It
turned out to need no invalidation callbacks at all — see below.
5. **Capacity default 200 → 80.** **Done** (migration 41, only rows still on the old
default). Eviction checked at scale first: trimming a 100-memory bank to 80 costs
0.8 kB and eight statements, and reads no vectors at all.
6. **Infinite scroll upward in `Play.jsx`** for the remaining 423 KB page load of a
finished adventure — the last open item from round two. Load the newest turns, fetch
older ones as the reader scrolls up.
## Baseline from the harness (2026-08-16)
`python -m tools.stress_session`, 200 actions × 4 KB, 100 memories × 1536 dims:
| shape | fetched | memories' share |
|---|---|---|
| `GET /adventures` (index) | 0.7 kB | — |
| `GET /adventures/{id}` (page load) | 426.7 kB | — |
| `POST /adventures/{id}/actions` (one turn) | **3,258.7 kB** | 96% |
| `GET /adventures/{id}/context` (Insights) | 3,223.7 kB | 97% |
| `run_post_turn` (background) | **3,139.1 kB** | 100% |
It reproduces both production figures independently — 426.7 kB against the measured
423 KB page load, 3,258.7 kB against 3,024 + 129 kB for a turn, and 31.4 KB per
embedding against 31.0 KB. `--no-embeddings` reports 112.0 kB for the same turn, so
the round-two blind spot is now a **29x** gap anyone can see in one flag.
**Two findings the SQL measurement could not have shown**, both the same root cause
in a different caller:
- **`run_post_turn` fetches the whole bank again**, every turn. `_evict_over_capacity`
walks `adventure.memories` to count the active ones, and `_embed_pending` walks it to
find the unembedded ones. So a played turn actually costs ~6.4 MB, not 3.2 — the
original estimate was half the real number.
- **Insights pays it a third time**, on a page the player can open repeatedly without
spending a turn.
So step 3 below is not just `retrieve_memories`: **every walk of
`adventure.memories` has to go**. `_evict_over_capacity` wants a count and an ordering,
`_embed_pending` wants rows where `embedding IS NULL` — neither needs a single vector,
and both are pure SQL.
## After (2026-08-16, same harness, same fixture)
| shape | before | after (cold) | after (warm) |
|---|---|---|---|
| `POST .../actions` (one turn) | 3,258.7 kB | 723.4 kB | **122.3 kB** |
| `run_post_turn` | 3,139.1 kB | 0.7 kB | 0.7 kB |
| `GET .../context` (Insights) | 3,223.7 kB | 117.9 kB | 117.9 kB |
| `GET .../memories` (drawer) | ~3,138 kB | 23.6 kB | 23.6 kB |
A played turn is turn + post_turn: **6,398 kB → 123 kB** once warm, a 52x cut. The
targets above were ~700 kB cold and ~130 kB warm, so both were met.
Steps 2–5 landed together, because they are one deployable unit: the columns are no
use unless something reads them, and deferring them breaks the old readers. Two
additions the plan did not anticipate:
- **`memories.embedded`, a boolean beside the blob** (migration 39/40). Once the
vectors are deferred, every "does this have an embedding?" check becomes a lazy
load — an N+1 of 6 KB reads down the Memories drawer. Same shape as
`actions.variant_count` beside `actions.variants`, and the same reason.
- **The cache needs no invalidation callbacks.** Vectors only ever change through
`set_vector`, which drops the one entry; everything that *removes* a memory from
play leaves the catalogue query, and entries missing from the catalogue are dropped
on the next read. So eviction, deletion and pruning need no hooks and cannot be
forgotten. Vectors are held as `array("f")` — 6 KB each, matching the column;
a list of Python floats would have been eight times the plan's RAM estimate.
**Step 6 landed on 2026-08-17**, along with everything else this plan left open — see
"Closing the plan" at the end. The page load is a window of 60 actions now: 62.6 kB on
a 600-action fixture, down from 606.0 kB, and no longer a function of the story's
length.
## Guardrails to add with this work
- **Query-count / byte assertions per endpoint**, extending the `test_egress.py` idea:
assert an endpoint issues at most N queries and fetches under X KB against
production-sized fixtures. This class of bug is invisible at ten rows.
- **Explicit column projections on read paths.** List endpoints name the fields they
need rather than loading whole entities, so the next heavy column is opt-**in**. This
is the structural version of what `deferred=True` does by hand.
- **Row-width review rule.** Any new large column justifies itself or goes out-of-line.
`actions` now carries five JSON columns.
Deliberately **not** taken: moving `context_snapshot` out of the database. It costs
nothing on reads now that it is deferred, and storage is ~$0.02/mo. Revisit only if
backups or storage start to hurt.
> **Revisit it.** That call weighed egress and got egress right, but it never weighed
> the free tier's *storage* ceiling — see "Storage, which this plan did not cost" below.
## Verification
- Harness: `python -m tools.stress_session`, memory bank **on**, before and after,
against the baseline table above. Target for the turn shape is 3,258 kB → ~700 kB
cold, ~130 kB warm (the cache leaves only what a turn reads besides the bank).
`run_post_turn` should fall to roughly nothing: neither of its two walks needs a
vector at all.
- Re-run the round-two shapes with an embedding model configured, so the 200-action
playthrough number is finally honest.
- The existing `test_egress.py` guard must still pass — nothing here should touch the
deferred action columns.
## Verified on production, 2026-08-17
Two things were still taken on trust when this shipped: every measurement had run on
SQLite, and every number came from a synthetic fixture. Both are now checked.
### The migration landed on real Postgres
`schema_version` reads **41**, matching the repo's `LATEST_VERSION`. The live schema has
`embedding_blob bytea` and `embedded boolean`, so the `{dialect: sql}` map in migration
38 spells BYTEA correctly against a real server — the one thing tests could not prove,
since `test_migration_38_is_spelled_for_both_dialects` only inspects the SQL string.
The backfill is complete: 134 memories, `embedded = 134`, `embedding_blob = 134`, no
stragglers and no rows skipped as malformed.
### The 5x is real, on real vectors
| | bytes | per memory |
|---|---|---|
| `embedding` (JSON) | 4,150,121 | 30,971 |
| `embedding_blob` (float32) | 823,296 | 6,144 |
**5.04x**, against the plan's predicted ~31 KB → 6,144 B. The largest real bank is 100
memories = 614,400 B of vectors, so the old code fetched **~3.10 MB per retrieval** on
that adventure — which is where the 3,153 kB measured on production came from. That
figure is now fully accounted for.
### SQLite and Postgres agree
`tools.stress_session` gained an `AIDND_STRESS_DATABASE_URL` escape hatch and was run
against a throwaway Neon database at the default fixture (200 actions, 100 memories):
| shape | SQLite | Postgres |
|---|---|---|
| index | 4.1 kB | 4.1 kB |
| page load | 426.7 kB | 425.0 kB |
| one turn, cold | 723.4 kB | 722.3 kB |
| one turn, warm | 122.3 kB | **121.1 kB** |
| Insights | 117.9 kB | 116.7 kB |
| Memories drawer | 23.7 kB | 21.7 kB |
| `run_post_turn` | 0.7 kB | 0.6 kB |
Within 0.5% everywhere. The dialect caveat in the harness docstring is real but small:
what dominates is which columns get asked for, and the ORM decides that identically.
The warm turn spends **1.7 kB on `memories`, 1% of the read** — the cache behaves on
psycopg exactly as it does on SQLite.
### The page load is worse than modelled, for a different reason
The synthetic fixture is **~2x heavier per action than production**: 994 B/action real
against ~2,133 B/action synthetic, so a real 200-action adventure is ~194 kB, not 427.
But the largest real adventure is **607 actions**, not 200, and costs **589.5 kB** in
one response. Step 6 is more urgent than this plan assumed, and for the opposite
reason to the one modelled — stories get *longer* than the fixture, not heavier.
Worth fixing the fixture's narration size when step 6 lands, so the harness stops
flattering the per-action figure while understating the length.
### Storage, which this plan did not cost
`context_snapshot` is **150.8 MB of uncompressed JSON across 944 actions** — ~163 kB a
row on average, and ~232 kB a row in the largest adventure, against the ~74 KB/row the
comment in `models.py` claims. TOAST compresses it to ~89 MB on disk, but
`octet_length` is what would cross the wire, because Postgres decompresses before
sending. Deferral is the only thing standing between a bulk read and a 137 MB query.
The database is **99.6 MB total**, of which `actions` is **88.9 MB**. Neon's free tier
is 512 MB. At ~94 kB of disk per action that ceiling arrives at roughly **5,400
actions**, and 944 are already stored. So the "~$0.02/mo, leave it in the database"
call above is wrong for the tier this actually runs on — not because reads cost
anything, but because the free tier meters *storage*, and that is the constraint with
a cliff. Dropping the dead `memories.embedding` column reclaims 4.05 MB (4%), which
helps and does not solve it.
None of the numbers above required reading a single row of anyone's content: counts,
`octet_length` sums and catalog sizes only.
## Closing the plan, 2026-08-17
Everything above landed the same day the verification did.
| | before | after |
|---|---|---|
| page load, 600 actions | 606.0 kB | **62.6 kB**, and flat in story length |
| adventures index, 6 adventures | 469.7 kB | **0.3 kB** |
| `context_snapshot` stored | ~89 MB | ~43 MB (after a VACUUM) |
| a played turn | 6.4 MB (2026-08-16) | 123 kB |
**Step 6, the window.** `GET /adventures/{id}` returns the newest `ACTION_PAGE`
actions and the story's length; `GET /{id}/actions?before_id=` walks back from there.
Anchored on an action rather than an offset — the offset version breaks precisely when
a turn lands mid-scroll, handing the reader one action twice and hiding another — and
that choice is also what makes it survive the story tree, since it compares indices to
order a branch rather than treating them as positions.
**Byte assertions.** `tests/test_egress.py` now carries per-action budgets as well as
column guards, plus one test whose only job is to fail if the fixture ever gets too
small for the budgets to catch anything.
**Column projections.** `ACTION_LIST_COLUMNS` and `MEMORY_LIST_COLUMNS` name what a
list response renders, and the adventures index selects four columns instead of the
entity. That one was not just future-proofing: an Adventure carries seven text and JSON
columns the index never shows.
**The JSON vector column is gone**, and dropping it exposed a live bug — changing your
embedding model had stopped re-embedding the bank the day migration 38 shipped. See
`tests/test_embedding_model_switch.py`.
**Storage.** `context_snapshot` is zlib-compressed through a TypeDecorator
(`app/compression.py`, migrations 43–45), so the call sites never learned about it.
3.5x on real Postgres. **This does not shrink anything until `VACUUM FULL actions`
runs** — Postgres marks dropped columns rather than reclaiming them, and the backfill
leaves a dead tuple per row.
Not done: nothing exercises the scroll behaviour in a browser. The frontend has no test
runner, and prepend-and-restore-scroll is the part most likely to feel wrong even when
it is correct.
-914
View File
@@ -1,914 +0,0 @@
# Phase 14 — Story tree (branching adventures)
**Goal:** replace the linear action list with a **tree**. A retry becomes a sibling
rather than a rewrite; continuing from one makes it a branch. Players can go back to any
turn, take a different path, and keep both — switching between them freely.
Depends on `13-memory-embedding-cost.md` shipping first: memory retrieval is the one read
a tree cannot window, so it is the cost floor of a turn under this design. Fix the floor
before building on it.
## Why (beyond the feature)
Seven bug classes stop existing, and every one traces to the same root — **the story is a
mutable list**:
| Bug | Why it goes away |
|---|---|
| Deleting a middle action skips a later one forever (`note_action_removed`) | Cursors become node ids, not positions in a shifting list |
| `prune_dangling_memories` orphaning actions behind the cursor | Nothing is ever removed |
| Editing a summarised action leaves its memory stale forever — **currently unfixed** | Editing makes a new node; the old memory stays correct for the old path |
| The one-turn memory holdback (`settled_story_actions`) | Nothing is mutated, so nothing goes stale — the concept is unnecessary |
| The legacy-cursor no-rewind trap found while fixing that | Same |
| "Anything reading `adventure.actions` during generation must exclude the retried action" — leaked into 4 call sites | The replaced turn is not on your path; it cannot leak |
| `variant_count` / mirrored `text` drifting from `variants` | The denormalisation disappears entirely |
It also resolves the 1NF violation: `variants` as a JSON repeating group becomes rows.
**Scored, 2026-08-18, with SP1–SP5 shipped.** Six of the seven are gone as described.
The seventh — the one-turn holdback — is gone too, but the reasoning above was wrong
about *why* it could go: siblings share a coordinate, so replacing what a turn says still
invalidates the memory covering it. What made it deletable is that the repair already
existed for undo and delete; see the trap note below. Two rows want a footnote:
- **Editing a summarised action** is still unfixed. The table says editing makes a new
node; nothing in SP1–SP5 makes it do that, and no subphase is scheduled to. `PATCH
/actions/{id}` still writes over the text in place, and the memory covering it goes
stale exactly as before. What *has* changed is that the machinery to fix it now exists
— an edit could write a sibling and switch to it, which is a retry the player typed —
so it is a small change whenever it is wanted.
- **The 1NF violation** is resolved. Nothing writes a `variants` array any more, in the
database or out of it — SP6 replaced the bundle that was its last producer, and the
array survives only in the v1 *reader*, which exists so files already saved still
import.
## Design decisions (settled 2026-08-16)
- **Full branching, with UI.** Not the "tree schema, no branch picker" middle option —
branching ships as a feature people use.
- **`branch_id` + `depth` on every node, not parent pointers alone.** Parent pointers
alone mean walking N links to read a story, which throws away the round-two windowing
work. `depth` replaces `index` as the ordering key.
- **A `branches` table with `parent_branch_id` and `fork_depth`.** The fork point is
**stored at fork time, never inferred**. Nothing is copied on a fork — a branch borrows
its ancestors' turns.
- **Lineage cached on the branch row.** `lineage = [(D,∞), (C,45), (B,30), (A,10)]`,
computed once at fork (parent's lineage + one entry). Reads never walk to reconstruct
it. Each ancestor is capped at the `fork_depth` of the branch beneath it.
- **Read the lineage lazily, windowed.** Query the newest few lineage entries, measure,
fetch more only if the context budget is not covered — the exact shape of
`history.window_covering()`. **Clause count is bounded by the context window, not by
fork count**, so a 200-fork story reads as cheaply as a 2-fork one.
- **Promote to a branch only on continue.** Attempts at the tip stay as sibling leaves;
one becomes a branch the moment a turn is played past it. Keeps the lineage chain to
"divergences I built a story on", not "every retry ever" — the difference between a
handful of entries and fifty.
- **Memories and the summary attach to the node that produced them**, found by walking
up. Shared ancestors are shared automatically, so a fork costs nothing and **nothing
needs recreating**. A memory covering depths 37–42 hangs off that branch's node 42 and
is invisible to any path not through it. Generalise the rule: *anything derived attaches
to the node that produced it* — the summary included, so stop storing it per turn.
- **Full lineage for memories, windowed lineage for the story.** Memory retrieval is
long-range recall and cannot be windowed, but memories are sparse (~1 per 6 actions), so
a long OR-clause returning ~33 small rows is fine. Two queries, one lineage.
- **Never auto-prune.** Nothing is deleted without an explicit user action. **This makes
a branch-management UI a hard dependency, not a nice-to-have** — storage grows without
limit otherwise.
- **Story cards stay adventure-wide.** A card invented on branch B shows on branch A.
Already true for undo today (script card mutations are not reverted), so this is a
documented limit, not a regression. Explicitly rejected event-sourcing card changes onto
nodes.
- **State carries over almost free.** `state_before` / `world_state_before` already
snapshot the script scoreboard and RPG world state per action, and `apply_variant()`
already restores them on a switch — a branch switch is the same move. Wrinkle: those are
*before* pictures; a node wants the *after*. And they are NULL on pre-column rows.
## Schema sketch
```
branches(id, adventure_id, parent_branch_id, fork_depth, lineage JSON, created_at)
actions(id, adventure_id, branch_id, depth, type, text, reasoning,
world_delta, state_after, world_state_after, context_snapshot, created_at)
memories(..., branch_id, depth) -- attached to the node that produced it
adventures(..., head_branch_id, head_depth)
```
Reading branch C, tip at depth 7, lineage `[(C,7), (B,5), (A,3)]`:
```sql
SELECT * FROM actions
WHERE (branch_id='C' AND depth <= 7)
OR (branch_id='B' AND depth <= 5)
OR (branch_id='A' AND depth <= 3)
ORDER BY depth DESC LIMIT 32
```
→ `A0 A1 A2 A3 B4 B5 C6 C7`. Depth is a position along *a* path, not a global turn
number — `A4` and `B4` are alternatives, not duplicates.
## Work
1. `branches` table, `branch_id`/`depth` on `actions`, `head_*` on `adventures`.
2. Lineage computation + a **single module that owns the branch clause** — same role
`context/history.py` plays today. Every query must go through it; one forgotten clause
shows the wrong story, quietly.
3. `history.py` rewritten against lineage windowing. `window_covering` keeps its shape.
4. Memories/summary attached to nodes; delete the cursor-position machinery
(`position_of_index`, `settled_*`, `note_action_removed`, `_rewind_cursors_to_index`).
5. Sibling storage for un-promoted tip attempts + the promotion step.
6. Node state moves from *before* to *after* snapshots.
7. Migration: every existing adventure becomes branch A; `index` → `depth`; `variants`
entries become sibling nodes; `variant_index` becomes the head pointer.
8. Frontend: branch picker replacing `VariantPager`, plus branch management (rename,
delete, switch) — required, given no auto-pruning.
## Open
Nothing. The last item — **`retry_of.index` reuse**, which stopped the world-state
cooldown clock advancing on a re-run of the same turn — was closed in SP4 by reusing the
retried node's *depth*, and pinned by a test in SP5. Everything else that stood open here
was decided on 2026-08-17; see the next section.
---
# Implementation plan (decided 2026-08-17)
Four decisions, taken before any code was written:
| Question | Decision |
|---|---|
| Phased or single migration? | **Structural-first, no runtime flag.** SP1–SP4 migrate to the tree *representation* with behaviour identical to today. Branching features layer on after. |
| How destructive is the migration? | **Keep the legacy columns for one release.** `index`, `variants`, `variant_index`, `variant_count` stay, unread, until the tree is proven live (SP8 drops them). |
| Branch-management UI scope | **Full tree visualisation** — a spatial view of forks, not just a picker. |
| Export bundle | **`ai-dnd-adventure-v2`**, with a v1 legacy reader so existing bundles keep importing. |
**Why no feature flag.** A linear story *is* a tree with one branch, so the intermediate
states are not half-migrated — they are the same product with a superset schema
underneath. That makes "current adventures are unaffected" a literal, testable pass
condition for every subphase up to SP4, which a flag would have replaced with two live
code paths through the context builder, the memory bank, undo and retry at once.
## The regression contract
`tests/test_story_tree_baseline.py` (built in SP0) drives the whole product over HTTP —
create, play, retry, switch, undo, page with `before_id`, memories, summary, export — and
asserts only on API responses, never internals.
**It must pass unmodified through SP1, SP2 and SP3.** That is the contract those
subphases are verified against. SP4 is the first subphase permitted to change it, and
even there only where the change is deliberate and named below.
## Traps found while reading (these are the ones that bite quietly)
- **`history._from_memory()` slices `adventure.actions`.** The "never load twice"
shortcut returns whatever is already in the session — which under a tree is *every
branch's* actions, not the path. It would silently assemble context from siblings.
This is the single highest-risk line in the change; SP2 must make the shortcut
branch-aware or delete it.
- **`scripting/pipeline.py::_history()` and `_info()` read `adventure.actions` directly**,
and hand it to user scripts as the documented history API. Same trap, user-visible.
- **`Adventure.actions` is `order_by="Action.index"`.** Ordering it by `depth` is not
enough — the collection is still every branch. Anything iterating it needs the path.
- **`limits.check_row_cap("actions")` counts every action in the adventure.** With no
auto-pruning, a branched adventure hits `MAX_ACTIONS_PER_ADVENTURE` while its *story*
is far shorter. The cap has to count the tree but be explained as the tree, or move.
- **The holdback cannot die in SP3.** `settled_story_actions` exists because retry
mutates a row in place. Retry stops mutating in SP4, so the holdback is only safe to
delete there — deleting it in SP3 reopens the exact bug it was written for. It survived
SP3 as `memorybank.settled_after`, which is the `- 1` in "how much story is past the
mark"; that subtraction is the whole of it.
*Closed in SP4, but not for the stated reason.* Siblings share a coordinate and the
mark names the coordinate, so replacing what a turn says still invalidates the memory
covering it — retry mutating a row was never the whole of the problem. What let the
holdback go is that the right repair (`forget_node` plus a rewind) already existed for
undo and delete, and retry and a sibling switch now make it too. **If a mark still
needs correcting when the story changes, correct it; do not decline to make the mark.**
## Subphases
Each ships independently, on its own branch, green before the next starts.
### SP0 — Baseline and regression net *(no product change)*
- `tests/test_story_tree_baseline.py` — the contract above. 24 tests covering opening a
windowed adventure, the turn engine (do/say/story/continue), script effects, retry and
variant switching, undo, paging up to the start, edit/delete, memories, export/import
round-trip, and world state.
**Done, 2026-08-17.** 259 existing tests green (17.8s), then **283 green** with the
baseline added, against unmodified `main`. That is the number every subphase below is
measured against.
A pre-migration (**schema 45**) fixture is needed too, built by a script rather than
committed as a binary — but its only consumer is the SP1 migration test, so it lands
there.
#### `--rich`: a correctness fixture beside the scale one
The measuring fixture is sized from production and leaves every column it does not weigh
at its default. Checked against a freshly built one, that is exactly the set of columns a
tree has to migrate: `state_before`/`world_state_before` NULL on all 600 rows, no
scenario and so no RPG layer, no adventure scripts, both cursors 0, and 100 retry
histories whose two attempts carry **byte-identical text with `variant_index` always 0** —
so "which attempt is live?", the one question SP4's migration answers, had no observable
answer.
`tools.stress_session --rich` fills in precisely those and nothing else:
```
cd backend
.venv/Scripts/python.exe -m tools.stress_session --rich --actions 30 --memories 12
```
An RPG scenario built from the real seed schema with a world state played forward (hp
100 → 71, flags flipping partway); per-action `state_before`/`world_state_before`
snapshots that are monotonic, so a bad rollback reads as a wrong number rather than as
nothing; a gold script on the adventure; story cards; non-zero memory and summary
cursors with a real `story_summary`; pinned and forgotten memories; retry attempts with
distinct texts, counts of 2 *and* 3, and a live attempt that is **often not the last one
written**; and a second adventure, so "does this leak across adventures?" is answerable —
a branch clause that forgot its adventure would still look correct on a database holding
exactly one.
It is a correctness fixture, so prefer it small: what it is for is variety per row, not
rows. **Its byte figures are not comparable to a plain run** and it does not replace the
scale fixture — the plain one still holds the egress ceilings, and was re-measured
unchanged (1.8 kB, actions 1.7 kB) after `--rich` was added.
The invariant `text == variants[variant_index]["text"]` holds on every retried row, and
is asserted when the fixture is built. SP4's migration reads exactly that to decide which
sibling becomes the head.
### SP1 — Schema and migration *(no behaviour change)*
| File | Change |
|---|---|
| `app/models.py` | New `Branch` model. `Action.branch_id`, `Action.depth`. `Adventure.head_branch_id`, `head_depth`. `Memory.branch_id`, `Memory.depth`. Legacy columns untouched. |
| `app/migrations.py` | Migrations 46+: create `branches`; add columns; index `(branch_id, depth)`; backfill. Dialect map wherever BLOB/BYTEA-style spellings diverge. |
Backfill: one branch per adventure, `lineage = [(A, ∞)]`; `actions.branch_id = A`,
`depth = index`; `adventures.head_*` from `max(index)`; memories take branch A and a
depth derived from `source_end`.
**Verify:** baseline test unmodified. New `tests/test_tree_migration.py` — every action
carries a branch and `depth == old index`, no row lost, head pointers correct, memories
mapped. Bootstrap run twice is a no-op. `tests/test_egress.py` ceilings unmoved (two
integers a row). **Post-deploy `VACUUM FULL actions;` is mandatory** — this rewrites
every row, which is the 144 MB lesson at the top of `STATUS.md`.
**Done, 2026-08-17** (branch `sp1-tree-schema`). **297 tests green**, the 283 from SP0
plus 14 in `test_tree_migration.py`. The baseline contract passes **unmodified**, which
was the pass condition. Five things the plan did not anticipate, all of them found by
building it:
- **The writes could not wait for SP2.** The file table above lists only `models.py` and
`migrations.py`, but a migration never visits a row written *after* it runs — so
shipping the columns without a writer would leave every turn played between the two
deploys with no branch, and from SP2 on a row with no branch is a row no read can see.
`app/tree.py` is that writer: `root_branch` / `head_branch` (get-or-create),
`place_action`, `place_memory`, `refresh_head`. One module for the same reason SP2 gets
one — a node written without a branch fails by *disappearing*, not by raising. Wired
into create/turn/import/undo/delete/memory, plus `seed_demo.py` and
`tools.stress_session` (a fixture built by `create_all` is stamped LATEST, so no
migration ever runs against it).
- **`adventures.head_branch_id` cannot be a foreign key.** `branches.adventure_id`
already points the other way, and the pair is then a cycle `create_all` refuses to
order; the fix for that is `use_alter`, which SQLite has no ALTER for. It is a plain
integer, documented as a cache, and `head_branch` recovers onto the root if it ever
names a branch that is gone.
- **`lineage` is NOT NULL, because `branches` comes from `create_all`.** The backfill
cannot insert a row and fill the lineage afterwards via a NULL marker, so it inserts
`'[]'` and guards step two on `json_array_length(lineage) = 0` — not `= '[]'`, because
Postgres `json` has no equality operator.
- **SQLite cannot drop a column a foreign key names.** So a current-schema database
cannot be rewound past `branch_id` at all, which broke the two existing tests that
simulate an old database by rewinding only the *stamp*. Fixed properly:
`migrations._column_already_there` makes every `ADD COLUMN` idempotent (the
`IF NOT EXISTS` the module docstring asks for and SQLite has no syntax for), and
`tests/schema_rewind.py` holds the inverse of the migrations that *can* be undone.
The SP1 fixture therefore builds a **genuine schema 45** by dropping the three tables
and recreating them from frozen pre-tree DDL, so the real ALTERs run — including the
one that adds a foreign key.
- **Deleting a branch takes its nodes with it**, and deleting an adventure takes its
branch — both verified, both at the database level via `ON DELETE CASCADE` on the two
`branch_id` columns. SP7's delete-a-branch needs no code of its own for the nodes.
Measured: `branches` costs **0.1 kB of a 733.5 kB turn (0 %)**, and the page load
(62.6 kB) and index (1.8 kB) shapes are byte-identical to the figures in `STATUS.md`.
One cost that is *not* free: the new table and its index add ~47 ms to every
`create_all`/`drop_all` cycle on SQLite, and the suite does one per test — 20 s → 38 s.
Test-only (DDL fsync), so no model change; if it ever matters, the fix is the test
harness, not the schema.
### SP2 — The branch clause *(reads move to lineage; still one branch)*
One module owns the clause; every action read goes through it. A forgotten clause shows
the wrong story, quietly, which is why it is one module and not a convention.
| File | Change |
|---|---|
| `app/context/lineage.py` *(new)* | Lineage computation and the branch clause. The only place that knows how a path is selected. |
| `app/context/history.py` | `_filters` takes the clause; order by `depth`; `window_covering` keeps its shape. **Fix `_from_memory`.** |
| `app/routers/adventures.py` | `action_window` anchors on the anchor's `depth`; `last_action`, `next_index`→`next_depth`, `_latest_narration`. |
| `app/scripting/pipeline.py` | `_history()`/`_info()` read the path, not the collection. |
**Verify:** baseline test unmodified. `test_history_window.py`, `test_action_paging.py`
green. New test builds a **two-branch fixture directly in the DB** and asserts the design
doc's own example reads back as `A0 A1 A2 A3 B4 B5 C6 C7`, and that a sibling's nodes are
invisible. Egress: a 20-fork fixture costs within a small factor of a 1-fork one —
clause count is bounded by the context window, not by fork count.
**Done, 2026-08-17** (branch `sp2-branch-clause`). **317 tests green**, the 297 from SP1
plus 20 in `test_branch_clause.py`. The baseline contract passes **unmodified**, which
was the pass condition. Six things worth not rediscovering:
- **The contract forced the write side, not the read side.** The SP0 baseline writes its
actions straight to the database and must pass unmodified — so "every writer calls
`place_action`" could not be the invariant, because the baseline is a writer and does
not. Neither did any of the eleven other test fixtures. The alternative was a read
tolerant of a NULL branch, which is the quiet-wrong-story failure this subphase exists
to make impossible. So the session enforces it instead: `tree.place_new_nodes` runs
from `Session.before_flush` and places anything unplaced, registered in `models.py` so
that importing the models arms it. The call sites keep their explicit calls — a node
placed at the call site is placed *before* the code around it reads the row back.
- **A branch could no longer be created with a flush.** `root_branch` did
`db.add(); db.flush()` to get the id its lineage names, and a nested flush inside
`before_flush` raises. It inserts through Core and reads the row back — same
transaction, three statements, once per adventure ever.
- **The identity map holds weak references, and it cost 25 % of the suite.** Resolving
the head branch per node re-read the `branches` row for every node in the flush, because
nothing held a strong reference between two calls: **201 SELECTs to write 200 actions**,
and 36 s → 45 s on the same 297 tests measured back to back. The head is now resolved
once per adventure per flush — **2 SELECTs**, and the same 297 tests then time within
noise of SP1 (44.2 s each; this machine's load drifts by ~20 % between runs, so trust
the statement count, not the stopwatch). Pinned by a test that counts reads of
`branches`, because the stopwatch is all the symptom there ever was.
- **The index screen is the one read scoped by head branch rather than by lineage.**
`_latest_narration` picks one row per adventure for a hundred adventures at once, and a
lineage clause each would put hundreds of OR-terms on that query. The two answers differ
only for a branch with no nodes of its own, which cannot exist — a branch is created by
playing a turn onto it.
- **Two reads are deliberately left un-pathed**, both documented where they live.
`max_action_index` allocates the legacy `index`, which must stay adventure-wide or two
branches issue the same number; and export is a flat v1 bundle whose reader has no idea
branches exist, which is why SP6 replaces the format rather than widening the query.
A third is a known divergence, not a decision: the index screen's `action_count` counts
the tree, and will overstate a branched story until SP5.
- **`Adventure.actions` was left ordered by `index` on purpose.** Ordering the collection
by depth would not make it a story — it is every branch's actions, and a path is a
selection out of it. What the relationship is still for is ownership and the
delete-orphan cascade.
Measured: a story forked **20 times reads its newest 32-action window for 3,178 B against
the 2,961 B an unforked story of the same length costs (1.07×)**, naming one branch of its
22 lineage entries. The pre-tree 600-action `--keep` fixture was migrated and then driven
over HTTP end to end: index **1,840 B**, page load **64,149 B** — the same shapes as
before the phase — and scrolling to the start took 9 pages and saw all 600 actions exactly
once.
### SP3 — Memories and summary attach to nodes
Cursors stop being positions in a shifting list. `memory_cursor`/`summary_cursor` become
node-anchored `(branch_id, depth)`.
Deleted: `position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`,
`prune_dangling_memories`, and their call sites in `undo_turn` and `delete_action`.
**Kept until SP4:** `settled_story_actions` and the holdback (see traps).
**Verify:** baseline test unmodified. `test_memory_settling.py` and
`test_memory_retrieval.py` updated, plus a new branch-isolation test — a memory created
on branch B is invisible from branch A, and shared ancestors are visible from both.
Memory retrieval reads the *full* lineage (it cannot be windowed) but stays sparse:
assert the byte cost on a deep fork.
**Done, 2026-08-18** (branch `sp3-node-cursors`). **330 tests green**, the 318 the branch
started from plus 12 — 11 in `test_branch_clause`'s new sibling `test_memory_nodes.py`
and one on migration 56. The baseline contract passes **unmodified**, which was the pass
condition. `app/context/cursors.py` is the new module; migrations 53–56 add
`memory_cursor_branch_id/_depth` and `summary_cursor_branch_id/_depth` and translate the
old counts into them.
Seven things worth not rediscovering:
- **An anchor is a coordinate, not a pointer, and that is what deleted the machinery.**
Half of this subphase was expected to be rewriting the cursor bookkeeping in depth
terms. None of it needed rewriting: `count_after(41)` is well defined with node 41
deleted, and deleting node 12 does not change what "past node 41" means. So
`note_action_removed`, `_rewind_cursors_to_index`, `position_of_index` and the
post-turn clamp did not become depth-shaped versions of themselves — they became
nothing. **If a mark still needs correcting when the story changes, it is still a
position.**
- **The clamp had its own trap and it also goes.** `run_post_turn` clamped both cursors
to the story length every pass, deliberately against the *full* count, because
clamping to the settled count rewound a caught-up adventure a step and re-covered an
action. An anchor past the tip is not a broken value: `settled_after` reports nothing
to do, and the story growing back past it resumes exactly where it left off.
- **`prune_dangling_memories` became a lookup, and got stricter by accident.** A memory
hangs off the node its block ends on, so "what did this node produce?" is
`(branch_id, depth)` — `memorybank.forget_node`. The scan it replaces could only ever
notice damage *after* the fact (a covered range past `max(index)`), and could not
notice at all when the node was deleted from the middle of a story that still had
later actions. Withdrawing the memory is half the job: the ground it covered is still
behind the mark, so the mark goes back to `source_start - 1` — a depth, whether or not
a row still sits there.
- **A memory with no node had to be spelled out in the clause.** A hand-written memory
summarises nothing, so it carries a branch and a NULL depth. Every ancestor entry in a
lineage clause is capped `depth <= fork`, and NULL fails that — so a typed memory would
have become invisible at the first fork after it was written, with nothing to see but a
prompt that stopped mentioning it. `Path.clause(unanchored=True)` is that case, and
actions never pass it: an action with no depth is a pre-tree row no read should see.
- **Retrieval reads the whole lineage, and it is free.** Measured on two stories of 84
actions and 14 memories each, one flat and one forked twenty times: **1,807 B against
1,823 B**. The clause carries 22 branch terms instead of one, and the clause is not what
crosses the wire. The egress shapes are otherwise byte-identical to SP1's — index
1.8 kB, page load 62.7 kB, turn 733.8 kB.
- **Three reads stay adventure-wide, deliberately.** Embedding and eviction are facts
about the row and about the bank, not about the path — skipping a sibling's memories
would only mean embedding them at the moment somebody switched to them, and evicting the
memories of a story nobody is reading is the right thing to evict first. The Memories
drawer is management rather than retrieval, and hiding a branch's memories there would
make them unfindable in a phase whose rule is that nothing is removed automatically.
- **The v1 bundle still speaks positions, in exactly two places.** Export counts the
anchor back into a position; import translates the other way, but only after the
actions exist, because that is the one moment the two coordinate systems can be lined
up. `cursors.position_of` and `cursors.anchor_at_position` are the whole of what still
knows about positions, and SP6's v2 format retires them.
Migrations 53–56 rewrite `adventures`, not `actions` — a few hundred rows against a few
hundred thousand — so **this deploy needs no `VACUUM FULL` of its own**. The one SP1 owes
is still owed.
### SP4 — Variants become sibling nodes
Retry stops mutating a row. It writes a sibling leaf at the same depth.
Deleted: `set_variants`, `variant_of`, `apply_variant`, `VARIANT_SNAPSHOT_KEYS`, and the
holdback. Node state moves from *before* to *after* snapshots (`state_after`,
`world_state_after`) — a sibling needs its own outcome, so this belongs here rather than
in its own subphase. Pre-column rows are NULL and must stay tolerated.
A migration converts existing `variants` JSON into sibling rows — reading the legacy
column that decision 2 kept, which is the whole reason it was kept.
`/variants` and `/variant` keep their URLs and response shapes here, re-implemented over
sibling rows, so the frontend keeps working until SP7 replaces it.
**Verify:** the first subphase allowed to move the baseline test, and only for
`variant_count`/`variant_index` semantics — the retry *outcomes* must not move.
`test_retry_variants.py` rewritten against siblings but asserting the same observable
results, including that the gold script is not double-applied. `test_state_revert.py`
against after-snapshots. Migration test: attempt count preserved, the active attempt
becomes the head.
**Done, 2026-08-18** (branch `sp4-sibling-nodes`). **347 tests green**, the 330 SP3
finished with plus 13 in the new `test_attempt_siblings.py`, 8 in `test_tree_migration
.py`, and three holdback tests deleted. `app/attempts.py` is the new module; migrations
57–60 add `live`, `state_after`, `world_state_after`, derive the after-snapshots, and
split every `variants` list into rows.
**The baseline test did not have to move, and neither did `test_retry_variants.py`.**
Both pass unmodified. SP4 was *permitted* to change the variant-count semantics and it
turned out nothing observable needed changing — which is the strongest form the pass
condition could have taken, and worth knowing before SP6 asks for the same licence.
Seven things worth not rediscovering:
- **A coordinate needs a `live` flag, and the branch clause is where it belongs.**
Siblings share `(branch_id, depth)`, so the lineage clause alone returns all of them
and the story tells itself twice. `Path.clause` adds `Action.live` for actions (and
only for actions — memories have no siblings to lose to), which means no read of the
story had to learn that retries exist. `app/attempts.py` is the only code that looks
past it.
- **The prompt has to move with the flag or retry becomes a storage multiplier.** A
`context_snapshot` is ~163 kB of prompt that every attempt at a turn shares, and the
old JSON list existed precisely to store it once. Giving each sibling row a copy would
have undone that. So the invariant is: the assembled prompt lives on the attempt the
story tells, and a superseded one keeps only its own slices (`ATTEMPT_KEYS` — the
world-state delta, the script report, the raw reply). Measured on the pre-tree
600-action fixture, migrated: **700 rows for the same 600-turn story, and the prompt
archive byte-identical at 0.50 MB.**
- **The holdback was not quite unnecessary — it was the wrong repair.** The plan said
retry stops mutating rows so nothing goes stale. Not so: siblings share a coordinate,
and the mark and the memory both name the *coordinate*, so replacing what a turn says
still invalidates them. What made the holdback deletable is that the right repair
already existed — `forget_node` plus a rewind, which undo and delete have called since
SP3. Retry and a sibling switch now call it too, and the holdback (`settled_count`,
`settled_after`, `settled_story_actions`, `newest_settled`) is gone. **A memory can
now cover the newest action**, which it never could before.
- **`state_after` needed the same flush guard `place_action` has.** The migration
derives every existing row's outcome from the next row's `state_before`, and the tip's
from the adventure's live state — but a row written by a fixture or a script after
that has nobody to derive from, and the failure mode is undo silently leaving the
scoreboard where it was. `tree.stamp_outcome` runs from `before_flush` beside
`place_new_nodes`. It writes the truth as of the flush: a writer that changes no state
between two nodes leaves the same state behind both.
- **Switching attempts hands the caller a different row id**, because that is what an
attempt being a node *means*. The endpoints are addressed by any attempt at the turn
rather than by the live one, so a client holding a stale id still asks about the right
turn — but `Play.jsx` matched the reply against `updated.id` and had to be changed to
match against the action it asked about. One line, and SP7 removes the pager anyway.
- **Deleting a turn deletes its attempts.** A discarded attempt is only reachable
*through* its coordinate, so leaving it behind would leave a row nothing can name and
no read can see. `delete_turn` is that rule in one place, called by undo and by
delete-an-action, and it works whichever attempt's id the caller happens to hold.
- **Siblings share the legacy `index`.** They are takes on one turn, so two rows now
carry one index — which `max_action_index` (a maximum, not a count) survives, and
which is what lets the v1 export fold a group back into a `variants` array. Export is
now the only producer of that shape anywhere; nothing in the database holds one.
Measured: index **1.8 kB**, page load **62.7 kB**, one turn **734.8 kB** — the first two
byte-identical to SP1's and SP3's, the turn up 1.0 kB (0.14 %) for the `live` column
across a 346-row read. Migrations 57–59 each rewrite every row of `actions` and 60
inserts one per discarded attempt, so **this deploy owes a `VACUUM FULL actions;`** —
the SP1 one is still owed too, and one vacuum after this deploy settles both.
### SP5 — Fork on continue
Playing past a non-head sibling promotes it: a `branches` row with `parent_branch_id`,
`fork_depth`, and a `lineage` computed once from the parent's. Nothing is copied.
`adventures.head_*` moves. **The cooldown clock must not advance on a re-run of the same
turn** — the one item still open above.
**Verify:** a fork creates exactly one branch row and copies no actions; lineage is
correct and capped at each `fork_depth`; both branches read independently; switching
restores the right script/world state; the cooldown test from `test_worldstate.py`
still holds across a retry.
**Done, 2026-08-18** (branch `sp5-fork-on-continue`). **365 tests green**, the 347 from
SP4 plus 18 in `test_branch_forking.py`. `tree.fork` is the whole of it; three endpoints
(`GET /branches`, `POST /branches/{id}/switch`, `POST /actions/{id}/fork`) are what SP7's
tree view will be drawn on.
**Where the promotion happens, and why not where the plan said.** The plan put it on the
*next turn*: attempts stay leaves, and one becomes a branch when a turn is played past
it. Promoting the winner then means moving a row off the branch the reader is standing
on and leaving that branch to pick a new node for the depth — it disturbs a story nobody
asked to change. The same divergence, seen from the other side, promotes the attempt you
are *leaving for*: `POST /actions/{id}/fork` gives the discarded attempt a branch and
moves the head to it, and the line it leaves is untouched. Branch count is identical
either way — one per divergence somebody actually built on — and one of the two never
rewrites a story in place. Without it, the losing attempts would also be unreachable
forever, since only the tip can be switched: fork-on-continue alone is a one-way door.
Six things worth not rediscovering:
- **A fork must not move the derived work, and it was about to.** The first cut moved the
memories at the forked coordinate onto the new branch and re-anchored the cursors that
named it. Both are wrong, and for the same reason: a memory describes whichever attempt
was *live* at that coordinate, which is the one staying on the parent. The right answer
needs no code at all — the lineage caps the parent at `fork_depth`, one depth short of
it, so the memory is simply out of range from the fork, invisible to both the retrieval
clause and `Path.depth_on`. The block is summarized again, from the text this branch
actually tells.
- **Depth had to stop following `index`.** They agreed until now. `index` is
adventure-wide (the v1 bundle is keyed on it) so on a story forked at depth 6 after
twenty turns it hands the next node depth 21 and leaves a fourteen-deep hole in the
middle of a path — which every windowing estimate then has to work around.
`next_depth` is `head_depth + 1`; `place_action` still derives depth from index for
fixtures and imports, which is what that default is for.
- **Undo has to stop at the fork.** It reads the newest two nodes on the path and deletes
the turn they make up — and on a fresh branch the second of them is borrowed from the
parent, whose story also contains it. The guard is on the row's own `branch_id`, not on
the fork depth, because that is the fact that decides it.
- **The cooldown clock came out right for free.** It lives in `_meta.last_changed` inside
the world state, and the world state is restored from the tip's `world_state_after` on
every switch — so each branch carries its own clock without anything knowing there is
one. The carried-over open item (a retry must not advance it) is SP4's reused depth,
and both are pinned by tests.
- **The session does not autoflush**, and `fork` read the sibling group after moving the
node out of it — so the move had not been written and the node was renumbered straight
back into the group it had just left. Read the group first. (`autoflush=False` is
deliberate, in `database.py`; anything in this phase that mutates then queries the same
rows has to order itself by hand.)
- **`/fork` has to be idempotent before it is anything else**, because a fork leaves the
promoted attempt alone on its branch: a repeated call — a double click, a retried
request — would otherwise be told the turn it just forked has nothing to fork to. The
"already the story" case is answered before the shape of the turn is looked at.
Measured, on a 40-turn story forked **twenty** times against the same story flat:
21 branches, 140 rows, an 80-action story, and a page load of **31,652 B against
31,433 B (1.007×)**. A branch costs **103 B** of id, parent, fork depth and cached
ancestry. No migration, no vacuum.
**One gap, deliberately left for SP6.** A forked adventure has no honest `v1` export —
the format has one story and there are two — so export emits every branch's turns
interleaved by index, which reads as a mangled story rather than as lost data. SP6's v2
bundle fixes it, and SP7 is where a player first gets any way to fork at all, so the
order those two ship in is the order that matters.
**Also known, and not fixed here:** the two cursors are one pair on the adventure, so
switching branches makes the mark on the branch being left unreadable from the new one
(`Path.depth_on` answers "nothing covered", which is the safe direction — redo the work,
never skip it). Switching back and forth therefore re-summarizes. Per-branch cursors are
the fix if it ever matters; it costs AI calls, not correctness.
### SP6 — Export/import v2
`ai-dnd-adventure-v2` carries branches and nodes; import accepts v1 and v2, mapping a v1
bundle's linear actions plus `variants` onto one branch with siblings.
`limits.check_bundle_lists` learns about branches.
**Verify:** v2 round-trip of a branched adventure is lossless; a v1 bundle still imports;
a bundle claiming more branches than rows is rejected rather than half-applied.
**Done, 2026-08-18** (branch `sp6-bundle-v2`). **381 tests green**, the 365 SP5 finished
with plus 16 in the new `test_bundle_v2.py`. `app/bundle.py` owns both formats; the two
endpoints in `routers/adventures.py` are a delegation and the shared plumbing, and the
`variants` array now exists nowhere but the v1 *reader*.
**The rule the module is built on: a bundle carries what was *chosen*, never what is
*derived*.** The head branch, the fork points, the live flags and the anchors are
decisions somebody made, and they are in the file. `lineage`, the head *depth*, `index`
and the variant ordinals are computed from those and are rebuilt on the way in. That is
not tidiness — a bundle is a text file anybody can edit, and every derived field shipped
beside its source is a chance for the file to disagree with itself in a way no read would
report. It is also the answer to "is the round trip lossless?": everything omitted is
reconstructed, and the tests assert the reconstruction rather than the bytes.
Six things worth not rediscovering:
- **`depth` cannot be the legacy `index`, and this is where that stops being academic.**
They agreed until SP5, and a bundle is the first writer that has to fill `index` for a
*forked* story — where two branches both have a node at depth 4. `index`'s one
remaining job is handing the next row a number nothing else holds, which is a fact
about the adventure rather than about a path, so the import allocates one per turn in
bundle order: siblings share it, the way SP4 leaves them, and no two coordinates do.
- **Validation happens before the adventure row exists.** Everything a hand-edited file
can get wrong about the shape of a tree — a node naming a branch that is not listed, a
fork with no depth, a branch forking from one listed after it — is a 400 raised by
`plan`, which touches no session. The alternative is an adventure holding a story with
a hole in it, and this whole phase exists because a story with a hole in it fails by
going quiet.
- **A branch may only fork from one listed before it.** That is how the export writes
them, and requiring it buys acyclicity for the price of a comparison — a cycle in the
parent chain would be an import that never returns rather than one that fails.
- **`{}` and absent are different snapshots.** An empty `state_after` means "this node
left an empty scoreboard behind"; a missing one means "nobody knows, leave the live
state alone" (`attempts.restore_state`). Trimming empty dicts on the way out would have
saved eighteen bytes a row and turned an undo that clears a score into one that leaves
it standing. Only `worldDelta`, which is display, is dropped when empty.
- **A file is allowed to be wrong about which attempt is live, and the import corrects it
rather than refusing.** "Exactly one sibling in a group is live" is an invariant of the
*database*, not of the format; a coordinate with none is a turn no read can see, so the
first attempt is made live. That is a different class from a missing branch, which is
structure, and is refused.
- **`limits.MAX_BRANCHES_PER_ADVENTURE` (1000) has no live counterpart.** Forking is a
POST that adds one row and has no cap of its own, so this is the one bundle cap that
does not mirror something creation enforces. Worth closing if branch management ever
makes forking cheap to repeat.
**The verify line above was slightly wrong, and the code does the honest version.** "More
branches than rows" fails on an adventure with no actions, which legitimately has one
branch and no rows. It became two rules instead: a cap on the branch list, and *every
branch a node names must exist*.
Measured with `tools/measure_bundle.py` on the 600-action `--rich` fixture — 600 turns,
750 nodes, because 150 of them were retried:
| | bytes | vs v1 |
|---|---|---|
| v1 shape | 587,475 | — |
| **v2** | **911,229** | **1.551×** |
| v2 without the outcomes | 544,318 | 0.927× |
**The tree is free; the outcomes are what cost.** Coordinates *save* 57.5 B a node
against v1's turn-and-variants shape, and the entire 1.55× is `state_after` /
`world_state_after` at 489 B a node — which are there because a bundle without them
imports a tree nobody can switch inside. Twenty forks add 660 B to the same file, **33 B
a branch**, so the format is as indifferent to fork count as the reads are. The longest
adventure production holds exports at 4.3 % of `MAX_IMPORT_BODY_BYTES`.
**Two lines of the SP0 baseline changed, not the one it predicted.** The note in
`test_story_tree_baseline.py` allowed for the `format` assertion; the `variants` array in
`test_export_keeps_retry_attempts` is the same fact from the other side — a bundle with
coordinates has no use for a repeating group. Everything else in that file still passes
unmodified.
**No migration, no vacuum.** SP6 adds no column and rewrites no row.
**And one trap, paid for once.** `tools/measure_bundle.py` imported `app` before
`tools.stress_session`, which is what points `AIDND_DB_PATH` at a throwaway file —
`app.database` reads it at module scope. It fails by *working*: the first run seeded a
synthetic user and adventure into the local `backend/data.db` and printed perfectly good
numbers, and only the second run tripped over the unique email. Anything importing that
harness must import it first, and the file now says so where the imports are.
### SP7 — Frontend: the tree becomes reachable
`VariantPager` is removed. A branch view replaces it, plus switch, rename and
delete-with-confirm. `api.js` gains the branch endpoints.
**Verify:** this is where the standing open gap gets closed — **drive the 600-action
`--keep` fixture in a browser by hand**, on the same scroll path that has never been
driven and already hid one bug. A vitest + jsdom harness covers the prepend arithmetic;
jsdom has no layout, so scroll position still needs eyes.
**Done, 2026-08-18** (branch `sp7-tree-ui`). **396 tests green**, the 381 SP6 finished
with plus 15 in the new `test_branch_management.py`. Driven by hand against the `--keep`
fixture in Chrome, which is how the one bug below was found.
**Shape chosen: a branch rail, not a spatial node map.** Three mockups were built and
compared before any of it was written, and the deciding argument was not aesthetic. A
node map is a second windowing problem — the fixture this subphase must be verified
against is 600 actions, which is 600 nodes — so building one would have spent SP7 on the
thing that delays the verification SP7 exists to do. The rail ships now; the map is a
later feature and costs nothing extra to add, because both draw from the same
`GET /branches`. The panel sits beside Plot/Memory/Scripts/Insights, which is this app's
existing idiom for a right-hand rail rather than a new one.
**SP7 was not a frontend-only subphase, and the spec above did not say so.** Of the three
operations it names, SP5 had built exactly one. `switch` existed; `rename` had no column
and no route, `delete` had no route at all. So it opens with migration 61
(`branches.name`), a `PATCH` and a `DELETE` — worth remembering for any future subphase
whose one-line spec says "plus the UI for X".
Five things worth not rediscovering:
- **A name is stored; a label is derived.** `branches.name` is NULL until somebody
chooses one, and the client draws an unnamed branch from its fork depth
(`Fork at moment 547`). A generated "branch 4" in the column would be a lie the moment
branch 3 is deleted and the ordinals shift under it; a fork depth is a coordinate, and
nothing can shift it. The v2 bundle carries the name for exactly the reason SP6 gives
for carrying the fork points — it is a decision, not something computed from one.
- **Refusing to delete the head is only half of it.** The other half is refusing any
branch the head was *forked from*: `parent_branch_id` cascades, so deleting an ancestor
takes the head with it and leaves `head_branch_id` pointing at a row that is gone. One
membership test against the head's own `lineage` covers both, because a lineage already
names itself and every branch it borrows from.
- **A deleted branch's cursor has to be cleared, and the reason is SQLite.** On Postgres
a stale branch id simply never resolves. SQLite hands the freed id to the next fork, at
which point the anchor resolves onto a branch it has never seen and reports a stretch
of story as already summarized — losing it from the memories for good. Same class as
the width-mismatch `cosine` returning 0.0: it reports nothing.
- **`VariantOut` had to grow an `id`.** A fork is addressed by the node being taken. The
group renumbers whenever an attempt is added, so an ordinal held across that points at
a different take — the same reason SP4's note called the pager's index match "one line,
and SP7 removes the pager anyway".
- **Four panels reloaded on the wrong thing, and only a browser could say so.**
`actions.length` was the refresh key for the Branches panel, the Status drawer
(script state), Insights and the Memory Bank. **A branch switch does not change the
length of the story** — it changes which story it is. So the Branches panel drew a
one-branch tree while the reader was already on a second, Insights showed the prompt
for the path just left, and the scoreboard kept the other line's numbers. The server
was correct throughout, and nothing in 396 tests could see any of it. All four now key
on `${actions.length}:${stateKey}`, and `stateKey` is bumped by `adoptWindow` — the two
operations that move the head — plus branch deletion, which removes that branch's
memories without a turn being played.
`tools/branch_fixture.py` exists because of this: it builds two branches of **equal
path length**, which is the case `actions.length` cannot distinguish at all. The
`--keep` fixture could not have found it, and neither could a fixture whose branches
happened to differ in length.
**What a switch puts back was checked end to end, not just server-side.** SP5 already
proved `restore_state` in `test_switching_restores_the_script_and_world_state`; what had
never been looked at is whether the *screen* re-reads it. On `tools/branch_fixture.py`,
switching between the two branches moves the World State drawer from **hp 60 to hp 95**
live, redraws the bar, swaps the story to the other take (`hp -5`, not `hp -40`) and
repoints Insights at the other path — "History: 5 of 5 actions", carrying the scratch and
not the beating.
**Every memory is now a memory of a story, not of an adventure — migration 62.** A
hand-written memory used to carry a NULL depth, described as "belongs to the adventure
rather than to a path". That reads as harmless and is not: a NULL is a coordinate no fork
can cap, so a note typed on one line followed the reader onto branches whose events it
never described. `tree.place_memory` anchors it at the head instead — *the story you were
reading when you wrote it* — and it then obeys exactly the rule a summarised memory obeys.
The whole `unanchored` escape clause in `lineage.Path.clause` existed for that one case
and is **deleted**, not merely unused. Its docstring argued a capped `depth <= fork` would
drop a typed memory "the moment its branch stopped being the newest entry"; anchoring is
the better answer to the same worry, because the memory is not exempt from the path, it is
*on* one.
**The drawer shows the path being read, and nothing else.** Same clause as retrieval, so
the bank you can see is the bank the model can see — one question, one answer. The earlier
attempt at this shipped an adventure-wide list with an `on_path` flag and an *another
branch* badge; anchoring makes that redundant, and the field, the badge and its CSS are
gone. Nothing is stranded by hiding: a memory lives on a branch, switching to that branch
shows it, and deleting the branch deletes it
(`test_deleting_a_branch_deletes_the_memories_written_on_it`).
Pinning is unchanged and still path-scoped: it decides *order*, the path decides
*existence*. A pinned memory on a branch you are not reading is not sent, because the path
clause runs before pinning is considered.
**Migration 62 anchors existing NULL-depth memories at depth 0 of their branch**, not at
the tip. 0 is at or before every fork point, so every memory stays visible from exactly
the paths it is visible from today — the anchor takes nothing out of anybody's bank on
deploy. Anchoring at the tip would have emptied them out of every branch forked earlier
than they were typed, on a database with real users on it.
**The scroll path was driven, and it holds.** Three prepends on the 602-action fixture,
60 actions and ~16,200 px each. The same DOM node stayed at viewport top 792 → 787 — a
**5 px drift** across the prepend — and the view stayed 48,174 px from the bottom, so PR
#2's throw-to-the-end does not reproduce. Console clean. One note for anyone measuring it
again: the fixture's prose repeats, so an anchor found by matching *text* lands on an
older copy of the same sentence and reads as a huge jump. Hold the DOM node.
**Still open, deliberately:** no vitest + jsdom harness. The verify line offers
hand-driving *or* the harness and this took the first. The harness remains the thing that
would catch a prepend regression without a person in the loop, and jsdom's lack of layout
means it would not have settled the 5 px question either way.
**One user-visible change nobody asked for, recorded here because a script
author would otherwise find it by being surprised.** Moving `_history` onto
`context_history.story_actions` fixed the branch bug it was there to fix, and
carried a second change with it: `story_actions` drops blank-text rows, which
`adventure.actions` did not. So a user script's `history` array and
`info.actionCount` both got shorter for any adventure holding one. It is the
right shape — a textless row is bookkeeping with no AI Dungeon counterpart, and
the prompt never included one — but a script that fires "every N actions" now
fires on different turns. Not compatible with both readings; this is the one
chosen.
### SP8 — Drop the legacy columns
Only once the tree is proven live. Migration drops `index`, `variants`, `variant_index`,
`variant_count`, followed by `VACUUM FULL actions;`. **Also `adventures.memory_cursor`
and `summary_cursor`** — unread since SP3, kept only so a rolled-back build resumes from
a real number. They are on `adventures`, so dropping them costs no vacuum.
**And `actions.state_before` / `world_state_before`**, unwritten and unread since SP4 for
the same reason: a rolled-back build still finds a real snapshot on every row it wrote
itself. They are deferred JSON on `actions`, so they go in the same rewrite as `index`
and cost nothing extra.
`variant_count` and `variant_index` are the two to check before dropping: SP4 left them
as a maintained cache of the sibling group's shape, because the pager reads both for
every row of a page and must not pay a query per turn to get them. They are dead only
once SP7's tree view has replaced the pager.
**Verify:** full suite; egress ceilings; a measured before/after size, aggregates only.
### SP9 — Takes, not chips: one pager, and a fork that makes a new take
**Why this exists.** SP7 was driven by hand and the tree was unusable. Three things were
wrong, and only the third is a bug:
* **A chip did two different things.** At the tip it switched; above the tip it only
*previewed*, and taking it needed a second button. One control, two meanings, and the
meaning depended on where the player was standing.
* **Nothing could fork but an AI turn that already had a second take.**
`POST /actions/{id}/fork` answers 400 when the turn has one take, so a player's own
message had no way to become anything else and "branch from here" did not exist.
* **Retry only worked on the newest action.** Mid-story there was no retry at all.
**The model, in the player's words.** Every action can gain another *take*. On an AI node
that means regenerate; on the player's own it means type something else. Stepping between
takes is free — `3/3` to `1/3` is navigation, and the story below simply empties, because
that take has no children yet. **A branch is created when you write below a take that is
not the live one**, never before.
That collapses SP5's fork and SP4's retry into one operation and deletes the
tip-versus-past distinction from the UI entirely. It survives only in the implementation,
where it decides whether a write needs a branch at all.
**Takes are grouped by parent, not by coordinate.** This is the load-bearing change.
`attempts.group()` filters `branch_id == … AND depth == …`, and the player's own example
breaks it:
```
B ── C C1 C2 <- three takes, one parent (B)
│ └── D1' D2' <- two takes, parent C2
└── D1 D2 D3 <- three takes, parent C1
```
Standing on the C2 path at that depth must read `2/2`, not `5`. Coordinate grouping gets
that right by accident — writing under a non-live take forks, so the two sets land on
different branches. It gets `C` wrong: once C is forked onto its own branch it is alone
at its coordinate and reads `1/1`, losing C1 and C2 from a pager that must still say
`1/3`.
**Decision: add `actions.parent_id`.** The alternative — making a branch's fork point a
*node* instead of a depth, so a promoted take never moves — was rejected. `lineage` exists
precisely so a read is an OR-clause per branch rather than a walk up parent pointers, and
re-pointing the fork at a node changes path resolution itself, which drags in `cursors`,
memory depths and both bundle formats. `parent_id` is read only to group a turn's takes:
one indexed lookup, never a walk, and nothing about how a path resolves changes. Per SP2's
sizing note an integer beside `depth` is cheap, and the backfill rewrites a heap measured
at 1.7 MB on 2026-08-18.
**`variant_count` / `variant_index` are reprieved, not revived.** SP8 was going to drop
both; the pager needs the group's shape again. It needs it *per parent*, which is not what
either column caches, so SP8 still drops them and SP9 computes the shape from `parent_id`.
**Verify:** the SP0 baseline passes unmodified — a linear story has one take per parent,
and none of this is reachable without a second one. Plus: `2/2` under one take while its
sibling holds `3/3`; a pager that still reads `1/3` after its take has been forked onto its
own branch; a write below a non-live take forks exactly once; a fork on a player's own
message generates a reply.
**Owed:** a migration adding one column, and a `VACUUM FULL actions;` after it.
## Standing constraints
- **No production data is read at any point in this phase.** Migrations, the e2e
baseline and every egress measurement run on local SQLite and the synthetic `--keep`
fixture. If a real Postgres is ever needed for a write path, it is a throwaway
database whose name contains `stress`/`scratch`, and it is asked for first.
- **Any migration that rewrites `actions` is followed by `VACUUM FULL actions;`** on the
direct endpoint, not `-pooler`. SP1, SP4 and SP8 each rewrite every row. **As of
2026-08-18 two are owed** (SP1's and SP4's) and neither has been deployed; one vacuum
after the SP4 deploy settles both.
-117
View File
@@ -1,117 +0,0 @@
# Pokémon League Championship demo: playtest handover
Read this before you resume work on the demo scenario. It covers the current
state of `05-league-championship.json`, what a live 10-turn playtest on
production confirmed, and two bugs the playtest found.
**Last updated: 2026-08-28.**
> **Superseded in part on 2026-08-28.** Both bugs below were investigated against the
> real data and **both root causes named here are wrong**. See
> `plan/16-world-state-refusals.md` for what was actually happening and what was
> changed. The playtest record and the "Confirmed working" section still stand.
---
## Where things stand
The scenario is live at `https://ai-dnd-1gmp.onrender.com` as
**"[Demo] League Championship: Round One"**, seeded from
`backend/app/seed_data/05-league-championship.json`. It replaced an earlier,
weaker draft titled "Road to the Champion" — that old scenario and its stale
test adventure (adventure id 42) are still in the production database. Delete
them by hand from `/scenarios` and `/adventures` when convenient; a delete
click froze the browser tab during this session behind what looked like a
native confirm dialog, so budget time for that if you try again.
The schema nests all five of the player's Pokémon and Milo's Pokémon under
`npcs`, not flattened into `player.<name>_hp` fields. Each npc entry carries
its own `stats` map (`hp`, `status`, and for Milo, `active_pokemon`,
`active_hp`, `active_status`, `pokemon_left`). `player.active_pokemon` is a
text stat that names whichever of the player's Pokémon is currently out. This
design survived a real playtest: see "Confirmed working" below.
Settings on the test account now point at the user's own OpenRouter key,
endpoint `https://openrouter.ai/api/v1`, model `deepseek/deepseek-v4-flash-0731`,
reasoning budget `-1`. The shared demo key's model
(`google/gemma-4-26b-a4b-it:free`) was hitting persistent 429s from OpenRouter
capacity, not from the app's own rate limiter — switch back to it only after
confirming that model isn't still rate-limited.
## Confirmed working: a live 10-turn playtest
Adventure id 43, played turn by turn against DeepSeek V4 Flash on production.
Milo's Graveler and Onix both fainted; Kabutops came out third. Across ten
turns:
- HP tracked correctly on both sides, including sandstorm chip damage each
turn once `sandstorm_active` flipped on.
- `status` stayed correctly independent per Pokémon (all `none` throughout
this run — no status move was tried).
- `player.active_pokemon` and `npc.milo.active_pokemon` both switched
correctly as Pokémon were sent out or fainted (Pidgeotto → Wartortle →
Machoke; Graveler → Onix → Kabutops).
- `player.potions` decremented correctly on use (3 → 2) and the healed
Pokémon's HP rose by the expected amount.
- The World State sidebar reflected every one of these changes live, without
a manual refresh, after the model's reply finished streaming.
This confirms the nested-`npcs` redesign from earlier in the session was the
right fix for "why did you flatten then" — parallel entities with independent
stats work as npcs, not as flattened player fields.
## Two bugs the playtest found
**1. The model sometimes skips the trailing `state` block entirely.**
> **Unconfirmed.** Truncation at `max_output_tokens` removes the block too, and it
> was never ruled out here. See `plan/16`.
On the very first turn of this run, DeepSeek V4 Flash narrated a Wartortle HP
drop but never appended the ` ```state ` block the engine parses. The engine
correctly left the state untouched — this is model non-compliance, not an
engine bug — but the drop was silent: no error, no visible sign in the UI
beyond "the numbers didn't move." A `Retry` on that same turn produced the
delta correctly. Confirmed by reading the raw action row over
`/api/adventures/{id}/actions` — `hasState` came back `false` on the first
attempt and `true` on the retry. If this happens often during your own play,
it is worth an authors'-note reminder or a stronger trailing-instruction
nudge in `engine.py`'s prompt scaffolding, not a schema change.
**2. The model reliably forgets `npc.milo.pokemon_left` and milestones on a
faint, despite an explicit instruction to update both.**
> **Wrong on both halves.** The model emitted `pokemon_left` at *both* faints; the
> engine clamped it to nothing and reported it as applied. And it never emitted a
> milestone because the milestone ids were absent from the prompt entirely. Neither
> was an attention problem. See `plan/16`. `ai_instructions` in
the scenario file already says: *"decrement `npc.milo.pokemon_left` when one
of his faints"* and *"Mark milestones as they happen."* Across two separate
faints in this session (Graveler, then Onix), the model correctly reset
`active_pokemon`/`active_hp`/`active_status` for the incoming Pokémon every
time, but never once touched `pokemon_left` (stuck at `3/3` through both
faints) and never checked off "Knock out Milo's lead Graveler," even though
that milestone was unambiguously satisfied. This looks like the faint
instruction is buried inside a longer bulleted list the model is only
partially attending to. Worth trying: pull the faint-handling instructions
into their own short paragraph, or add a stat-guide line for `pokemon_left`
and the milestones that makes them as visually prominent as `hp`/`status`.
**Separately, not necessarily a bug:** `world.turn` (a `counter` stat defined
in the schema) stayed at `0` for all ten turns. `ai_instructions` never tells
the model to increment it — the instructions cover HP, status, potions,
active_pokemon, and pokemon_left, but not turn. If you want the counter to
mean something, add an explicit line telling the model to bump
`world.turn` by 1 every reply.
## Suggested next steps
1. Decide whether to patch `ai_instructions` for the two gaps above, then
redeploy and play a few more turns to confirm faints correctly decrement
`pokemon_left` and flip milestones.
2. Clean up the stale "Road to the Champion" scenario and adventure 42.
3. Try a status-condition move (Ivysaur's Poison Powder or similar) — this
playtest never exercised the `status` stat changing away from `none`, so
it is unverified in practice even though the schema supports it.
4. If DeepSeek keeps skipping state blocks more than rarely, consider the
trailing-reminder wording in `engine.py` (`build_state_reminder` or
equivalent) rather than switching models — the schema itself is sound.
-343
View File
@@ -1,343 +0,0 @@
# World-state refusals: what the engine throws away, and who gets told
Read this before you play the Pokémon demo again. It records why two bugs in
`plan/15-pokemon-demo-handover.md` were diagnosed wrongly, what the engine was
actually doing, and what changed. Everything here is merged and green. It **has** now been driven in a browser;
read "Driven in a browser" at the end first, because three of the five changes
did not work and one bug explains all three.
**Last updated: 2026-08-28.**
---
## The one sentence version
`apply_delta` records three outcomes for every change the model sends —
`applied`, `clamped`, `rejected` — and everything downstream read only
`applied`. A refused change therefore reached the player as an ordinary chip and
reached the model, on the next turn, as a change that had succeeded.
## What was actually wrong
`plan/15` recorded two bugs and named a cause for each. Both causes were wrong,
and the investigation is worth keeping because the same reasoning trap is easy
to repeat: **the visible evidence was "the number did not move", and the natural
reading of that is that the model never tried.**
### `pokemon_left` was emitted every time
Adventure 43's action rows carry `milo pokemon_left` in `world_changes` at both
faints, each with `"delta": 0, "value": 3`. The model saw the faint and wrote
the path. It was not forgetting anything.
`old == new == 3` is reachable only from a **positive** value. `pokemon_left`
was `min 0, max 3, initial 3, max_delta_per_turn 1`, so `+2` capped to `+1`,
reached 4, and clamped back to the ceiling of 3. Net zero.
So the model sent the remaining count as an absolute — "two left" — instead of
a delta of `-1`. Milo's four stats alternate between the two conventions:
| stat | convention |
|---|---|
| `active_pokemon` | text, absolute |
| `active_hp` | number, delta |
| `active_status` | text, absolute |
| `pokemon_left` | number, delta |
HP survives because damage is naturally phrased as a change. A count is
naturally phrased as a state, so it got text semantics.
**The general rule this produces:** a numeric stat whose `initial` equals the
boundary it moves away from turns every wrong-signed change into a silent
no-op. Every `hp` in the scenario has that shape (`initial == max`). It has
never fired only because damage is phrased as a decrease by luck of language.
### Milestones were never emitted at all
Zero milestone changes across nine AI turns. `EMIT_RULE` asks for
`"milestones.<id>": true` and `apply_delta` matches `<id>` against the schema
key, but `render_state_section` printed only the description, and
`render_reference` skipped the milestones section entirely
(`STAT_SECTIONS = ("world", "player")`). The string `graveler_defeated` was
nowhere in the prompt.
The same playtest is its own control: `sandstorm_active` is a flag, flags
*are* printed by name, and it worked.
### The replay was teaching the model to repeat itself
Found while deciding whether to feed refusals forward, and the most damaging of
the three. `_history_text` re-attached each past turn's state block from
`world_delta["delta"]` — **what the model sent**, not what was applied. So the
turn after the faint contained the model's own block claiming
`"npc.milo.pokemon_left": 2`, directly above a live values line reading
`pokemon_left 3/3`, with nothing to say which was true.
That is a per-turn lesson that sending `2` is correct. The identical mistake at
the second faint is what that lesson predicts.
## What changed
Five changes, on `fix-silent-clamps-and-milestone-ids`.
1. **`Action.world_changes` reports refusals** (`models.py`). Reads `clamped`
and `rejected` beside `applied`. Accepted stats carry `clamped`; refusals
become `kind: "rejected"` entries. The `fix` key is present only when the
engine wrote one — it is empty for every accepted change, and this property
runs for every action of every list response.
2. **The UI distinguishes three outcomes** (`Play.jsx`, `index.css`). Clamped to
a standstill reads `no change — at its limit` on a dashed chip; a partial
clamp is marked `(limited)`; a rejection carries its reason. Dashed and
dimmed rather than red: the rules refusing a change is them working.
3. **Milestones are named to the model** (`engine.py`). The goals line is now
`Goals (mark with milestones.<id>): graveler_defeated — Knock out Milo's lead
Graveler; …`, the same treatment NPCs get with `(npc.milo)`.
4. **Refusals carry a generated correction** (`engine.py`). Each rejection, and
each clamp that moved nothing, builds a `fix` string from the stat definition
at the point of refusal, so it quotes the real limits and lists the real
names. `render_refusals()` renders them into the prompt directly above
`EMIT_REMINDER`, for the previous AI turn only.
5. **History replays what was accepted** (`builder.py`). `applied_delta()`
rebuilds the block from `report["applied"]`, dropping any numeric entry where
`new == old` so a change that moved nothing cannot be copied as a zero.
Plus the demo scenario: `pokemon_left` became `pokemon_fainted`
(`type: counter`, `initial: 0`), which puts a wrong sign on the counter rule
where it is rejected out loud instead of absorbed. The faint instruction moved
into its own paragraph, and a `world.turn` line was added — it sat at 0 for the
whole playtest because nothing ever told the model to move it.
### The design call worth not re-litigating
**A clamp that reduced a change but still moved the value says nothing.** Only
total losses are reported. If you tell a model its 80 damage became 30, it can
treat the shortfall as a debt and send the remaining 50 next turn — which is the
swing `max_delta_per_turn` exists to prevent. A rejection has no partial credit
to chase. `test_a_clamp_that_still_moved_the_value_says_nothing` pins this.
## How to test it
532 backend tests pass and the frontend builds. **The UI work has no automated
cover** — this project has no frontend test runner, which is the standing
reason its UI bugs are found by hand.
Run the backend from `backend/` with
`.venv/Scripts/python.exe -m pytest tests/`. The new file is
`tests/test_change_visibility.py` (23 tests). Each of the three mechanisms fails
its own test when disabled; that was checked by sabotage, not assumed.
To drive it, re-seed the scenario and play the demo:
1. **A refusal chip.** Open the World State drawer, use its ✎ edit mode to put
Milo's `active_hp` at full, then play a turn where he takes no damage but the
model tries to heal him. Easier and more reliable: send a deliberately wrong
block by editing an AI turn. What you are looking for is a dashed chip
reading `no change — at its limit`, not a `+0`.
2. **The milestone.** Knock out Graveler. `graveler_defeated` should tick, and
a `✓ graveler defeated` chip should appear. This is the single clearest
pass/fail in the whole change — it never once happened before.
3. **The faint counter.** At the same faint, `pokemon_fainted` should go 0 → 1.
If the model sends an absolute again, it is now refused rather than absorbed,
and the refusal note should appear in the *next* turn's prompt. Read it under
Insights → the turn's context snapshot, section `world_state_refusals`.
4. **The replay.** In the same snapshot, check the replayed history: a past
turn's `state` block should carry only the changes that were accepted.
5. **`world.turn`** should now advance by 1 per reply.
Check the narrow layout too. The chips grew longer text, and `.chg` is inside
the story column with nothing to scroll sideways — `overflow-wrap: anywhere` is
doing the work, and it was not re-checked at 390 px. Chrome clamps its minimum
window width to ~500 px, so relaunch with `--window-size=` rather than trying to
resize a maximized window.
## Still open
- **The missing `state` block from `plan/15` bug 1 is unexplained.** Truncation
at `max_output_tokens` removes the block, which `LENGTH_HEADROOM` exists to
prevent, and it was never ruled out. The distinguishing evidence is whether
the narration ends mid-sentence with `finish_reason: length`.
- **Every `hp` stat still has the `initial == max` shape.** Now visible when it
bites, rather than silent, but not designed out.
- ~~**The stale "Road to the Champion" scenario and adventure 42** are still on
production. Deleting them is hand-work and was deliberately not automated.~~
Automated on 2026-08-28: `seed.py` now deletes seeded scenarios no seed file
claims. Adventure 42 survives with a NULL `scenario_id`, and losing the cover
art is the whole cost.
- **The Bandit Camp demo (`04-rpg-world-state.json`) was not checked** for the
same milestone problem. Its milestones were equally unnamed to the model
before this change, so it is worth asking whether one has ever fired there.
---
## Driven in a browser, 2026-08-28
Adventure 45, four turns, on production against the demo model. Two of the five
changes worked. Three did not reach anyone, for one reason.
### The bug: the stored column dropped two of the three lists
`world_delta_of` in `routers/adventures.py` wrote `delta` and `applied` only.
Every consumer that distinguishes outcomes reads the other two:
- `Action.world_changes` builds `clamped_paths` from `world_delta["clamped"]`,
so every chip carried `clamped: false`. `Play.jsx`'s `blocked = c.clamped &&
d === 0` could never be true, and `(limited)` could never render.
- With no `rejected` list, a `kind: "rejected"` chip was unreachable.
- `worldstate.refusals` reads both lists, so `render_refusals` always returned
an empty string and no correction ever reached the next prompt.
The `fix` string survived only because the engine stores it inside the `applied`
entry. Turn 3 is the record: the snapshot's `report.clamped` held
`npc.ivysaur.hp` and `npc.milo.active_hp` with correct `fix` text, both chips
came back `clamped: false`, turn 4's prompt contained no correction, and the
model repeated the same mistake.
`world_delta_of` now carries all three lists.
**Why the 21 tests passed.** The `action()` helper in
`test_change_visibility.py` built the column by hand as `{"delta": delta,
**report}`, with every list present. The write path was never exercised. The
helper now fills the column through `world_delta_of`. Removing the two lines
again fails 9 of the 23 tests; that was checked, not assumed.
### The two that were not code faults
`graveler_defeated` never fired and `world.turn` never moved. Both instructions
are present and correct in the assembled prompt: the goals line reads `Goals
(mark with milestones.<id>): graveler_defeated — …`, and the scenario says to
add 1 to `world.turn` every reply. The demo model ignores both. Change 3
landed; the model is the limit.
### The absolutes were coming from the scenario's own wording
The model sent a total, not a change, for almost every number:
`npc.ivysaur.hp: 96`, `npc.milo.active_hp: 65` then `88`, `player.potions: 2`.
Because every `hp` has `initial == max`, each one clamped back to where it
started. `potions` went **up** when the player spent one.
`EMIT_RULE` does say "CHANGES ONLY, as deltas (not new totals)", and
`EMIT_REMINDER` repeats "deltas only". The scenario contradicted both at closer
range. `milo.active_hp.desc` said "**Reset this to** the newcomer's full HP",
which is an instruction to send an absolute, and that desc is injected every
turn. The bullets said "**Drop** the HP … and **raise** it", naming a direction
but never a sign. The only line that said "(not a delta)" was
`player.active_pokemon`, so naming the exception made the rule look optional.
The one stat whose desc used delta wording, `pokemon_fainted` ("Add 1 each
time"), is the one that worked.
Fixed in `05-league-championship.json`: every `hp` desc and the potion desc now
state the sign, the bullets do too, and the lead-in says plainly that every
number is a change with a worked example. `milo.active_hp.max_delta_per_turn`
went 65 → 98, because a switch legitimately moves that stat a full bar and the
old cap made the reset unreachable in one turn.
**Not fixed:** the `initial == max` shape itself. It is now loud rather than
silent, and the wording removes the usual cause, but the shape is still there.
---
## Driven against Claude, 2026-08-28
The four turns above ran on the free demo model, which ignored two instructions
that were present and correct in the prompt. To separate model behavior from
code, the same demo was played again through `backend/tools/claude_shim.py`, an
OpenAI-compatible endpoint backed by the local `claude` command line tool. The
model was `sonnet`. See the README for how to run it.
Every one of the five changes worked:
| Turn | Chips |
|---|---|
| 1 | `turn +1`, `active pokemon → Wartortle`, `wartortle hp -8`, `milo active hp -46`, dashed `type advantage used refused — no such flag` |
| 2 | `turn +1`, `milo active hp -52`, `milo pokemon fainted +1`, `milo active pokemon → Onix`, `✓ graveler defeated` |
| 3 | `turn +1 (limited)`, dashed `milo active hp no change — at its limit` |
| 4 | `turn +1`, `milo active hp +32`, `✓ type advantage used` |
`graveler_defeated` fired for the first time in five playtests, `world.turn`
moved every turn, `pokemon_fainted` went 0 to 1 at the faint, and every HP number
was a signed change rather than a total. The two failures left open above were
the demo model, not the instructions.
**The refusal loop is verified end to end.** Turn 3 clamped to nothing. Turn 4's
assembled prompt, read from `GET /api/adventures/1/actions/9/context`, carried
the correction verbatim:
```
[Part of your last state block was not applied. Correct it in this turn's block:
- `npc.milo.active_hp` did not move. It is already at its minimum of 0 (it runs
from 0 to 98; it moves at most 98 per turn).]
```
The model's next delta was `{"world.turn": 1, "npc.milo.active_hp": 32,
"milestones.type_advantage_used": true}`. That is the loop the whole change
exists for, and it had never been observed running.
### The one bug that is still a schema fault
At the faint the model set `npc.milo.active_pokemon` to `Onix` and sent no
positive `active_hp` change, so Onix arrived on the field at 0 of 98 and every
later hit was refused. Sonnet followed the rest of the scenario closely, so this
is the schema rather than the model: `active_hp` is one stat with one `max` of
98, shared by Graveler at 98, Onix at 90, and Kabutops at 88. A switch has to
raise it, and nothing in the schema can raise it on the referee's side.
The fix is to give Milo's three Pokemon their own NPC entries, which also removes
the `initial == max` shape from this demo. Not done: the session's remaining time
went to the guest starter instead.
### Cost
About $0.04 a turn, billed against the Claude subscription rather than a card.
Roughly 13k of each request's 20k prompt tokens is the command line tool's own
overhead; the app's prompt is about 7k.
---
## What shipped alongside it, 2026-08-28
**A permanent local test rig.** `backend/tools/claude_shim.py` is now in the
repo. It finds the `claude` binary through `AIDND_CLAUDE_BIN`, then `PATH`, then
the per-user install, and takes `--port` and `--claude`.
**The demo says Pokemon in its title, and has a Pokeball for cover art.**
`05-league-championship.json` is now `[Demo] Pokemon League Championship: Round
One`. Renaming a seed exposed a fault: `seed.py` matches a seed to its row by
title, so a rename inserted a second public scenario and stranded the first,
which is the same failure this file records for "Road to the Champion". Seeds
now carry `previous_titles`, and `_find_renamed` lands the rename on the
existing row. The scenario kept its id, and no orphan appeared.
The cover art is a 3.2 kB PNG data URI. An SVG one was tried first and stored as
an empty `image_url`: `app/images.py` accepts raster formats only, on purpose,
because SVG can carry script and these bytes are served from the app's own
origin. `backend/tools/make_pokeball.py` draws the ball with `zlib` alone, so
regenerating it needs no image library.
**Seeded scenarios no seed file claims are deleted.** `previous_titles` stops a
rename stranding a row, but the rows already stranded still needed deleting by
hand on every deployment. `_sweep_unclaimed` removes them on the next boot. Only
a NULL owner with `is_public` is reachable, so nothing a player made can be
touched, and `adventures.scenario_id` is `ON DELETE SET NULL`, so an adventure
started from a deleted demo keeps its story and loses only the artwork. The
sweep is skipped when a seed file fails to parse, and an empty seed directory
never reaches it: neither reads as an instruction to delete live content.
**Every new guest gets the played adventure.** `app/starter.py` copies a shipped
export bundle into each new guest account, from the guest mint in
`routers/auth.py`. The bundle is this session's adventure trimmed to its first
two exchanges, which ends on the knockout and shows an applied change, a refused
one, and a milestone. It stops before the Onix bug above, and the state it
leaves has Onix at its own 90 HP so a guest can play on from it.
An adventure has no cover art of its own and inherits its scenario's, and a
bundle carries no scenario id, because an id is local to one database. The
starter file names its source under `scenarioTitle` instead, and
`starter._link_scenario` looks it up, so the copy shows the Pokeball rather than
a monogram tile.
The row building that `POST /adventures/import` did inline moved into
`bundle.materialize`, which both callers now use. The limit and rate checks
stayed in the endpoint: the starter writes a file the server ships, so it has no
untrusted list to cap. `tests/test_starter_adventure.py` covers the file, the
copy, the chips, the playable end state, and the two failure paths.
-582
View File
@@ -1,582 +0,0 @@
# Phase 17: refactor for readability
The code works and it is tested. It is also hard to read, because four files hold
most of it, one schema migration is half finished, and the published guide is a
hand-maintained copy of another file. This phase fixes those three things without
changing what the app does.
## How to use this file
This file is both the plan and the running status. Update the progress table at
the end of every stage. Read the table first when you pick the work back up.
Each stage lands as its own commit on `refactor-17`. No stage starts until the
stage before it is green.
## Progress
| Stage | Work | Status | Landed |
|---|---|---|---|
| 0 | Hygiene: worktrees, branches, undocumented settings | done, except the branch deletion | 2026-08-29 |
| 1 | Split the four largest files | done: one test setup, and all four files split | 2026-08-29 |
| 2 | Remove duplication | done: all seven items | 2026-08-29 |
| 3 | SP8: drop the legacy columns | code done, all eight columns gone; the deploy and `VACUUM FULL` remain | 2026-08-29 |
| 4 | Documentation | not started | |
| 5 | A frontend test runner | not started | |
## Baseline
Record these numbers before you start. They are how you tell a refactor from a
rewrite.
- 549 backend tests pass in 103 seconds.
- `backend/app` holds 13,486 lines across 45 files.
- `backend/tests` holds 12,221 lines across 38 files.
- `frontend/src` holds 9,171 lines across 21 files.
- The app exposes 70 endpoints and applies 64 migrations on boot.
Run the suite with `cd backend && .venv/Scripts/python.exe -m pytest tests/ -q`.
## What the review found
### The code is clean at the statement level
A scan for unreferenced functions across `backend/app` returned only FastAPI route
handlers, which the decorator references rather than the name. A scan of
`index.css` found 5 unused class names out of 373, and every one of the 5 is either
CodeMirror's or built from a template string. There is no dead code to delete and
no `TODO` or `FIXME` anywhere in the tree.
Read that as a constraint. The gains in this phase come from moving code, not from
finding rot.
### Four files hold most of the complexity
| File | Lines | What it holds |
|---|---|---|
| `backend/app/routers/adventures.py` | 2353 | Nine concerns: the turn engine, branches, takes, import and export, adventure scripts, refresh from scenario, insights, memory CRUD, and action CRUD |
| `frontend/src/pages/Play.jsx` | 2280 | 27 components, plus a 722-line `Play()` holding 18 `useState` calls and 7 `useEffect` calls |
| `frontend/src/index.css` | 2865 | One stylesheet. The `max-width: 720px` block at line 2655 overrides rules written 2000 lines above it |
| `backend/app/worldstate/engine.py` | 918 | Four jobs: parse a delta, apply a delta, render the context sections, and instantiate a schema |
### The schema is half migrated
`plan/14-phase-story-tree.md` defines SP8, which drops the columns the tree
replaced. SP8 was gated on the tree running in production. It now does. Eight
columns are still written on every turn and read by nothing:
- `actions.index`, `variants`, `variant_index`, `variant_count`
- `actions.state_before`, `world_state_before`
- `adventures.memory_cursor`, `summary_cursor`
Until they go, `models.py` carries two vocabularies for one idea, and every reader
has to be told which one is live.
### The published guide is a hand-written copy
`docs/guide.html` is 84 KB of hand-written HTML covering the same material as the
63 KB `docs/GUIDE.md`. Nothing generates one from the other. They have already
drifted: the HTML last changed on 2026-08-20 and the Markdown on 2026-08-28, and
section 3.6, "Counting visits", exists only in the Markdown. GitHub Pages publishes
the HTML, so readers get the stale copy. `docs/architecture.html` is 87 KB of
hand-written HTML with no Markdown source at all.
### Duplication the tests already protect
- `worldstate.apply_delta` and `apply_override` each re-implement the same path
routing for `flags.`, `milestones.`, and `npc.<id>.`. That is six parallel
branches at `engine.py:573`, `587`, `617`, `675`, `697`, and `740`.
- 35 of the 38 test files repeat the same 8-line temporary-database prologue. There
is no `conftest.py`.
- `ScriptedProvider` or `FakeProvider` is defined 12 times, once per test file that
needs a fake model.
- The debounced autosave helper is copied into `Play.jsx:204`,
`ScenarioEditor.jsx:23`, and `ScriptEditor.jsx:22`.
- `get_adventure_or_404` is called by hand in about 20 handlers. It is a function,
not a dependency, so every handler repeats the call.
- `routers/chat.py:22` imports `SSE_HEADERS` and `sse` from `routers/adventures.py`.
One router reaches into another for shared plumbing.
### One setting is documented nowhere
`AIDND_TRUSTED_PROXY_HOPS` is read at `limits.py:55`. It does not appear in
`backend/.env.example`, `README.md`, or `render.yaml`. It sets how many proxy hops
`client_ip()` trusts, which is what stops a rotated `X-Forwarded-For` header from
buying a fresh rate-limit bucket. If a deployment adds a proxy hop and nobody sets
this variable, the bypass comes back silently.
The unprefixed `DATABASE_URL` that `database.py:24` also accepts turned out to be
documented already, inside the `AIDND_DATABASE_URL` entry in
`backend/.env.example`. No change needed there.
### `Settings.stream` is dead
`models.py:622` defines the column, and `schemas.py:452` and `499` expose it.
Nothing reads it in the backend or the frontend. This is item S1 in
`docs/self-review.md`.
### Local clutter
Everything here is gitignored, so it costs repository weight nothing. It costs disk
and attention.
- Two abandoned worktrees, each a full copy of the tree:
`.claude/worktrees/simplify-comments` and `.claude/worktrees/sp7-tree-ui`. Both
branches landed on `main` as squashes.
- 21 local branches. 13 read as unmerged to `git branch --no-merged`, and
`plan/STATUS.md` already records that they landed as squashes.
- Three stale SQLite files in `backend/`: `data.backup-2026-07-06.db`,
`data.backup-pre-phase8.db`, and `scroll_fixture.db`, 2.9 MB together. The live
local database, `backend/data.db`, is not one of them and is not touched.
## Stage 0: hygiene
No source changes. Do this first, so later stages run against a quiet tree.
1. Remove both worktrees with `git worktree remove`, then delete the branches that
landed as squashes. Verify each one first: a branch is safe to delete when
`git log main..<branch>` names only commits whose message already appears in
`git log main`.
2. Delete the three stale `.db` files. Leave `backend/data.db` alone.
3. Add `AIDND_TRUSTED_PROXY_HOPS` to `backend/.env.example`, with the reason it
exists. Add it to the deploy section of `README.md` too, because that is where
somebody putting the app behind another proxy looks.
**Check:** the suite still passes, and `git status` is clean.
### What Stage 0 actually did, 2026-08-29
Both worktrees are gone, which freed about 104 MB. Removing `sp7-tree-ui` needed one
extra step: a Vite dev server had been running out of that worktree since
2026-08-18, holding `frontend/.vite` open and owning port 5173. It was serving a tree
54 commits behind `main`. Stopping it freed the directory. Restart the real one with
`start.ps1`.
The three stale `.db` files are deleted. `backend/data.db` is untouched.
`AIDND_TRUSTED_PROXY_HOPS` is now in `backend/.env.example` and in the README deploy
section.
**Still owed.** The 19 squash-landed branches are still there. `git branch -D` is
blocked by the permission classifier, which is a reasonable guard on a destructive
command. All 19 are verified safe by the rule above. Run this to clear them:
```
git branch -D bugfix-code-review docs-story-tree fix-prepend-autoscroll \
fix-silent-clamps-and-milestone-ids fix-store-refusals-and-delta-wording \
handover-2026-08-17 measure-post-vacuum-sizes phase-14-story-tree \
phase-7-public-repo phase-8-accounts sp1-tree-schema sp10-memory-bank-eviction \
sp2-branch-clause sp3-node-cursors sp4-sibling-nodes sp5-fork-on-continue \
sp6-bundle-v2 sp7-tree-ui sp7b-take-pager worktree-keep-fixture-and-status \
worktree-simplify-comments
```
`worktree-keep-fixture-and-status` is the one that needed checking by hand. Its
commit subject appears nowhere in `main`, but the `--keep` flag it adds is on `main`
at `tools/stress_session.py:764`, along with both gotchas the message describes, at
`:686` and `:692`. Only the message differs.
The remote branches are left alone. Deleting those is a separate decision.
## Stage 1: split the four largest files
Every change in this stage moves code. None of it changes behavior. The 549 tests
are the check, and they must pass without being edited, except where this section
says otherwise.
### `routers/adventures.py` becomes a package
Split it into `backend/app/routers/adventures/`:
| Module | Holds | Source lines |
|---|---|---|
| `__init__.py` | The `APIRouter`, the shared dependencies, and re-exports | |
| `paging.py` | `ACTION_LIST_COLUMNS`, `ACTION_PAGE`, `action_window`, `annotate_takes`, `current_window` | 27-146, 1203-1261 |
| `crud.py` | List, create, get, patch, delete, plus the script-state and world-state readers | 225-580 |
| `turns.py` | The turn engine: `generate_turn`, `_generate_turn`, `run_player_turn`, the turn lock, and the SSE helpers | 581-1004 |
| `takes.py` | Retry, variants, takes, forking, and undo | 1005-1192, 1503-1778 |
| `branches.py` | The branch endpoints | 1193-1502 |
| `bundle_io.py` | Export and import | 1779-1837 |
| `scripts.py` | The per-adventure script endpoints | 1838-1941 |
| `refresh.py` | Refresh from scenario | 1942-2152 |
| `insights.py` | The context dry run and the per-action context | 2153-2189 |
| `memories.py` | Memory bank CRUD | 2190-2279 |
Target no file above 450 lines.
**The tests couple to the module, so read this before you start.** Twelve test
files call `monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ...)`.
`monkeypatch` replaces a name in the module where the calling code looks it up, so
re-exporting from `__init__.py` does not keep those patches working. Once
`_generate_turn` lives in `turns.py`, the target becomes
`adventures.turns.OpenAICompatibleProvider`.
Do Stage 2's `conftest.py` work first if you want that to be a one-line change
instead of twelve. Otherwise retarget all twelve here. The other patched names are
`adventures.limits`, `adventures.check_demo_cap`, `adventures.generate_turn`, and
`adventures._active_turns`, each used once.
Keep these importable from the package root, because tests import them by name:
`ACTION_PAGE`, `SNIPPET_MAX`, `_snippet`, `world_delta_of`, `acquire_turn_lock`,
`retry_action`, `undo_turn`, and `_active_turns`.
`_active_turns` is module-level mutable state guarded by a lock. It must live in
exactly one module, `turns.py`, and every other module must import the module and
reach through it. If two modules import the set by value, the lock guards two
different sets and the turn lock stops working.
### What the router split actually did, 2026-08-29
`backend/app/routers/adventures.py` is now a package of 14 modules. The largest
is `turns.py` at 443 lines. Four modules exist that the table above does not
list, because the plan's eleven still mixed unrelated work:
| Extra module | Why it exists |
|---|---|
| `deps.py` | Holds the `APIRouter` and the ownership check. It imports nothing else in the package, so every endpoint module can import the router without importing its siblings. |
| `scenario_text.py` | Copying a scenario's text and cards has two callers, `crud.create_adventure` and `refresh`. Leaving it in either one made the other import an endpoint module. |
| `nodes.py` | Story-tree navigation that four modules use: `last_action`, `next_index`, `next_depth`, `stand_on`, `db_tip`, `delete_turn`. |
| `actions.py` | The three action endpoints. They page and delete rather than play a turn, so they do not belong in `crud.py`. |
Two decisions differ from the plan above.
**The package root does not re-export `acquire_turn_lock` or `_active_turns`.**
The plan said to keep them importable, but that makes a broken patch look like a
working one. Rebinding `adventures.generate_turn` changes the alias and leaves
every caller reading the original, and the test still passes. Leaving those names
off the package root raises `AttributeError` instead. Eighteen test call sites and
four in `backend/tools/` now say `adventures.turns.<name>`. The package root still
re-exports the pure helpers, so `adventures.ACTION_PAGE`, `adventures.undo_turn`,
and `chat.py`'s `from .adventures import SSE_HEADERS, sse` are unchanged.
**`world_delta_of` lives in `turns.py`.** It reads as a world-state helper and sat
beside the world-state endpoints, but `_generate_turn` is its only caller.
`_active_turns` behaved as the plan warned. Every module reaches it as
`turns._active_turns`, and a check confirms the four modules see one set object
and one lock.
One test coupled to the router for an unrelated module: it called
`adventures.worldstate.instantiate`. It imports `app.worldstate` directly now.
The split moved text rather than retyping it. An AST comparison against the
pre-split file confirms all 86 definitions are identical, once the `turns.`
prefix is normalized away. The 549 tests pass, and the OpenAPI schema still lists
the same 35 operations.
### `worldstate/engine.py` becomes a package
Split `backend/app/worldstate/engine.py` into four modules under
`backend/app/worldstate/`:
- `schema.py`: `has_schema`, `instantiate`, `reconcile`, `band_label`, `npc_name`,
`npc_triggers`, `_initials`.
- `parse.py`: `extract_delta`, `_tolerant_load`, `render_delta_block`,
`applied_delta`, `refusals`, `render_refusals`.
- `apply.py`: `apply_delta`, `apply_override`, and the shared path resolver Stage 2
introduces.
- `render.py`: `render_state_section`, `render_reference`, `_stat_line`,
`_describe_stat`.
`worldstate/__init__.py` re-exports every public name it exports today, so no call
site changes. The engine is imported as a module, not monkeypatched, so this split
carries none of the router's test coupling.
### `pages/Play.jsx` becomes a directory
Split it into `frontend/src/pages/Play/`:
- `index.jsx`: the page component.
- `usePlaySession.js`: the 18 `useState` calls and 7 `useEffect` calls that drive
one adventure, behind one hook.
- `panels/`: `PlotPanel`, `MemoryPanel`, `ScriptsPanel`, `InsightsPanel`,
`BranchPanel`.
- `drawers/`: `StatusDrawer`, `WorldStateDrawer`, and the `StatRow`, `StatGroup`,
`StateTree`, and `StateValue` parts they use.
- `reports/`: `StateChangeChips`, `WorldStateReport`, `ScriptReport`,
`CacheReport`, `TokenBreakdown`.
- `TakePager.jsx`, `RefreshModal.jsx`.
There is no frontend test runner until Stage 5, so this split is verified by
`npm run lint`, `npm run build`, and by driving the Play screen in a browser. Drive
it. `plan/STATUS.md` records that the last two Play bugs were both found by hand and
were unreachable from any test that existed.
### `index.css` becomes a directory
Split it into `frontend/src/styles/`, imported in order from `index.css`:
`tokens.css`, `base.css`, `nav.css`, `cards.css`, `play.css`, `drawers.css`,
`schema-editor.css`, `insights.css`, `modals.css`, and `theme.css`.
Move each `@media` block next to the rules it overrides, rather than leaving one
`max-width: 720px` block at the end. Keep the source order identical when you move
rules, because CSS resolves ties by order and the file relies on that in at least
two known places, recorded in `plan/STATUS.md` and in the comments at
`index.css:1011` and `1082`.
**Check:** 549 tests pass, `npm run lint` and `npm run build` are clean, and the
Play screen works in a browser at desktop and at 500 px wide.
### What Stage 1 actually did to the frontend, 2026-08-29
`Play.jsx` is now `frontend/src/pages/Play/`, twelve files. The largest is
`index.jsx` at 781 lines. `index.css` is now an `@import` list over
`frontend/src/styles/`, eighteen files.
Two decisions differ from the plan above.
**`usePlaySession.js` does not exist yet.** The page component still owns all of
the session state. Moving eighteen `useState` calls and seven `useEffect` calls
is a rewrite, not a move, and no frontend test would catch a mistake in it
today. It waits for Stage 5.
**The `@media` blocks stayed where they were.** `responsive.css` still holds one
`max-width: 720px` block, at the end of the import order. Moving a `@media` block
next to the rules it overrides moves it earlier in the cascade, which changes
which of two equal-specificity rules wins. Nothing in the test suite would catch
that. Do this after Stage 5.
Each split is verified by a different proof, because neither one has a test:
- CSS: the parts rebuild `index.css` byte for byte, and the built bundle is
identical before and after at 56686 bytes.
- JSX: every non-blank line of the original appears exactly once, in order, across
the twelve files. A name-resolution check confirms every identifier each file
references is defined or imported there, with no unused imports.
The line split stranded a comment at six of the boundaries. A leading comment
sits above the section it describes, so each boundary cut one loose and left it
at the end of the file before it. All six moved to the section they describe.
`npm run lint` and `npm run build` are clean, and 549 tests pass. Driving the
Play screen covered the story view, all five panels, both drawers including the
world-state edit form, the branch map, the refresh dialog, and the take pager,
which stepped onto a take that lives on another branch and switched to it. The
console reported no errors. The extension cannot resize the render viewport and
the app sends `X-Frame-Options: DENY`, so the narrow-width check ran by setting
the `max-width` media queries to `all` in the live stylesheet. All 71 narrow
rules found their elements: the nav collapses to one button, the panel tabs move
onto the title row, a panel fills the screen, the composer stacks, and both
drawers become edge tabs.
## Stage 2: remove duplication
Each item here is small and is covered by tests that already exist.
1. **One path resolver in `worldstate`.** `apply_delta` and `apply_override` route
`flags.<name>`, `milestones.<id>`, `npc.<id>.<stat>`, `world.<stat>`, and
`player.<stat>` with parallel code. Extract a resolver that returns the
container, the stat definition, and the kind, then let the two functions differ
only in the write rule. Their rules genuinely differ, so do not merge the
functions themselves. `apply_override` ignores `cooldown`,
`max_delta_per_turn`, and the rule that a counter cannot decrease, and it lets a
milestone be un-set. `tests/test_worldstate.py` covers both.
2. **Add `backend/tests/conftest.py`.** Move the temporary-database prologue there,
so the other 35 files drop 8 lines each. The prologue has to run before
`from app.main import app`, because `main.py` calls `bootstrap(engine)` at import
time. A `conftest.py` runs before any test module, which satisfies that.
3. **One fake provider.** Move `ScriptedProvider` and `FakeProvider` to `conftest.py`
as one fixture, replacing 12 copies.
4. **Move the SSE helpers out of the router.** Put `sse`, `SSE_HEADERS`, and
`turn_error` in `backend/app/sse.py`, so `chat.py` stops importing from
`adventures`.
5. **Make `get_adventure_or_404` a dependency.** About 20 handlers repeat the call.
A `Depends` removes the line from each one and puts the ownership check in the
signature, where a reader looking for it expects it.
6. **One `useDebouncedSave` hook** in `frontend/src/hooks/`, replacing the three
copies.
7. **Delete `Settings.stream`.** Remove the column, the two schema fields, and add
the migration that drops it. This is item S1 in `docs/self-review.md`. Mark it
applied there.
**Check:** 549 tests pass. Test count may drop if consolidating fixtures removes a
duplicate case. If it does, say which case and why in the commit message.
### What Stage 2 actually did, 2026-08-29
All seven items landed. Items 2 and 3 went in early, with the test setup in
`32cd7c1`, because the conftest had to exist before the router split could move
any test. Items 1, 4, and 5 are `2c57b1c`. Items 6 and 7 are `e0bf2b6`.
**Item 1, the path resolver.** `_resolve` returns a `_Target` naming the kind,
the section, the key, the stat definition, and the character, or a rejection.
The two callers now differ only in the write rule, which is what the item asked
for. Building the container is a method on `_Target` rather than part of
resolving, because a rejected path must not leave an empty section behind.
A refactor here is hard to check by reading, so it was checked by running. A
differential harness fed 3960 generated payloads through the old and the new
implementation and compared the state and the report. Ignoring `fix`, there are
zero differences. 674 rejections gained a `fix` string and none lost one, all of
them in `apply_override`, which had been the terser of the two. That is an
improvement rather than a regression: `apply_delta` already worded those
strings, and the world-state editor renders them.
**Item 5, the ownership dependency.** All 32 handlers converted, not the 20 the
review estimated. Two proofs, because tests alone would not catch a change in
the order of the checks or in the public HTTP surface:
- An AST pass confirmed the `get_adventure_or_404` call was the first statement
in every one of the 32 handlers. If it were not, hoisting it into a dependency
would move work that used to run after something else.
- The generated OpenAPI document was diffed against one built from a `git clone`
at `HEAD`. The only difference is that `rename_branch` now lists `branch_id`
before `adventure_id`, which is parameter order in the spec and not a route
change.
Seven tests in `test_state_revert.py` needed updating. They call handlers as
plain functions rather than over HTTP, so they have to pass `adventure=` now.
That file is the only one that does this.
**Item 7 needed a migration guard.** Migration 65 drops `settings.stream`, and
it is the first migration that drops a column. `_column_already_there` already
existed for the `ADD COLUMN` case. Dropping needs the mirror, `_column_already_gone`,
because `create_all` builds the current schema, which is already missing the
column, and fixtures like `test_tree_migration.pre_tree` stamp an old version
against a database built that way and replay. Verified by running migration 65
twice, once against a database that still had the column and once against one
that did not.
**One duplicate was left alone.** `FakeProvider` in `test_chat.py` is not a copy
of `ScriptedProvider`. It records the key, model, and endpoint it was
constructed with, which is how the chat tests assert on what would have gone
over the wire. `fakes.ScriptedProvider` streams replies and records prompts.
Merging them would give one class two unrelated jobs.
## Stage 3: SP8, drop the legacy columns
This is the only stage that touches the production database. Follow
`plan/14-phase-story-tree.md`, which specifies it.
1. Confirm nothing reads the eight columns. `ACTION_LIST_COLUMNS` in the adventures
package lists `index`, `variant_count`, and `variant_index` today, so that tuple
changes here. `bundle.py:37` and `context/history.py:424` both describe `index`
as unread; verify that rather than trusting the comment.
2. Add the migration that drops them. Remove the columns from `models.py`, and
remove the fields from `schemas.py` and from `ActionOut`.
3. Deploy, then run `VACUUM FULL actions;` on the direct Neon endpoint, not the
`-pooler` one. Dropping a column rewrites toasted values, and nothing reclaims
that space on its own. `plan/STATUS.md` records what happened the last time
nobody ran it: the database reached 144.2 MB against a 512 MB tier.
4. `VACUUM FULL` takes an `ACCESS EXCLUSIVE` lock, so the app blocks on `actions`
for the duration. It took 5.5 seconds at 144 MB.
**Check:** 549 tests pass, `/api/health` answers `{"ok":true}` after the deploy, and
one existing adventure opens, takes a turn, retries it, and pages back through the
transcript.
### What Stage 3 actually did, 2026-08-29
Steps 1 and 2 landed. Step 3, the deploy and the `VACUUM FULL`, is still open,
because it runs against production and belongs with the release, not with the
branch.
**Step 1, the read audit.** Nothing outside the migrations reads the eight
columns. `TakePager.jsx` reads `action.take_count` and `action.take_index` only,
which is what makes dropping the three payload fields safe. The comments in
`bundle.py` and `context/history.py` were accurate.
**Step 2, the drop.** Migrations 66 to 73 drop one column each. `index` is a
keyword in SQLite, so migration 71 quotes it. `models.py`, `schemas.py`, and
`ACTION_LIST_COLUMNS` lost the same eight, `Adventure.actions` now orders by
`id`, and `attempts.renumber`, `context/history.max_action_index`, and
`nodes.next_index` are deleted.
Two problems came out of the migration passes rather than the DDL:
- `_split_variants_into_siblings` wrote through `Base.metadata.tables["actions"]`,
the live ORM table, so it stopped compiling the moment migration 66 removed
five of its columns. It now writes through `_ACTIONS_AT_60`, a frozen `Table`
carrying its own `MetaData`. That declaration is a snapshot of a past schema
and must not be updated to track `models.py`.
- Five passes read columns that migrations 66 to 73 drop. A `create_all`
database replays every migration against the current schema, so each pass now
calls `_has_columns` and returns early when the columns are absent. This is
the rule `_column_already_there` applies to DDL, applied to the passes.
`bootstrap` gained a `through` argument. A migration test that asserts on
something a later migration removes stops at the version it is about, rather
than reading a schema several versions newer.
**One pre-existing bug found and not fixed.** `bundle._write_nodes` never sets
`Action.parent_id`, and `paging.annotate_takes` groups on `parent_id`, so an
imported adventure's take pager reads 1 of 1. The frontend has read only
`take_count` since SP9, so this predates Stage 3 and is not a regression from
it. `test_retry_variants.py` documents it.
**Check:** 555 backend tests pass, up from 549. `test_tree_migration.py` gained
eight parametrized cases asserting each column is gone after a real schema-45
database migrates all the way. A `create_all` database would pass those without
running the migration, which is why the fixture is a frozen pre-tree one.
## Stage 4: documentation
1. **Generate `docs/guide.html` from `docs/GUIDE.md`.** Write a small build script
that renders the Markdown into the existing hand-written HTML shell, keeping the
current styles and metadata. After that, one edit updates both. Note in
`README.md` that the HTML is generated and that you edit the Markdown.
2. **Decide what `docs/architecture.html` is.** It has no Markdown source. Either
give it one and generate it the same way, or state at the top of the file that
it is hand-written, so the next person does not look for a source that does not
exist.
3. **Split `plan/STATUS.md`.** It is 63 KB, and most of it is dated session logs.
Keep "Pick up here", "Things worth remembering", and "Running things" in
`STATUS.md`. Move the dated entries to `plan/history/`, newest file first.
4. **Add `docs/DEVELOPING.md`.** Record how to run the app, how to run the tests,
where each subsystem lives, and the invariants a newcomer breaks first:
- Anything that reads `adventure.actions` during generation takes
`exclude_action_id`. It leaks into four places, not one.
- `memory_cursor` and `summary_cursor` are positions into `story_actions()`, and
`Memory.source_start` and `source_end` are `Action.index` values. The two
spaces diverge as soon as anything is deleted.
- `/auth/me` must not raise. It is the SPA's bootstrap call, so anything it
touches that can raise takes the whole frontend down.
- The global field rule in the stylesheet keys off `input[type=...]`, so a bare
`<input>` and any new input type get browser default styling until you add
them.
- A modal opened from `.side-panel` needs `createPortal`, because the panel's
filling transform animation makes it the containing block for
`position: fixed`.
**Check:** the generated HTML matches the Markdown section for section, including
section 3.6. Every link in `README.md` and `docs/index.html` resolves.
## Stage 5: a frontend test runner
`plan/STATUS.md` names the missing runner as the reason this project keeps finding
UI bugs by hand. Two shipped bugs were unreachable from any test that existed.
1. Add Vitest, React Testing Library, and jsdom. Add a `test` script to
`frontend/package.json`.
2. Add the step to the existing `frontend` job in `.github/workflows/ci.yml`,
between lint and build.
3. Write the first tests against the two bug classes that already recurred:
- The take pager renders when a retry's reply arrives over SSE. The stream builds
its own `ActionOut`, so it was the one payload that never carried the
annotation.
- Retaking a player turn does not produce `> You > You`. The editor is seeded
with stored text that is already formatted.
4. Add a test for `usePlaySession` from Stage 1, since that hook now holds the state
the page used to hold inline.
**Check:** `npm test` passes locally and in CI.
## Risks
| Risk | Where | How you catch it |
|---|---|---|
| A monkeypatch silently stops patching, and a test passes while calling a real provider | Stage 1, `routers/adventures` split | After retargeting, break the fake on purpose and confirm the tests that use it fail |
| `_active_turns` ends up imported by value into two modules, so the turn lock guards two sets | Stage 1 | `tests/test_state_revert.py` reaches for `adventures._active_turns`. Keep that import path working, and confirm a second concurrent turn still returns 409 |
| A CSS rule changes meaning because the split reorders it | Stage 1, stylesheet split | Compare the built CSS before and after. Order within each section must not change |
| The column drop rewrites the table and nobody reclaims the space | Stage 3 | Run `VACUUM FULL actions;` on the direct endpoint, and read sizes from `sum(octet_length(col))` rather than `n_live_tup` |
| Splitting `Play.jsx` breaks something no test covers | Stage 1 | Drive the Play screen by hand at both widths. Stage 5 exists to shrink this risk for next time |
## What this phase does not do
- It does not replace the hand-rolled migration runner with Alembic. 64 migrations
run correctly, and `docs/GUIDE.md` section 2.6 already explains the choice.
- It does not move `bootstrap(engine)` out of import time in `main.py`. The current
behavior means a failed migration means no service, which is deliberate. Stage 2's
`conftest.py` removes the boilerplate that import-time bootstrapping forces on
tests, which is the part that actually hurts.
- It does not change any API shape, any database content, or any prompt.
-145
View File
@@ -1,145 +0,0 @@
# Appendix: the memory-prompt A/B, run 2 — a fresh story
An independent replication of `plan/18-appendix-memory-ab-run.md`, produced by
`backend/tools/memory_ab.py` through `tools/claude_shim.py` after
`MEMORY_MAX_WORDS` was added. The story is **newly generated**, not the one run 1
used, so this tests the prompts against different prose rather than re-scoring
the same text.
## What replicated, and what did not
| | run 1 before | run 2 before | run 1 after | run 2 after |
|---|---|---|---|---|
| memory 1 | 34 w, "you" | 89 w, "the player" | 72 w, named | 54 w, named |
| memory 2 | 105 w, "the player" | 107 w, "the player" | 68 w, named | 55 w, named |
**Naming replicated cleanly.** Four control memories across two runs, and not one
of them names the protagonist. Four treatment memories, and all four do. That is
the change working, and it is the finding to rely on.
**Length replicated, and the ceiling holds.** The control ran 34, 89, 105, 107
words — a four-fold spread with no stated budget. The treatment after
`MEMORY_MAX_WORDS` ran 54 and 55.
**The person-drift did not recur, and the claim about it should be read
narrowly.** Run 1 produced two control memories in two different persons — "You
crept low" and "The player asked Gwen" — which is exactly the reported
complaint. Run 2's controls were both "the player", consistently. So drifting
between *second* and *third* person is a real thing a model does, observed once,
not something it does every time. What is consistent across both runs is that
the control never reaches for the character's name, because it has never been
told one.
---
*Everything below is the raw run, unedited.*
One story, generated through `build_context` against `sonnet` at `http://127.0.0.1:8787/v1`. Both prompts then summarize the same blocks, so the prompt is the only variable. The control is `MEMORY_SYSTEM_PROMPT` as of `9cdcb55`.
| memory | arm | words | framing |
|---|---|---|---|
| 1 | before | 89 | "the player" |
| 1 | after | 54 | named |
| 2 | before | 107 | "the player" |
| 2 | after | 55 | named |
## Memory 1
**Before:** The player and Gwen scouted a bandit camp at dawn, agreeing on a quiet approach with Gwen circling the right flank (targeting the spear-carrying guard) while the player advanced from the left; the strongbox sat hidden under an oiled tarp in the camp's center. The player searched an empty bedroll undetected, taking a belt knife, a handful of copper and silver coins, and a charcoal-marked parchment map of the camp, then began moving low toward the strongbox while the guard remained oblivious and Gwen stayed ready with her bow.
**After:** Kaelen and Gwen infiltrated the bandit camp at dawn, splitting flanks with Gwen covering right from the tree line, bow ready, while Kaelen approached left. Kaelen looted a bedroll near an overturned cart, taking a belt knife, coins, and a charcoal map of the camp, then began moving low toward the tarp-covered strongbox undetected.
## Memory 2
**Before:** The player crept through the bandit camp toward a tarp-covered iron strongbox, reflexively reached to pull Gwen down beside him despite her being forty feet away at the tree line, and the resulting half-second lapse nearly caused a second bandit (a wood-gatherer) to spot him at the woodpile; Gwen almost loosed an arrow but held on his signal, and the bandit left unaware. The player then waited for the spearman to turn toward the fire and moved undetected to crouch beside the strongbox, where Gwen signaled approval and pointed to the simple hasp-and-padlock lock before he asked her whether she still trusted his read on the situation.
**After:** Kaelen infiltrated the bandit camp toward the tarp-covered strongbox while Gwen covered him from the tree line; he instinctively reached to pull her down beside him but she was forty feet away, nearly exposing him to an unnoticed second bandit gathering wood, which frayed Gwen's trust before he safely reached the strongbox's simple hasp-and-padlock lock.
## The story both arms summarized
**start:** Dawn breaks grey over the treeline as you and Gwen crouch at the edge of the bandit camp. Smoke curls from a dying fire; three bedrolls lie empty. Somewhere ahead, the stolen caravan strongbox waits.
Gwen checks her bowstring and looks to you. "Quiet, or loud?"
**do:** go quiet and signal Gwen to circle around the right flank
**ai:** You lean close, voice barely a breath. "Quiet. Take the right flank, stay in the tree line — I'll come at them from the left when you're set. Don't loose until I move first, unless someone spots you."
Gwen's mouth quirks, half approval, half habit. "Try not to trip over anything this time." She melts sideways into the brush without a sound, her passage barely stirring a fern, and angles wide around the camp's edge, bow already loose in her hand.
You give her a slow count — enough time to get into position — watching the camp in the meantime. A lone bandit paces near the fire, spear resting on his shoulder, yawning like the night watch had been long and dull. Beyond him, tents sag in a loose ring, and past those, half-hidden under an oiled tarp, a squat iron-banded shape sits guarded by nothing more than habit and complacency. The strongbox.
You catch a flicker of movement at the tree line opposite — Gwen, settling into a crouch behind a fallen log, bow half-drawn, patient as stone. She lifts two fingers, then points toward the spear-carrier: hers if it goes loud.
You ease forward through the underbrush, testing each footfall before committing your weight, keeping the dying fire's smoke between you and the pacing guard's sightline. Your pulse is steady, senses sharpened — the cool mana still humming faintly under your skin from old habit, ready if you need it. No alarm yet. No eyes turned your way. The camp sleeps in its false security, and for now, the quiet is yours to keep or break.
You reach the shadow of an overturned cart at the camp's rim, close enough now to hear the guard mutter something to himself. Gwen is in position, arrow nocked, waiting on your signal.
**do:** search the nearest bedroll for anything useful
**ai:** You glance toward the nearest bedroll — one of the three empty ones, rumpled blankets still holding the shape of whoever slept there, maybe ten feet from the cart's shadow. The guard's back is turned, spear-shoulder rotating slowly as he paces his short loop. You judge the distance, the timing, and decide it's worth the risk.
You slip low across the open ground, boots finding bare dirt instead of dry leaves, and drop to a crouch beside the bedroll. Your fingers move quick and quiet through the folded blanket, a stiff leather pack shoved half-underneath. Coins clink faintly — you cup your palm over the pouch before it can rattle again — and your hand closes on a stubby belt knife, a handful of copper and silver, and a folded scrap of parchment that might be a rough map of the camp's layout, marks scratched in charcoal.
The guard's pacing falters. He half-turns, spear dipping, some animal instinct prickling at the edge of his awareness. You freeze mid-motion, breath held, willing yourself into the cart's shadow. Across the clearing, you catch the barest shift from Gwen — her draw hand tightening, ready to loose if this goes wrong.
The moment stretches. Then the guard grunts, spits into the dying fire, and resumes his slow circuit, oblivious. You let your breath go slow and silent, tucking the coin pouch and parchment into your jerkin, the knife into your belt beside your own.
It's not much, but it's something — coin for later, and maybe a map worth reading twice before you commit to a route through this camp. You ease back from the bedroll, retreating to the cart's cover, eyes flicking to Gwen. She gives the faintest nod: *still clear, still yours to call.*
The guard yawns again, spear drooping, utterly unaware how close he came to dying loud. The strongbox waits under its tarp, patient as ever. The quiet is still yours — for now.
**do:** move toward the strongbox, keeping low
**ai:** You keep to the cart's shadow a moment longer, judging the guard's rhythm — three steps out, a pause, three steps back, spear dipping low on the turn. When his back rotates fully away, you move.
Low and fast, you cross the gap between the cart and the nearest tent, one hand brushing the canvas to steady yourself rather than grip it — no rustle, no tug on the guy-lines. You thread between two more tents, close enough now to smell tallow and old woodsmoke, and drop into a crouch behind a stack of crated supplies not ten feet from the tarp-covered strongbox.
From here you can see it properly: iron bands dark with rust, a heavy lock through a hasp, the oiled tarp thrown over it more to keep off dew than to hide it. Careless. Whoever's running this camp trusts their numbers more than their diligence.
You risk a glance back toward the tree line. Gwen hasn't moved, but her head has tracked your progress the whole way, bow still half-drawn, patient. She catches your eye and gives the barest tilt of her chin toward the strongbox — *go on, I've got you* — before her gaze slides back to the pacing guard, who is still oblivious, still yawning his way through a boring watch.
Closer now, you can make out voices from one of the tents — low, unhurried, two men talking about nothing in particular. Not alarmed. Not aware. The camp's quiet holds, fragile as it is, and the strongbox sits within reach, close enough that you could touch the tarp if you stretched out a hand.
Your fingers itch to check the lock, to see if it needs force or finesse — but that's a decision for the next breath, not this one. For now you crouch in the crate-shadow, heartbeat steady, senses stretched wide across the camp: the pacing guard, the murmuring tent, Gwen's stillness at your back. Nothing has broken yet. The strongbox is yours to reach for.
**do:** grab her wrist and pull her down behind the woodpile
**ai:** Your hand moves on instinct before thought catches up — reaching sideways for Gwen's wrist to haul her down out of sight — but your fingers close on nothing but cool air. She isn't beside you. She's forty feet off across the clearing, still nested behind her log at the tree line, exactly where you left her.
The mistake costs you half a second, and half a second is enough. Boots crunch on the far side of the crates — close, closer than the pacing guard should be. A second bandit, one you hadn't accounted for, rounds the supply stack with a coil of rope over one shoulder, heading for the woodpile stacked just past your cover.
You drop flat behind the crates instead, breath locked in your chest, willing your travel-stained leathers to melt into the shadow. The bandit hums tunelessly, kneels at the woodpile — near enough you could count the notches in his boot heel — and starts loading split logs into his arms, utterly unaware he's sharing six feet of dirt with an exiled ranger and a very recent, very foolish reflex.
Across the camp, Gwen has gone rigid, bow lifting a fraction — she saw the second man appear, saw you freeze, and for one taut moment she's clearly bracing to put an arrow in his throat if he so much as glances your way. You catch her eye through the crate-gap and give the smallest shake of your head. *Not yet. Hold.*
The wood-gatherer straightens, arms full, and ambles off toward the fire without ever knowing how close he came. Your heart hammers against your ribs. Gwen eases her draw, but her jaw stays tight, and even at this distance you can feel the flicker of irritation under her patience — not at the bandit, at you, for a half-second of carelessness that nearly bought you both a fight you hadn't chosen.
The strongbox still waits behind its tarp, closer now than ever. But the quiet feels thinner than it did a moment ago.
**do:** wait for the bandit to turn, then move
**ai:** You settle back against the crates, still as the wood itself, and let your breathing fall into the same slow rhythm as the camp around you. The wood-gatherer's footsteps fade toward the fire pit, and a moment later you hear the clatter of logs dropped beside the flames, a muttered word to the pacing spearman, then silence again but for the crackle of embers catching new fuel.
You count another ten heartbeats before you risk a look. The spearman has stopped to warm his hands, back fully to you now, attention on the fire instead of his rounds. No one else stirs between you and the tarp.
This is the gap.
You rise from the crate-shadow in one smooth motion, low and quick, closing the last stretch of open ground between the crates and the strongbox in a handful of silent strides. The tarp is close enough to touch now — you drop to a crouch beside it, back pressed to the iron-banded corner, and finally let yourself breathe.
Across the clearing, Gwen's stance has eased a fraction. She tracked every step of that crossing, and though her jaw is still set from the wood-gatherer's near miss, there's the faintest approval in how she holds herself now — the tension of someone watching a mistake corrected rather than repeated. She lifts two fingers again, then points toward the lock hasp: *your move.*
The lock itself is heavy, old iron, a simple hasp-and-padlock rather than anything clever — the kind of thing built to deter opportunists, not a trained hand. You could pick it quiet, or put a boot to it loud. The voices still murmur from the nearby tent, unconcerned. The spearman still warms his hands at the fire. The quiet holds, thinner than before, but yours.
**do:** ask her whether she still trusts my read on this
**ai:** You crouch there a moment longer, one hand still braced on the strongbox's cold iron corner, and risk a glance across the clearing. Gwen's eyes are already on you — they usually are — and you mouth more than whisper the question, tilting your head just enough to ask it: *still with me on this?*
For a beat she doesn't answer, just watches the spearman warming his hands, the murmuring tent, the whole fragile balance of the camp's false calm. Then she gives a small, economical shrug, the kind that means *mostly*, and taps two fingers against her bow stave — a gesture you've learned means *your instincts, not your luck.* It isn't a full yes. The wood-gatherer's near miss still sits behind her eyes, and you can read it plainly enough: she'll follow your read, but she's watching it closer than she was an hour ago.
Her hand shifts, index finger drawing a small circle in the air, then pointing at the lock — *pick it, don't break it. Loud is still your last resort, not your first.* It's about as much reassurance as you're going to get from a ranger who's had one scare too many this morning, and honestly, it's fair. You nod once, slow, so she can see you take it.
The spearman shifts his weight at the fire but doesn't turn. The tent's murmur continues, unbroken. Whatever trust frayed at the woodpile hasn't snapped — it's just drawn tighter, watchful, waiting to see what you do with the lock in front of you. The strongbox sits solid and patient at your back, and beyond the thin canvas walls, the camp sleeps on in its dangerous, borrowed quiet.
You turn back to the hasp, letting your fingers find the mechanism, senses still split between the metal under your hands and the fire-lit shape of the spearman thirty feet off. The moment is yours to use well — or not.
-134
View File
@@ -1,134 +0,0 @@
# Appendix: the memory-prompt A/B, run 2026-08-31
The evidence behind `plan/18-persona-and-memory-quality.md`. Reproduce it with:
python tools/claude_shim.py --port 8787 &
python tools/memory_ab.py --out ab.md
One story, generated a turn at a time through the app's own `build_context`.
Both prompts then summarize the **same** blocks, so the story is held constant
and the prompt is the only variable. Every call is a separate request, so
neither arm sees the other's output, and the model is never told what is being
measured. The control is `MEMORY_SYSTEM_PROMPT` as of commit `9cdcb55`, read
from git rather than pasted, so it cannot drift from what shipped.
The model was a Claude model, reached through `tools/claude_shim.py`. See
"What this does not show" in plan/18 before generalising from it.
## What it showed
| memory | arm | words | how it names the protagonist |
|---|---|---|---|
| 1 | before | 34 | second person — "**You** crept low…" |
| 1 | after | 72 | "**Kaelen** and Gwen crouched…" |
| 2 | before | 105 | "**The player** asked Gwen to…" |
| 2 | after | 68 | "**Kaelen** and Gwen infiltrated…" |
Two things came out of this, and only one of them was the thing being tested.
**The reported fault reproduced.** Two consecutive memories, from one story,
written minutes apart, in two different persons. That is the complaint, observed
rather than argued from the prompt text. Both after-memories name Kaelen.
**A fault nobody had noticed.** "1-2 plain sentences" is not a length: 34 words
for one block, 105 for the next. `memory_top_k` injects five memories every
turn, so the bank's running cost was set by a number never stated.
`MEMORY_MAX_WORDS = 50` now states it. Re-run over this same story, the two
blocks came back at **32 and 58 words**, still named, still third person, still
carrying the camp map, the strongbox behind the second tent, and the strap
frayed near through:
> Kaelen crept low toward the strongbox, now known from the map to be behind
> the second tent, with the dozing watchman still unaware and Gwen holding her
> covering position on the flank.
**A likely accuracy win, at n=1.** The player's move was `grab her wrist and
pull her down behind the woodpile`. The before-memory recorded "The player asked
Gwen to grab her wrist and pull her down", inverting who acted. This is the
failure the brief predicts — with no cast there is nothing to say whose wrist
"her wrist" is — but one sample is an observation, not a result.
---
*Everything below is the raw run, unedited.*
## Memory 1
**Before:** You crept low through the mist toward the second tent, following the map's mark toward the strongbox while Gwen held her flanking position, the sleeping watchman still dozing on his spear near the lean-to.
**After:** Kaelen and Gwen crouched at dawn outside the bandit camp; Kaelen signaled Gwen to circle the right flank while a drowsy watchman kept post near the lean-to, and Gwen took position unseen near the strongbox tent. Kaelen searched an abandoned bedroll, finding a whetstone, a few copper coins, dried meat, and a charcoal-scrawled camp map revealing the stolen strongbox was hidden behind the second tent, then began moving low toward that location.
## Memory 2
**Before:** The player asked Gwen to grab her wrist and pull her down behind the woodpile just before a second bandit—half-dressed, carrying a piss-bucket—stumbled out, relieved himself, and returned to bed in the first tent; Gwen lingered a beat before freeing her wrist and murmured "Good eyes." After waiting five heartbeats to confirm the bandit was asleep, the player and Gwen returned to the strongbox tent, where they found the old iron caravan lock too sturdy to pick and instead began testing the straps, discovering one frayed near through at the fold; the player then asked Gwen whether she still trusted their read on the situation.
**After:** Kaelen and Gwen infiltrated the bandit camp to reclaim a stolen strongbox, sheltering behind a woodpile while a second bandit relieved himself nearby before returning to sleep; afterward Kaelen returned to the strongbox tent, found its lock too sturdy to force by hand but noticed one strap frayed near through, and paused there with Gwen covering him to ask whether she still trusted his read on the situation.
## Summary
**Before:** Under cover of mist, the player and Gwen scouted a bandit camp, aiming to reach a strongbox marked on their map while a watchman dozed near a lean-to. Approaching the second tent, the player pulled Gwen down behind a woodpile just in time to avoid a half-dressed bandit who stepped out to relieve himself before returning to sleep in the first tent; Gwen lingered a moment before pulling free, murmuring "Good eyes." After waiting to confirm the camp was settled, the two returned to the strongbox tent, finding its old iron caravan lock too sturdy to pick. They shifted to testing the straps securing it instead, discovering one frayed nearly through at the fold—a possible way in. The player then turned to Gwen to ask whether she still trusted their read on the situation, leaving the moment of decision unresolved.
**After:** At dawn, Kaelen and Gwen scouted a bandit camp holding a stolen strongbox. Kaelen sent Gwen to circle the right flank near the strongbox tent while a drowsy watchman kept post, then searched an abandoned bedroll himself, finding a whetstone, a few copper coins, dried meat, and a charcoal map marking the strongbox's location behind the second tent. Moving low toward it, the pair sheltered behind a woodpile while a second bandit briefly woke and relieved himself before returning to sleep. Reaching the strongbox tent, Kaelen found its lock too sturdy to force by hand, but noticed one securing strap frayed nearly through. With Gwen covering him, he paused to ask whether she still trusted his read on the situation before attempting to exploit the weak strap.
## The story that was summarized
**start:** Dawn breaks grey over the treeline as you and Gwen crouch at the edge of the bandit camp. Smoke curls from a dying fire; three bedrolls lie empty. Somewhere ahead, the stolen caravan strongbox waits.
Gwen checks her bowstring and looks to you. "Quiet, or loud?"
**do:** go quiet and signal Gwen to circle around the right flank
**ai:** You melt back from the ridge line without a sound, boots finding moss instead of twigs, and catch Gwen's eye. Two fingers point right, then curl in a half-circle — *swing wide, come at the flank*. She reads it instantly, dips her chin, and peels off through the bracken, bow already half-raised, moving low and quiet along the tree line until the mist swallows her.
You hold position at the camp's edge, breath slow, watching the dying fire spit its last embers. A bandit shifts on watch near the lean-to, spear butt planted in the dirt, more bored than alert. No one else stirs. Somewhere off to the right you catch the faintest whisper of disturbed leaves — Gwen, further along than you'd expect, patient as ever. She's given herself a clean angle on the strongbox tent from the flank, out of the watchman's sightline entirely.
The plan is good. Quiet holds. All you need now is the first move — and the choice of who falls first, and how.
The watchman yawns, oblivious, close enough that a single silent step would put you in reach of his throat before he ever draws breath to shout.
**do:** search the nearest bedroll for anything useful
**ai:** You crouch by the nearest bedroll — a heap of stitched furs still holding the sleeper's warmth, though its owner is long since up and yawning by the fire. You keep one eye on the watchman as your fingers work through the folds. A whetstone, a handful of copper coins that clink softly against your palm before you still them with a fist, a scrap of oilcloth wrapped around dried meat gone hard at the edges. Nothing worth the risk so far.
Then your fingers catch on something stiffer, tucked beneath the bedroll's edge — a folded scrap of parchment, half-torn, charcoal-scrawled. A rough map of the camp, crude but legible: the lean-to marked with an X, a second mark deeper in camp labeled in shorthand you recognize as bandit cant for *strongbox*. It's tucked behind the second tent, not the first — further from the watchman than you'd guessed, closer to where Gwen is now waiting in the mist.
You fold it away inside your jerkin, pulse ticking a little faster. Small find, real value — it just turned guesswork into a plan. The watchman still hasn't stirred, spear planted, chin dropping toward his chest. Gwen's silhouette is a smudge of shadow at the flank, patient, waiting on your signal.
The camp holds still around you — watchman drowsing, fire dying, Gwen coiled and ready on the flank. The map sits warm against your ribs, the strongbox's true location now known. Your move.
**do:** move toward the strongbox, keeping low
**ai:** You drop low, weight rolling from heel to ball of each foot, threading between the empty bedroll and a stack of split firewood. The watchman's chin has sunk near to his chest, spear listing in his slack grip. You give him a wide berth anyway — no need to gamble breath on a man already halfway to sleep.
The second tent is smaller than the first, its flap laced shut with a crude leather cord. Just as the parchment promised, its bulk sits wrong for a tent meant to sleep anyone — square-cornered, iron-banded, too heavy for the ground it rests on. You crouch beside it, easing the lacing open one loop at a time, and the flap parts on a dull gleam of banded oak and iron: the strongbox, lock intact, straps still buckled from the caravan raid.
A shift of cloth behind you — not the watchman, too far behind you for that. You go still, hand dropping to your knife hilt, eyes cutting sideways through the tent's shadow toward the sound. Nothing resolves yet, just a settling, maybe wind through canvas, maybe not. Across the clearing, Gwen has gone motionless too, bow half-drawn, watching the same dark gap between tents that's caught your attention.
The strongbox is here, within reach, real weight under your palm instead of a rumor on a scrap of parchment. But something in that gap between the tents hasn't declared itself yet, and the watchman is still only a few careless steps away from waking to find you crouched over his camp's whole reason for being.
**do:** grab her wrist and pull her down behind the woodpile
**ai:** Your hand shoots out and closes around Gwen's wrist before she's fully turned toward the gap between the tents. She goes rigid for half a heartbeat — a killer's instinct, ready to break the grip — then reads your face and lets herself be pulled. You drag her down behind the woodpile together, shoulders pressed to split logs, breath shallow.
The shape that had been shifting in the shadow resolves itself: a second bandit, roused early, stumbling out from behind the tents with his breeches half-laced and a piss-bucket in hand, utterly uninterested in anything but the treeline. He doesn't look toward the strongbox tent. Doesn't look toward the woodpile. He shuffles past, empties the bucket against a stump, and turns back the way he came, already yawning.
Gwen's breath eases out slow beside you. She doesn't pull her wrist away immediately — a beat longer than she needs to — before easing it free herself. "Good eyes," she breathes, barely sound at all. Her gaze flicks to your hand, then back to your face, something unreadable and warm passing behind it. Watching your back instead of charging in — that's the kind of thing she remembers.
Through the gap in the logs you track the second bandit's path: he ducks into the first tent, not the watch line, and doesn't reemerge. Three accounted for now — the drowsing watchman, this one bedding back down, and whoever else the camp is hiding. The strongbox tent is still open behind you, lock waiting, straps waiting. Gwen settles back into her crouch, bow across her knees, eyes on you for the next signal.
**do:** wait for the bandit to turn, then move
**ai:** You go still as stone, eyes fixed on the tent flap the second bandit vanished into. Seconds crawl. Then the canvas stirs one last time — a grunt, a shifting weight settling onto a bedroll, the particular silence of a man who's decided the day can wait a while longer. You count five heartbeats to be sure, watching for any second shadow, any sign he's only feigning sleep. Nothing. Even the watchman by the fire hasn't stirred, spear still planted, chin still low.
You ease up from the woodpile, moving on the balls of your feet, and slip back to the strongbox tent. Gwen catches the motion and rises with you, silent as a drawn breath, bow angled to cover the gap between tents while you work. The lock is old iron, caravan-make, not bandit work — it'll need proper tools or proper force, not fingers.
You crouch and test the straps first, feeling for a weak buckle, a give point. One strap is frayed near through at the fold. With steady pressure it might part without the sharp crack of metal — quieter than forcing the lock, if slower. Gwen shifts her weight beside you, patient, trusting you to read the moment right, her eyes flicking once toward the dozing watchman and back.
The camp holds its breath around you both. The strongbox waits under your hands, and the next move is yours to make.
**do:** ask her whether she still trusts my read on this
**ai:**
-655
View File
@@ -1,655 +0,0 @@
# The protagonist has no name, and the summarizer is told nothing
Two changes, in order. Phase 1 gives the adventure a persona. Phase 2 uses it,
along with the cast, to fix the memories. Phase 1 is worth shipping on its own;
Phase 2 depends on it and is much smaller once it lands.
**Both phases are built and green (631 backend tests). Phase 1 was driven in a
browser (21/21 checks). Phase 2 was run end to end against a real model, as a
controlled A/B on one story — see "Run with a real model". A bank written under
the old prompt can be rewritten in place — see the last section.**
**Last updated: 2026-08-31.**
---
## The one sentence version
Memories come back inconsistent and vague because the summarizer is handed six
actions of second-person prose and nothing else — no protagonist, no cast, no
setting, no instruction about what person to write in — so it cannot say who
"you" is or who "she" is, and neither can any memory it writes.
## The evidence
This is the entire prompt that writes a memory (`memorybank.py:391`):
```
system: You compress interactive-fiction story excerpts into memories. Respond
with 1-2 plain sentences in past tense stating the concrete facts and
events (names, places, items, promises, injuries). No preamble, no
commentary.
user: Story excerpt:
<6 actions, joined by blank lines, truncated to 2000 tokens>
Memory:
```
Nothing else is passed. Not `adventure.memory` (the plot essentials), not the
NPC names and descriptions the scenario already defines, not the world state,
and not the protagonist — because until Phase 1 there is no protagonist to pass.
Two failures follow from that, and they are the two complaints:
**Inconsistent framing.** The prompt never says what person to write in. The
model picks one per call. Across a single bank you get "You entered the crypt",
"The player entered the crypt", and "He entered the crypt" describing the same
kind of event.
**No idea who anyone is.** Given `You push the door open. She grabs your arm.`
the only honest memory is *"You entered a room and she stopped you."* Retrieved
forty turns later into a scene with three women in it, that memory is worse than
nothing.
The summary inherits both problems, because `_update_story_summary` builds from
the memory bullets.
---
# Phase 1 — the persona
## What already exists, and what does not
`stat_schema` already gives the player a stat block, and it is already
namespaced beside the NPCs:
```
world.day
player.hp <- the player's stats, already sectioned
npc.gwen.trust
flags.alarm_raised
milestones.escaped
```
NPCs carry `name`, `keys`, and `desc` (`schema.py`, `render_reference`). The
`player` section carries none of those. That asymmetry is the whole gap: the
player has stats but no identity, so the block renders as `You: hp 100/100` and
reads to the model as a floating global rather than a character.
## The paths do not change
`player.hp` stays `player.hp`. It does not become `kaelen.hp` or `user.hp`.
The reason is `_history_text()` in `context/builder.py`. It replays every past
turn's stored delta back into the prompt, and those stored blobs contain literal
strings like `{"player.hp": -15}`. A path that carries the persona's name breaks
the moment a player renames their character: every replayed block in history
then names a path the schema no longer defines, `applied_delta` renders it
anyway, and the model copies the broken form. A rename would also require
migrating every stored `world_state`, every seed scenario, and the `EMIT_RULE`
example text, for no functional gain.
What changes is the **label**, using the trick NPCs already use — print the
display name and the path together:
```
before: You: hp 100/100, mana 30/50.
after: Kaelen (player): hp 100/100, mana 30/50.
```
The name is visible to the reader, the path stays copyable by the model.
## Where the persona lives
Three plain columns on `adventures`, alongside `memory` and `authors_note`:
```python
persona_name: Mapped[str] = mapped_column(String(80), default="")
persona_pronouns: Mapped[str] = mapped_column(String(40), default="")
persona_desc: Mapped[str] = mapped_column(Text, default="")
```
**Not in `stat_schema`.** Two reasons. It has to work for an adventure with no
RPG layer at all, which is most of them, and `_initials()` in
`worldstate/schema.py` treats every dict inside a section as a stat definition —
a `persona` key dropped into `stat_schema.player` would be instantiated as a
stat, rendered as a stat line in the guide, and handed an `initial` value.
**Adventure-level, not scenario-level.** Two people playing the same scenario
are different characters. A scenario steers the protagonist through its plot
essentials, which it already can. Keeping personas off `scenarios` also avoids
having to decide what "Update from scenario" does to a persona the player has
edited: the answer is nothing, because the scenario never had one.
**Not per-branch.** Branches are alternative futures within one adventure; the
protagonist is the same person down all of them.
Empty `persona_name` means the feature is off and behavior is exactly what it is
today. That is the entire backward-compatibility story — no backfill.
## Pronouns get their own field
One short string: `he/him`, `she/her`, `they/them`. It exists because Phase 2
will tell the summarizer to write in third person. Without a stated pronoun the
model infers one from the name, and once it infers wrong that error is baked
into every memory it writes from then on and into the summary built from them.
A field is cheaper than a wrong guess repeated forever.
Blank is a valid value. When it is blank nothing is rendered, and the Phase 2
prompt tells the summarizer to use the name rather than a pronoun.
## Where it enters the prompt
A new static section in `build_context`, between `ai_instructions` and
`plot_essentials`:
```
Player character:
You are Kaelen (he/him). A half-elf ranger, exiled from the northern holds.
```
**It goes in the system block on purpose.** The description is user-only and
never changes during a turn, so it sits inside the cached prefix and costs
nothing after the first turn. This is why "only the user can change it" is not
just a product choice — it is what keeps the section free. Anything the AI could
rewrite would have to move below the history with the other live sections, and
would re-price the prompt on every change.
It is emitted whether or not the adventure has an RPG layer. It is not gated
behind `has_ws`.
## Where it enters the world state
`worldstate/render.py`, two edits:
- `render_state_section` takes the display name and prints
`Kaelen (player): …` in place of the hardcoded `You: …`. Falls back to `You:`
when no persona is set.
- `render_reference` gains one line tying the name to the path, mirroring the
NPC header it already writes:
`- Protagonist Kaelen — stats are addressed as player.<stat>.`
Without this the model reads "Kaelen" in the prose and invents `kaelen.hp`.
The persona description is **not** repeated in the stat guide. It already has
its own section, and the guide is about paths.
## The AI cannot edit it
Nothing to build. `_resolve()` in `worldstate/apply.py` rejects any path it does
not recognize, so a delta of `{"persona.name": "Bob"}` already falls through to
"`persona.name` is not a tracked value", and that refusal already reaches the
model through `render_refusals`. Worth one test to hold the behavior in place.
## Setting it, and changing it
**At the start.** `PlaceholderModal` (`components.jsx`) already exists and
already opens before an adventure begins — but only when the scenario contains
`${...}` tokens. It becomes a "Begin adventure" modal that always opens, with
the three persona fields at the top and any placeholder fields below.
**Placeholders stay independent of the persona.** A scenario using `${Name}`
will ask for a name twice. That is accepted: no scenario in the repo uses
placeholders at all (grepped across `seed_data/` and `starter_data/` — zero
occurrences), so the collision is hypothetical. If it ever shows up in practice,
pre-filling the `Name` field from the persona is a few lines in the modal with
no backend change.
**Later.** `AdventureUpdate` gains the three fields, and `PlotPanel` gets a
Protagonist block at the top. The debounced-save wiring is already there.
`WorldStateDrawer` shows the name as the character-sheet heading but does not
edit it — one edit surface, not two.
## Files
| File | Change |
|---|---|
| `models.py` | three columns on `Adventure` |
| `migrations.py` | 74, 75, 76 |
| `schemas.py` | `AdventureCreate`, `AdventureUpdate`, `AdventureOut` |
| `context/builder.py` | `persona` section after `plot_essentials` |
| `worldstate/render.py` | state-section label, reference line |
| `worldstate/__init__.py` | export whatever `render.py` newly exposes |
| `routers/adventures/crud.py` | store persona at creation |
| `bundle.py` | export key + import read |
| `frontend/components.jsx` | modal gains persona fields |
| `frontend/pages/Scenarios.jsx`, `Home.jsx` | always open the modal |
| `frontend/pages/Play/panels/PlotPanel.jsx` | Protagonist block |
| `frontend/pages/Play/drawers/WorldStateDrawer.jsx` | heading |
| `frontend/api.js` | pass the fields through |
## Tests
| File | What |
|---|---|
| new `test_persona.py` | create with persona; update; renders in the system block; works with no `stat_schema`; empty name is a no-op; AI delta at `persona.*` refused |
| `test_worldstate.py` | state line reads `Kaelen (player):`; `player.hp` still resolves |
| `test_prompt_caching.py` | persona is in the static prefix, above history |
| `test_bundle_v2.py` | round-trips; a bundle without the key still imports |
## Bundle format
No `FORMAT` bump. The export gains a `persona` key and the import reads it with
`.get()`, so a v2 bundle written before this change imports with an empty
persona — which is the same as not having one.
---
# Phase 2 — what the summarizer is told
Depends on Phase 1 only for the protagonist's name. Everything else it needs is
already in the database and simply never passed.
## The cast brief
Build one short block and prepend it to both the memory prompt and the summary
prompt:
```
Cast:
- Kaelen (he/him) — the protagonist. A half-elf ranger, exiled from the
northern holds...
- Gwen — a loyal ranger and Kaelen's ally. Quick with a bow, dry-humoured.
Setting:
<adventure.memory, the plot essentials>
```
Sources, in order:
1. **Protagonist** — the Phase 1 persona. Falls back to "the player" when unset,
so the change still helps adventures with no persona.
2. **NPCs** — `stat_schema.npcs`: `name` + `desc`. Already there, never used
outside the turn prompt.
3. **Story cards** — for adventures with no RPG layer, run the existing
`_match_cards()` over the block being summarized and take the matched cards'
names and entries. This reuses the trigger code and yields exactly the
entities that appear in *that block*, not the whole world.
## Two rules for the brief
**Fixed descriptions only. No live stats.** It is tempting to include
`Gwen: trust 40 (wary)`. Do not. It makes every memory prompt different, which
loses prompt caching, and worse, it makes the same event summarized at two
different times come out framed differently — which is the problem being fixed.
**The summary gets it for free.** `_update_story_summary` builds from the memory
bullets, so better memories produce a better summary with no further change.
Pass the brief there too, because that function falls back to raw story text
when memory-writing has fallen behind.
## The prompt changes
`MEMORY_SYSTEM_PROMPT` gains an explicit framing rule:
- write in third person, never "you"
- refer to the protagonist by name
- name characters rather than using bare pronouns
Expected effect:
```
before: You entered a room and she stopped you.
after: Kaelen bribed Gwen with fifty silver to hold the north door while
he went down alone.
```
---
# Phase 1 as built
Everything above describes what shipped. Three notes on where it differs from
the sketch, and what has not been verified.
## Decisions taken during the build
**The section reads as one paragraph.** `render_persona` joins the sentence and
the description with a space rather than `SEPARATOR`. A blank line inside a
two-sentence section about one character reads as two unrelated notes.
**`Field` gained a `maxLength` prop.** The persona name is `VARCHAR(80)` and the
schema rejects 81 characters with a 422. Without the attribute the player only
learns that from a toast after typing, so the cap is now enforced in the input
as well. Every other `Field` is unaffected — the prop is optional.
**The blank-adventure button opens the modal too.** It used to create the
adventure immediately. It now collects a persona first, and passes
`title: 'Blank Adventure'` explicitly, because there is no scenario to take a
title from.
**`test_prompt_caching.py`'s fixture now sets a persona**, so every test in the
file that guards the static block runs with one present.
## Verified
- Migration 73 → 76 on a real pre-existing SQLite database: columns added, the
existing row preserved, persona empty, `render_persona` returns `""`.
- 593 backend tests pass, including 26 new ones in `test_persona.py` and 3 in
`test_bundle_v2.py`.
- `npm run build` is clean.
## Driven in a browser
Chromium via Playwright, against a fresh database with the demo scenarios
seeded. No API key needed: Insights assembles the prompt without calling a
model, so every check below runs on the real assembled context rather than on
a unit-test stub. 21/21 checks passed.
What was confirmed on screen and in the live `/context` payload:
| | |
|---|---|
| The modal opens for a scenario with **no** `${...}` placeholders | it is now the only way to name a character, so it can no longer be conditional |
| The section order is real | `narrator, world_state_guide, world_state_rule, ai_instructions, **persona**, plot_essentials, …` |
| The persona is in the system half | `You are Kaelen (he/him). A half-elf ranger…` present in `prompt.system`, absent from `prompt.story` |
| The live stat line carries the name and the path | `Kaelen (player): hp 100/100 (full health), mana 30/50 (brimming)…` |
| The stat guide ties the name to the path | `Protagonist Kaelen … player.<stat>` |
| The drawer heading follows the name | rail read `KAELEN`, not `You` |
| A rename propagates | renamed to Aria in the Plot panel → drawer read `ARIA` after a reload, and the prompt read `You are Aria (he/him).` / `Aria (player):` |
| Clearing every field restores the old behavior exactly | no `persona` section, stat line back to `You:`, no `Protagonist` line in the guide |
| The blank-adventure button collects a persona and keeps its title | `title='Blank Adventure' persona='Wren'` |
| **A persona works with no RPG layer at all** | a blank adventure's sections were `['narrator', 'persona', 'length_hint']` — the case this feature was added for |
Two request failures in the run were the sandbox rather than the app: Google
Fonts is blocked by the egress policy, and the analytics beacon is aborted when
the page unloads. Neither appears with normal network access.
The driver script is not in the repo. There is no frontend test runner yet
(plan/17 stage 5), and one Playwright script is not the place to start one.
## A note for whoever runs the tests
`tiktoken` downloads `cl100k_base` from `openaipublic.blob.core.windows.net` on
first use, and 182 tests fail with a proxy error where that host is blocked.
The encoding is reconstructable offline from the npm package `js-tiktoken`,
whose `dist/ranks/cl100k_base.cjs` holds the same ranks in a compressed form —
decode it, write `<base64> <rank>` per line sorted by rank, and the result
matches the SHA-256 that `tiktoken_ext/openai_public.py` hardcodes, so the
reconstruction is verifiable rather than trusted. Drop it at
`$TIKTOKEN_CACHE_DIR/<sha1 of the URL>`.
---
# Phase 2 as built
## The cast comes from the story cards, not from `stat_schema`
This is the one thing the sketch above got wrong, and it made the change much
smaller. `scenario_text.scenario_card_specs` already turns **every schema NPC
into a story card** on the adventure at creation, deduplicated against the
hand-written cards by name. So the cards are a single unified cast source that
covers schema NPCs, an author's own cards, and an adventure with no RPG layer,
through one path instead of three. `memorybank` never reads `stat_schema`.
## Keyword matching alone was not enough
The sketch said to run `_match_cards` over the block. Built that way first, and
it failed the exact case the change exists for.
The block `You push the door open. She grabs your arm.` matches **no** card
keyword, so the brief listed the protagonist and nobody else — leaving the
summarizer guessing at precisely the moment it was handed a brief to stop
guessing. Seed 04 gives Gwen the trigger keys `"Gwen, ranger, her"`, and even
that does not save it: the text says "she", not "her".
So the roster is **matched cards first, then topped up with the other
`character` cards** to `MAX_CAST_MEMBERS`. Places and items are not topped up —
an unmentioned tavern is not who "she" was — but a place that *is* mentioned
still matches normally.
That asymmetry with the turn prompt is deliberate. Including an untriggered card
as lore would be wrong: it is not relevant to the next sentence. Including an
untriggered character in a roster is right: the question the roster answers is
"who could these pronouns be", not "what is on stage".
## `_match_cards` became `match_cards`
Two callers now run the same rule, so it is public and exported from
`app.context`. One rule, one implementation.
## What actually gets sent
Verified against the seeded Bandit Camp scenario, with the real
`_create_due_memories` path and a stub provider:
```
Cast:
- Kaelen (he/him) — the protagonist. A half-elf ranger, exiled from the
northern holds for a killing he still won't explain.
- Bandit Camp — A rough camp of bandits in a forest clearing, holding a
stolen caravan strongbox.
- Gwen — A loyal ranger and the player's ally. Quick with a bow,
dry-humoured, fiercely protective. …
- Bandit Leader — The scarred leader of the bandit camp, guarding the
strongbox. …
Setting:
The player and Gwen, a loyal ranger ally, are raiding a bandit camp to
recover a stolen strongbox. …
Story excerpt:
… You are six paces from the strongbox when she hisses a warning. …
Memory:
```
That last line is the case in miniature: "she" is now resolvable.
**With no persona set**, the roster still lists the NPCs and the setting, and
the system prompt tells the model to call the protagonist "the player". Phase 2
therefore improves adventures that never set a persona at all.
**With no persona, no cards and no plot essentials**, the user message is byte
for byte what it was before this change — `Story excerpt:` first, no stray blank
lines. A test holds that.
## Decided, from the sketch's open questions
**Existing memories: left alone at first, and now rewritable on demand.** The
original answer was to let eviction age them out at `memory_bank_capacity` (80),
on the grounds that re-summarizing would duplicate whatever was still in the
bank, because nothing deletes the old rows. That reasoning was wrong about the
only option: a memory can be rewritten in place rather than written again.
See "Rewriting a bank written under the old prompt", at the end of this file.
**Retrieval framing mismatch: watched, not fixed.** `retrieve_memories` still
embeds the last 4 actions raw, in second person, while new memories are third
person and named. Embeddings handle paraphrase well, so this is speculative.
If retrieval quality visibly dips, prepending the same brief to the query text
is the first thing to try.
## Run with a real model, and what it changed
Run end to end against a real model. One story generated through the app's own
`build_context` a turn at a time, then **both** memory prompts run over the
**same** blocks, so the story is held constant and the prompt is the only
variable. Neither arm sees the other's output, and the model is never told what
is being measured. The control is `MEMORY_SYSTEM_PROMPT` as of commit `9cdcb55`,
read out of git rather than pasted, so it cannot drift from what shipped.
The harness is `backend/tools/memory_ab.py`, and it goes through
`OpenAICompatibleProvider` rather than calling a model directly, so the run
exercises the provider, the streaming path and `complete()`. Pointed at
`tools/claude_shim.py` it spends a Claude subscription instead of API credit:
python tools/claude_shim.py --port 8787 &
python tools/memory_ab.py --out ab.md
**The full transcript, with every memory, both summaries and the story they were
written from, is in `plan/18-appendix-memory-ab-run.md`.**
### The reported fault reproduced, and the fix held
| | words | framing |
|---|---|---|
| memory 1, before | 34 | second person — "**You** crept low through the mist…" |
| memory 2, before | 105 | third person — "**The player** asked Gwen to…" |
| memory 1, after | 72 | "**Kaelen** and Gwen crouched at dawn…" |
| memory 2, after | 68 | "**Kaelen** and Gwen infiltrated the bandit camp…" |
Two consecutive memories in one bank, written from the same story minutes apart,
in two different persons. That is the complaint, reproduced under controlled
conditions rather than argued from the prompt text.
**A second run, on a freshly generated story, qualifies that.** Its two control
memories were both "the player", consistently — the second-to-third person drift
did not recur. So the drift is a real thing a model does, seen once, not
something it does every time. Read the person-drift row as one observation.
What holds across both runs is the thing the change is actually for: **four
control memories, and not one names the protagonist. Four treatment memories,
and all four do.** The control has never been told a name. See
`plan/18-appendix-memory-ab-run-2.md`.
The control also got a fact wrong that the treatment did not. The player's move
was `grab her wrist and pull her down behind the woodpile`; the before-memory
recorded "The player asked Gwen to grab her wrist and pull her down behind the
woodpile", inverting who acted. One sample, so this is an observation rather
than a claim — but it is the failure mode the brief predicts, since without a
cast there is no way to tell whose wrist "her wrist" is.
### It also found a real problem, which is now fixed
"1-2 plain sentences" is not a length. The same model wrote 34 words for one
block and 105 for the next. A 105-word memory is a paragraph, and
`memory_top_k` injects five of them every turn, so the bank's running cost is
set by a number nobody had ever stated.
`MEMORY_MAX_WORDS = 50` now states it, and the prompt says which details to keep
when trimming: the ones a later scene could turn on. Re-run over the identical
story, the same two blocks came back at **32 and 58 words**, still naming
Kaelen, still third person, and still carrying every load-bearing fact — the
camp map, the strongbox behind the second tent, the strap frayed near through.
The second run, on different prose, came back at **54 and 55** against controls
of 89 and 107. Overshooting 50 slightly is expected: models exceed word budgets,
which is why `builder.length_hint` carries a `LENGTH_BUFFER` for the same reason.
Across both runs the control ran 34, 89, 105 and 107 words — a four-fold spread
with no budget stated anywhere. That is the number this found.
The variance is the real gain. Before, across both runs: 34 to 107. After: 32
to 58.
### What this does not show
The model behind the run is a Claude model. The app talks to an
OpenAI-compatible endpoint, and the parser in `worldstate/parse.py` exists
because weaker free models emit trailing commas and leading `+`. So this shows
the prompt is followable and that the brief supplies the missing information.
It does not show that a weaker production model complies as well. An explicit
framing rule is usually worth *more* on a weaker model, but that is an
expectation, not a measurement.
Two side observations from the same run, both pre-existing behavior working
correctly: the model sent `{"milestones.strongbox_found": false}`, `apply_delta`
refused it ("a milestone is sticky, so only true is accepted"), the refusal
reached the model through `render_refusals`, and the next turn sent `true`. The
Phase 1 persona also held across all six generated turns.
---
# Rewriting a bank written under the old prompt
An adventure played before this change keeps a bank of unnamed, second-person
memories, and those are exactly the rows `memory_top_k` injects into every turn
from now on. Ageing them out only works for an adventure that is still being
played, and only after another 80 memories have been written. So there is a
backfill: `backend/tools/rewrite_memories.py`.
```
cd backend
python -m tools.rewrite_memories # what would change
python -m tools.rewrite_memories --write --limit 3 # try three of them
python -m tools.rewrite_memories --write --embed # the whole backfill
```
Without `--write` it makes no model calls and spends nothing. It reads whichever
database the app reads — `AIDND_DB_PATH`, or `DATABASE_URL` on a hosted deploy.
**In place, not delete-and-regenerate.** The obvious alternative is to drop the
bank and rewind `cursors.MEMORY`, letting the post-turn pass write it again.
That loses everything the row carries besides its text: whether it is pinned,
how often it has been retrieved, and the node it hangs off, which is what makes
a fork inherit the right memories and no others. It would also trickle the bank
back at `MAX_MEMORIES_PER_RUN` per turn, so an adventure nobody is playing would
never recover. Rewriting `text` keeps the row and costs one call per memory.
**One prompt assembly, not two.** `memorybank.summarize_block` is now the single
place a memory prompt is built, and both the post-turn pass and the tool call
it. A backfill that assembled its own prompt would be writing memories with a
prompt that never shipped, and nothing would report the difference.
**Reading the block back is the one genuinely new part.** A memory records
`source_start`, `source_end` and `branch_id`, and `memorybank.source_block`
turns those back into actions. The subtlety is the branch: the read has to use
the lineage of *the branch the memory was written on*, not the branch the
adventure is playing now. After a fork, the same depths hold different actions
on each side, so a read through the adventure's current path would summarize the
wrong story and say nothing about it. `test_memory_rewrite.py` builds that fork
and holds the rule.
**What it will not touch:**
- A memory with no source range — hand-written, or migrated by 62 from before
memories had coordinates. There is no block to rewrite it from, and the player
may have typed it.
- A memory whose actions have since been deleted.
- An adventure whose owner has no API key in Settings, because summarization
spends the user's own key by construction and never the shared demo key.
`--api-key`, `--model` and `--endpoint` override that — the last of these
points a run at `tools/claude_shim.py`, so a backfill can spend a Claude
subscription instead of API credit.
**The vector is cleared for every memory it rewrites**, because the stored one
describes wording that no longer exists. That takes the memory out of the ranked
bank until something embeds the new text: the app's own post-turn pass does it
`MAX_EMBED_BATCH` at a time, or `--embed` does it in the run. Re-embedding always
uses the owner's own embedding model and endpoint, never `--endpoint`, because a
vector is only meaningful against the vectors it is ranked beside.
**Stop the app before running with `--embed`.** A running process caches vectors
by memory id and expects to be the only writer (`memorybank._vector_cache`), so
a vector written from outside it can sit behind a stale cached copy until it
restarts. Clearing alone is safe at any time: an unembedded memory leaves the
catalogue, which is what the cache invalidates on.
## Running it against the hosted deploy
Two things make production different from a local database, and both are easy
to get wrong quietly.
**It holds other people's stories, and each adventure is summarized with its
owner's key.** An unfiltered `--write` would spend other people's money on
memories they did not ask to have rewritten. `--email` restricts a run to named
accounts and `--adventure` to single adventures; the dry run costs nothing and
prints the owner of each. Guests have no email and are reachable only by id,
which is the right amount of friction for rewriting a stranger's bank.
**The stored API keys are encrypted with `AIDND_SECRET_KEY`.** Render generates
that value and holds it for the web service, so a run from a checkout has to
carry the same one. With a different secret, `decrypt_secret` returns "" rather
than failing, and every adventure is reported as having no key — a run that
looks like it worked and did nothing.
```
AIDND_DATABASE_URL=<the Neon URL from the Render dashboard> \
AIDND_SECRET_KEY=<the value the web service has> \
python -m tools.rewrite_memories --email you@example.com
```
Run it from a checkout rather than from a shell on Render. The image copies
`backend/app` alone, so `tools/` is not on the box, and the free plan has no
shell anyway. The database is the same one either way.
Two smaller notes for that environment. The Neon URL to use is the direct
endpoint, not `-pooler`, for the same reason the sizing queries in STATUS use
it. And an adventure owned by a visitor playing on the shared demo key is
skipped, because summarization has never spent that key.
**The story summary is not rewritten.** It is one text per adventure rather than
a bank, and `_update_story_summary` hands the model the whole of it and asks for
an updated version under the new framing rule, so the next scheduled update
should carry it over to third person by itself. If it does not, that is a
separate and much smaller fix than this one.

Some files were not shown because too many files have changed in this diff Show More