Split the world-state engine into four modules
`backend/app/worldstate/engine.py` held 918 lines covering four separate jobs: reading a scenario's schema, parsing the block the model writes, applying a change within the schema's limits, and rendering state as prompt text. Each is now its own module, the largest 418 lines. An AST comparison against the old file confirms all 29 definitions are identical. No call site changes, because `worldstate/__init__.py` exports the same names it did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fa812c056
commit
8422ff24f6
@@ -0,0 +1,147 @@
|
||||
"""Turning world state into the text the model and the player read.
|
||||
|
||||
`render_state_section` writes the current state into the prompt.
|
||||
`render_reference` writes the schema itself, so the model knows which stats
|
||||
exist and what they are called.
|
||||
"""
|
||||
from .schema import STAT_SECTIONS, _initials, band_label, npc_name
|
||||
|
||||
|
||||
def _stat_line(defs: dict, values: dict) -> str:
|
||||
parts = []
|
||||
for name, d in defs.items():
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
if d.get("type") == "text":
|
||||
val = values.get(name, d.get("initial", ""))
|
||||
parts.append(f'{name} "{val}"' if val else f"{name} (unset)")
|
||||
continue
|
||||
val = values.get(name, d.get("initial", 0))
|
||||
hi = d.get("max")
|
||||
shown = f"{val}/{hi}" if hi is not None else f"{val}"
|
||||
label = band_label(d, val)
|
||||
parts.append(f"{name} {shown}" + (f" ({label})" if label else ""))
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def render_state_section(world_state: dict, stat_schema: dict,
|
||||
visible_npcs: dict[str, str]) -> str:
|
||||
"""Returns the compact context block that every turn includes.
|
||||
|
||||
`visible_npcs` maps a card id to a display name, for the NPCs currently in
|
||||
the scene.
|
||||
"""
|
||||
ws = world_state if isinstance(world_state, dict) else {}
|
||||
lines: list[str] = []
|
||||
|
||||
world_defs = stat_schema.get("world") or {}
|
||||
world_line = _stat_line(world_defs, ws.get("world") or {})
|
||||
header = "World state" + (f" — {world_line}." if world_line else ".")
|
||||
lines.append(header)
|
||||
|
||||
player_defs = stat_schema.get("player") or {}
|
||||
player_line = _stat_line(player_defs, ws.get("player") or {})
|
||||
if player_line:
|
||||
lines.append(f"You: {player_line}.")
|
||||
|
||||
npcs = stat_schema.get("npcs") or {}
|
||||
npc_state = ws.get("npc") or {}
|
||||
for npc_key, name in visible_npcs.items():
|
||||
ndef = npcs.get(npc_key) or {}
|
||||
stat_defs = ndef.get("stats") or {}
|
||||
values = npc_state.get(npc_key) or _initials(stat_defs)
|
||||
npc_line = _stat_line(stat_defs, values)
|
||||
if npc_line:
|
||||
# Show the id so the AI can address it as npc.<id>.<stat>.
|
||||
lines.append(f"{name} (npc.{npc_key}): {npc_line}.")
|
||||
|
||||
flag_defs = stat_schema.get("flags") or {}
|
||||
flag_state = ws.get("flags") or {}
|
||||
flag_parts = [
|
||||
f"{name} {'yes' if flag_state.get(name, bool(d.get('initial', False))) else 'no'}"
|
||||
for name, d in flag_defs.items() if isinstance(d, 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 = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items()
|
||||
if not reached.get(mid, {}).get("reached")]
|
||||
done = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items()
|
||||
if reached.get(mid, {}).get("reached")]
|
||||
if goals:
|
||||
lines.append("Goals (mark with milestones.<id>): " + "; ".join(goals) + ".")
|
||||
if done:
|
||||
lines.append("Achieved: " + "; ".join(done) + ".")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _describe_stat(name: str, d: dict) -> str | None:
|
||||
"""Returns one reference line for a stat.
|
||||
|
||||
The description and the band ladder are independent, and each is included
|
||||
only when present, so a stat may have either, both, or neither.
|
||||
"""
|
||||
bits: list[str] = []
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
# Fragments are joined with "; " and end with a single ".", so remove
|
||||
# any trailing period the author put on the description.
|
||||
bits.append(desc.strip().rstrip("."))
|
||||
if d.get("type") == "text":
|
||||
bits.append("free text")
|
||||
return f"{name} — {'; '.join(bits)}." if bits else None
|
||||
lo, hi = d.get("min"), d.get("max")
|
||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||
bits.append(f"range {lo}–{hi}")
|
||||
bands = d.get("bands")
|
||||
if isinstance(bands, list) and bands:
|
||||
ladder = ", ".join(
|
||||
f"{b[0]}–{b[1]} {b[2]}"
|
||||
for b in bands if isinstance(b, list) and len(b) == 3
|
||||
)
|
||||
if ladder:
|
||||
bits.append(f"bands: {ladder}")
|
||||
return f"{name} — {'; '.join(bits)}." if bits else None
|
||||
|
||||
|
||||
def render_reference(stat_schema: dict) -> str:
|
||||
"""Returns a fixed, per-scenario legend for the stats.
|
||||
|
||||
Each line gives what a stat means, from its `desc`, and its band ladder. The
|
||||
legend does not change from turn to turn, and it is separate from the live
|
||||
values.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for section in STAT_SECTIONS:
|
||||
for name, d in (stat_schema.get(section) or {}).items():
|
||||
if isinstance(d, dict):
|
||||
row = _describe_stat(name, d)
|
||||
if row:
|
||||
lines.append(row)
|
||||
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
||||
if not isinstance(ndef, dict):
|
||||
continue
|
||||
name = npc_name(ndef, npc_key)
|
||||
desc = ndef.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.")
|
||||
for sname, sdef in (ndef.get("stats") or {}).items():
|
||||
if isinstance(sdef, dict):
|
||||
row = _describe_stat(f"{name} {sname}", sdef)
|
||||
if row:
|
||||
lines.append(row)
|
||||
for name, d in (stat_schema.get("flags") or {}).items():
|
||||
if isinstance(d, dict):
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.")
|
||||
if not lines:
|
||||
return ""
|
||||
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
|
||||
Reference in New Issue
Block a user