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
+50 -5
View File
@@ -326,6 +326,17 @@ class StoryCard(Base):
adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards")
def _change_label(parts: list[str]) -> str:
"""Names a world-state path for the inline turn summary.
`npc.gwen.trust` becomes "gwen trust". Every other shape uses its last
segment, so `player.hp` becomes "hp".
"""
if len(parts) == 3 and parts[0] == "npc":
return f"{parts[1]} {parts[2]}"
return parts[-1] if parts else ""
class Action(Base):
__tablename__ = "actions"
# Phase 14: every story read selects one branch up to one depth, then
@@ -470,26 +481,60 @@ class Action(Base):
under an AI message. Labels are path-based (no schema needed):
`npc.gwen.trust` -> "gwen trust".
The summary reports refused changes as well as accepted ones. A stat the
engine clamped carries `clamped`, and a stat it refused outright becomes
a `rejected` entry carrying the reason. Reporting only the accepted
changes made a clamp indistinguishable from a change that never
happened: a value the model pushed past its ceiling came back as a
delta of 0 and rendered as an ordinary chip, so a refused update read on
screen as an applied one.
Reads `world_delta`, never `context_snapshot`. This runs for every
action in a list response, and touching the deferred snapshot here would
drag the entire prompt archive out of the database."""
wd = self.world_delta if isinstance(self.world_delta, dict) else None
if wd is None:
return []
applied = wd.get("applied") or []
clamped_paths = {
str(e.get("path", "")) for e in (wd.get("clamped") or []) if isinstance(e, dict)
}
out: list[dict] = []
for entry in applied:
parts = str(entry.get("path", "")).split(".")
for entry in wd.get("applied") or []:
path = str(entry.get("path", ""))
parts = path.split(".")
section, name = parts[0], parts[-1]
if section == "flags":
out.append({"kind": "flag", "label": name, "on": bool(entry.get("new"))})
elif section == "milestones":
out.append({"kind": "milestone", "label": name})
else:
label = f"{parts[1]} {parts[2]}" if section == "npc" and len(parts) == 3 else name
old, new = entry.get("old"), entry.get("new")
delta = new - old if isinstance(old, (int, float)) and isinstance(new, (int, float)) else None
out.append({"kind": "stat", "label": label, "delta": delta, "value": new})
chip = {
"kind": "stat",
"label": _change_label(parts),
"delta": delta,
"value": new,
"clamped": path in clamped_paths,
}
# Carried only when the engine wrote one. It is empty for every
# accepted change, and a key per chip per action is paid on
# every page load.
if entry.get("fix"):
chip["fix"] = str(entry["fix"])
out.append(chip)
for entry in wd.get("rejected") or []:
if not isinstance(entry, dict):
continue
parts = str(entry.get("path", "")).split(".")
chip = {
"kind": "rejected",
"label": _change_label(parts),
"reason": str(entry.get("reason", "")),
}
if entry.get("fix"):
chip["fix"] = str(entry["fix"])
out.append(chip)
return out