Name every stat in the guide by the path the AI has to write
The referee refused `npc.<id>.status` on a character that has a status stat under another name, and the refusal was right: the model was reaching for a path the guide never gave it. The guide is the fixed list of everything a scenario tracks, and it named stats in prose. A player stat and a world stat both read as a bare name, and an NPC's stats read as the display name plus the stat — "Trainer Milo active_status". The model had to turn that back into `npc.milo.active_status` itself, and in the Pokemon demo five other characters carry a stat called `status`, so the path it built was `npc.milo.status`. The live values do state the paths, but only for the NPCs a scene has mentioned, so an NPC off screen was addressable only by guesswork. Each line now leads with the path: `player.potions`, `npc.milo.active_status`, `flags.sandstorm_active`. An NPC's header is written even when it has no description, because it is the one line that ties a display name to its id. `EMIT_RULE` points at the guide for paths rather than at the live values alone. A free-text stat is marked `(free text)` beside its path, which is the wording `EMIT_RULE` already used to describe it — the guide had been writing "free text" at the end of the line instead. Such a stat now also gets a line when it has no description, where before it was dropped and the model was left to send a number for a stat that holds a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017imUKVPhqophVUZZwJK7ST
This commit is contained in:
@@ -33,8 +33,9 @@ EMIT_RULE = (
|
|||||||
"while a large change — or reaching a stat's minimum or maximum — is reserved for a "
|
"while a large change — or reaching a stat's minimum or maximum — is reserved for a "
|
||||||
"genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a "
|
"genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a "
|
||||||
"lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of "
|
"lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of "
|
||||||
'its range in a single ordinary turn. Use the paths exactly as shown in the world state: '
|
'its range in a single ordinary turn. Every stat is listed by its exact path in the '
|
||||||
'"player.<stat>", '
|
'stat guide and again beside its live value — copy a path from there rather than '
|
||||||
|
'building one out of a name. The shapes are "player.<stat>", '
|
||||||
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
|
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
|
||||||
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
|
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
|
||||||
'"milestones.<id>": true when an objective is completed. Some stats marked (free text) in '
|
'"milestones.<id>": true when an objective is completed. Some stats marked (free text) in '
|
||||||
@@ -854,20 +855,31 @@ def render_state_section(world_state: dict, stat_schema: dict,
|
|||||||
|
|
||||||
|
|
||||||
def _describe_stat(name: str, d: dict) -> str | None:
|
def _describe_stat(name: str, d: dict) -> str | None:
|
||||||
"""Returns one reference line for a stat.
|
"""Returns one reference line for a stat, named by the path the AI writes.
|
||||||
|
|
||||||
|
`name` is that path. The guide is the model's only complete list of what
|
||||||
|
exists, so a stat named any other way leaves it to work the path out from
|
||||||
|
the live values, and those cover only what is on screen this turn.
|
||||||
|
|
||||||
The description and the band ladder are independent, and each is included
|
The description and the band ladder are independent, and each is included
|
||||||
only when present, so a stat may have either, both, or neither.
|
only when present, so a stat may have either, both, or neither.
|
||||||
"""
|
"""
|
||||||
|
is_text = d.get("type") == "text"
|
||||||
|
# `EMIT_RULE` sends the model here to find out which stats take a whole
|
||||||
|
# value instead of a change, and it names this marker, so the two have to
|
||||||
|
# be written the same way.
|
||||||
|
label = f"{name} (free text)" if is_text else name
|
||||||
bits: list[str] = []
|
bits: list[str] = []
|
||||||
desc = d.get("desc")
|
desc = d.get("desc")
|
||||||
if isinstance(desc, str) and desc.strip():
|
if isinstance(desc, str) and desc.strip():
|
||||||
# Fragments are joined with "; " and end with a single ".", so remove
|
# Fragments are joined with "; " and end with a single ".", so remove
|
||||||
# any trailing period the author put on the description.
|
# any trailing period the author put on the description.
|
||||||
bits.append(desc.strip().rstrip("."))
|
bits.append(desc.strip().rstrip("."))
|
||||||
if d.get("type") == "text":
|
if is_text:
|
||||||
bits.append("free text")
|
# Free text has no range and no bands, so the marker is the whole
|
||||||
return f"{name} — {'; '.join(bits)}." if bits else None
|
# entry. It still earns a line without a description, because the
|
||||||
|
# marker is what stops the model sending a delta.
|
||||||
|
return f"{label} — {'; '.join(bits)}." if bits else f"{label}."
|
||||||
lo, hi = d.get("min"), d.get("max")
|
lo, hi = d.get("min"), d.get("max")
|
||||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||||
bits.append(f"range {lo}–{hi}")
|
bits.append(f"range {lo}–{hi}")
|
||||||
@@ -879,7 +891,7 @@ def _describe_stat(name: str, d: dict) -> str | None:
|
|||||||
)
|
)
|
||||||
if ladder:
|
if ladder:
|
||||||
bits.append(f"bands: {ladder}")
|
bits.append(f"bands: {ladder}")
|
||||||
return f"{name} — {'; '.join(bits)}." if bits else None
|
return f"{label} — {'; '.join(bits)}." if bits else None
|
||||||
|
|
||||||
|
|
||||||
def render_reference(stat_schema: dict) -> str:
|
def render_reference(stat_schema: dict) -> str:
|
||||||
@@ -893,7 +905,7 @@ def render_reference(stat_schema: dict) -> str:
|
|||||||
for section in STAT_SECTIONS:
|
for section in STAT_SECTIONS:
|
||||||
for name, d in (stat_schema.get(section) or {}).items():
|
for name, d in (stat_schema.get(section) or {}).items():
|
||||||
if isinstance(d, dict):
|
if isinstance(d, dict):
|
||||||
row = _describe_stat(name, d)
|
row = _describe_stat(f"{section}.{name}", d)
|
||||||
if row:
|
if row:
|
||||||
lines.append(row)
|
lines.append(row)
|
||||||
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
||||||
@@ -901,18 +913,27 @@ def render_reference(stat_schema: dict) -> str:
|
|||||||
continue
|
continue
|
||||||
name = npc_name(ndef, npc_key)
|
name = npc_name(ndef, npc_key)
|
||||||
desc = ndef.get("desc")
|
desc = ndef.get("desc")
|
||||||
if isinstance(desc, str) and desc.strip():
|
# The header is written even for an NPC with no description, because it
|
||||||
lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.")
|
# is the only line that ties a display name to the id the AI has to
|
||||||
|
# address. The live values state it too, but only for the NPCs a scene
|
||||||
|
# has mentioned, so without this an NPC off screen can only be guessed
|
||||||
|
# at — and a guess is refused as a character or a stat that does not
|
||||||
|
# exist.
|
||||||
|
head = f"NPC {name} (npc.{npc_key})"
|
||||||
|
lines.append(
|
||||||
|
f"{head} — {desc.strip().rstrip('.')}."
|
||||||
|
if isinstance(desc, str) and desc.strip() else f"{head}."
|
||||||
|
)
|
||||||
for sname, sdef in (ndef.get("stats") or {}).items():
|
for sname, sdef in (ndef.get("stats") or {}).items():
|
||||||
if isinstance(sdef, dict):
|
if isinstance(sdef, dict):
|
||||||
row = _describe_stat(f"{name} {sname}", sdef)
|
row = _describe_stat(f"npc.{npc_key}.{sname}", sdef)
|
||||||
if row:
|
if row:
|
||||||
lines.append(row)
|
lines.append(row)
|
||||||
for name, d in (stat_schema.get("flags") or {}).items():
|
for name, d in (stat_schema.get("flags") or {}).items():
|
||||||
if isinstance(d, dict):
|
if isinstance(d, dict):
|
||||||
desc = d.get("desc")
|
desc = d.get("desc")
|
||||||
if isinstance(desc, str) and desc.strip():
|
if isinstance(desc, str) and desc.strip():
|
||||||
lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.")
|
lines.append(f"flags.{name} — {desc.strip().rstrip('.')}.")
|
||||||
if not lines:
|
if not lines:
|
||||||
return ""
|
return ""
|
||||||
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
|
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
|
||||||
|
|||||||
@@ -209,10 +209,48 @@ def test_reference_includes_desc_and_bands_independently():
|
|||||||
assert "very weak" in guide and "range 0–100" in guide
|
assert "very weak" in guide and "range 0–100" in guide
|
||||||
# A counter like day has no desc or bands, so it contributes nothing
|
# A counter like day has no desc or bands, so it contributes nothing
|
||||||
# here. Flags do show their desc.
|
# here. Flags do show their desc.
|
||||||
assert "has_key (flag) — Holds the key." in guide
|
assert "flags.has_key — Holds the key." in guide
|
||||||
# NPCs contribute their own description and per-NPC stat lines.
|
# NPCs contribute their own description and per-NPC stat lines.
|
||||||
assert "NPC Gwen (gwen) — A loyal ranger." in guide
|
assert "NPC Gwen (npc.gwen) — A loyal ranger." in guide
|
||||||
assert "Gwen trust" in guide and "The Drake ferocity" in guide
|
assert "npc.gwen.trust" in guide and "npc.drake.ferocity" in guide
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_names_every_stat_by_its_path():
|
||||||
|
"""The guide is the model's only complete list of what exists, so it has
|
||||||
|
to name each stat the way a state block must name it. Naming a stat after
|
||||||
|
its owner's display name ("Trainer Milo active_status") left the model to
|
||||||
|
build the path itself, and the paths it built were refused as stats and
|
||||||
|
characters that do not exist."""
|
||||||
|
guide = w.render_reference(SCHEMA)
|
||||||
|
assert "player.hp" in guide
|
||||||
|
assert "player.outfit" in guide
|
||||||
|
# The old wording, which read as prose rather than as an address.
|
||||||
|
assert "Gwen trust" not in guide
|
||||||
|
assert "The Drake ferocity" not in guide
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_names_an_npc_that_has_no_description():
|
||||||
|
"""The live values name an NPC only while a scene mentions them, so the
|
||||||
|
guide is the only place an off-screen NPC's id is stated. The Drake has no
|
||||||
|
`desc`, and used to reach the model as "The Drake ferocity" alone."""
|
||||||
|
guide = w.render_reference(SCHEMA)
|
||||||
|
assert "NPC The Drake (npc.drake)." in guide
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_marks_free_text_stats_the_way_the_emit_rule_names_them():
|
||||||
|
"""`EMIT_RULE` tells the model that a stat "marked (free text) in the stat
|
||||||
|
guide" takes a whole value rather than a delta, so the guide has to carry
|
||||||
|
that marker literally."""
|
||||||
|
assert "(free text)" in w.EMIT_RULE
|
||||||
|
guide = w.render_reference(SCHEMA)
|
||||||
|
assert "player.outfit (free text) — What the player is wearing." in guide
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_lists_a_free_text_stat_with_no_description():
|
||||||
|
"""The marker alone is the entry. Dropping the line would leave the model
|
||||||
|
sending a number for a stat that holds a string."""
|
||||||
|
schema = {"player": {"holding": {"type": "text", "initial": ""}}}
|
||||||
|
assert "player.holding (free text)." in w.render_reference(schema)
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_paths_rejected_not_fatal():
|
def test_unknown_paths_rejected_not_fatal():
|
||||||
|
|||||||
Reference in New Issue
Block a user