Add Phase 12: native RPG world state

Structured world/player/NPC stats, two-way flags, and sticky milestones
per scenario (stat_schema). The AI proposes a per-turn delta; a Python
engine referees it (clamp to min/max, per-turn cap, cooldown, counters).
Band word-labels plus a fixed stat guide (descriptions + full ranges)
keep the model grounded. World State drawer + Insights delta report;
undo/retry roll it back via the Phase 11 snapshot pattern.

- migrations 26-28 (scenarios.stat_schema, adventures.world_state,
  actions.world_state_before); all nullable, additive, safe on existing rows
- migration 29 raises the default context budget 4096 -> 16384
  (custom values preserved)
- seeded demo scenario 04-rpg-world-state.json (Bandit Camp)
- 19 new tests (33 total pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parththakkar106
2026-07-21 14:47:29 +05:30
co-authored by Claude Opus 4.8
parent 64356414b1
commit 4dac445f90
18 changed files with 1509 additions and 5 deletions
+32 -1
View File
@@ -16,7 +16,7 @@ from dataclasses import dataclass
import tiktoken
from .. import models
from .. import models, worldstate
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
@@ -56,6 +56,23 @@ def _script_memory(adventure: models.Adventure) -> dict:
return memory if isinstance(memory, dict) else {}
def _visible_npcs(adventure: models.Adventure, stat_schema: dict) -> dict[str, str]:
"""NPC story cards (by schema-configured type) whose keys appear in the
recent story — the ones "in scene", so only their stats get injected."""
actions = [a for a in adventure.actions if a.text.strip()]
recent = SEPARATOR.join(a.text for a in actions[-6:]).lower()
types = worldstate.npc_types(stat_schema)
visible: dict[str, str] = {}
for card in adventure.story_cards:
if (card.type or "").lower() not in types:
continue
for key in (k.strip().lower() for k in card.keys.split(",")):
if key and key in recent:
visible[str(card.id)] = card.name or f"NPC {card.id}"
break
return visible
def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
"""AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word
('boat' triggers on 'boats'). Returns one record per card with the keyword that fired."""
@@ -82,6 +99,20 @@ def build_context(
# ----- Always-included components -----
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
# RPG world state (Phase 12): current stats/milestones + how to report changes.
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
guide = worldstate.render_reference(stat_schema)
if guide:
system_sections.append(Section("world_state_guide", guide))
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(adventure, stat_schema)
)
if block:
system_sections.append(Section("world_state", block))
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip()))
if adventure.ai_instructions.strip():
+11
View File
@@ -79,6 +79,17 @@ MIGRATIONS: list[tuple[int, str]] = [
# action's hooks ran, enabling undo/retry to roll state back. JSON is valid
# on both SQLite and Postgres.
(25, "ALTER TABLE actions ADD COLUMN state_before JSON"),
# Phase 12: RPG world state. `stat_schema` defines the stats/bands/rules and
# milestones for a scenario; `world_state` holds an adventure's live values;
# `world_state_before` snapshots it per action for undo/retry (mirrors
# state_before). JSON is valid on both SQLite and Postgres.
(26, "ALTER TABLE scenarios ADD COLUMN stat_schema JSON"),
(27, "ALTER TABLE adventures ADD COLUMN world_state JSON"),
(28, "ALTER TABLE actions ADD COLUMN world_state_before JSON"),
# Raise the default context budget 4096 -> 16384 (Phase 12 injects a stat
# guide + world state each turn). Only bumps rows still on the old default,
# so anyone who picked a custom value keeps it.
(29, "UPDATE settings SET context_token_budget = 16384 WHERE context_token_budget = 4096"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+9 -1
View File
@@ -62,6 +62,9 @@ class Scenario(Base):
authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[str] = mapped_column(String(500), default="")
# Phase 12: RPG world-state template — stat definitions (bands, rules) and
# milestones. NULL/empty means this scenario has no RPG layer.
stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
@@ -88,6 +91,9 @@ class Adventure(Base):
ai_instructions: Mapped[str] = mapped_column(Text, default="")
story_summary: Mapped[str] = mapped_column(Text, default="")
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 6: opt-in per adventure (extra AI calls)
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -185,6 +191,8 @@ class Action(Base):
# script hooks ran, so undo/retry can roll the shared scoreboard back.
# NULL for actions created before this column existed.
state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Phase 12: same idea for the RPG world_state, so undo/retry rolls it back too.
world_state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
@@ -258,7 +266,7 @@ class Settings(Base):
# `reasoning: {max_tokens}`); 0 = param not sent. Added on top of
# max_output_tokens so story output keeps its full budget.
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
context_token_budget: Mapped[int] = mapped_column(Integer, default=4096)
context_token_budget: Mapped[int] = mapped_column(Integer, default=16384)
narrator_prompt: Mapped[str] = mapped_column(
Text,
default=(
+51 -2
View File
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import auth, limits, memorybank, models, schemas
from .. import auth, limits, memorybank, models, schemas, worldstate
from ..context import build_context
from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
@@ -91,6 +91,8 @@ def create_adventure(
memory=fill_placeholders(scenario.memory, values) if scenario else "",
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
# Phase 12: seed the live RPG state from the scenario's template.
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
)
db.add(adventure)
db.flush()
@@ -154,6 +156,21 @@ def get_script_state(
return {"state": state}
@router.get("/{adventure_id}/world-state")
def get_world_state(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
):
"""The RPG world state (live values) plus the scenario's stat_schema, so the
play view can render the sheet + milestones. `schema` is null with no RPG layer."""
adventure = get_adventure_or_404(adventure_id, db, user)
schema = adventure.scenario.stat_schema if adventure.scenario else None
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
return {
"state": state,
"schema": schema if worldstate.has_schema(schema) else None,
}
def snapshot_state(adventure: models.Adventure) -> dict:
"""Deep copy of the shared script_state, to staple onto an action so undo/
retry can restore it. Independent of later hook mutations."""
@@ -161,6 +178,13 @@ def snapshot_state(adventure: models.Adventure) -> dict:
return copy.deepcopy(state)
def snapshot_world_state(adventure: models.Adventure) -> dict:
"""Deep copy of the RPG world_state, for the same undo/retry rollback as
snapshot_state (Phase 12)."""
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
return copy.deepcopy(state)
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure(
adventure_id: int,
@@ -269,6 +293,7 @@ async def generate_turn(
# Scoreboard as it stands before this AI turn's context/output hooks mutate
# it — stapled onto the AI action so retry can start over from here.
state_before = snapshot_state(adventure)
world_state_before = snapshot_world_state(adventure)
system_text, story_text, snapshot = build_context(adventure, settings, memories)
# onModelContext: scripts see (and may rewrite) the whole assembled context.
@@ -331,14 +356,30 @@ async def generate_turn(
return
snapshot["script"] = snapshot["script"] | pipeline.report()
# RPG world state (Phase 12): pull the AI's state delta out of the reply,
# let the engine referee it, and strip the block from the shown text.
ai_index = next_index(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield sse({"type": "error", "detail": "The AI returned only a state update and no story text."})
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_index
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
ai_action = models.Action(
adventure_id=adventure.id,
index=next_index(adventure),
index=ai_index,
type="ai",
text=text,
reasoning="".join(reasoning_chunks).strip() or None,
context_snapshot=snapshot,
state_before=state_before,
world_state_before=world_state_before,
)
db.add(ai_action)
adventure.updated_at = models.utcnow()
@@ -377,6 +418,7 @@ async def run_player_turn(
# Scoreboard before the input hook mutates it — the pre-turn state that
# undo restores to (the AI action keeps its own post-input snapshot).
state_before = snapshot_state(adventure)
world_state_before = snapshot_world_state(adventure)
# onInput sees the formatted text (as in AI Dungeon: "> You ...").
formatted = format_player_input(payload.type, payload.text)
modified, stop = pipeline.run("input", formatted)
@@ -390,6 +432,7 @@ async def run_player_turn(
type=payload.type,
text=modified,
state_before=state_before,
world_state_before=world_state_before,
)
db.add(player_action)
db.commit()
@@ -448,6 +491,8 @@ def retry_action(
# top of the discarded attempt. NULL for pre-migration actions.
if last_ai.state_before is not None:
adventure.script_state = copy.deepcopy(last_ai.state_before)
if last_ai.world_state_before is not None:
adventure.world_state = copy.deepcopy(last_ai.world_state_before)
db.delete(last_ai)
db.commit()
db.refresh(adventure)
@@ -488,6 +533,8 @@ def undo_turn(
db.delete(first_removed)
if first_removed.state_before is not None:
adventure.script_state = copy.deepcopy(first_removed.state_before)
if first_removed.world_state_before is not None:
adventure.world_state = copy.deepcopy(first_removed.world_state_before)
db.flush() # apply deletes so pruning sees the shrunken action list
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
@@ -514,6 +561,7 @@ def export_adventure(
"aiInstructions": adv.ai_instructions,
"storySummary": adv.story_summary,
"scriptState": adv.script_state,
"worldState": adv.world_state,
"autoSummarize": adv.auto_summarize,
"memoryBankEnabled": adv.memory_bank_enabled,
"memoryCursor": adv.memory_cursor,
@@ -577,6 +625,7 @@ def import_adventure(
ai_instructions=str(bundle.get("aiInstructions") or ""),
story_summary=str(bundle.get("storySummary") or ""),
script_state=bundle.get("scriptState") or {},
world_state=bundle.get("worldState") or {},
auto_summarize=bool(bundle.get("autoSummarize", False)),
memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)),
memory_cursor=int(bundle.get("memoryCursor", 0)),
+6
View File
@@ -109,6 +109,7 @@ def export_scenario(
"authorsNote": s.authors_note,
"aiInstructions": s.ai_instructions,
"tags": s.tags,
"statSchema": s.stat_schema,
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in s.story_cards
@@ -137,6 +138,7 @@ _SCENARIO_KEYS = {
"instructions": "ai_instructions",
}
_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags",
"statSchema", "stat_schema",
"createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"}
@@ -165,6 +167,10 @@ def import_scenario(
elif isinstance(tags, str):
fields["tags"] = tags
schema = bundle.get("statSchema") or bundle.get("stat_schema")
if isinstance(schema, dict):
fields["stat_schema"] = schema
scenario = models.Scenario(**fields, user_id=user.id)
if not scenario.title:
scenario.title = "Imported Scenario"
+4
View File
@@ -66,6 +66,9 @@ class ScenarioBase(BaseModel):
authors_note: Prose = ""
ai_instructions: Prose = ""
tags: Tags = ""
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
# None means no RPG layer.
stat_schema: dict | None = None
class ScenarioCreate(ScenarioBase):
@@ -80,6 +83,7 @@ class ScenarioUpdate(BaseModel):
authors_note: Prose | None = None
ai_instructions: Prose | None = None
tags: Tags | None = None
stat_schema: dict | None = None
script_ids: list[int] | None = None
+4
View File
@@ -94,6 +94,8 @@ def _matches(scenario: models.Scenario, data: dict) -> bool:
the write and avoid churning rows on every boot."""
if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS):
return False
if (scenario.stat_schema or None) != (data.get("stat_schema") or None):
return False
have_cards = sorted(_card_tuple(c, lambda o, f: getattr(o, f)) for c in scenario.story_cards)
want_cards = sorted(
_card_tuple(c, lambda o, f: o.get(f, ""))
@@ -133,6 +135,8 @@ def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
def _apply_scalars(scenario: models.Scenario, data: dict) -> None:
for field in _SCALARS:
setattr(scenario, field, data.get(field, ""))
# Phase 12: RPG world-state template (a JSON dict, not a scalar string).
scenario.stat_schema = data.get("stat_schema") or None
def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
@@ -0,0 +1,68 @@
{
"title": "[Demo] The Bandit Camp (RPG world state)",
"description": "A short RPG scene showing the built-in world-state system: the AI tracks your HP and mana, an in-game day counter, an NPC ally's health and trust, and story milestones. No scripting — the engine keeps the numbers honest. Open the World State panel on the left to watch it change.",
"prompt": "Dawn breaks grey over the treeline as you and Gwen crouch at the edge of the bandit camp. Smoke curls from a dying fire; three bedrolls lie empty. Somewhere ahead, the stolen caravan strongbox waits.\n\nGwen checks her bowstring and looks to you. \"Quiet, or loud?\"",
"memory": "The player and Gwen, a loyal ranger ally, are raiding a bandit camp to recover a stolen strongbox. The player is a capable adventurer. This is a dangerous but winnable encounter.",
"authors_note": "Keep it tense and consequential. Reckless moves should cost health; clever ones should pay off. Gwen reacts to how the player treats her.",
"ai_instructions": "Write in second person, present tense. End each reply where the player can act. Let the world state guide the fiction — if the player is badly hurt, show it.",
"tags": "demo, rpg, world-state, combat, short",
"stat_schema": {
"npc_card_types": ["character"],
"world": {
"day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." }
},
"player": {
"hp": {
"desc": "The player's physical health. At 0 they fall.",
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35,
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"],
[50, 80, "scratched"], [80, 100, "healthy"], [100, 101, "full health"]]
},
"mana": {
"desc": "Magical energy for spells; spent casting, restored by resting.",
"min": 0, "max": 50, "initial": 30, "max_delta_per_turn": 25,
"bands": [[0, 10, "drained"], [10, 30, "steady"], [30, 51, "brimming"]]
}
},
"flags": {
"alarm_raised": { "desc": "True once the bandits know they're under attack; stealth is blown.", "initial": false },
"player_hidden": { "desc": "True while the player is out of sight in cover.", "initial": true }
},
"npc": {
"health": {
"desc": "This companion's physical health.",
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35,
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"],
[50, 80, "scratched"], [80, 101, "healthy"]]
},
"trust": {
"desc": "How much this companion trusts the player; rises with smart, loyal play and falls with recklessness.",
"min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
"bands": [[-100, -30, "hostile"], [-30, 30, "wary"], [30, 70, "friendly"],
[70, 101, "devoted"]]
}
},
"milestones": {
"camp_cleared": { "desc": "Clear the bandit camp of enemies" },
"strongbox_found": { "desc": "Recover the stolen strongbox" },
"gwen_survives": { "desc": "Escape with Gwen still alive" }
}
},
"story_cards": [
{
"type": "character",
"name": "Gwen",
"keys": "Gwen, ranger, her",
"entry": "Gwen is a loyal ranger and the player's ally. Quick with a bow, dry-humoured, fiercely protective. Her trust in the player rises when they fight smart and watch her back, and falls when they are reckless with her life.",
"notes": ""
},
{
"type": "location",
"name": "Bandit Camp",
"keys": "camp, bandits, strongbox",
"entry": "A rough camp of bandits in a forest clearing, holding a stolen caravan strongbox.",
"notes": ""
}
],
"scripts": []
}
+26
View File
@@ -0,0 +1,26 @@
"""Phase 12 — RPG world state: the AI proposes stat/milestone deltas, this
module validates and clamps them against a scenario's stat_schema."""
from .engine import (
EMIT_RULE,
apply_delta,
band_label,
extract_delta,
has_schema,
instantiate,
npc_types,
render_reference,
render_state_section,
)
__all__ = [
"EMIT_RULE",
"apply_delta",
"band_label",
"extract_delta",
"has_schema",
"instantiate",
"npc_types",
"render_reference",
"render_state_section",
]
+395
View File
@@ -0,0 +1,395 @@
"""RPG world-state engine.
The scenario carries a `stat_schema` (the template: which stats exist, their
bands and rules, and the milestones). An adventure carries a live `world_state`
instantiated from it. Each turn the AI proposes a *delta* (only what changed);
`apply_delta` is the referee — it clamps to min/max, caps per-turn change,
enforces cooldowns, and marks milestones sticky.
Nothing here ever raises on bad AI output: a malformed delta yields `{}` and the
turn continues, exactly like a broken script never breaks a turn.
"""
import copy
import json
import re
# stat_schema top-level sections that hold stat definitions.
STAT_SECTIONS = ("world", "player")
DEFAULT_NPC_TYPES = ("character", "npc")
# Appended once to the system prompt so the model knows how to report changes.
EMIT_RULE = (
"After your narration, if and ONLY IF something in the world state changed this "
"turn, append a fenced code block labelled `state` containing a JSON object of "
"the CHANGES ONLY, as deltas (not new totals). Use paths like "
'"player.hp", "world.day", "npc.<id>.trust"; "flags.<name>": true or false to '
'toggle an on/off state; and "milestones.<id>": true when an objective is '
"completed. Send only things that actually changed; never restate unchanged "
"values. If nothing changed, omit the block entirely. Example:\n"
'```state\n{"player.hp": -15, "flags.has_key": true, "milestones.escaped": true}\n```'
)
# ```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.
_TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL)
def has_schema(stat_schema: dict | None) -> bool:
"""True when a scenario actually defines an RPG layer."""
if not isinstance(stat_schema, dict):
return False
return any(
isinstance(stat_schema.get(k), dict) and stat_schema[k]
for k in (*STAT_SECTIONS, "npc", "milestones", "flags")
)
def npc_types(stat_schema: dict) -> set[str]:
raw = stat_schema.get("npc_card_types")
types = raw if isinstance(raw, list) and raw else DEFAULT_NPC_TYPES
return {str(t).lower() for t in types}
def _initials(defs: dict) -> dict:
return {
name: d.get("initial", 0)
for name, d in defs.items()
if isinstance(d, dict)
}
def instantiate(stat_schema: dict | None) -> dict:
"""Build a fresh live world_state from a schema (initial values only)."""
if not has_schema(stat_schema):
return {}
ws: dict = {}
for section in STAT_SECTIONS:
ws[section] = _initials(stat_schema.get(section) or {})
ws["npc"] = {} # per-card, filled lazily on first change
ws["milestones"] = {} # only reached ones are stored
ws["flags"] = {
name: bool(d.get("initial", False))
for name, d in (stat_schema.get("flags") or {}).items()
if isinstance(d, dict)
}
ws["_meta"] = {"last_changed": {}}
return ws
def band_label(stat_def: dict, value) -> str | None:
"""The word label for `value` from a stat def's bands, if any.
Bands are [lo, hi, label]; matched as lo <= value < hi, with the top band
inclusive of its upper bound so a maxed stat still gets a label.
"""
bands = stat_def.get("bands")
if not isinstance(bands, list) or not isinstance(value, (int, float)):
return None
last_hi = None
for band in bands:
if not (isinstance(band, list) and len(band) == 3):
continue
lo, hi, label = band
last_hi = hi
if lo <= value < hi:
return str(label)
# Inclusive top edge.
if bands and value == last_hi:
return str(bands[-1][2])
return None
# --------------------------------------------------------------------------- #
# Delta extraction
# --------------------------------------------------------------------------- #
def _tolerant_load(blob: str) -> dict:
# Strip trailing commas and leading + on numbers, both of which weaker
# free models emit and strict JSON rejects.
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def extract_delta(text: str) -> tuple[str, dict]:
"""Pull the trailing state block out of an AI response.
Returns (clean_text, delta). `delta` is `{}` when there is no block or it
can't be parsed; `clean_text` has the block removed. Only strips a bare
trailing object when it actually parses to a delta, so ordinary prose
ending in `}` is never eaten.
"""
matches = list(_FENCE_RE.finditer(text))
if matches:
m = matches[-1]
delta = _tolerant_load(m.group(1))
clean = (text[: m.start()] + text[m.end():]).strip()
return clean, delta
m = _TRAILING_RE.search(text)
if m:
delta = _tolerant_load(m.group(1))
if delta and all("." in str(k) for k in delta):
clean = text[: m.start()].strip()
return clean, delta
return text.strip(), {}
# --------------------------------------------------------------------------- #
# Delta application (the referee)
# --------------------------------------------------------------------------- #
def _coerce_number(value):
if isinstance(value, bool): # bool is an int subclass — reject here
return None
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
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"})
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"})
return
if stat_def.get("type") == "counter" and delta < 0:
report["rejected"].append({"path": path, "reason": "counter can't decrease"})
return
clamped = False
cap = stat_def.get("max_delta_per_turn")
if cap is not None and abs(delta) > cap:
delta = cap if delta > 0 else -cap
clamped = True
old = container.get(key, stat_def.get("initial", 0))
new = old + delta
lo, hi = stat_def.get("min"), stat_def.get("max")
if lo is not None and new < lo:
new, clamped = lo, True
if hi is not None and new > hi:
new, clamped = hi, True
# Keep ints integral for display.
if isinstance(old, int) and float(new).is_integer():
new = int(new)
container[key] = new
meta["last_changed"][path] = action_index
entry = {"path": path, "old": old, "new": new}
report["applied"].append(entry)
if clamped:
report["clamped"].append(entry)
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
action_index: int) -> tuple[dict, dict]:
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
`world_state`. Returns (new_world_state, report)."""
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
if not ws:
ws = instantiate(stat_schema)
ws.setdefault("_meta", {}).setdefault("last_changed", {})
meta = ws["_meta"]
report: dict = {"applied": [], "clamped": [], "rejected": []}
if not isinstance(delta, dict):
return ws, report
milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") or {}
npc_defs = stat_schema.get("npc") or {}
for raw_path, change in delta.items():
path = str(raw_path)
parts = path.split(".")
# flags.<name> — free two-way boolean, either value accepted.
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"})
continue
if not isinstance(change, bool):
report["rejected"].append({"path": path, "reason": "not a boolean"})
continue
flags = ws.setdefault("flags", {})
old = bool(flags.get(fid, False))
if change != old:
flags[fid] = change
report["applied"].append({"path": path, "old": old, "new": change})
continue
# milestones.<id> — sticky boolean, only `true` accepted.
if parts[0] == "milestones" and len(parts) == 2:
mid = parts[1]
if mid not in milestones:
report["rejected"].append({"path": path, "reason": "unknown milestone"})
continue
if change is not True:
report["rejected"].append({"path": path, "reason": "not true"})
continue
reached = ws.setdefault("milestones", {})
if reached.get(mid, {}).get("reached"):
continue # already done — silent no-op
reached[mid] = {"reached": True, "at": action_index}
report["applied"].append({"path": path, "old": False, "new": True})
continue
# world.<stat> / player.<stat>
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"})
continue
container = ws.setdefault(parts[0], {})
_apply_stat(container, parts[1], stat_def, change, path,
action_index, meta, report)
continue
# npc.<cardId>.<stat>
if parts[0] == "npc" and len(parts) == 3:
stat_def = npc_defs.get(parts[2])
if not isinstance(stat_def, dict):
report["rejected"].append({"path": path, "reason": "unknown npc stat"})
continue
npcs = ws.setdefault("npc", {})
container = npcs.setdefault(parts[1], _initials(npc_defs))
_apply_stat(container, parts[2], stat_def, change, path,
action_index, meta, report)
continue
report["rejected"].append({"path": path, "reason": "unknown path"})
return ws, report
# --------------------------------------------------------------------------- #
# Context rendering
# --------------------------------------------------------------------------- #
def _stat_line(defs: dict, values: dict) -> str:
parts = []
for name, d in defs.items():
if not isinstance(d, dict):
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:
"""Compact, always-included context block. `visible_npcs` maps card-id ->
display name for NPCs currently in 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}.")
npc_defs = stat_schema.get("npc") or {}
npc_state = ws.get("npc") or {}
for card_id, name in visible_npcs.items():
values = npc_state.get(card_id) or _initials(npc_defs)
npc_line = _stat_line(npc_defs, values)
if npc_line:
lines.append(f"{name}: {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) + ".")
milestones = stat_schema.get("milestones") or {}
reached = ws.get("milestones") or {}
goals = [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()
if reached.get(mid, {}).get("reached")]
if goals:
lines.append("Goals: " + "; ".join(goals) + ".")
if done:
lines.append("Achieved: " + "; ".join(done) + ".")
return "\n".join(lines)
def _describe_stat(name: str, d: dict) -> str | None:
"""One reference line for a stat. Description and band-ladder are independent —
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 drop any
# trailing period the author already put on the description.
bits.append(desc.strip().rstrip("."))
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:
"""A fixed, per-scenario legend describing what each stat means (its `desc`)
and its band ladder. Static across turns — 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 name, d in (stat_schema.get("npc") or {}).items():
if isinstance(d, dict):
row = _describe_stat(f"NPC {name}", d)
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)