From ab546d6f3cf0c7cfb83c9d6e11c9f083d6e05630 Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Fri, 28 Aug 2026 17:22:02 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF --- .../app/seed_data/05-league-championship.json | 18 ++--- plan/16-world-state-refusals.md | 79 +++++++++++++++++-- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/backend/app/seed_data/05-league-championship.json b/backend/app/seed_data/05-league-championship.json index 34c70df..8702759 100644 --- a/backend/app/seed_data/05-league-championship.json +++ b/backend/app/seed_data/05-league-championship.json @@ -4,7 +4,7 @@ "prompt": "The floodlights come up over the championship field and the referee raises both flags. Round One of eight. Beat the trainer in front of you or go home.\n\nAcross the packed rock terrain, Trainer Milo tosses his first Poké Ball. Graveler lands hard enough to crack the stone under it, four arms flexing, and settles into a low stance.\n\n\"Rock-types all the way down,\" Milo calls over the noise. \"Hope you brought something that hits harder than it looks.\"\n\nYour Pidgeotto is already in the air, wings locked into a slow circle above the field. It is fast, but Rock-type moves will tear it out of the sky, and Graveler is built to take a hit. Three Hyper Potions sit in your belt pouch.\n\nGraveler grinds one fist into its palm. The referee drops the flag.\n\nWhat's your move?", "memory": "The player is a Trainer in Round One of an eight-round League Championship. The Round One opponent is Milo, a Rock-type specialist with three Pokémon: Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). The player's team is Pidgeotto (Normal/Flying), Ivysaur (Grass/Poison), Wartortle (Water), Machoke (Fighting), and Pikachu (Electric). Pidgeotto is out first. The player carries Hyper Potions, which restore HP and can be swapped for a status heal when needed. A battle is lost when every Pokémon on one side has fainted.", "authors_note": "Play it like a real battle. Type matchups decide damage, status conditions stick around and cost the afflicted Pokémon turns, and switching costs the player a turn while the opponent attacks freely. Milo is competent: he switches to punish bad matchups and targets whatever is weakest.", - "ai_instructions": "Write in second person, present tense. Narrate the battle move by move, naming the active Pokémon on each side and the move each one uses. End every reply at a decision point where the player can choose a move, switch Pokémon, or use an item.\n\nReflect type advantage and disadvantage in the damage you narrate and in the HP numbers: a super-effective hit should take roughly double a neutral hit, a resisted hit roughly half. Ground and Rock moves hit Pikachu and Pidgeotto hard. Water, Grass, and Fighting moves are strong against Milo's Rock-types.\n\nEvery turn, update the state:\n- Add 1 to `world.turn` in every reply, including this one.\n- Drop the active Pokémon's HP on both sides when they take damage (`npc..hp` for the player's own Pokémon, e.g. npc.pidgeotto.hp; `npc.milo.active_hp` for Milo's), and raise it when they are healed.\n- Set a Pokémon's `status` stat (`npc..status`, or `npc.milo.active_status`) to the condition it just picked up: poisoned, burned, paralyzed, frozen, asleep, or none when it is cured or wakes up. Status matters. Poison and burn shave HP at the end of each turn, paralysis and freeze cost turns, sleep stops a Pokémon acting until it wakes.\n- When the player switches, set `player.active_pokemon` to the incoming Pokémon's name in full (not a delta).\n- When the player uses a potion, decrement `player.potions` by 1 and raise the healed Pokémon's HP. Potions cannot be used once the count reaches 0.\n- When a Pokémon's HP hits 0 it faints, its status becomes `fainted`, and its trainer must send out a replacement (the player picks the next `player.active_pokemon`). A fainted Pokémon cannot be healed or switched back in.\n- Update `npc.milo.active_pokemon` and reset `npc.milo.active_hp` and `npc.milo.active_status` whenever Milo sends out a new Pokémon.\n\nWhen one of Milo's Pokémon faints, do both of these in the same reply: add 1 to `npc.milo.pokemon_fainted`, and set `npc.milo.active_pokemon` with a fresh `npc.milo.active_hp` and `npc.milo.active_status` for the one he sends out next. `npc.milo.pokemon_fainted` counts up from 0 and never goes down.\n\nMark each milestone as it happens, using the id shown beside the goal in the world state. Do not decide the whole battle in one reply; give the player a turn between every exchange.", + "ai_instructions": "Write in second person, present tense. Narrate the battle move by move, naming the active Pokémon on each side and the move each one uses. End every reply at a decision point where the player can choose a move, switch Pokémon, or use an item.\n\nReflect type advantage and disadvantage in the damage you narrate and in the HP numbers: a super-effective hit should take roughly double a neutral hit, a resisted hit roughly half. Ground and Rock moves hit Pikachu and Pidgeotto hard. Water, Grass, and Fighting moves are strong against Milo's Rock-types.\n\nEvery turn, update the state. Every number you send is a change to add to the current value, never the value you want it to end up at. The world state lines show you totals; the block you write carries the difference. A Pokémon at 96 HP that takes 30 damage is `-30`, not `66`.\n- Add 1 to `world.turn` in every reply, including this one. It counts the turns this battle has lasted, so it moves whether or not anything else does.\n- Send a negative number for HP lost and a positive number for HP restored on both sides (`npc..hp` for the player's own Pokémon, e.g. npc.pidgeotto.hp; `npc.milo.active_hp` for Milo's). Every Pokémon starts at its full HP, so a positive number on an undamaged one changes nothing.\n- Set a Pokémon's `status` stat (`npc..status`, or `npc.milo.active_status`) to the condition it just picked up: poisoned, burned, paralyzed, frozen, asleep, or none when it is cured or wakes up. Status matters. Poison and burn shave HP at the end of each turn, paralysis and freeze cost turns, sleep stops a Pokémon acting until it wakes.\n- When the player switches, set `player.active_pokemon` to the incoming Pokémon's name in full (not a delta).\n- When the player uses a potion, send `-1` for `player.potions` and a positive number for the healed Pokémon's HP. The count falls when a potion is spent. It never rises. Potions cannot be used once the count reaches 0.\n- When a Pokémon's HP hits 0 it faints, its status becomes `fainted`, and its trainer must send out a replacement (the player picks the next `player.active_pokemon`). A fainted Pokémon cannot be healed or switched back in.\n- Whenever Milo sends out a new Pokémon, set `npc.milo.active_pokemon` and `npc.milo.active_status` to their new values in full, and send `npc.milo.active_hp` as the positive change that carries it from where it stands to the newcomer's full HP. From a fainted Pokémon at 0 that is the newcomer's full HP: Graveler 98, Onix 90, Kabutops 88.\n\nWhen one of Milo's Pokémon faints, do both of these in the same reply: send `1` for `npc.milo.pokemon_fainted`, which adds one to the count rather than setting it, and set `npc.milo.active_pokemon` with a fresh `npc.milo.active_hp` and `npc.milo.active_status` for the one he sends out next. `npc.milo.pokemon_fainted` counts up from 0 and never goes down.\n\nMark each milestone as it happens, using the id shown beside the goal in the world state. Do not decide the whole battle in one reply; give the player a turn between every exchange.", "tags": "demo, pokemon, battle, tournament, world-state, combat", "icon": "⚡", "stat_schema": { @@ -14,7 +14,7 @@ }, "player": { "potions": { - "desc": "Hyper Potions left in the player's bag. Each one restores HP or cures a status condition, and is used up when it is used.", + "desc": "Hyper Potions left in the player's bag. Each one restores HP or cures a status condition, and is used up when it is used. Send -1 when the player uses one. Never send the number remaining.", "min": 0, "max": 5, "initial": 3, "max_delta_per_turn": 1, "bands": [[0, 1, "out of potions"], [1, 2, "one left"], [2, 4, "a few left"], [4, 6, "well stocked"]] }, @@ -33,7 +33,7 @@ "desc": "The player's own Pokémon. Normal/Flying. Fast, but Rock-type moves hit it for four times damage.", "stats": { "hp": { - "desc": "Pidgeotto's HP.", + "desc": "Pidgeotto's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.", "min": 0, "max": 84, "initial": 84, "max_delta_per_turn": 60, "bands": [[0, 1, "fainted"], [1, 20, "critical"], [20, 45, "hurt"], [45, 70, "scuffed"], [70, 85, "healthy"]] }, @@ -49,7 +49,7 @@ "desc": "The player's own Pokémon. Grass/Poison. Super effective on Milo's Rock/Ground types; can inflict poison or sleep.", "stats": { "hp": { - "desc": "Ivysaur's HP.", + "desc": "Ivysaur's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.", "min": 0, "max": 96, "initial": 96, "max_delta_per_turn": 60, "bands": [[0, 1, "fainted"], [1, 24, "critical"], [24, 50, "hurt"], [50, 78, "scuffed"], [78, 97, "healthy"]] }, @@ -65,7 +65,7 @@ "desc": "The player's own Pokémon. Water. Bulky, and super effective on the whole of Milo's team.", "stats": { "hp": { - "desc": "Wartortle's HP.", + "desc": "Wartortle's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.", "min": 0, "max": 92, "initial": 92, "max_delta_per_turn": 60, "bands": [[0, 1, "fainted"], [1, 23, "critical"], [23, 48, "hurt"], [48, 75, "scuffed"], [75, 93, "healthy"]] }, @@ -81,7 +81,7 @@ "desc": "The player's own Pokémon. Fighting. Hits Rock-types hardest of the team but is slow and takes Ground moves badly.", "stats": { "hp": { - "desc": "Machoke's HP.", + "desc": "Machoke's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.", "min": 0, "max": 104, "initial": 104, "max_delta_per_turn": 65, "bands": [[0, 1, "fainted"], [1, 26, "critical"], [26, 54, "hurt"], [54, 84, "scuffed"], [84, 105, "healthy"]] }, @@ -97,7 +97,7 @@ "desc": "The player's own Pokémon. Electric. Frail, and its Electric moves do nothing at all to Ground-types.", "stats": { "hp": { - "desc": "Pikachu's HP.", + "desc": "Pikachu's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.", "min": 0, "max": 76, "initial": 76, "max_delta_per_turn": 60, "bands": [[0, 1, "fainted"], [1, 19, "critical"], [19, 40, "hurt"], [40, 62, "scuffed"], [62, 77, "healthy"]] }, @@ -117,8 +117,8 @@ "desc": "Which Pokémon Milo currently has on the field: Graveler, Onix, or Kabutops. Replace this the moment he sends out a different one." }, "active_hp": { - "desc": "The HP of Milo's active Pokémon. Reset this to the newcomer's full HP whenever he sends out a different Pokémon: Graveler 98, Onix 90, Kabutops 88.", - "min": 0, "max": 98, "initial": 98, "max_delta_per_turn": 65, + "desc": "The HP of Milo's active Pokémon. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored. When Milo sends out a different Pokémon, send the positive change that carries this from where it stands to the newcomer's full HP: Graveler 98, Onix 90, Kabutops 88.", + "min": 0, "max": 98, "initial": 98, "max_delta_per_turn": 98, "bands": [[0, 1, "fainted"], [1, 25, "critical"], [25, 50, "hurt"], [50, 78, "scuffed"], [78, 99, "healthy"]] }, "active_status": { diff --git a/plan/16-world-state-refusals.md b/plan/16-world-state-refusals.md index b3b9ee8..bbf69dc 100644 --- a/plan/16-world-state-refusals.md +++ b/plan/16-world-state-refusals.md @@ -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.): 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.