M5: genre-neutral authoritative narrative state, with review corrections

Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
JesseMarkowitz
2026-09-05 07:01:50 -04:00
co-authored by Claude Opus 5
parent 62a997f364
commit b7005e6fdd
57 changed files with 7257 additions and 474 deletions
+7 -3
View File
@@ -27,13 +27,13 @@ os.environ["AIDND_DB_PATH"] = db_path
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fakes import GOLD_PER_TURN, gold_reply # noqa: E402
from fakes import TALLY_PER_TURN, tally_reply # noqa: E402
_turn = itertools.count(1)
class DeterministicProvider:
"""Banks one turn's worth of gold per reply, numbered so text is checkable.
"""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
@@ -47,7 +47,11 @@ class DeterministicProvider:
pass
async def generate(self, parts, *, temperature, max_tokens):
yield ("text", gold_reply(f"Beat {next(_turn)}."))
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
+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
+49 -37
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
+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():
+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}")
+53 -31
View File
@@ -36,7 +36,7 @@ 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
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
@@ -52,7 +52,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tavern", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -131,7 +130,7 @@ def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}).get("gold", 0)
return tally_of(adv.narrative_state)
finally:
db.close()
@@ -250,7 +249,7 @@ def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client)
_undo(client)
assert _adventure(client)["can_redo"] is True
ScriptedProvider.replies = ["A different road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
_play(client, "go the other way")
# Ordinary Redo cannot walk into the old future any more...
@@ -294,8 +293,8 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
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 = [
"You find a purse.\n```state\n{\"player.gold\": 10}\n```",
"You find the hoard.\n```state\n{\"player.gold\": 500}\n```",
tally_reply("You find a purse.", 10),
tally_reply("You find the hoard.", 510),
]
_play(client, "search")
_play(client, "keep searching")
@@ -304,7 +303,7 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
_undo(client)
assert _gold(client.adv_id) == 10
ScriptedProvider.replies = ["You leave empty-handed.\n```state\n{\"player.gold\": 1}\n```"]
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"
@@ -425,6 +424,7 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
_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
@@ -432,7 +432,8 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
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.
# 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
@@ -485,15 +486,15 @@ def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
prose that creates no continuation, and M3 does not change it.
"""
ScriptedProvider.replies = [
"You accuse her.\n```state\n{\"player.gold\": 10}\n```",
"She draws a knife.\n```state\n{\"player.gold\": 20}\n```",
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 = ["She shakes her head.\n```state\n{\"player.gold\": 1}\n```"]
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."},
@@ -791,39 +792,55 @@ def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
assert "Mara wears a green cloak." in _texts(client)
def test_editing_a_turn_with_an_undone_future_is_refused(client):
"""The first unsafe case: the story past the head is not on screen, so an
edit here would silently change the words it was written from."""
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.text
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.")
r = _edit(client, target_id, "Something else entirely.")
assert r.status_code == 400
assert "not on screen" in r.json()["detail"]
# Refused, not partially applied.
assert _rows(client.adv_id)[0].adventure_id == client.adv_id
assert [a.text for a in _rows(client.adv_id) if a.id == target.id] == [before]
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_turn_a_divergence_left_behind_is_refused(client):
"""The second unsafe case, and the one 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."""
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.")
r = _edit(client, shared_id, "Rewritten under both lines.")
assert r.status_code == 400
assert "left behind by a new continuation" in r.json()["detail"]
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):
@@ -857,14 +874,19 @@ def test_a_take_that_is_not_live_stays_editable(client):
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."""
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 == "ai" and a.live],
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
key=lambda a: a.depth,
)
target = live[-2]
target = live[-1]
assert _edit(client, target.id, "x").status_code == 400
_redo(client)
+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)
+4 -4
View File
@@ -31,7 +31,7 @@ from pathlib import Path
import pytest
from fakes import GOLD_PER_TURN
from fakes import TALLY_PER_TURN, tally_of
HERE = Path(__file__).resolve().parent
SERVER = HERE / "_restart_server.py"
@@ -149,8 +149,8 @@ class Server:
return [a["text"] for a in page["actions"]]
def gold(self, adventure_id: int) -> int:
state = self.call("GET", f"/adventures/{adventure_id}/world-state", expect=200)
return state["state"]["player"]["gold"]
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.
@@ -229,7 +229,7 @@ def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
assert at_tip["gold"] == at_save["gold"] + 4 * GOLD_PER_TURN
assert at_tip["gold"] == at_save["gold"] + 4 * TALLY_PER_TURN
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
# --- the boundary -----------------------------------------------------
+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."
+22 -22
View File
@@ -34,7 +34,7 @@ from app.main import app
from app import auth
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
@@ -50,7 +50,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Abbey", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -153,7 +152,7 @@ def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}).get("gold", 0)
return tally_of(adv.narrative_state)
finally:
db.close()
@@ -459,7 +458,7 @@ def test_d13_a_different_continuation_forks_and_keeps_the_old_future(client):
# Restore itself created nothing.
assert _branch_count(client.adv_id) == branches_before
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
# The write forked...
@@ -482,7 +481,7 @@ def test_d13_the_save_point_still_names_the_same_position_after_divergence(clien
_turns(client, 3)
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
kept = _list(client)
@@ -514,7 +513,7 @@ def test_a_save_point_on_a_line_the_story_left_still_restores(client):
old_branch, old_depth = _head(client.adv_id)
_restore(client, fork_point["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
new_branch, _ = _head(client.adv_id)
assert new_branch != old_branch
@@ -539,7 +538,7 @@ def test_restore_onto_an_inherited_position_keeps_the_line_being_read(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
new_branch, _ = _head(client.adv_id)
@@ -761,7 +760,7 @@ def test_a_branch_a_save_point_names_cannot_be_deleted(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
@@ -789,7 +788,7 @@ def test_deleting_the_save_point_then_lets_the_branch_go(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
@@ -819,7 +818,7 @@ def test_a_branch_no_save_point_names_still_deletes(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
@@ -839,11 +838,11 @@ def test_a_save_point_on_a_descendant_also_protects_the_branch(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Second line.", 1)]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Third line.", 1)]
_play(client, "third line")
deepest = _save(client, "Down on the deepest line")
assert deepest["branch_id"] != middle_branch
@@ -864,7 +863,7 @@ def test_a_save_point_elsewhere_does_not_block_an_unrelated_branch(client):
elsewhere = _save(client, "Safe on the root")
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
@@ -883,7 +882,7 @@ def test_the_refusal_names_several_save_points_without_running_on(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
for n in range(5):
_save(client, f"Point {n}")
@@ -967,7 +966,7 @@ def test_e01_an_old_futures_memory_stays_out_of_a_new_continuation(client):
db.close()
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
assert _visible_memories(client) == []
@@ -990,7 +989,7 @@ def test_e04_the_transcript_after_a_restore_holds_only_the_active_lineage(client
assert displaced
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
ScriptedProvider.replies = gold_replies("New")
_turns(client, 2)
@@ -1140,7 +1139,7 @@ def test_save_points_on_two_branches_survive_the_round_trip(client):
old_line = _save(client, "Down the old road")
_restore(client, shared["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
new_line = _save(client, "Down the new road")
@@ -1252,7 +1251,8 @@ def test_an_m3_database_gains_the_save_point_table_and_keeps_its_story():
}
assert "ix_checkpoints_adventure" in {i["name"] for i in insp.get_indexes("checkpoints")}
with m3.connect() as conn:
assert conn.execute(text("PRAGMA user_version")).scalar() == 80
assert (conn.execute(text("PRAGMA user_version")).scalar()
== migrations.LATEST_VERSION)
# No Save Points were invented for a campaign whose owner named none...
assert conn.execute(text("SELECT COUNT(*) FROM checkpoints")).scalar() == 0
# ...and the campaign it already had is untouched.
@@ -1374,7 +1374,7 @@ def test_the_bulk_resolution_does_not_confuse_coordinates_across_branches(client
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way") # forks; new branch has rows at 5,6
on_new_line = _save(client, "On the new line")
@@ -1428,7 +1428,7 @@ def test_the_branch_list_reports_how_many_save_points_each_line_carries(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
on_new = _save(client, "On the new line")
@@ -1450,11 +1450,11 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Second line.", 1)]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Third line.", 1)]
_play(client, "third line")
_save(client, "Deep one")
+115 -1
View File
@@ -1,4 +1,14 @@
"""Tests for undo and retry rolling back the shared `world_state`.
"""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`,
@@ -25,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
@@ -335,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}
+13 -5
View File
@@ -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
+9 -6
View File
@@ -120,11 +120,13 @@ def pre_tree():
# SQLite refuses to drop a column that a foreign key references,
# and that is exactly the case for `branch_id`.
#
# `checkpoints` (M4) is dropped first and for a different reason: it
# references both `branches` and `adventures`, and SQLite refuses to
# drop a table another table still points at. Any future table that
# references these four has to be added to the front of this list.
for table in ("checkpoints", "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))
@@ -597,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 ("checkpoints", "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