Ask the Pokemon demo for changes, not for totals

The model sent a total rather than a change for almost every number:
`npc.ivysaur.hp: 96`, `npc.milo.active_hp: 88`, `player.potions: 2`. Because
every `hp` starts at its maximum, each one clamped back to where it started, and
the potion count rose when the player spent one.

`EMIT_RULE` does say "deltas (not new totals)", but the scenario contradicted it
at closer range. `milo.active_hp`'s description said "Reset this to the
newcomer's full HP", which asks for an absolute and is injected every turn. The
bullets said "drop the HP, and raise it when healed", naming a direction but no
sign. Five HP descriptions said only whose HP it was. The one line that said
"not a delta" covered `player.active_pokemon`, so naming the exception made the
rule look optional. `pokemon_fainted` is the control: its description says "add
1 each time", and it is the only number that behaved.

Every stat description now states the sign, and the lead-in gives a worked
example. `milo.active_hp.max_delta_per_turn` goes 65 to 98, because a switch
moves that stat a full bar from 0 and the old cap made the reset unreachable in
one turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
This commit is contained in:
parththakkar106
2026-08-28 17:22:02 +05:30
co-authored by Claude Opus 5
parent 1988979aeb
commit ab546d6f3c
2 changed files with 83 additions and 14 deletions
+74 -5
View File
@@ -2,8 +2,9 @@
Read this before you play the Pokémon demo again. It records why two bugs in
`plan/15-pokemon-demo-handover.md` were diagnosed wrongly, what the engine was
actually doing, and what changed. Everything here is merged and green. **None of
it has been driven in a browser.**
actually doing, and what changed. Everything here is merged and green. It **has** now been driven in a browser;
read "Driven in a browser" at the end first, because three of the five changes
did not work and one bug explains all three.
**Last updated: 2026-08-28.**
@@ -116,13 +117,13 @@ to chase. `test_a_clamp_that_still_moved_the_value_says_nothing` pins this.
## How to test it
530 backend tests pass and the frontend builds. **The whole of the UI work is
unverified** — this project has no frontend test runner, which is the standing
532 backend tests pass and the frontend builds. **The UI work has no automated
cover** — this project has no frontend test runner, which is the standing
reason its UI bugs are found by hand.
Run the backend from `backend/` with
`.venv/Scripts/python.exe -m pytest tests/`. The new file is
`tests/test_change_visibility.py` (21 tests). Each of the three mechanisms fails
`tests/test_change_visibility.py` (23 tests). Each of the three mechanisms fails
its own test when disabled; that was checked by sabotage, not assumed.
To drive it, re-seed the scenario and play the demo:
@@ -162,3 +163,71 @@ resize a maximized window.
- **The Bandit Camp demo (`04-rpg-world-state.json`) was not checked** for the
same milestone problem. Its milestones were equally unnamed to the model
before this change, so it is worth asking whether one has ever fired there.
---
## Driven in a browser, 2026-08-28
Adventure 45, four turns, on production against the demo model. Two of the five
changes worked. Three did not reach anyone, for one reason.
### The bug: the stored column dropped two of the three lists
`world_delta_of` in `routers/adventures.py` wrote `delta` and `applied` only.
Every consumer that distinguishes outcomes reads the other two:
- `Action.world_changes` builds `clamped_paths` from `world_delta["clamped"]`,
so every chip carried `clamped: false`. `Play.jsx`'s `blocked = c.clamped &&
d === 0` could never be true, and `(limited)` could never render.
- With no `rejected` list, a `kind: "rejected"` chip was unreachable.
- `worldstate.refusals` reads both lists, so `render_refusals` always returned
an empty string and no correction ever reached the next prompt.
The `fix` string survived only because the engine stores it inside the `applied`
entry. Turn 3 is the record: the snapshot's `report.clamped` held
`npc.ivysaur.hp` and `npc.milo.active_hp` with correct `fix` text, both chips
came back `clamped: false`, turn 4's prompt contained no correction, and the
model repeated the same mistake.
`world_delta_of` now carries all three lists.
**Why the 21 tests passed.** The `action()` helper in
`test_change_visibility.py` built the column by hand as `{"delta": delta,
**report}`, with every list present. The write path was never exercised. The
helper now fills the column through `world_delta_of`. Removing the two lines
again fails 9 of the 23 tests; that was checked, not assumed.
### The two that were not code faults
`graveler_defeated` never fired and `world.turn` never moved. Both instructions
are present and correct in the assembled prompt: the goals line reads `Goals
(mark with milestones.<id>): graveler_defeated — …`, and the scenario says to
add 1 to `world.turn` every reply. The demo model ignores both. Change 3
landed; the model is the limit.
### The absolutes were coming from the scenario's own wording
The model sent a total, not a change, for almost every number:
`npc.ivysaur.hp: 96`, `npc.milo.active_hp: 65` then `88`, `player.potions: 2`.
Because every `hp` has `initial == max`, each one clamped back to where it
started. `potions` went **up** when the player spent one.
`EMIT_RULE` does say "CHANGES ONLY, as deltas (not new totals)", and
`EMIT_REMINDER` repeats "deltas only". The scenario contradicted both at closer
range. `milo.active_hp.desc` said "**Reset this to** the newcomer's full HP",
which is an instruction to send an absolute, and that desc is injected every
turn. The bullets said "**Drop** the HP … and **raise** it", naming a direction
but never a sign. The only line that said "(not a delta)" was
`player.active_pokemon`, so naming the exception made the rule look optional.
The one stat whose desc used delta wording, `pokemon_fainted` ("Add 1 each
time"), is the one that worked.
Fixed in `05-league-championship.json`: every `hp` desc and the potion desc now
state the sign, the bullets do too, and the lead-in says plainly that every
number is a change with a worked example. `milo.active_hp.max_delta_per_turn`
went 65 → 98, because a switch legitimately moves that stat a full bar and the
old cap made the reset unreachable in one turn.
**Not fixed:** the `initial == max` shape itself. It is now loud rather than
silent, and the wording removes the usual cause, but the shape is still there.