diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index f75973c..34defd5 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -528,16 +528,27 @@ def world_delta_of(snapshot: dict | None) -> dict | None: """Returns the bulk-read slice of a context snapshot, for `Action.world_delta`. `context_snapshot` is deferred because it holds the whole assembled prompt. - The two parts that every action needs, the world-change chips and the emit - block replayed into history, get their own small column instead. Update this - function wherever a snapshot is written. + The parts that every action needs get their own small column instead: the + world-change chips, the emit block replayed into history, and the refusal + note fed back to the model. Update this function wherever a snapshot is + written. + + Carry all three report lists, not just `applied`. `Action.world_changes` + marks a chip from `clamped` and builds its refusal chips from `rejected`, + and `worldstate.refusals` reads both. Storing `applied` alone left every + consumer unable to tell a refused change from one that worked, which is the + distinction this column exists to carry. The two extra lists are subsets of + one turn's block, so they cost a few hundred bytes per action at most. """ ws = (snapshot or {}).get("world_state") if not isinstance(ws, dict): return None + report = ws.get("report") or {} return { "delta": ws.get("delta") or {}, - "applied": (ws.get("report") or {}).get("applied") or [], + "applied": report.get("applied") or [], + "clamped": report.get("clamped") or [], + "rejected": report.get("rejected") or [], } diff --git a/backend/tests/test_change_visibility.py b/backend/tests/test_change_visibility.py index 2d8829f..fa9d07c 100644 --- a/backend/tests/test_change_visibility.py +++ b/backend/tests/test_change_visibility.py @@ -16,6 +16,7 @@ import pathlib from app import models from app import worldstate as w +from app.routers.adventures import world_delta_of SEED = pathlib.Path(__file__).resolve().parents[1] / "app" / "seed_data" @@ -37,9 +38,40 @@ SCHEMA = { def action(delta, index=1): - """Returns an unsaved Action carrying the report `apply_delta` produced.""" + """Returns an unsaved Action carrying the report `apply_delta` produced. + + The column is filled through `world_delta_of`, the same function the turn + endpoint uses, rather than by assembling the dict here. An earlier version + of this helper built the shape by hand with every report list present. That + hid a live bug: `world_delta_of` stored `applied` alone, so no refusal ever + reached a chip or the next prompt while all of these tests passed. + """ ws, report = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, delta, index) - return models.Action(world_delta={"delta": delta, **report}) + snapshot = {"world_state": {"delta": delta, "report": report, "state": ws}} + return models.Action(world_delta=world_delta_of(snapshot)) + + +def test_world_delta_of_keeps_every_report_list(): + """The stored column must carry the refusals, not just the successes. + + `Action.world_changes` marks a clamped chip from `clamped` and builds its + refusal chips from `rejected`, and `worldstate.refusals` reads both. Dropping + either list leaves every consumer unable to tell a refused change from one + that worked. + """ + _, report = w.apply_delta( + w.instantiate(SCHEMA), SCHEMA, + {"player.arrows": 3, "player.nonesuch": 1, "player.hp": -5}, 1, + ) + stored = world_delta_of({"world_state": {"delta": {}, "report": report}}) + assert [e["path"] for e in stored["applied"]] == ["player.arrows", "player.hp"] + assert [e["path"] for e in stored["clamped"]] == ["player.arrows"] + assert [e["path"] for e in stored["rejected"]] == ["player.nonesuch"] + + +def test_world_delta_of_survives_a_snapshot_with_no_world_state(): + assert world_delta_of({"story": "s"}) is None + assert world_delta_of(None) is None # --------------------------------------------------------------------------- #