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:
committed by
Parth
co-authored by
Claude Opus 5
parent
2ca2dadc72
commit
47c7800903
@@ -147,6 +147,12 @@ def _history_text(action: models.Action) -> str:
|
||||
and stop emitting state itself. Player turns and turns with no block pass
|
||||
through unchanged.
|
||||
|
||||
The block replays the changes the engine ACCEPTED, not the ones the model
|
||||
sent. Replaying what was sent showed the model a refused change standing as
|
||||
though it had been applied, while the live values in the same prompt
|
||||
disagreed with it. Nothing marked which of the two was true, so the model
|
||||
read its own refused change as correct and sent it again.
|
||||
|
||||
This function reads `world_delta` rather than `context_snapshot`. It runs
|
||||
for every action in the replayed history, and `context_snapshot` is deferred
|
||||
so that a turn never loads the prompt archive from the database.
|
||||
@@ -154,7 +160,7 @@ def _history_text(action: models.Action) -> str:
|
||||
text = action.text
|
||||
wd = action.world_delta if isinstance(action.world_delta, dict) else None
|
||||
if wd:
|
||||
block = worldstate.render_delta_block(wd.get("delta") or {})
|
||||
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
|
||||
if block:
|
||||
text = f"{text}\n{block}"
|
||||
return text
|
||||
@@ -255,15 +261,21 @@ def build_context(
|
||||
lines_text = "\n".join(f"- {m['text']}" for m in memory_bank["used"])
|
||||
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
|
||||
world_state_section = None
|
||||
refusal_note = ""
|
||||
if has_ws:
|
||||
# One read serves both the in-scene NPCs and the refusal note below.
|
||||
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
|
||||
block = worldstate.render_state_section(
|
||||
adventure.world_state, stat_schema,
|
||||
_visible_npcs(
|
||||
history.tail(adventure, NPC_WINDOW, exclude_action_id), stat_schema
|
||||
),
|
||||
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
|
||||
)
|
||||
if block:
|
||||
world_state_section = Section("world_state", block)
|
||||
# Corrections for the previous AI turn only. A refusal the model has
|
||||
# already had one chance to fix is stale, and repeating it every turn
|
||||
# would price a correction into the whole rest of the adventure.
|
||||
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
|
||||
if last_ai is not None:
|
||||
refusal_note = worldstate.render_refusals(last_ai.world_delta)
|
||||
|
||||
authors_note_text = adventure.authors_note.strip()
|
||||
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
|
||||
@@ -290,6 +302,7 @@ def build_context(
|
||||
+ count_tokens(front_memory)
|
||||
+ count_tokens(length_note)
|
||||
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
|
||||
+ count_tokens(refusal_note)
|
||||
)
|
||||
available = max(256, settings.context_token_budget - reserved)
|
||||
|
||||
@@ -379,6 +392,10 @@ def build_context(
|
||||
# the model acts.
|
||||
note_sections.append(Section("length_hint", length_note))
|
||||
if has_ws:
|
||||
# A correction for the previous turn sits directly above the reminder
|
||||
# to emit a block, which is the instruction it modifies.
|
||||
if refusal_note:
|
||||
note_sections.append(Section("world_state_refusals", refusal_note))
|
||||
# The emit rule sits in the system block, far from where the model
|
||||
# generates text, so repeat it last where it has the most effect.
|
||||
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
|
||||
|
||||
+50
-5
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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- Drop the active Pokémon's HP on both sides when they take damage (`npc.<name>.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.<name>.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, and decrement `npc.milo.pokemon_left` when one of his faints.\n\nMark milestones as they happen. 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:\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.<name>.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.<name>.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.",
|
||||
"tags": "demo, pokemon, battle, tournament, world-state, combat",
|
||||
"icon": "⚡",
|
||||
"stat_schema": {
|
||||
@@ -125,10 +125,11 @@
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "The status condition on Milo's active Pokémon: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
},
|
||||
"pokemon_left": {
|
||||
"desc": "How many of Milo's three Pokémon are still able to battle. Milo loses the round when this reaches 0.",
|
||||
"min": 0, "max": 3, "initial": 3, "max_delta_per_turn": 1,
|
||||
"bands": [[0, 1, "defeated"], [1, 2, "last Pokémon"], [2, 3, "two left"], [3, 4, "full team"]]
|
||||
"pokemon_fainted": {
|
||||
"desc": "How many of Milo's three Pokémon have fainted. Add 1 each time one goes down. Milo loses the round when this reaches 3.",
|
||||
"type": "counter",
|
||||
"min": 0, "max": 3, "initial": 0, "max_delta_per_turn": 1,
|
||||
"bands": [[0, 1, "full team"], [1, 2, "two left"], [2, 3, "last Pokémon"], [3, 4, "defeated"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ them against a scenario's `stat_schema`.
|
||||
from .engine import (
|
||||
EMIT_REMINDER,
|
||||
EMIT_RULE,
|
||||
applied_delta,
|
||||
apply_delta,
|
||||
apply_override,
|
||||
band_label,
|
||||
@@ -16,14 +17,17 @@ from .engine import (
|
||||
npc_name,
|
||||
npc_triggers,
|
||||
reconcile,
|
||||
refusals,
|
||||
render_delta_block,
|
||||
render_reference,
|
||||
render_refusals,
|
||||
render_state_section,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EMIT_REMINDER",
|
||||
"EMIT_RULE",
|
||||
"applied_delta",
|
||||
"apply_delta",
|
||||
"apply_override",
|
||||
"band_label",
|
||||
@@ -33,7 +37,9 @@ __all__ = [
|
||||
"npc_name",
|
||||
"npc_triggers",
|
||||
"reconcile",
|
||||
"refusals",
|
||||
"render_delta_block",
|
||||
"render_reference",
|
||||
"render_refusals",
|
||||
"render_state_section",
|
||||
]
|
||||
|
||||
@@ -66,6 +66,68 @@ def render_delta_block(delta: dict) -> str:
|
||||
return ""
|
||||
return "```state\n" + json.dumps(delta, ensure_ascii=False) + "\n```"
|
||||
|
||||
|
||||
def applied_delta(world_delta: dict | None) -> dict:
|
||||
"""Returns the changes the engine accepted, shaped as the AI sends them.
|
||||
|
||||
Replaying the delta the AI sent would show it a refused change standing as
|
||||
though it had been applied, contradicted by the live values in the same
|
||||
prompt. The model has no way to read that as a correction, so it repeats
|
||||
the change. Replaying what was accepted removes the contradiction.
|
||||
|
||||
A numeric change that ended where it started is omitted, because it moved
|
||||
nothing and a zero in the replayed block reads as a value worth sending.
|
||||
"""
|
||||
if not isinstance(world_delta, dict):
|
||||
return {}
|
||||
out: dict = {}
|
||||
for entry in world_delta.get("applied") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
path = str(entry.get("path", ""))
|
||||
old, new = entry.get("old"), entry.get("new")
|
||||
if path.startswith("milestones."):
|
||||
out[path] = True
|
||||
elif path.startswith("flags."):
|
||||
out[path] = bool(new)
|
||||
elif isinstance(old, (int, float)) and isinstance(new, (int, float)):
|
||||
if new != old:
|
||||
out[path] = new - old
|
||||
else:
|
||||
out[path] = new
|
||||
return out
|
||||
|
||||
|
||||
def refusals(world_delta: dict | None) -> list[str]:
|
||||
"""Returns a correction line for each change the engine did not carry out.
|
||||
|
||||
Covers the changes that were lost: a rejection, and a clamp that left the
|
||||
value where it started. A clamp that reduced a change but still moved the
|
||||
value is deliberately absent. The model's intent landed in that case, and
|
||||
reporting the shortfall invites it to send the remainder on the next turn,
|
||||
which is the swing `max_delta_per_turn` exists to prevent.
|
||||
"""
|
||||
if not isinstance(world_delta, dict):
|
||||
return []
|
||||
lines = []
|
||||
for entry in world_delta.get("rejected") or []:
|
||||
if isinstance(entry, dict) and entry.get("fix"):
|
||||
lines.append(str(entry["fix"]))
|
||||
for entry in world_delta.get("clamped") or []:
|
||||
if isinstance(entry, dict) and entry.get("fix"):
|
||||
lines.append(str(entry["fix"]))
|
||||
return lines
|
||||
|
||||
|
||||
def render_refusals(world_delta: dict | None) -> str:
|
||||
"""Renders `refusals` as the note appended after the most recent AI turn."""
|
||||
lines = refusals(world_delta)
|
||||
if not lines:
|
||||
return ""
|
||||
body = "\n".join(f"- {ln}" for ln in lines)
|
||||
return ("[Part of your last state block was not applied. Correct it in this "
|
||||
f"turn's block:\n{body}]")
|
||||
|
||||
# ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL.
|
||||
_FENCE_RE = re.compile(r"```(?:state|json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE)
|
||||
# Fallback: a bare JSON object hugging the end of the text.
|
||||
@@ -316,21 +378,64 @@ def _coerce_number(value):
|
||||
return None
|
||||
|
||||
|
||||
def _names_phrase(kind: str, defs: dict, limit: int = 12) -> str:
|
||||
"""Lists what the model could have written instead, for a wrong name.
|
||||
|
||||
A rejection that only says a name is unknown leaves the model guessing
|
||||
again. Naming the alternatives turns it into a correction it can act on.
|
||||
"""
|
||||
names = [k for k in (defs or {}) if isinstance(k, str)]
|
||||
if not names:
|
||||
return f"This scenario tracks no {kind}."
|
||||
shown = ", ".join(f"`{n}`" for n in names[:limit])
|
||||
more = f", and {len(names) - limit} more" if len(names) > limit else ""
|
||||
plural = f"{kind}s" if not kind.endswith("s") else kind
|
||||
return f"The {plural} are: {shown}{more}."
|
||||
|
||||
|
||||
def _limits_phrase(stat_def: dict) -> str:
|
||||
"""Names a stat's numeric limits, for a correction sent back to the model."""
|
||||
lo, hi = stat_def.get("min"), stat_def.get("max")
|
||||
cap = stat_def.get("max_delta_per_turn")
|
||||
bits = []
|
||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||
bits.append(f"it runs from {lo} to {hi}")
|
||||
elif isinstance(lo, (int, float)):
|
||||
bits.append(f"it never goes below {lo}")
|
||||
elif isinstance(hi, (int, float)):
|
||||
bits.append(f"it never goes above {hi}")
|
||||
if isinstance(cap, (int, float)):
|
||||
bits.append(f"it moves at most {cap} per turn")
|
||||
return "; ".join(bits)
|
||||
|
||||
|
||||
def _apply_stat(container: dict, key: str, stat_def: dict, change,
|
||||
path: str, action_index: int, meta: dict, report: dict) -> None:
|
||||
delta = _coerce_number(change)
|
||||
if delta is None:
|
||||
report["rejected"].append({"path": path, "reason": "not a number"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a number",
|
||||
"fix": f"`{path}` takes a number, written as a change such as -5 or 8.",
|
||||
})
|
||||
return
|
||||
|
||||
cooldown = stat_def.get("cooldown") or 0
|
||||
last = meta["last_changed"].get(path)
|
||||
if cooldown and last is not None and action_index - last < cooldown:
|
||||
report["rejected"].append({"path": path, "reason": "cooldown"})
|
||||
waited = action_index - last
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "cooldown",
|
||||
"fix": f"`{path}` changed {waited} turn(s) ago and cannot change again "
|
||||
f"until {cooldown} turns have passed.",
|
||||
})
|
||||
return
|
||||
|
||||
if stat_def.get("type") == "counter" and delta < 0:
|
||||
report["rejected"].append({"path": path, "reason": "counter can't decrease"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "counter can't decrease",
|
||||
"fix": f"`{path}` only counts up. Send a positive change such as 1, "
|
||||
f"never a negative and never the running total.",
|
||||
})
|
||||
return
|
||||
|
||||
clamped = False
|
||||
@@ -353,6 +458,15 @@ def _apply_stat(container: dict, key: str, stat_def: dict, change,
|
||||
container[key] = new
|
||||
meta["last_changed"][path] = action_index
|
||||
entry = {"path": path, "old": old, "new": new}
|
||||
if clamped and new == old:
|
||||
# The clamp cancelled the change. Nothing moved, so the model needs the
|
||||
# same correction a rejection gets: without it the only evidence is a
|
||||
# value that stayed put, which reads as the change never being asked for.
|
||||
edge = "maximum" if hi is not None and new == hi else "minimum"
|
||||
entry["fix"] = (
|
||||
f"`{path}` did not move. It is already at its {edge} of {new}"
|
||||
+ (f" ({_limits_phrase(stat_def)})." if _limits_phrase(stat_def) else ".")
|
||||
)
|
||||
report["applied"].append(entry)
|
||||
if clamped:
|
||||
report["clamped"].append(entry)
|
||||
@@ -367,13 +481,22 @@ def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
truncation apply.
|
||||
"""
|
||||
if not isinstance(change, str):
|
||||
report["rejected"].append({"path": path, "reason": "not a string"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a string",
|
||||
"fix": f"`{path}` holds text. Send its new value in full, not a number "
|
||||
f"and not a change.",
|
||||
})
|
||||
return
|
||||
|
||||
cooldown = stat_def.get("cooldown") or 0
|
||||
last = meta["last_changed"].get(path)
|
||||
if cooldown and last is not None and action_index - last < cooldown:
|
||||
report["rejected"].append({"path": path, "reason": "cooldown"})
|
||||
waited = action_index - last
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "cooldown",
|
||||
"fix": f"`{path}` changed {waited} turn(s) ago and cannot change again "
|
||||
f"until {cooldown} turns have passed.",
|
||||
})
|
||||
return
|
||||
|
||||
new = change.strip()
|
||||
@@ -481,7 +604,11 @@ def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tup
|
||||
if parts[0] in STAT_SECTIONS and len(parts) == 2:
|
||||
stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1])
|
||||
if not isinstance(stat_def, dict):
|
||||
report["rejected"].append({"path": path, "reason": "unknown stat"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown stat",
|
||||
"fix": f"`{parts[0]}` has no stat `{parts[1]}`. "
|
||||
f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}",
|
||||
})
|
||||
continue
|
||||
container = ws.setdefault(parts[0], {})
|
||||
set_stat(container, parts[1], stat_def, value, path)
|
||||
@@ -490,19 +617,31 @@ def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tup
|
||||
if parts[0] == "npc" and len(parts) == 3:
|
||||
ndef = npcs.get(parts[1])
|
||||
if not isinstance(ndef, dict):
|
||||
report["rejected"].append({"path": path, "reason": "unknown npc"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown npc",
|
||||
"fix": f"There is no character `{parts[1]}`. "
|
||||
f"{_names_phrase('character', npcs)}",
|
||||
})
|
||||
continue
|
||||
stat_defs = ndef.get("stats") or {}
|
||||
stat_def = stat_defs.get(parts[2])
|
||||
if not isinstance(stat_def, dict):
|
||||
report["rejected"].append({"path": path, "reason": "unknown npc stat"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown npc stat",
|
||||
"fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. "
|
||||
f"{_names_phrase('stat', stat_defs)}",
|
||||
})
|
||||
continue
|
||||
npc_state = ws.setdefault("npc", {})
|
||||
container = npc_state.setdefault(parts[1], _initials(stat_defs))
|
||||
set_stat(container, parts[2], stat_def, value, path)
|
||||
continue
|
||||
|
||||
report["rejected"].append({"path": path, "reason": "unknown path"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown path",
|
||||
"fix": f"`{path}` is not a tracked value. Use player.<stat>, "
|
||||
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
|
||||
})
|
||||
|
||||
return ws, report
|
||||
|
||||
@@ -536,10 +675,16 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
if parts[0] == "flags" and len(parts) == 2:
|
||||
fid = parts[1]
|
||||
if fid not in flag_defs:
|
||||
report["rejected"].append({"path": path, "reason": "unknown flag"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown flag",
|
||||
"fix": f"There is no flag `{fid}`. {_names_phrase('flag', flag_defs)}",
|
||||
})
|
||||
continue
|
||||
if not isinstance(change, bool):
|
||||
report["rejected"].append({"path": path, "reason": "not a boolean"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a boolean",
|
||||
"fix": f"`{path}` takes true or false.",
|
||||
})
|
||||
continue
|
||||
flags = ws.setdefault("flags", {})
|
||||
old = bool(flags.get(fid, False))
|
||||
@@ -552,10 +697,18 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
if parts[0] == "milestones" and len(parts) == 2:
|
||||
mid = parts[1]
|
||||
if mid not in milestones:
|
||||
report["rejected"].append({"path": path, "reason": "unknown milestone"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown milestone",
|
||||
"fix": f"There is no milestone `{mid}`. "
|
||||
f"{_names_phrase('milestone', milestones)}",
|
||||
})
|
||||
continue
|
||||
if change is not True:
|
||||
report["rejected"].append({"path": path, "reason": "not true"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not true",
|
||||
"fix": f"`{path}` can only be set to true. A milestone is "
|
||||
f"reached once and never taken back.",
|
||||
})
|
||||
continue
|
||||
reached = ws.setdefault("milestones", {})
|
||||
if reached.get(mid, {}).get("reached"):
|
||||
@@ -568,7 +721,11 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
if parts[0] in STAT_SECTIONS and len(parts) == 2:
|
||||
stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1])
|
||||
if not isinstance(stat_def, dict):
|
||||
report["rejected"].append({"path": path, "reason": "unknown stat"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown stat",
|
||||
"fix": f"`{parts[0]}` has no stat `{parts[1]}`. "
|
||||
f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}",
|
||||
})
|
||||
continue
|
||||
container = ws.setdefault(parts[0], {})
|
||||
if stat_def.get("type") == "text":
|
||||
@@ -583,12 +740,20 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
if parts[0] == "npc" and len(parts) == 3:
|
||||
ndef = npcs.get(parts[1])
|
||||
if not isinstance(ndef, dict):
|
||||
report["rejected"].append({"path": path, "reason": "unknown npc"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown npc",
|
||||
"fix": f"There is no character `{parts[1]}`. "
|
||||
f"{_names_phrase('character', npcs)}",
|
||||
})
|
||||
continue
|
||||
stat_defs = ndef.get("stats") or {}
|
||||
stat_def = stat_defs.get(parts[2])
|
||||
if not isinstance(stat_def, dict):
|
||||
report["rejected"].append({"path": path, "reason": "unknown npc stat"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown npc stat",
|
||||
"fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. "
|
||||
f"{_names_phrase('stat', stat_defs)}",
|
||||
})
|
||||
continue
|
||||
npc_state = ws.setdefault("npc", {})
|
||||
container = npc_state.setdefault(parts[1], _initials(stat_defs))
|
||||
@@ -600,7 +765,11 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
action_index, meta, report)
|
||||
continue
|
||||
|
||||
report["rejected"].append({"path": path, "reason": "unknown path"})
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "unknown path",
|
||||
"fix": f"`{path}` is not a tracked value. Use player.<stat>, "
|
||||
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
|
||||
})
|
||||
|
||||
return ws, report
|
||||
|
||||
@@ -666,14 +835,18 @@ def render_state_section(world_state: dict, stat_schema: dict,
|
||||
if flag_parts:
|
||||
lines.append("Flags: " + ", ".join(flag_parts) + ".")
|
||||
|
||||
# Show the id beside each goal, the same as NPCs and flags. The AI marks a
|
||||
# milestone as `milestones.<id>`, and `apply_delta` rejects an id the schema
|
||||
# does not define, so a goal listed by description alone gives the model no
|
||||
# way to name it and it can only guess.
|
||||
milestones = stat_schema.get("milestones") or {}
|
||||
reached = ws.get("milestones") or {}
|
||||
goals = [d.get("desc", mid) for mid, d in milestones.items()
|
||||
goals = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items()
|
||||
if not reached.get(mid, {}).get("reached")]
|
||||
done = [d.get("desc", mid) for mid, d in milestones.items()
|
||||
done = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items()
|
||||
if reached.get(mid, {}).get("reached")]
|
||||
if goals:
|
||||
lines.append("Goals: " + "; ".join(goals) + ".")
|
||||
lines.append("Goals (mark with milestones.<id>): " + "; ".join(goals) + ".")
|
||||
if done:
|
||||
lines.append("Achieved: " + "; ".join(done) + ".")
|
||||
|
||||
|
||||
@@ -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) == ""
|
||||
@@ -166,7 +166,7 @@ def test_world_changes_still_works_without_the_snapshot(client):
|
||||
ai = [a for a in r.json()["actions"] if a["type"] == "ai"]
|
||||
assert ai, "fixture should have AI actions"
|
||||
assert ai[0]["world_changes"] == [
|
||||
{"kind": "stat", "label": "hp", "delta": -15, "value": 85}
|
||||
{"kind": "stat", "label": "hp", "delta": -15, "value": 85, "clamped": False}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1174,6 +1174,16 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
||||
.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); }
|
||||
.chg-flag { color: var(--accent-bright); }
|
||||
.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
|
||||
/* A refused change, and a clamp that left the value where it started. The
|
||||
dashed border separates it from the changes that took effect. It is not
|
||||
styled as an error, because the rules refusing a change is them working. */
|
||||
.chg-refused {
|
||||
border-style: dashed;
|
||||
color: var(--text-dim);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
|
||||
}
|
||||
/* A clamp that reduced a change but still moved the value. */
|
||||
.chg-limited { opacity: 0.75; font-style: italic; }
|
||||
.ws-report code { color: var(--accent-bright); }
|
||||
.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
|
||||
.ws-flag { color: var(--text-dim); font-style: italic; }
|
||||
|
||||
@@ -1205,7 +1205,27 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
|
||||
)
|
||||
}
|
||||
|
||||
// Reasons the engine gives for refusing a change, in the player's words.
|
||||
const REJECT_REASONS = {
|
||||
'not a number': 'expected a number',
|
||||
'not a string': 'expected text',
|
||||
'not a boolean': 'expected on or off',
|
||||
'not true': 'a milestone can only be set',
|
||||
"counter can't decrease": 'this only counts up',
|
||||
cooldown: 'changed too recently',
|
||||
'unknown stat': 'no such stat',
|
||||
'unknown npc': 'no such character',
|
||||
'unknown npc stat': 'no such stat',
|
||||
'unknown flag': 'no such flag',
|
||||
'unknown milestone': 'no such milestone',
|
||||
'unknown path': 'unrecognized name',
|
||||
}
|
||||
|
||||
// Compact chips shown under an AI message summarizing what state changed.
|
||||
//
|
||||
// Refused changes appear here too. A clamped stat is marked, and a stat whose
|
||||
// clamp left it exactly where it started reads "no change" rather than "+0",
|
||||
// which looked like an ordinary update.
|
||||
function StateChangeChips({ changes }) {
|
||||
if (!changes?.length) return null
|
||||
const nice = (s) => String(s).replace(/_/g, ' ')
|
||||
@@ -1218,12 +1238,32 @@ function StateChangeChips({ changes }) {
|
||||
if (c.kind === 'milestone') {
|
||||
return <span key={i} className="chg chg-ms">✓ {nice(c.label)}</span>
|
||||
}
|
||||
const d = c.delta
|
||||
const dir = typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat'
|
||||
const txt = typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}`
|
||||
if (c.kind === 'rejected') {
|
||||
const why = REJECT_REASONS[c.reason] || c.reason
|
||||
// The engine's own wording quotes the real limits, so prefer it.
|
||||
return (
|
||||
<span key={i} className={`chg chg-stat chg-${dir}`}>
|
||||
<span key={i} className="chg chg-refused" title={c.fix || `The story asked to change this and the rules refused: ${why}.`}>
|
||||
{nice(c.label)} <span className="chg-val">refused — {why}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const d = c.delta
|
||||
// A clamp that cancels the change entirely is its own outcome. It is
|
||||
// neither an update nor a refusal, and "+0" read as the former.
|
||||
const blocked = c.clamped && d === 0
|
||||
const dir = blocked ? 'refused' : typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat'
|
||||
const txt = blocked
|
||||
? 'no change — at its limit'
|
||||
: typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}`
|
||||
const title = blocked
|
||||
? c.fix || 'The story asked to change this and it is already at the limit the scenario allows.'
|
||||
: c.clamped
|
||||
? 'The scenario limits how far this can move in one turn, so the change was reduced.'
|
||||
: undefined
|
||||
return (
|
||||
<span key={i} className={`chg chg-stat chg-${dir}`} title={title}>
|
||||
{nice(c.label)} <span className="chg-val">{txt}</span>
|
||||
{c.clamped && !blocked ? <span className="chg-limited"> (limited)</span> : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user