`apply_delta` records three outcomes for every change the model sends:
`applied`, `clamped`, and `rejected`. Everything downstream read only
`applied`. A refused change reached the player as an ordinary chip, and
reached the model on the next turn as a change that had succeeded.
Five parts:
- `Action.world_changes` reads `clamped` and `rejected` beside `applied`.
Accepted stats carry a `clamped` flag; refusals become `kind: "rejected"`
entries. The `fix` key is present only when the engine wrote one, because
this property runs for every action of every list response.
- The UI separates the three outcomes. A clamp to a standstill reads
`no change - at its limit` on a dashed chip, a partial clamp is marked
`(limited)`, and a rejection carries its reason. Dashed and dimmed rather
than red: a refused change means the rules are working.
- The goals line names the milestone id, as `milestones.<id>`. The ids
appeared nowhere in the prompt before, so the model could not send one.
- Each rejection, and each clamp that moved nothing, builds a `fix` string
from the stat definition at the point of refusal. `render_refusals()`
renders them into the next prompt above `EMIT_REMINDER`.
- `_history_text` replays `applied_delta()` instead of the sent delta, so a
past turn's state block shows only what the engine accepted.
A clamp that reduced a change but still moved the value reports nothing. If
you tell a model its 80 damage became 30, it can treat the shortfall as a
debt and send the remaining 50 next turn, which is the swing
`max_delta_per_turn` prevents.
In the demo scenario, `pokemon_left` becomes `pokemon_fainted`
(`type: counter`, `initial: 0`). Starting at the ceiling turned a wrong-signed
delta into a silent no-op; counting up puts the wrong sign on the counter
rule, which refuses it out loud. The instructions also now ask for
`world.turn`, which sat at 0 for a whole playtest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
Each teammate is its own named entity with hp/status, the same shape
Milo already uses, rather than <name>_hp/<name>_status flattened
under player. Also replaces the five mutually-exclusive _active
flags with a single player.active_pokemon text field, mirroring
npc.milo.active_pokemon, so the AI no longer has to self-enforce
"exactly one flag true."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
Each of 5 team Pokemon now tracks its own HP and status condition,
one flag marks who's active, and potions are a depletable resource.
The opponent NPC tracks its active Pokemon the same way. Drops the
crowd-favor stat, which didn't fit a battle scenario.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF