Report the world-state changes the engine refuses

`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
This commit is contained in:
parththakkar106
2026-08-28 16:26:34 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 2ca2dadc72
commit 47c7800903
9 changed files with 560 additions and 39 deletions
+229
View File
@@ -0,0 +1,229 @@
"""Tests for the changes the engine refuses, and for naming a milestone.
A refused change used to leave no trace a player could see. The turn summary
read `world_delta["applied"]` alone, so a value the model pushed past its
ceiling came back with a delta of 0 and rendered as an ordinary chip. The
engine had recorded the refusal and nothing showed it.
The milestone half is the same shape. `apply_delta` matches a milestone by its
schema id, and the context named goals by description only, so the model had no
way to learn the id it was being asked to send.
python -m pytest tests/test_change_visibility.py -v
"""
import json
import pathlib
from app import models
from app import worldstate as w
SEED = pathlib.Path(__file__).resolve().parents[1] / "app" / "seed_data"
SCHEMA = {
"world": {"day": {"type": "counter", "min": 1, "initial": 1}},
"player": {
"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30},
# Starts at its own ceiling and only ever falls, which is the shape that
# turns a wrong-signed delta into a change of nothing.
"arrows": {"min": 0, "max": 6, "initial": 6, "max_delta_per_turn": 1},
},
"npcs": {"gwen": {"name": "Gwen", "stats": {"trust": {"min": -100, "max": 100, "initial": 0}}}},
"flags": {"has_key": {"desc": "Holds the key", "initial": False}},
"milestones": {
"rescue_gwen": {"desc": "Rescue Gwen"},
"escape_keep": {"desc": "Escape the keep"},
},
}
def action(delta, index=1):
"""Returns an unsaved Action carrying the report `apply_delta` produced."""
ws, report = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, delta, index)
return models.Action(world_delta={"delta": delta, **report})
# --------------------------------------------------------------------------- #
# The goals line names the id the AI has to send
# --------------------------------------------------------------------------- #
def test_goals_line_names_the_milestone_id():
line = w.render_state_section(w.instantiate(SCHEMA), SCHEMA, {})
assert "rescue_gwen — Rescue Gwen" in line
assert "escape_keep — Escape the keep" in line
# The path is spelled out, because the id alone does not say how to send it.
assert "milestones.<id>" in line
def test_achieved_line_names_the_id_too():
ws, _ = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, {"milestones.rescue_gwen": True}, 1)
line = w.render_state_section(ws, SCHEMA, {})
assert "Achieved: rescue_gwen — Rescue Gwen." in line
# A reached milestone leaves the goals list.
assert "rescue_gwen — Rescue Gwen;" not in line
def test_every_milestone_in_the_demo_is_named_to_the_model():
"""The demo that exposed this must not lose the ids again."""
schema = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))["stat_schema"]
line = w.render_state_section(w.instantiate(schema), schema, {})
for mid in schema["milestones"]:
assert mid in line
# --------------------------------------------------------------------------- #
# A refused change reaches the turn summary
# --------------------------------------------------------------------------- #
def test_a_clamp_that_changes_nothing_is_still_reported():
"""The Pokémon bug: a positive delta on a stat already at its ceiling.
`arrows` sits at 6 of a maximum 6, so `+2` caps to `+1`, reaches 7, and
clamps back to 6. The value never moves, and the summary has to say so.
"""
chips = action({"player.arrows": 2}).world_changes
arrows = [c for c in chips if c["label"] == "arrows"]
assert len(arrows) == 1
assert arrows[0]["delta"] == 0
assert arrows[0]["clamped"] is True
def test_a_clamp_that_still_moves_the_value_is_marked():
chips = action({"player.hp": -80}).world_changes
hp = [c for c in chips if c["label"] == "hp"][0]
assert hp["delta"] == -30 # max_delta_per_turn
assert hp["clamped"] is True
def test_an_accepted_change_is_not_marked():
chips = action({"player.hp": -10}).world_changes
hp = [c for c in chips if c["label"] == "hp"][0]
assert hp["delta"] == -10
assert hp["clamped"] is False
def test_a_refusal_becomes_its_own_entry_carrying_the_reason():
chips = action({"world.day": -1, "player.stamina": 5}).world_changes
refused = {c["label"]: c["reason"] for c in chips if c["kind"] == "rejected"}
assert refused == {"day": "counter can't decrease", "stamina": "unknown stat"}
def test_a_refused_npc_stat_keeps_the_npc_in_its_label():
chips = action({"npc.gwen.bogus": 5}).world_changes
refused = [c for c in chips if c["kind"] == "rejected"][0]
assert refused["label"] == "gwen bogus"
def test_accepted_and_refused_changes_appear_together():
chips = action({"player.hp": -10, "world.day": -1}).world_changes
kinds = [c["kind"] for c in chips]
assert "stat" in kinds and "rejected" in kinds
def test_a_turn_that_changed_nothing_still_has_no_chips():
assert models.Action(world_delta=None).world_changes == []
def test_flags_and_milestones_are_unchanged_by_the_new_fields():
chips = action({"flags.has_key": True, "milestones.rescue_gwen": True}).world_changes
assert {"kind": "flag", "label": "has_key", "on": True} in chips
assert {"kind": "milestone", "label": "rescue_gwen"} in chips
# --------------------------------------------------------------------------- #
# The demo stat counts up, so a wrong sign is refused rather than absorbed
# --------------------------------------------------------------------------- #
def test_milos_faint_counter_counts_up_from_zero():
"""A stat that starts at its ceiling cannot report a wrong sign.
`pokemon_left` began at 3 of a maximum 3, so the model sending the count it
had left rather than a delta clamped to no change at all. Counting the
faints up from 0 puts the wrong direction on the counter rule, which
refuses it out loud.
"""
schema = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))["stat_schema"]
stat = schema["npcs"]["milo"]["stats"]["pokemon_fainted"]
assert stat["initial"] == 0 and stat["type"] == "counter"
ws = w.instantiate(schema)
after, report = w.apply_delta(ws, schema, {"npc.milo.pokemon_fainted": 1}, 1)
assert after["npc"]["milo"]["pokemon_fainted"] == 1
_, report = w.apply_delta(ws, schema, {"npc.milo.pokemon_fainted": -1}, 1)
assert len(report["rejected"]) == 1
refused = report["rejected"][0]
assert refused["path"] == "npc.milo.pokemon_fainted"
assert refused["reason"] == "counter can't decrease"
def test_the_demo_no_longer_mentions_the_old_stat():
"""The instructions name the stat, so a rename has to reach them too."""
raw = (SEED / "05-league-championship.json").read_text(encoding="utf-8")
assert "pokemon_left" not in raw
assert "pokemon_fainted" in json.loads(raw)["ai_instructions"]
def test_the_demo_asks_for_the_turn_counter():
"""`world.turn` sat at 0 for a whole playtest: nothing told the model to
move it. The schema defining a stat is not an instruction to update it."""
d = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))
assert "world.turn" in d["stat_schema"]["world"] or "turn" in d["stat_schema"]["world"]
assert "world.turn" in d["ai_instructions"]
# --------------------------------------------------------------------------- #
# 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.
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
delta showed the model a change the live values disagreed with.
"""
from app.context.builder import _history_text
a = action({"player.arrows": 2, "player.hp": -10})
a.text = "The arrow flies."
replayed = _history_text(a)
assert '"player.hp": -10' in replayed
assert "arrows" not in replayed
def test_history_replay_keeps_flags_and_milestones_and_text():
from app.context.builder import _history_text
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
a.text = "The lock gives."
replayed = _history_text(a)
assert '"flags.has_key": true' in replayed
assert '"milestones.rescue_gwen": true' in replayed
def test_a_refusal_reaches_the_model_with_the_valid_names():
note = w.render_refusals(action({"milestones.bogus": True}).world_delta)
assert "There is no milestone `bogus`" in note
# The correction has to name what it could have sent instead.
assert "`rescue_gwen`" in note and "`escape_keep`" in note
def test_a_wrong_sign_on_a_counter_is_explained():
note = w.render_refusals(action({"world.day": -1}).world_delta)
assert "only counts up" in note
def test_a_clamp_that_moved_nothing_quotes_the_limit():
note = w.render_refusals(action({"player.arrows": 2}).world_delta)
assert "did not move" in note
assert "maximum of 6" in note and "it runs from 0 to 6" in note
def test_a_clamp_that_still_moved_the_value_says_nothing():
"""Reporting a trimmed change invites the model to send the remainder next
turn, which is the swing `max_delta_per_turn` exists to prevent."""
assert w.render_refusals(action({"player.hp": -80}).world_delta) == ""
def test_a_clean_turn_adds_no_note():
assert w.render_refusals(action({"player.hp": -10}).world_delta) == ""
assert w.render_refusals(None) == ""