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:
co-authored by
Claude Opus 4.8
parent
64356414b1
commit
4dac445f90
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user