Files
interactive-story/backend/app/worldstate/render.py
T
parththakkar106andClaude Opus 5 8422ff24f6 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
2026-08-29 01:09:41 +05:30

148 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)