A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fixing onboarding stuff

+1077 -401
+11
CLAUDE.md
··· 65 65 66 66 All LLM prompts live in `prompts/` as markdown files, loaded at runtime via `load_prompt(name)`. Don't put prompt text inline in Python code — if it's instructions for a model, it goes in `prompts/`. Tool docstrings in the `tools/*.py` modules are the exception since they're tightly coupled to the function signatures and JSON schemas. 67 67 68 + ## Cold-Start Flow 69 + 70 + When `storied play` runs against a fresh world (no character or no `style.md`), the CLI enters a single-invocation onboarding sequence: 71 + 72 + 1. **Onboarding session** — `prompts/new-game.md` runs against the DM engine. The DM weaves character creation with worldbuilding preferences, calling `tune` (writes `style.md`) and `create_character` along the way. The DM must not call `set_scene` or `establish` during onboarding. When both artifacts are captured, the DM calls `end_session`. 73 + 2. **Artifact re-check** — if either `character.yaml` or `style.md` is still missing after the onboarding session exits, the CLI prints a friendly "onboarding incomplete" message and exits. Re-running `storied play` drops back into onboarding to finish the gap. 74 + 3. **World seeding** — `seed_world` runs synchronously (with a progress spinner). It reads `style.md` and prepends it as a `## Player Preferences` block to the seeder's user message so the bootstrap world reflects the tone/genre the player asked for. 75 + 4. **Normal play** — a fresh `DMEngine` starts with `prompts/dm-system.md`, background ticker and advancement evaluator come online, and the player drops into the opening scene. 76 + 77 + All four phases happen in one `storied play` invocation. Sandbox mode (`--sandbox`) skips onboarding and seeding entirely — it starts with the stock DM system prompt every time. 78 + 68 79 ## Content Layers 69 80 70 81 Game content is organized in three layers that overlay each other,
+37
design/architecture.md
··· 92 92 93 93 ## Component Responsibilities 94 94 95 + ### Cold-Start Onboarding (`cli.py` + `prompts/new-game.md`) 96 + 97 + When `storied play` launches against a fresh world — no character 98 + sheet or no `style.md` — it enters an onboarding sequence rather than 99 + the normal DM loop. The sequence runs entirely in one invocation: 100 + 101 + 1. **Onboarding conversation.** A DM engine loads 102 + `prompts/new-game.md` and weaves character creation with 103 + worldbuilding preferences. The DM has two mandatory deliverables 104 + before `end_session`: `tune()` (writes `worlds/{world}/style.md` 105 + capturing tone, themes, pacing) and `create_character()`. The 106 + prompt forbids `set_scene`, `establish`, and any attempt to start 107 + the adventure — those belong to the seeder and the live DM. 108 + 2. **Artifact gate.** After the onboarding engine exits, `cmd_play` 109 + re-checks both files. If either is missing, it prints a friendly 110 + "onboarding incomplete" message and exits; re-running 111 + `storied play` re-enters onboarding to fill the gap. The DM 112 + notices what already exists in context and only works on the 113 + missing deliverable. 114 + 3. **Synchronous planning phase.** `seed_world()` runs as a blocking 115 + Claude subprocess using `prompts/world-seed.md`. It now reads 116 + `style.md` and prepends it as a `## Player Preferences` block to 117 + the seeder's user message, so the 12–16 entities it establishes 118 + and the opening `set_scene` reflect the player's preferences — not 119 + a lowest-common-denominator interpretation of the character 120 + sheet alone. 121 + 4. **Live play.** A fresh `DMEngine` starts with `prompts/dm-system.md`, 122 + `BackgroundTicker` and `BackgroundAdvancement` come online, and 123 + the player drops into the opening scene. 124 + 125 + `cli.py` extracts the input/stream/ticker/advancement loop into 126 + `_run_engine_loop` so both the onboarding engine and the play engine 127 + share the same interactive shell. Onboarding runs with 128 + `is_onboarding=True` and no background agents; play runs with 129 + `is_onboarding=False` and full ticker + advancement. `--sandbox` 130 + skips cold-start entirely and goes straight to the DM system prompt. 131 + 95 132 ### DM Engine (`engine.py`) 96 133 97 134 The agentic loop: builds the turn's context, spawns Claude with the MCP
-49
prompts/character-creation.md
··· 1 - You are helping a player create a new 5e character for a solo adventure. 2 - 3 - ## Your Role 4 - 5 - Guide the player through character creation conversationally. Don't present it as a form - have a natural back-and-forth where you learn about who they want to play. 6 - 7 - ## The Flow 8 - 9 - Start by asking what kind of hero they imagine. Let their answer guide you: 10 - 11 - - If they have a clear concept ("a sneaky halfling thief"), help them build toward it 12 - - If they're unsure, ask questions to discover what appeals to them 13 - - If they want to explore options, briefly describe what's available 14 - 15 - ## Key Decisions 16 - 17 - Work through these naturally (not necessarily in order): 18 - 19 - 1. **Concept**: Who is this person? What's their deal? 20 - 2. **Race**: Human, Elf, Dwarf, Halfling, etc. Look up races for traits. 21 - 3. **Class**: Fighter, Wizard, Rogue, etc. Look up classes for features. 22 - 4. **Background**: Acolyte, Criminal, Soldier, etc. Provides skills and flavor. 23 - 5. **Ability Scores**: Roll 4d6kh3 six times, let them assign scores. 24 - 6. **Starting Equipment**: Based on class and background. 25 - 7. **Details**: Name, personality, bonds, flaws - whatever feels right. 26 - 27 - ## Ability Scores 28 - 29 - Roll ability scores using the standard method (4d6, drop lowest, six times). Let the player assign the results to abilities as they choose based on their concept. 30 - 31 - ## Looking Things Up 32 - 33 - Use `recall` freely to check: 34 - - Racial traits (darkvision, resistances, etc.) 35 - - Class features (hit dice, proficiencies, starting abilities) 36 - - Background features (skills, tools, equipment) 37 - - Spell lists if they're a caster 38 - 39 - Get the details right - this character will be with them for a while. 40 - 41 - ## Finalizing 42 - 43 - Once you have everything, call `create_character` with the full character data. This writes `character.yaml` (structured stats) and `character.md` (the prose backstory you pass via the `backstory` argument). 44 - 45 - After the character exists, you can call `update_character` to add proficiencies (skills/saves/tools), `add_item` to populate starting equipment, and `update_character({"features": [...]})` to add class features. Equipment goes into location subsections like `on_person`, `worn`, or whatever the player describes. 46 - 47 - **IMPORTANT**: After calling `create_character`, give a brief summary of the character (a "quick reference" with key abilities, spells if any, and notable features) but do NOT start the adventure. The session will end and the player will start fresh with `storied play` using the full DM system. 48 - 49 - Don't rush. Character creation is part of the fun. Let them explore, change their mind, and discover who they want to play.
+78
prompts/new-game.md
··· 1 + You are the DM running a **cold-start onboarding session** for a new solo 5e adventure. This is not gameplay yet. Your job this session is to capture two things before the story can begin: 2 + 3 + 1. **The kind of world and story the player wants** — recorded via `tune`. 4 + 2. **The player's character** — recorded via `create_character`. 5 + 6 + Neither of these has been created yet (or one of them has — see below). You won't actually run any adventure in this session. Once both artifacts are captured and you call `end_session`, a separate world-building step will establish the opening world and set up the first scene. The player will drop straight into play from there. 7 + 8 + ## Check what already exists 9 + 10 + Your context may already contain a character sheet or a style block. That means the player started onboarding before and quit partway through. Pick up where they left off: 11 + 12 + - **Character sheet present, no Style section** → skip character creation entirely. Open by acknowledging their character and focus the whole session on worldbuilding preferences. 13 + - **Style section present, no character sheet** → their worldbuilding preferences are already captured. Focus on character creation, but stay aware of the established tone as you guide the character conversation. 14 + - **Neither present** → full onboarding, both deliverables. 15 + - **Both present** → something is wrong; thank the player, call `end_session`, and let them re-run. 16 + 17 + ## Weaving the conversation 18 + 19 + Character creation and worldbuilding belong together. As the player describes who they want to play, listen for tone signals. As they describe the kind of story they want, suggest character directions that fit. Don't split the session into "phase 1: worldbuilding, phase 2: character" — let the two strands interleave. 20 + 21 + Some questions to work in naturally, not as a form: 22 + 23 + - What kind of story excites you? Heroic, grim, political, weird, cozy, brutal? 24 + - What tone — gritty realism, high fantasy, dark horror, comedic, sword-and-sorcery? 25 + - Pacing — breakneck action, slow burn, investigation-heavy, travelogue? 26 + - What *don't* you want? Things to avoid — tropes, themes, intensity levels. 27 + - What's the hero of this story like? What are they drawn to, what do they avoid, what do they want? 28 + - Who are they? Race, class, background, name, quirks. 29 + 30 + Get two or three of these on the table early. Use them to shape the character suggestions. Use the character concept to sharpen the world prefs. Call `tune` as soon as you have a clear vibe — don't wait until the end. 31 + 32 + ## Using `tune` 33 + 34 + `tune` writes `style.md` and is **full-replacement** — each call overwrites the file. So every subsequent call must incorporate everything you'd previously captured. Structure the text as short prose paragraphs the DM will read every turn: 35 + 36 + - **Tone** (one or two lines — grim, playful, tense, etc.) 37 + - **Themes** (what the story is about) 38 + - **Pacing** (breakneck, slow burn, investigation-heavy) 39 + - **What to lean into** (moments the player wants) 40 + - **What to avoid** (hard no's) 41 + 42 + Call `tune` at least twice — once after the initial conversation when you have a first vibe, again after the character is clearer and you can refine the preferences with the character in mind. 43 + 44 + ## Character creation 45 + 46 + Work through these naturally: 47 + 48 + 1. **Concept** — who is this person, what's their deal? 49 + 2. **Race** — look up traits with `recall`. 50 + 3. **Class** — look up features with `recall`. 51 + 4. **Background** — skills, tools, flavor. 52 + 5. **Ability scores** — roll 4d6kh3 six times, let the player assign. 53 + 6. **Starting equipment** — class + background. 54 + 7. **Details** — name, personality, bonds, flaws. 55 + 56 + Use `recall` freely for rules lookups. Get the details right. 57 + 58 + Once the character is fully fleshed out, call `create_character` with the full data. After that, you can use `update_character`, `add_item`, `update_character({"features": [...]})` to populate proficiencies, equipment, and class features. Equipment goes into location subsections like `on_person`, `worn`, etc. 59 + 60 + ## What you MUST NOT do 61 + 62 + - **Do NOT call `set_scene`.** The opening scene is the seeder's job, not yours. Calling it now would commit the player to a location and moment before the world exists. 63 + - **Do NOT call `establish`, `mark`, `amend_mark`, or `note_discovery`.** World entities are the seeder's job. You capture preferences and character; the seeder builds the world from them. 64 + - **Do NOT start the adventure.** No narration, no "you find yourself in...", no opening scene. This session is setup only. 65 + 66 + Your surface this session is: `tune`, `create_character`, `update_character`, `add_item`, `recall`, `roll`, `end_session`. That's it. 67 + 68 + ## Wrapping up 69 + 70 + When both `tune` and `create_character` have been called — and only then — give a brief summary: 71 + 72 + - A one-paragraph reflection on the world and story vibe you've captured. 73 + - A quick character sheet reference (key abilities, notable features, starting gear highlights). 74 + - A short note that when the session ends, the world will be built and their first scene will be waiting. 75 + 76 + Then call `end_session` with a wrap-up situation like "Ready to begin: [character] preparing to step into [vibe description]." 77 + 78 + Don't rush any of this. Onboarding is part of the fun. Let the player explore, change their mind, and discover both who they want to play and the world they want to play in.
+5 -1
prompts/world-seed.md
··· 9 9 10 10 ## What You're Given 11 11 12 - A character sheet — name, race, class, backstory, personality. This is the only thing that exists. No session, no entities, no campaign log. You're building the world from scratch. 12 + A character sheet — name, race, class, backstory, personality — and, when the player has already gone through onboarding, a **`## Player Preferences`** block at the top of your user message. That block comes from `style.md` and captures the tone, themes, genre, and pacing the player asked for. 13 + 14 + **When preferences are present, anchor the world in them.** A request for "grim political intrigue, no heroic fantasy" means the starting location is a tense city district, the NPCs have compromising secrets, and the threads hinge on factional maneuvering — not farmhands and goblin raids. A request for "cozy slice-of-life with light mystery" means the opening is a village at dawn, the threads are small and personal, the stakes are low but meaningful. Let the preferences decide the *feel*; let the character decide the *specifics*. 15 + 16 + If no preferences block is present, fall back to inferring tone from the character's backstory alone. 13 17 14 18 ## What to Build 15 19
+35 -7
src/storied/character/operations.py
··· 407 407 ) -> str: 408 408 """Apply relative coin changes (positive=gain, negative=spend). 409 409 410 - Each denomination is clamped to 0 minimum. 410 + Atomic: if any denomination would go below zero, the entire call is 411 + rejected with an error and the purse is not modified. The DM should 412 + handle change in a single call (e.g. paying 5 cp from a 9 sp / 0 cp 413 + purse is ``{"sp": -1, "cp": +5}``, not two separate calls), and 414 + this rejection is what surfaces the mistake when they don't. 411 415 """ 412 416 data = load_character(player_id) 413 417 if data is None: ··· 417 421 "purse", {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0} 418 422 ) 419 423 420 - changes = [] 424 + # Validate every denomination would land ≥ 0 before touching anything. 425 + proposed: dict[str, int] = {} 426 + shortfalls: list[str] = [] 421 427 for denom, delta in deltas.items(): 422 428 if denom not in ("cp", "sp", "ep", "gp", "pp"): 423 429 continue 424 430 old = purse.get(denom, 0) 425 431 new = old + delta 426 432 if new < 0: 427 - changes.append(f"{denom} {old} → 0 (short {-new} {denom})") 428 - purse[denom] = 0 429 - else: 430 - changes.append(f"{denom} {old} → {new}") 431 - purse[denom] = new 433 + shortfalls.append( 434 + f"{denom}: have {old}, need {-delta} (short {-new})" 435 + ) 436 + proposed[denom] = new 437 + 438 + if shortfalls: 439 + coins = [] 440 + for denom in ("pp", "gp", "ep", "sp", "cp"): 441 + amount = purse.get(denom, 0) 442 + if amount: 443 + coins.append(f"{amount} {denom}") 444 + purse_str = ", ".join(coins) if coins else "empty" 445 + return ( 446 + "Cannot adjust coins — insufficient funds. " 447 + f"{'; '.join(shortfalls)}. Purse: {purse_str}. " 448 + "Nothing was changed. To make change, fold the payment and " 449 + "the coins coming back into a single call: e.g. paying 5 cp " 450 + 'from a 9 sp / 0 cp purse is {"sp": -1, "cp": 5} ' 451 + "(1 silver out, 5 copper back). Don't call adjust_coins twice " 452 + "to spend then make change — you'll double-charge." 453 + ) 454 + 455 + changes = [] 456 + for denom, new in proposed.items(): 457 + old = purse.get(denom, 0) 458 + changes.append(f"{denom} {old} → {new}") 459 + purse[denom] = new 432 460 433 461 save_character(player_id, data) 434 462
+5
src/storied/claude.py
··· 108 108 "--strict-mcp-config", 109 109 "--mcp-config", mcp_config, 110 110 "--effort", effort, 111 + # Keep per-machine sections (cwd, env, memory paths, git 112 + # status) out of the system prompt. We control the entire 113 + # prompt explicitly via --system-prompt, and the dynamic 114 + # bits are the most plausible cross-campaign leak surface. 115 + "--exclude-dynamic-system-prompt-sections", 111 116 ] 112 117 113 118 if not persist_session:
+404 -269
src/storied/cli.py
··· 5 5 import argparse 6 6 import sys 7 7 from pathlib import Path 8 + from typing import TYPE_CHECKING 8 9 9 10 import argcomplete 10 11 12 + if TYPE_CHECKING: 13 + from rich.console import Console 14 + 15 + from storied.advancement import BackgroundAdvancement 16 + from storied.engine import DMEngine 17 + from storied.planner import BackgroundTicker 18 + 11 19 # Slash commands available during play 12 20 SLASH_COMMANDS = { 13 21 "/help": "Show this help message", ··· 16 24 "/save": "Save session state (without quitting)", 17 25 "/context": "Show token usage", 18 26 "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", 19 - "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)", 27 + "/note": ( 28 + "Add a note to your character sheet " 29 + "(e.g. /note remember the prayer words)" 30 + ), 20 31 } 21 32 22 33 def _format_character_display( ··· 86 97 # Write metadata 87 98 meta_path = output_path.parent / "meta.yaml" 88 99 source_hash = get_file_hash(pdf_path) 100 + from datetime import UTC, datetime 101 + 89 102 import yaml 90 - from datetime import datetime, timezone 91 103 92 104 meta = { 93 105 "version": "5.2.1", 94 106 "source_url": SRD_URL, 95 107 "source_hash": source_hash, 96 - "extracted_at": datetime.now(timezone.utc).isoformat(), 108 + "extracted_at": datetime.now(UTC).isoformat(), 97 109 } 98 110 meta_path.write_text(yaml.dump(meta, sort_keys=False)) 99 111 log(f"Wrote: {meta_path}") ··· 157 169 """Reset player and world state to start fresh.""" 158 170 import shutil 159 171 160 - from storied import paths 161 172 from storied.paths import ( 162 173 configure, 163 174 data_home, ··· 208 219 return 0 209 220 210 221 211 - def cmd_play(args: argparse.Namespace) -> int: 212 - """Start an interactive DM session.""" 213 - import atexit 214 - import readline 215 - import shutil 216 - import tempfile 222 + def _is_cold_start(world_id: str, player_id: str) -> bool: 223 + """True when the story can't run because onboarding artifacts are missing. 217 224 218 - from rich.console import Console 219 - from rich.markdown import Markdown 220 - from rich.panel import Panel 221 - from rich.rule import Rule 222 - from rich.text import Text 223 - 225 + Cold start means either the player has no character yet, or the 226 + world has no ``style.md`` capturing the player's worldbuilding 227 + preferences. Either gap sends ``cmd_play`` into the onboarding 228 + flow on launch. 229 + """ 224 230 from storied.character import load_character 225 - from storied.engine import DMEngine 231 + from storied.paths import world_path 226 232 227 - # Set up readline history 228 - history_file = Path.home() / ".storied_history" 229 - try: 230 - readline.read_history_file(history_file) 231 - except FileNotFoundError: 232 - pass 233 - readline.set_history_length(1000) 234 - atexit.register(readline.write_history_file, history_file) 233 + character = load_character(player_id) 234 + style_path = world_path(world_id) / "style.md" 235 + return character is None or not style_path.exists() 235 236 236 - # Set up slash command autocomplete 237 - def slash_completer(text: str, state: int) -> str | None: 238 - if text.startswith("/"): 239 - matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)] 240 - return matches[state] if state < len(matches) else None 241 - return None 242 237 243 - readline.set_completer_delims(readline.get_completer_delims().replace("/", "")) 244 - readline.set_completer(slash_completer) 245 - readline.parse_and_bind("tab: complete") 246 - readline.parse_and_bind("set enable-bracketed-paste on") 238 + def _run_engine_loop( 239 + engine: "DMEngine", 240 + *, 241 + console: "Console", 242 + args: argparse.Namespace, 243 + player_id: str, 244 + is_sandbox: bool, 245 + is_onboarding: bool, 246 + first_message: str | None, 247 + ticker: "BackgroundTicker | None" = None, 248 + advancement: "BackgroundAdvancement | None" = None, 249 + ) -> None: 250 + """Run the interactive input/stream loop against a DMEngine. 247 251 248 - console = Console() 249 - world_id = args.world if args.world else "default" 250 - player_id = "default" 251 - sandbox = getattr(args, "sandbox", False) 252 - 253 - from storied.paths import configure, data_home, resolve_data_home 254 - 255 - # Sandbox mode: throwaway worlds + players in a temp directory. 256 - # User homebrew rules stay pointed at the real ~/.storied/rules/ 257 - # so the sandbox isn't a pristine playground — it's still "your 258 - # rules", just with a fresh world to mess around in. 259 - if sandbox: 260 - sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) 261 - (sandbox_dir / "worlds" / world_id).mkdir(parents=True) 262 - (sandbox_dir / "players" / player_id).mkdir(parents=True) 263 - configure( 264 - data_home=sandbox_dir, 265 - user_rules_home=Path.home() / ".storied" / "rules", 266 - ) 267 - else: 268 - configure( 269 - data_home=resolve_data_home(getattr(args, "base_path", None)) 270 - ) 271 - data_home().mkdir(parents=True, exist_ok=True) 252 + Three modes: 253 + - ``is_onboarding``: cold-start flow. Exits via ``end_session`` or 254 + Ctrl+D; the CLI re-checks artifacts after the loop returns. 255 + - ``is_sandbox``: throwaway session with no save-on-quit. 256 + - Normal play: saves the session on Ctrl+D / Ctrl+C. 257 + """ 258 + from rich.markdown import Markdown 259 + from rich.rule import Rule 272 260 273 - # Export STORIED_HOME so any subprocess that re-enters storied 274 - # (e.g. via run_code) sees the same data directory. 275 - import os 276 - os.environ["STORIED_HOME"] = str(data_home()) 261 + from storied.display import StreamRenderer 277 262 278 - base_path = data_home() 279 - creation_mode = False 280 - 281 - if sandbox: 282 - console.print(Panel.fit( 283 - "[bold]Storied Sandbox[/bold]\n" 284 - "No character, no world — just you and the DM.\n" 285 - "Type [cyan]Ctrl+D[/cyan] to quit.", 286 - title="Sandbox", 287 - border_style="cyan", 288 - )) 289 - prompt_name = "dm-system" 290 - else: 291 - # Check if character exists 292 - character = load_character(player_id) 293 - creation_mode = character is None 263 + terse_exit = is_sandbox or is_onboarding 294 264 295 - if creation_mode: 296 - console.print(Panel.fit( 297 - "[bold]Welcome to Storied![/bold]\n" 298 - "Let's create your character!", 299 - title="Character Creation", 300 - border_style="yellow", 301 - )) 302 - prompt_name = "character-creation" 265 + def on_graceful_exit() -> None: 266 + if is_sandbox: 267 + console.print("[cyan]Sandbox session ended.[/cyan]") 268 + elif is_onboarding: 269 + console.print("[yellow]Onboarding interrupted.[/yellow]") 303 270 else: 304 - console.print(Panel.fit( 305 - "[bold]Welcome to Storied![/bold]\n" 306 - "Let the DM know when you're ready to quit.\n" 307 - "Type [cyan]/context[/cyan] to see token usage.", 308 - title="Storied", 309 - border_style="green", 310 - )) 311 - prompt_name = "dm-system" 312 - 313 - console.print(f"[dim]World: {world_id}{' (sandbox)' if sandbox else ''}[/dim]") 314 - console.print() 315 - 316 - # Seed the world if the character exists but no session yet 317 - if not creation_mode and not sandbox: 318 - from storied.session import load_session 319 - 320 - session = load_session(player_id) 321 - if session is None: 322 - from storied.planner import seed_world 323 - 324 - console.print(f"[dim]Seeding the world for {character['name']}...[/dim]") 325 - 326 - def on_seed_progress(msg: str) -> None: 327 - console.print(f"[dim] {msg}[/dim]") 328 - 329 - result = seed_world( 330 - world_id=world_id, 331 - player_id=player_id, 332 - on_progress=on_seed_progress, 333 - ) 334 - 271 + console.print("[dim]Saving session...[/dim]") 272 + try: 273 + save_msg = "I need to quit now. Please save the game." 274 + list(engine.stream_action(save_msg)) 275 + except Exception: 276 + pass 335 277 console.print( 336 - f"[dim] Done — {result.tool_calls} tool calls, " 337 - f"{result.elapsed:.1f}s[/dim]" 278 + "[yellow]Session saved. Farewell, adventurer![/yellow]" 338 279 ) 339 - console.print() 340 - transcript_path = Path(args.transcript) if args.transcript else None 341 - engine = DMEngine( 342 - world_id=world_id, 343 - player_id=player_id, 344 - prompt_name=prompt_name, 345 - transcript_path=transcript_path, 346 - ) 347 - engine.debug = args.debug 348 - 349 - # Debug mode: dump the base system prompt once at startup. The 350 - # per-turn context is dumped separately before each stream_action 351 - # call below. Between them, the user sees exactly what the DM 352 - # subprocess is seeing each turn. 353 - if args.debug: 354 - console.print(Rule("System Prompt", style="dim")) 355 - console.print(engine._base_prompt, style="dim", highlight=False) 356 - console.print(Rule(style="dim")) 357 - console.print() 358 - 359 - # Background ticker for mid-session world advancement 360 - ticker = None 361 - advancement = None 362 - if not creation_mode and not sandbox: 363 - from storied.advancement import BackgroundAdvancement 364 - from storied.planner import BackgroundTicker 365 - 366 - ticker = BackgroundTicker( 367 - world_id=world_id, 368 - player_id=player_id, 369 - ) 370 - # Kick off initial tick in background 371 - ticker.maybe_tick(engine._campaign_log) 372 - 373 - advancement = BackgroundAdvancement( 374 - world_id=world_id, 375 - player_id=player_id, 376 - ) 377 - 378 - # If in creation mode, start the conversation 379 - if creation_mode: 380 - console.print("[dim]The DM will guide you through character creation...[/dim]") 381 - console.print() 382 - 383 - from storied.display import StreamRenderer 384 - 385 - # Kick off the conversation with appropriate first message 386 - if sandbox: 387 - first_message = ( 388 - "[Sandbox session — no character, no world. The player wants to " 389 - "experiment freely. Jump straight into whatever they ask.]" 390 - ) 391 - elif creation_mode: 392 - first_message = "Let's create a character!" 393 - else: 394 - first_message = "[Session starting]" 395 280 396 281 try: 397 282 while True: 398 - # Get player input (or use first_message to kick off creation) 399 283 if first_message: 400 284 action = first_message 401 285 first_message = None 402 286 else: 403 287 try: 404 288 console.print(Rule(style="dim blue")) 405 - if creation_mode or sandbox: 289 + if terse_exit: 406 290 action = input("> ") 407 291 else: 408 292 game_time = engine.get_current_time() 409 293 action = input(f"[{game_time}] > ") 410 294 except EOFError: 411 295 console.print() 412 - if sandbox: 413 - console.print("[cyan]Sandbox session ended.[/cyan]") 414 - elif not creation_mode: 415 - console.print("[dim]Saving session...[/dim]") 416 - try: 417 - save_msg = "I need to quit now. Please save the game." 418 - list(engine.stream_action(save_msg)) 419 - except Exception: 420 - pass 421 - console.print( 422 - "[yellow]Session saved. Farewell, adventurer![/yellow]" 423 - ) 424 - else: 425 - console.print("[yellow]Farewell![/yellow]") 296 + on_graceful_exit() 426 297 break 427 298 428 299 if not action.strip(): ··· 433 304 stats = engine.get_context_stats() 434 305 console.print() 435 306 436 - # Header: real API usage if available, estimate otherwise 307 + # Header: real per-turn input from the API (cache_read + 308 + # cache_write + new) if we have a result, else the static 309 + # estimate. The API number is ground truth for "how much 310 + # of the context window did this turn use." 437 311 limit_k = stats["model_limit"] / 1000 438 312 game_time = engine.get_current_time() 439 313 if stats["last_input"] > 0: ··· 441 315 usage_str = f"{input_k:.1f}k/{limit_k:.0f}k tokens (last turn)" 442 316 else: 443 317 context_k = stats["context_total"] / 1000 444 - usage_str = f"~{context_k:.1f}k/{limit_k:.0f}k system tokens (estimated)" 318 + usage_str = ( 319 + f"~{context_k:.1f}k/{limit_k:.0f}k " 320 + "system tokens (estimated)" 321 + ) 445 322 console.print( 446 323 f"[bold]Context Usage[/bold] [dim]({game_time})[/dim]" 447 324 f" [dim]{engine.model} · {usage_str}[/dim]" 448 325 ) 449 326 450 - # Color mapping for context sections 451 327 _SECTION_COLORS: dict[str, str] = { 452 328 "Style": "dim", 453 329 "Character": "green", ··· 459 335 "Initiative": "red", 460 336 } 461 337 462 - # Build section list from all context parts 463 338 sections: list[tuple[str, int, str]] = [ 464 - ("DM Instructions", stats["system_prompt"], "bright_blue"), 465 - ("Tool Schemas", stats.get("tool_surface", 0), "bright_magenta"), 339 + ( 340 + "DM Instructions", 341 + stats["system_prompt"], 342 + "bright_blue", 343 + ), 344 + ( 345 + "Tool Schemas", 346 + stats.get("tool_surface", 0), 347 + "bright_magenta", 348 + ), 466 349 ] 467 350 for key, tokens in stats["context_parts"].items(): 468 351 if key.startswith("Entity:") or key.startswith("Linked:"): 469 352 label = key.split(":", 1)[1] 470 - color = "magenta" if key.startswith("Entity:") else "dark_magenta" 353 + color = ( 354 + "magenta" 355 + if key.startswith("Entity:") 356 + else "dark_magenta" 357 + ) 471 358 else: 472 359 label = key 473 360 color = _SECTION_COLORS.get(key, "white") 474 361 sections.append((label, tokens, color)) 475 362 476 - # 40x5 grid (200 cells = 200k tokens, 1 cell = 1k) 363 + # Conversation history lives in the Claude subprocess 364 + # session, not in the per-turn system prompt. The API's 365 + # cache reports it as part of the input total — we 366 + # surface the gap between what the engine builds fresh 367 + # and what the model actually saw as a "Conversation" 368 + # bucket so the grid shows the full picture. 369 + last_input = stats.get("last_input", 0) 370 + if last_input > 0: 371 + convo_tokens = max(0, last_input - stats["context_total"]) 372 + if convo_tokens > 0: 373 + sections.append( 374 + ("Conversation", convo_tokens, "bright_white"), 375 + ) 376 + 477 377 grid_w, grid_h = 40, 5 478 378 total_cells = grid_w * grid_h 479 379 cells: list[str] = [] ··· 486 386 cells.extend([color] * n_cells) 487 387 legend_items.append((name, tokens, color)) 488 388 489 - # Pad remaining cells 490 389 remaining = total_cells - len(cells) 491 390 if remaining > 0: 492 391 cells.extend(["dim"] * remaining) 493 392 494 - # Render grid 495 393 for row in range(grid_h): 496 394 line = "" 497 395 for col in range(grid_w): ··· 501 399 line += f"[{c}]{char}[/{c}]" 502 400 console.print(line) 503 401 504 - # Legend 505 402 for name, tokens, color in legend_items: 506 - tokens_str = f"~{tokens:,}" if tokens < 1_000 else f"~{tokens / 1_000:.1f}k" 507 - console.print(f" [{color}]█[/{color}] {name}: [dim]{tokens_str}[/dim]") 403 + tokens_str = ( 404 + f"~{tokens:,}" 405 + if tokens < 1_000 406 + else f"~{tokens / 1_000:.1f}k" 407 + ) 408 + console.print( 409 + f" [{color}]█[/{color}] {name}: " 410 + f"[dim]{tokens_str}[/dim]" 411 + ) 508 412 509 - # Session totals (actual API counts) 510 413 if stats["total_input"] > 0: 511 414 console.print() 512 415 last_in = f"{stats['last_input']:,}" 513 416 last_out = f"{stats['last_output']:,}" 514 417 total_in = f"{stats['total_input']:,}" 515 418 total_out = f"{stats['total_output']:,}" 419 + # Cache breakdown — most of each turn's input is 420 + # actually a cache read (cheap, ~10% of new-token 421 + # cost), but it still counts toward the window. 422 + new = stats.get("last_new", 0) 423 + cache_read = stats.get("last_cache_read", 0) 424 + cache_write = stats.get("last_cache_write", 0) 425 + breakdown_parts = [] 426 + if new: 427 + breakdown_parts.append(f"{new:,} new") 428 + if cache_read: 429 + breakdown_parts.append(f"{cache_read:,} cached") 430 + if cache_write: 431 + breakdown_parts.append(f"{cache_write:,} cache-write") 432 + breakdown = ( 433 + f" ({' · '.join(breakdown_parts)})" 434 + if breakdown_parts 435 + else "" 436 + ) 516 437 console.print( 517 - f" [dim]Last turn: {last_in} in · {last_out} out[/dim]" 438 + f" [dim]Last turn: {last_in} in" 439 + f"{breakdown} · {last_out} out[/dim]" 518 440 ) 519 441 console.print( 520 442 f" [dim]Session: {total_in} in · {total_out} out[/dim]" ··· 547 469 console.print() 548 470 continue 549 471 550 - # Handle /save command — everything is already durably written 551 - # on each set_scene; this just confirms state without a round-trip. 472 + # Handle /save command 552 473 if action.strip().lower() == "/save": 553 474 from storied.session import load_session 554 475 console.print() ··· 577 498 f"{ooc_msg}]" 578 499 ) 579 500 580 - # Handle /note command — append directly to the player's notes.md 581 - # without a DM round-trip. The DM still sees the latest notes on 582 - # the next turn via its character context. 501 + # Handle /note command 583 502 if action.strip().lower().startswith("/note"): 584 503 note_msg = action.strip()[5:].strip() 585 504 if not note_msg: ··· 601 520 try: 602 521 console.print(Rule(style="dim blue")) 603 522 604 - # Debug mode: dump the per-turn context before the 605 - # response streams. `stream_action` rebuilds it 606 - # internally, but the double-build is cheap and keeps 607 - # the debug path a pure observer. 608 523 if args.debug: 609 524 console.print(Rule("Turn Context", style="dim")) 610 525 console.print( ··· 627 542 console.print(f"[dim]{chunk}[/dim]") 628 543 prev_type = "tool" 629 544 elif chunk == "": 630 - # Flush signal from engine (deferred tool starting) 631 545 renderer.flush() 632 546 else: 633 547 if prev_type == "tool": ··· 644 558 renderer.flush() 645 559 console.print() 646 560 647 - # Show debug token info if enabled 648 561 if args.debug and engine._last_result: 649 562 r = engine._last_result 650 563 in_tokens = r.usage.get("input_tokens", 0) ··· 655 568 f"{engine._total_output_tokens:,} out[/dim]" 656 569 ) 657 570 658 - # Check for completed background tick 659 571 if ticker: 660 572 tick_result = ticker.pop_result() 661 573 if tick_result and tick_result.tool_calls > 0: 662 574 console.print() 663 575 console.print( 664 - f"[dim]The world shifted while you considered your next move. " 576 + "[dim]The world shifted while you considered " 577 + f"your next move. " 665 578 f"({tick_result.tool_calls} changes)[/dim]" 666 579 ) 667 - # Maybe launch a new tick if the day advanced 668 580 ticker.maybe_tick(engine._campaign_log) 669 581 670 - # Advancement evaluator — tick the turn counter and check results 671 582 if advancement: 672 583 if engine.combat_ended: 673 584 advancement.on_combat_end() ··· 680 591 "[dim]The character reflects on recent experiences...[/dim]" 681 592 ) 682 593 683 - # Check if session ended (player quit gracefully) 684 594 if engine.session_ended: 685 - console.print( 686 - "[yellow]Session saved. Farewell, adventurer![/yellow]" 687 - ) 688 - break 689 - 690 - # Check if character was just created 691 - if creation_mode and engine.character_created: 692 - console.print() 693 - console.print(Panel.fit( 694 - "[bold green]Character created![/bold green]\n" 695 - "Run [cyan]storied play[/cyan] again to begin your adventure.", 696 - border_style="green", 697 - )) 595 + if is_onboarding: 596 + console.print( 597 + "[green]Onboarding complete.[/green]" 598 + ) 599 + else: 600 + console.print( 601 + "[yellow]Session saved. Farewell, adventurer![/yellow]" 602 + ) 698 603 break 699 604 700 605 except KeyboardInterrupt: 701 606 console.print("\n[red][Interrupted][/red]") 702 - if sandbox: 703 - console.print("[cyan]Sandbox session ended.[/cyan]") 704 - elif not creation_mode: 705 - console.print("[dim]Saving session...[/dim]") 706 - try: 707 - save_msg = "I need to quit now. Please save the game." 708 - list(engine.stream_action(save_msg)) 709 - except Exception: 710 - pass 711 - console.print( 712 - "[yellow]Session saved. Farewell, adventurer![/yellow]" 713 - ) 714 - else: 715 - console.print("[yellow]Farewell![/yellow]") 607 + on_graceful_exit() 716 608 break 717 609 718 610 except KeyboardInterrupt: 719 611 console.print() 612 + on_graceful_exit() 613 + 614 + 615 + def cmd_play(args: argparse.Namespace) -> int: 616 + """Start an interactive DM session.""" 617 + import atexit 618 + 619 + # Set up readline history 620 + import contextlib 621 + import readline 622 + import shutil 623 + import tempfile 624 + 625 + from rich.console import Console 626 + from rich.panel import Panel 627 + from rich.rule import Rule 628 + 629 + from storied.character import load_character 630 + from storied.engine import DMEngine 631 + history_file = Path.home() / ".storied_history" 632 + with contextlib.suppress(FileNotFoundError): 633 + readline.read_history_file(history_file) 634 + readline.set_history_length(1000) 635 + atexit.register(readline.write_history_file, history_file) 636 + 637 + # Set up slash command autocomplete 638 + def slash_completer(text: str, state: int) -> str | None: 639 + if text.startswith("/"): 640 + matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)] 641 + return matches[state] if state < len(matches) else None 642 + return None 643 + 644 + readline.set_completer_delims(readline.get_completer_delims().replace("/", "")) 645 + readline.set_completer(slash_completer) 646 + readline.parse_and_bind("tab: complete") 647 + readline.parse_and_bind("set enable-bracketed-paste on") 648 + 649 + console = Console() 650 + world_id = args.world if args.world else "default" 651 + player_id = "default" 652 + sandbox = getattr(args, "sandbox", False) 653 + sandbox_dir: Path | None = None 654 + 655 + from storied.paths import configure, data_home, resolve_data_home 656 + 657 + # Sandbox mode: throwaway worlds + players in a temp directory. 658 + # User homebrew rules stay pointed at the real ~/.storied/rules/ 659 + # so the sandbox isn't a pristine playground — it's still "your 660 + # rules", just with a fresh world to mess around in. 661 + if sandbox: 662 + sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) 663 + (sandbox_dir / "worlds" / world_id).mkdir(parents=True) 664 + (sandbox_dir / "players" / player_id).mkdir(parents=True) 665 + configure( 666 + data_home=sandbox_dir, 667 + user_rules_home=Path.home() / ".storied" / "rules", 668 + ) 669 + else: 670 + configure( 671 + data_home=resolve_data_home(getattr(args, "base_path", None)) 672 + ) 673 + data_home().mkdir(parents=True, exist_ok=True) 674 + 675 + # Export STORIED_HOME so any subprocess that re-enters storied 676 + # (e.g. via run_code) sees the same data directory. 677 + import os 678 + os.environ["STORIED_HOME"] = str(data_home()) 679 + 680 + from storied.paths import world_path 681 + 682 + transcript_path = Path(args.transcript) if args.transcript else None 683 + 684 + try: 685 + # Cold-start detection: if either the character or the world's 686 + # style.md is missing, we can't run the story — enter onboarding. 687 + # Sandbox skips this entirely; it starts fresh every time by design. 688 + cold_start = not sandbox and _is_cold_start(world_id, player_id) 689 + 690 + # Onboarding phase: DM captures worldbuilding preferences (via 691 + # tune) and creates the character (via create_character) in a 692 + # single woven conversation. When both artifacts land and the 693 + # DM calls end_session, we fall through to the normal seed + 694 + # play path in the same invocation. 695 + if cold_start: 696 + console.print(Panel.fit( 697 + "[bold]Welcome to Storied![/bold]\n" 698 + "Let's figure out the kind of adventure you want\n" 699 + "and build your character.", 700 + title="New Campaign", 701 + border_style="yellow", 702 + )) 703 + console.print(f"[dim]World: {world_id}[/dim]") 704 + console.print() 705 + 706 + onboarding_engine = DMEngine( 707 + world_id=world_id, 708 + player_id=player_id, 709 + prompt_name="new-game", 710 + transcript_path=transcript_path, 711 + ) 712 + onboarding_engine.debug = args.debug 713 + 714 + if args.debug: 715 + console.print(Rule("System Prompt", style="dim")) 716 + console.print( 717 + onboarding_engine._base_prompt, 718 + style="dim", 719 + highlight=False, 720 + ) 721 + console.print(Rule(style="dim")) 722 + console.print() 723 + 724 + _run_engine_loop( 725 + onboarding_engine, 726 + console=console, 727 + args=args, 728 + player_id=player_id, 729 + is_sandbox=False, 730 + is_onboarding=True, 731 + first_message="Let's get started.", 732 + ) 733 + 734 + # Re-check artifacts after the onboarding engine exits. 735 + character = load_character(player_id) 736 + style_exists = (world_path(world_id) / "style.md").exists() 737 + missing = [] 738 + if character is None: 739 + missing.append("character") 740 + if not style_exists: 741 + missing.append("world style") 742 + if missing: 743 + console.print() 744 + console.print(Panel.fit( 745 + f"[yellow]Onboarding incomplete — missing: " 746 + f"{', '.join(missing)}.[/yellow]\n" 747 + f"Run [cyan]storied play[/cyan] again to continue.", 748 + border_style="yellow", 749 + )) 750 + return 0 751 + 752 + # Welcome panel for continuing play (skipped when we just 753 + # finished onboarding — the transition into seeding + play 754 + # speaks for itself). 720 755 if sandbox: 721 - console.print("[cyan]Sandbox session ended.[/cyan]") 722 - elif not creation_mode: 723 - console.print("[dim]Saving session...[/dim]") 724 - try: 725 - save_msg = "I need to quit now. Please save the game." 726 - list(engine.stream_action(save_msg)) 727 - except Exception: 728 - pass 729 - console.print( 730 - "[yellow]Session saved. Farewell, adventurer![/yellow]" 756 + console.print(Panel.fit( 757 + "[bold]Storied Sandbox[/bold]\n" 758 + "No character, no world — just you and the DM.\n" 759 + "Type [cyan]Ctrl+D[/cyan] to quit.", 760 + title="Sandbox", 761 + border_style="cyan", 762 + )) 763 + console.print(f"[dim]World: {world_id} (sandbox)[/dim]") 764 + console.print() 765 + elif not cold_start: 766 + console.print(Panel.fit( 767 + "[bold]Welcome to Storied![/bold]\n" 768 + "Let the DM know when you're ready to quit.\n" 769 + "Type [cyan]/context[/cyan] to see token usage.", 770 + title="Storied", 771 + border_style="green", 772 + )) 773 + console.print(f"[dim]World: {world_id}[/dim]") 774 + console.print() 775 + 776 + # Seed the world if there's no session yet. After cold-start 777 + # onboarding, style.md is now on disk and seed_world reads it 778 + # so the world it builds reflects the player's preferences. 779 + if not sandbox: 780 + character = load_character(player_id) 781 + from storied.session import load_session 782 + 783 + session = load_session(player_id) 784 + if session is None and character is not None: 785 + from storied.planner import seed_world 786 + 787 + console.print( 788 + f"[dim]Seeding the world for {character['name']}...[/dim]" 789 + ) 790 + 791 + def on_seed_progress(msg: str) -> None: 792 + console.print(f"[dim] {msg}[/dim]") 793 + 794 + result = seed_world( 795 + world_id=world_id, 796 + player_id=player_id, 797 + on_progress=on_seed_progress, 798 + ) 799 + 800 + console.print( 801 + f"[dim] Done — {result.tool_calls} tool calls, " 802 + f"{result.elapsed:.1f}s[/dim]" 803 + ) 804 + console.print() 805 + 806 + # Build the play engine. 807 + engine = DMEngine( 808 + world_id=world_id, 809 + player_id=player_id, 810 + prompt_name="dm-system", 811 + transcript_path=transcript_path, 812 + ) 813 + engine.debug = args.debug 814 + 815 + if args.debug: 816 + console.print(Rule("System Prompt", style="dim")) 817 + console.print(engine._base_prompt, style="dim", highlight=False) 818 + console.print(Rule(style="dim")) 819 + console.print() 820 + 821 + # Background agents only run during live play, not sandbox. 822 + ticker = None 823 + advancement = None 824 + if not sandbox: 825 + from storied.advancement import BackgroundAdvancement 826 + from storied.planner import BackgroundTicker 827 + 828 + ticker = BackgroundTicker( 829 + world_id=world_id, 830 + player_id=player_id, 731 831 ) 732 - else: 733 - console.print("[yellow]Farewell![/yellow]") 832 + ticker.maybe_tick(engine._campaign_log) 833 + 834 + advancement = BackgroundAdvancement( 835 + world_id=world_id, 836 + player_id=player_id, 837 + ) 838 + 839 + first_message = ( 840 + "[Sandbox session — no character, no world. The player wants to " 841 + "experiment freely. Jump straight into whatever they ask.]" 842 + if sandbox 843 + else "[Session starting]" 844 + ) 845 + 846 + _run_engine_loop( 847 + engine, 848 + console=console, 849 + args=args, 850 + player_id=player_id, 851 + is_sandbox=sandbox, 852 + is_onboarding=False, 853 + first_message=first_message, 854 + ticker=ticker, 855 + advancement=advancement, 856 + ) 734 857 finally: 735 - if sandbox_dir and sandbox_dir.exists(): 858 + if sandbox_dir is not None and sandbox_dir.exists(): 736 859 shutil.rmtree(sandbox_dir, ignore_errors=True) 737 860 738 861 return 0 ··· 895 1018 896 1019 # srd command group 897 1020 srd_parser = subparsers.add_parser("srd", help="SRD processing commands") 898 - srd_subparsers = srd_parser.add_subparsers(dest="srd_command", help="SRD subcommands") 1021 + srd_subparsers = srd_parser.add_subparsers( 1022 + dest="srd_command", help="SRD subcommands" 1023 + ) 899 1024 900 1025 # srd download 901 1026 download_parser = srd_subparsers.add_parser("download", help="Download SRD PDF") ··· 916 1041 download_parser.set_defaults(func=cmd_srd_download) 917 1042 918 1043 # srd convert 919 - convert_parser = srd_subparsers.add_parser("convert", help="Convert SRD PDF to markdown") 1044 + convert_parser = srd_subparsers.add_parser( 1045 + "convert", help="Convert SRD PDF to markdown" 1046 + ) 920 1047 convert_parser.add_argument( 921 1048 "--pdf", 922 1049 help="Path to PDF (default: rules/sources/SRD_CC_v5.2.1.pdf)", ··· 928 1055 convert_parser.set_defaults(func=cmd_srd_convert) 929 1056 930 1057 # srd split 931 - split_parser = srd_subparsers.add_parser("split", help="Split SRD markdown into sections") 1058 + split_parser = srd_subparsers.add_parser( 1059 + "split", help="Split SRD markdown into sections" 1060 + ) 932 1061 split_parser.add_argument( 933 1062 "--input", "-i", 934 1063 help="Input markdown file (default: rules/srd-5.2.1/srd.md)", ··· 940 1069 split_parser.set_defaults(func=cmd_srd_split) 941 1070 942 1071 # srd clean 943 - clean_parser = srd_subparsers.add_parser("clean", help="Clean up extracted markdown files") 1072 + clean_parser = srd_subparsers.add_parser( 1073 + "clean", help="Clean up extracted markdown files" 1074 + ) 944 1075 clean_parser.add_argument( 945 1076 "--dir", "-d", 946 1077 help="Sections directory (default: rules/srd-5.2.1/sections)", ··· 1003 1134 reset_parser.set_defaults(func=cmd_reset) 1004 1135 1005 1136 # seed command 1006 - seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet") 1137 + seed_parser = subparsers.add_parser( 1138 + "seed", help="Seed an empty world from a character sheet" 1139 + ) 1007 1140 seed_parser.add_argument( 1008 1141 "--world", "-w", 1009 1142 default="default", ··· 1074 1207 parser.print_help() 1075 1208 return 0 1076 1209 1077 - if args.command in ("srd", "index") and not getattr(args, f"{args.command}_command", None): 1210 + if args.command in ("srd", "index") and not getattr( 1211 + args, f"{args.command}_command", None 1212 + ): 1078 1213 for action in parser._subparsers._actions: 1079 1214 if isinstance(action, argparse._SubParsersAction): 1080 1215 sub = action.choices.get(args.command)
+37 -13
src/storied/engine.py
··· 10 10 11 11 from storied import notifications 12 12 from storied.character import format_character_context, load_character 13 - from storied.notification_formatters import ( 14 - DEFERRED_FORMATTERS, 15 - TOOL_LABELS, 16 - _extract_json_field, 17 - _extract_roll_reason, 18 - ) 19 13 from storied.claude import ( 20 14 Result, 21 15 TextDelta, ··· 24 18 ToolStop, 25 19 stream_with_tools, 26 20 ) 27 - from storied.mcp_server import start_server as start_mcp_server 28 21 from storied.content import ContentResolver 29 22 from storied.log import CampaignLog, TranscriptLog 23 + from storied.mcp_server import start_server as start_mcp_server 24 + from storied.notification_formatters import ( 25 + DEFERRED_FORMATTERS, 26 + TOOL_LABELS, 27 + _extract_json_field, 28 + _extract_roll_reason, 29 + ) 30 30 from storied.paths import data_home, player_path, world_path 31 31 from storied.session import ( 32 32 extract_wiki_links, ··· 441 441 + sum(context_breakdown.values()) 442 442 ) 443 443 444 - # Usage from last result event 445 - last_input = 0 444 + # Usage from the last result event. The Anthropic API splits 445 + # input across three buckets: new tokens, cache reads, and cache 446 + # writes. The "real" per-turn input — which equals the size of 447 + # the model's context window for that turn — is the sum. 448 + last_new = 0 449 + last_cache_read = 0 450 + last_cache_write = 0 446 451 last_output = 0 447 452 if self._last_result: 448 - last_input = self._last_result.usage.get("input_tokens", 0) 449 - last_output = self._last_result.usage.get("output_tokens", 0) 453 + usage = self._last_result.usage 454 + last_new = usage.get("input_tokens", 0) 455 + last_cache_read = usage.get("cache_read_input_tokens", 0) 456 + last_cache_write = usage.get("cache_creation_input_tokens", 0) 457 + last_output = usage.get("output_tokens", 0) 458 + last_input = last_new + last_cache_read + last_cache_write 450 459 451 460 return { 452 461 "model_limit": model_limit, ··· 455 464 "context_parts": context_breakdown, 456 465 "context_total": context_total, 457 466 "last_input": last_input, 467 + "last_new": last_new, 468 + "last_cache_read": last_cache_read, 469 + "last_cache_write": last_cache_write, 458 470 "last_output": last_output, 459 471 "total_input": self._total_input_tokens, 460 472 "total_output": self._total_output_tokens, ··· 546 558 desc = _extract_json_field(current_tool_json, "description") 547 559 label = desc if desc else "Running code" 548 560 yield f"[{label}...]" 549 - elif deferred_notification and current_tool_name in DEFERRED_FORMATTERS: 561 + elif ( 562 + deferred_notification 563 + and current_tool_name in DEFERRED_FORMATTERS 564 + ): 550 565 formatter = DEFERRED_FORMATTERS[current_tool_name] 551 566 yield f"[{formatter(current_tool_json)}...]" 552 567 ··· 562 577 case Result() as r: 563 578 self._session_id = r.session_id 564 579 self._last_result = r 565 - self._total_input_tokens += r.usage.get("input_tokens", 0) 580 + # Total input the model actually processed = new 581 + # uncached input + cache reads + cache writes. The 582 + # raw `input_tokens` field is just the new chunk; 583 + # the bulk of each turn lives behind cache_read. 584 + real_in = ( 585 + r.usage.get("input_tokens", 0) 586 + + r.usage.get("cache_read_input_tokens", 0) 587 + + r.usage.get("cache_creation_input_tokens", 0) 588 + ) 589 + self._total_input_tokens += real_in 566 590 self._total_output_tokens += r.usage.get("output_tokens", 0) 567 591 self._log_transcript("result", { 568 592 "session_id": r.session_id,
+31 -20
src/storied/mcp_server.py
··· 69 69 vi: VectorIndex, 70 70 srd_root: Path | None = None, 71 71 ) -> None: 72 - """Auto-populate an empty index from all three content layers. 72 + """Idempotently populate the index from all three content layers. 73 73 74 - Populates in priority-inverse order so higher-priority layers are 75 - indexed last (letting the SRD seed fast-path do its job first): 74 + Safe to call repeatedly: the SRD layer is skipped once 75 + ``vi.has_source("srd")`` is true, and the user/world reindex passes 76 + are mtime-aware. Populates in priority-inverse order so higher- 77 + priority layers are indexed last (letting the SRD seed fast-path do 78 + its job first): 76 79 77 80 1. Shipped SRD — ``<shipped_rules>/srd-5.2.1/`` via the prebuilt 78 81 ``search.db`` seed if present, else reindex the sections dir. 79 - Tagged ``source="srd"``. 82 + Tagged ``source="srd"``. Skipped when already seeded. 80 83 2. User homebrew — ``<user_rules>/`` if the directory exists. 81 84 Tagged ``source="user"``. 82 85 3. World content — ``<world_dir>/``. Tagged ``source="world"``. ··· 85 88 the shipped layer at a tmp path. In production it resolves to 86 89 ``paths.shipped_rules_path() / "srd-5.2.1"``. 87 90 """ 88 - # 1. Shipped SRD 89 - if srd_root is None: 90 - srd_root = paths.shipped_rules_path() / "srd-5.2.1" 91 - srd_seed = srd_root / "search.db" 92 - if srd_seed.exists(): 93 - vi.reseed(srd_seed) 94 - else: 95 - srd_dir = srd_root / "sections" 96 - if srd_dir.exists(): 97 - vi.reindex_directory(srd_dir, source="srd") 91 + # 1. Shipped SRD — skip if already seeded. `reseed` replaces the 92 + # entire db file, so calling it when the SRD is already present 93 + # would wipe any world/transcript rows that have accumulated. 94 + if not vi.has_source("srd"): 95 + if srd_root is None: 96 + srd_root = paths.shipped_rules_path() / "srd-5.2.1" 97 + srd_seed = srd_root / "search.db" 98 + if srd_seed.exists(): 99 + vi.reseed(srd_seed) 100 + else: 101 + srd_dir = srd_root / "sections" 102 + if srd_dir.exists(): 103 + vi.reindex_directory(srd_dir, source="srd") 98 104 99 105 # 2. User homebrew — flat layout, source="user" 100 106 user_rules = paths.user_rules_path() 101 107 if user_rules.exists(): 102 108 vi.reindex_directory(user_rules, source="user") 103 109 104 - # 3. World content — flat layout, source="world" 110 + # 3. World content — flat layout, source="world". Skip the 111 + # transcripts/ subdirectory: those files are indexed separately 112 + # by the engine after each turn with source="transcript". 105 113 if world_dir.exists(): 106 - vi.reindex_directory(world_dir, source="world") 114 + vi.reindex_directory( 115 + world_dir, source="world", skip_subdirs=frozenset({"transcripts"}), 116 + ) 107 117 108 118 109 119 async def _compose_server(role: str) -> FastMCP: ··· 187 197 188 198 world_dir = paths.world_path(world_id) 189 199 190 - vector_index = VectorIndex( 191 - world_dir / "search.db", 192 - on_empty=lambda vi: _populate_index(world_dir, vi), 193 - ) 200 + vector_index = VectorIndex(world_dir / "search.db") 201 + # Populate eagerly so the first recall never races the transcript 202 + # upsert at turn end. `_populate_index` is idempotent — subsequent 203 + # calls skip the SRD reseed and mtime-check the user/world layers. 204 + _populate_index(world_dir, vector_index) 194 205 195 206 ctx = init_ctx( 196 207 world_id=world_id,
+18 -5
src/storied/planner.py
··· 7 7 from threading import Thread 8 8 9 9 from storied.character import format_character_context, load_character 10 - from storied.claude import Result, run_with_tools 10 + from storied.claude import run_with_tools 11 11 from storied.engine import load_prompt 12 12 from storied.log import CampaignLog 13 13 from storied.mcp_server import start_server as start_mcp_server 14 - from storied.paths import data_home 14 + from storied.paths import data_home, world_path 15 15 from storied.session import ( 16 16 extract_wiki_links, 17 17 load_session, ··· 61 61 62 62 63 63 def find_nearby_entities( 64 - session: dict, 64 + session: dict[str, object], 65 65 world_id: str, 66 66 ) -> list[tuple[str, Path]]: 67 67 """Find entities near the player by walking wikilinks. ··· 280 280 if character is None: 281 281 return SeedResult() 282 282 283 - # Build context from the character sheet 284 - context = format_character_context(character) 283 + # Build context from the character sheet, prefixed with any style 284 + # preferences captured during onboarding so the seeded world reflects 285 + # the tone/genre/pacing the player asked for. 286 + style_block = "" 287 + style_path = world_path(world_id) / "style.md" 288 + if style_path.exists(): 289 + style_text = style_path.read_text().strip() 290 + if style_text: 291 + style_block = ( 292 + "## Player Preferences\n\n" 293 + f"{style_text}\n\n" 294 + "---\n\n" 295 + ) 296 + 297 + context = style_block + format_character_context(character) 285 298 system_prompt = load_prompt("world-seed") 286 299 287 300 campaign_log = CampaignLog(world_id)
+28 -16
src/storied/search.py
··· 7 7 import re 8 8 import shutil 9 9 import struct 10 + from collections.abc import Callable 10 11 from dataclasses import dataclass 11 12 from pathlib import Path 12 - from typing import Callable 13 13 14 14 import pysqlite3 as sqlite3 15 15 import sqlite_vec ··· 151 151 def __init__( 152 152 self, 153 153 db_path: Path, 154 - on_empty: Callable[["VectorIndex"], None] | None = None, 155 154 ): 156 155 self._db_path = db_path 157 156 self._embed_fn: Callable[[list[str]], list[list[float]]] = _default_embed 158 - self._on_empty = on_empty 159 157 self._conn = self._open_or_recreate() 160 158 161 159 @staticmethod ··· 174 172 shutil.copy2(seed_path, self._db_path) 175 173 self._conn = self._open_or_recreate() 176 174 175 + def has_source(self, source: str) -> bool: 176 + """True if at least one document is already indexed from ``source``.""" 177 + row = self._conn.execute( 178 + "SELECT 1 FROM documents WHERE source = ? LIMIT 1", 179 + (source,), 180 + ).fetchone() 181 + return row is not None 182 + 177 183 def close(self) -> None: 178 184 """Close the database connection.""" 179 185 self._conn.close() ··· 289 295 decay_ref: Current game day for age-decay on transcripts. 290 296 If None, no decay is applied. 291 297 """ 292 - # Auto-populate if the index is empty (fires at most once) 293 - if self._on_empty: 294 - count = self._conn.execute( 295 - "SELECT count(*) FROM documents" 296 - ).fetchone()[0] 297 - if count == 0: 298 - self._on_empty(self) 299 - self._on_empty = None 300 - 301 298 # Normalize source_filter to a set for O(1) membership checks. 302 299 allowed_sources: set[str] | None 303 300 if source_filter is None: ··· 332 329 333 330 score = 1.0 / (1.0 + distance) 334 331 335 - if decay_ref is not None and game_day is not None: 336 - if source in ("transcript", "player"): 337 - score *= age_decay(decay_ref, game_day) 332 + if ( 333 + decay_ref is not None 334 + and game_day is not None 335 + and source in ("transcript", "player") 336 + ): 337 + score *= age_decay(decay_ref, game_day) 338 338 339 339 hits.append(SearchHit( 340 340 doc_id=doc_id, ··· 348 348 hits.sort(key=lambda h: h.score, reverse=True) 349 349 return hits[:limit] 350 350 351 - def reindex_directory(self, directory: Path, source: str) -> int: 351 + def reindex_directory( 352 + self, 353 + directory: Path, 354 + source: str, 355 + skip_subdirs: frozenset[str] | None = None, 356 + ) -> int: 352 357 """Index all .md files under a directory. 353 358 354 359 Skips files whose mtime hasn't changed since last index. 355 360 Returns the number of documents indexed (counting chunks). 361 + 362 + ``skip_subdirs`` is a set of top-level subdirectory names to 363 + exclude from the walk. Used when indexing a world_dir to skip 364 + ``transcripts/`` — those files are indexed separately by the 365 + engine under ``source="transcript"``. 356 366 """ 357 367 existing = {} 358 368 for row in self._conn.execute( ··· 366 376 367 377 for md_file in sorted(directory.rglob("*.md")): 368 378 rel = md_file.relative_to(directory) 379 + if skip_subdirs and rel.parts and rel.parts[0] in skip_subdirs: 380 + continue 369 381 content = md_file.read_text() 370 382 mtime = md_file.stat().st_mtime 371 383 content_type = rel.parts[0] if len(rel.parts) > 1 else ""
+25 -4
src/storied/tools/character.py
··· 294 294 ) -> str: 295 295 """Adjust the player's coins by relative amounts. 296 296 297 - Use negative values to spend, positive to gain. Omit any denomination 298 - you don't want to change. Coins are clamped to zero on the underlying purse. 297 + Negative values spend, positive values gain. Omit any denomination 298 + you're not changing. The call is **atomic**: if any denomination 299 + would go below zero, the whole call is rejected and nothing on the 300 + sheet changes — fix the deltas and call again. 301 + 302 + **Express each transaction as a single call with the NET delta to 303 + the purse.** If the player can't pay exact change, fold the 304 + payment AND the change you're handing back into one call. Don't 305 + spend first and then call again to "make change" — that's how you 306 + end up double-charging. 307 + 308 + Examples: 309 + spend 5 gp: {"gp": -5} 310 + loot 10 gp + 5 sp: {"gp": 10, "sp": 5} 311 + sell an item for 12 sp: {"sp": 12} 312 + 313 + pay 5 cp from a 9 sp / 0 cp purse (1 silver in, 5 cp change back): 314 + {"sp": -1, "cp": 5} 315 + pay 7 sp from a 1 gp / 0 sp purse (1 gold in, 3 sp change back): 316 + {"gp": -1, "sp": 3} 317 + pay 80 gp with 1 platinum: {"pp": -1, "gp": 20} 318 + 319 + The math: 1 pp = 10 gp, 1 gp = 10 sp = 2 ep, 1 sp = 10 cp. 299 320 300 321 Args: 301 322 deltas: Coin changes per denomination (cp/sp/ep/gp/pp). 302 - Example: spend 5 gp → {"gp": -5}; gain 10 gp + 5 sp → {"gp": 10, "sp": 5} 303 323 304 324 Returns: 305 - Summary of changes and new purse balance 325 + Summary of changes and new purse balance, or an error message 326 + explaining the shortfall if the call was rejected. 306 327 """ 307 328 # Drop zero-deltas before passing to the underlying op so the result 308 329 # message only mentions denominations the DM actually touched.
+44 -3
tests/test_character.py
··· 1143 1143 assert data["state"]["purse"]["gp"] == 53 1144 1144 assert data["state"]["purse"]["sp"] == 5 1145 1145 1146 - def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path): 1146 + def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path): 1147 + before = load_character("test-player")["state"]["purse"]["gp"] 1147 1148 result = adjust_coins( 1148 1149 "test-player", {"gp": -100} 1149 1150 ) 1150 1151 data = load_character("test-player") 1151 - assert data["state"]["purse"]["gp"] == 0 1152 - assert "short" in result.lower() 1152 + # Rejected — purse is unchanged. 1153 + assert data["state"]["purse"]["gp"] == before 1154 + assert "cannot adjust" in result.lower() 1155 + assert "insufficient" in result.lower() 1156 + 1157 + def test_adjust_coins_rejects_partial_underflow( 1158 + self, mira: dict, player_dir: Path, 1159 + ): 1160 + # Mira has gp but not enough cp. The mixed delta should be 1161 + # rejected as a whole — neither denomination should change. 1162 + before = load_character("test-player")["state"]["purse"] 1163 + result = adjust_coins( 1164 + "test-player", {"gp": -1, "cp": -100} 1165 + ) 1166 + data = load_character("test-player") 1167 + assert data["state"]["purse"]["gp"] == before["gp"] 1168 + assert data["state"]["purse"]["cp"] == before["cp"] 1169 + assert "cannot adjust" in result.lower() 1170 + 1171 + def test_adjust_coins_making_change(self, player_dir: Path): 1172 + from storied.character.data import create_character 1173 + create_character( 1174 + player_id="test-player", 1175 + name="Coin Test", 1176 + race="Human", 1177 + char_class="Fighter", 1178 + level=1, 1179 + abilities={ 1180 + "strength": 10, "dexterity": 10, "constitution": 10, 1181 + "intelligence": 10, "wisdom": 10, "charisma": 10, 1182 + }, 1183 + hp_max=10, 1184 + ac=10, 1185 + purse={"sp": 9}, 1186 + ) 1187 + # Paying 5 cp from a 9 sp / 0 cp purse must work as one atomic call 1188 + # with the silver going out and the change coming back together. 1189 + result = adjust_coins("test-player", {"sp": -1, "cp": 5}) 1190 + data = load_character("test-player") 1191 + assert data["state"]["purse"]["sp"] == 8 1192 + assert data["state"]["purse"]["cp"] == 5 1193 + assert "cannot" not in result.lower() 1153 1194 1154 1195 1155 1196 class TestNotes:
+83
tests/test_cli.py
··· 1 + """Tests for CLI helpers — cold-start detection and onboarding artifacts.""" 2 + 3 + import pytest 4 + 5 + from storied.character import create_character 6 + from storied.cli import _is_cold_start 7 + from storied.paths import world_path 8 + 9 + 10 + @pytest.fixture 11 + def _minimal_character() -> None: 12 + """Create a minimal character so load_character finds one.""" 13 + create_character( 14 + player_id="default", 15 + name="Test", 16 + race="Human", 17 + char_class="Fighter", 18 + level=1, 19 + abilities={ 20 + "strength": 10, 21 + "dexterity": 10, 22 + "constitution": 10, 23 + "intelligence": 10, 24 + "wisdom": 10, 25 + "charisma": 10, 26 + }, 27 + hp_max=10, 28 + ac=10, 29 + ) 30 + 31 + 32 + @pytest.fixture 33 + def _world_style() -> None: 34 + """Write a non-empty style.md for the default world.""" 35 + world_dir = world_path("default") 36 + world_dir.mkdir(parents=True, exist_ok=True) 37 + (world_dir / "style.md").write_text("Grim. Slow-burn.\n") 38 + 39 + 40 + class TestColdStartDetection: 41 + def test_cold_start_when_nothing_exists(self): 42 + assert _is_cold_start("default", "default") is True 43 + 44 + def test_cold_start_when_only_character_exists( 45 + self, 46 + _minimal_character: None, 47 + ): 48 + assert _is_cold_start("default", "default") is True 49 + 50 + def test_cold_start_when_only_style_exists( 51 + self, 52 + _world_style: None, 53 + ): 54 + assert _is_cold_start("default", "default") is True 55 + 56 + def test_not_cold_start_when_both_exist( 57 + self, 58 + _minimal_character: None, 59 + _world_style: None, 60 + ): 61 + assert _is_cold_start("default", "default") is False 62 + 63 + def test_not_cold_start_ignores_other_worlds( 64 + self, 65 + _minimal_character: None, 66 + ): 67 + # Style lives in a different world — doesn't count for this one. 68 + world_dir = world_path("other") 69 + world_dir.mkdir(parents=True, exist_ok=True) 70 + (world_dir / "style.md").write_text("A vibe.\n") 71 + 72 + assert _is_cold_start("default", "default") is True 73 + 74 + 75 + class TestOnboardingPromptExists: 76 + def test_new_game_prompt_file_exists(self): 77 + from storied.engine import load_prompt 78 + 79 + content = load_prompt("new-game") 80 + # Sanity check: the prompt mentions both onboarding deliverables. 81 + assert "tune" in content 82 + assert "create_character" in content 83 + assert "end_session" in content
+156 -8
tests/test_mcp_server.py
··· 11 11 12 12 import pytest 13 13 14 - from storied.initiative import Combatant 15 14 from storied.mcp_server import _compose_server 16 - from storied.tools import ToolContext, _context 15 + from storied.tools import ToolContext 17 16 from storied.tools.character import refresh_advancement_visibility 18 17 from storied.tools.combat import _flip_into_combat, _flip_out_of_combat 19 18 ··· 296 295 from storied.mcp_server import _populate_index 297 296 298 297 vi = MagicMock() 298 + vi.has_source.return_value = False 299 299 _populate_index( 300 300 tmp_path / "worlds" / "missing", 301 301 vi, ··· 313 313 world_dir = tmp_path / "worlds" / "test" 314 314 world_dir.mkdir(parents=True) 315 315 vi = MagicMock() 316 + vi.has_source.return_value = False 316 317 _populate_index( 317 318 world_dir, vi, srd_root=tmp_path / "srd-missing", 318 319 ) 319 - vi.reindex_directory.assert_called_once_with(world_dir, source="world") 320 + vi.reindex_directory.assert_called_once_with( 321 + world_dir, source="world", 322 + skip_subdirs=frozenset({"transcripts"}), 323 + ) 320 324 321 325 def test_srd_sections_dir(self, tmp_path): 322 326 from unittest.mock import MagicMock ··· 328 332 srd_dir.mkdir(parents=True) 329 333 world_dir = tmp_path / "worlds" / "test" 330 334 vi = MagicMock() 335 + vi.has_source.return_value = False 331 336 _populate_index(world_dir, vi, srd_root=srd_root) 332 337 # SRD sections present → reindex SRD; no user layer, no world dir 333 338 assert vi.reindex_directory.call_count == 1 ··· 336 341 def test_user_layer_indexed(self, tmp_path): 337 342 """When the user homebrew directory exists, _populate_index 338 343 reindexes it with source='user' after the shipped SRD.""" 339 - from unittest.mock import MagicMock 344 + from unittest.mock import MagicMock, call 340 345 341 346 from storied.mcp_server import _populate_index 342 347 ··· 350 355 world_dir.mkdir(parents=True) 351 356 352 357 vi = MagicMock() 358 + vi.has_source.return_value = False 353 359 _populate_index( 354 360 world_dir, vi, srd_root=tmp_path / "srd-missing", 355 361 ) 356 362 # Should have indexed user and world, in that order 357 - calls = vi.reindex_directory.call_args_list 358 - assert len(calls) == 2 359 - assert calls[0] == ((user_dir,), {"source": "user"}) 360 - assert calls[1] == ((world_dir,), {"source": "world"}) 363 + assert vi.reindex_directory.call_args_list == [ 364 + call(user_dir, source="user"), 365 + call( 366 + world_dir, 367 + source="world", 368 + skip_subdirs=frozenset({"transcripts"}), 369 + ), 370 + ] 371 + 372 + def test_populate_index_is_idempotent(self, tmp_path): 373 + """Once the SRD is seeded, subsequent calls must not reseed 374 + (which would wipe world/transcript rows by file-copying the SRD 375 + db over the live one).""" 376 + from unittest.mock import MagicMock 377 + 378 + from storied.mcp_server import _populate_index 379 + 380 + srd_root = tmp_path / "srd-5.2.1" 381 + srd_root.mkdir(parents=True) 382 + srd_seed = srd_root / "search.db" 383 + srd_seed.write_bytes(b"sqlite stub") 384 + world_dir = tmp_path / "worlds" / "test" 385 + world_dir.mkdir(parents=True) 386 + 387 + vi = MagicMock() 388 + vi.has_source.return_value = True # SRD already seeded 389 + _populate_index(world_dir, vi, srd_root=srd_root) 390 + vi.reseed.assert_not_called() 361 391 362 392 def test_flip_helpers_no_op_when_root_unset(self): 363 393 """The combat-tag flip helpers must not crash when no top-level ··· 390 420 (srd_root / "sections").mkdir() 391 421 world_dir = tmp_path / "worlds" / "test" 392 422 vi = MagicMock() 423 + vi.has_source.return_value = False 393 424 _populate_index(world_dir, vi, srd_root=srd_root) 394 425 vi.reseed.assert_called_once_with(srd_seed) 395 426 # When the seed exists, we don't also reindex SRD sections 396 427 vi.reindex_directory.assert_not_called() 428 + 429 + 430 + class TestRulesLookupRace: 431 + """Regression test for the transcript-upsert-vs-first-search race. 432 + 433 + Before the fix, the lazy ``on_empty`` seeding would miss its window 434 + whenever the DM's first turn didn't call ``recall``: the engine would 435 + upsert the transcript at turn end, making the db non-empty, and the 436 + next search would skip seeding because ``count > 0``. Rules lookups 437 + would then return nothing. The fix eagerly populates in 438 + ``start_server``, and ``_populate_index`` is idempotent via 439 + ``has_source("srd")``. 440 + """ 441 + 442 + def test_srd_stays_available_after_transcript_upsert_race( 443 + self, tmp_path, monkeypatch, 444 + ): 445 + from storied import paths 446 + from storied.mcp_server import _populate_index 447 + from storied.search import VectorIndex 448 + 449 + # Minimal shipped SRD seed the populate helper can copy from. 450 + srd_root = tmp_path / "shipped" / "srd-5.2.1" 451 + srd_root.mkdir(parents=True) 452 + seed_db = srd_root / "search.db" 453 + seed_index = VectorIndex(seed_db) 454 + seed_index.upsert( 455 + "srd:character-origins.md:0", 456 + "# Character Origins\n\n**Half-Elf** gets +2 Charisma.", 457 + { 458 + "source": "srd", 459 + "content_type": "rules", 460 + "path": str(srd_root / "sections" / "character-origins.md"), 461 + "title": "Character Origins", 462 + }, 463 + ) 464 + seed_index.close() 465 + 466 + monkeypatch.setattr( 467 + paths, "shipped_rules_path", lambda: tmp_path / "shipped", 468 + ) 469 + 470 + world_dir = paths.world_path("default") 471 + world_dir.mkdir(parents=True, exist_ok=True) 472 + db_path = world_dir / "search.db" 473 + 474 + # Eager populate the way start_server now does. 475 + vi = VectorIndex(db_path) 476 + _populate_index(world_dir, vi) 477 + assert vi.has_source("srd") 478 + 479 + # Turn 1 ends without any recall — engine upserts the transcript. 480 + (world_dir / "transcripts").mkdir(exist_ok=True) 481 + day_path = world_dir / "transcripts" / "day+001.md" 482 + day_path.write_text("### Day 1\n\nHey, welcome.") 483 + vi.upsert( 484 + "transcript:transcripts/day+001.md:0", 485 + day_path.read_text(), 486 + { 487 + "source": "transcript", 488 + "content_type": "transcripts", 489 + "path": str(day_path), 490 + "title": "Day 1", 491 + "game_day": 1, 492 + }, 493 + ) 494 + 495 + # Turn 2: the DM finally calls recall. SRD must still be there. 496 + hits = vi.search( 497 + "half-elf charisma", 498 + limit=3, 499 + source_filter=["srd", "user", "world"], 500 + ) 501 + assert len(hits) >= 1 502 + assert any(h.source == "srd" for h in hits) 503 + 504 + def test_populate_is_idempotent_on_repeated_start_server( 505 + self, tmp_path, monkeypatch, 506 + ): 507 + """A second start_server (onboarding → play handoff) must not 508 + wipe the world/transcript rows by re-copying the SRD seed.""" 509 + from storied import paths 510 + from storied.mcp_server import _populate_index 511 + from storied.search import VectorIndex 512 + 513 + srd_root = tmp_path / "shipped" / "srd-5.2.1" 514 + srd_root.mkdir(parents=True) 515 + seed_db = srd_root / "search.db" 516 + seed_index = VectorIndex(seed_db) 517 + seed_index.upsert( 518 + "srd:x.md:0", "# X", {"source": "srd", "path": "x.md"}, 519 + ) 520 + seed_index.close() 521 + 522 + monkeypatch.setattr( 523 + paths, "shipped_rules_path", lambda: tmp_path / "shipped", 524 + ) 525 + 526 + world_dir = paths.world_path("default") 527 + world_dir.mkdir(parents=True, exist_ok=True) 528 + db_path = world_dir / "search.db" 529 + 530 + vi = VectorIndex(db_path) 531 + _populate_index(world_dir, vi) 532 + 533 + # Upsert a transcript row to represent prior session state. 534 + vi.upsert( 535 + "transcript:transcripts/day+001.md:0", 536 + "turn content", 537 + {"source": "transcript", "path": "transcripts/day+001.md"}, 538 + ) 539 + assert vi.has_source("transcript") 540 + 541 + # Second populate — must not wipe transcripts via reseed. 542 + _populate_index(world_dir, vi) 543 + assert vi.has_source("srd") 544 + assert vi.has_source("transcript")
+80 -6
tests/test_seeder.py
··· 10 10 from storied.character import create_character 11 11 from storied.mcp_server import _compose_server 12 12 from storied.planner import SeedResult, seed_world 13 - from storied.tools import ToolContext 14 13 15 14 16 15 class TestSeederTools: ··· 101 100 "event": { 102 101 "type": "content_block_start", 103 102 "index": 0, 104 - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__establish"}, 103 + "content_block": { 104 + "type": "tool_use", 105 + "id": "t1", 106 + "name": "mcp__storied__establish", 107 + }, 105 108 }, 106 109 }), 107 110 json.dumps({ ··· 109 112 "event": { 110 113 "type": "content_block_start", 111 114 "index": 1, 112 - "content_block": {"type": "tool_use", "id": "t2", "name": "mcp__storied__set_scene"}, 115 + "content_block": { 116 + "type": "tool_use", 117 + "id": "t2", 118 + "name": "mcp__storied__set_scene", 119 + }, 113 120 }, 114 121 }), 115 122 json.dumps({ ··· 122 129 123 130 mock_proc = MagicMock() 124 131 mock_proc.stdin = MagicMock() 125 - mock_proc.stdout = iter([l.encode() + b"\n" for l in lines]) 132 + mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) 126 133 mock_proc.stderr = iter([]) 127 134 mock_proc.wait.return_value = 0 128 135 mock_proc.returncode = 0 ··· 173 180 "event": { 174 181 "type": "content_block_start", 175 182 "index": 0, 176 - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__establish"}, 183 + "content_block": { 184 + "type": "tool_use", 185 + "id": "t1", 186 + "name": "mcp__storied__establish", 187 + }, 177 188 }, 178 189 }), 179 190 json.dumps({ ··· 186 197 187 198 mock_proc = MagicMock() 188 199 mock_proc.stdin = MagicMock() 189 - mock_proc.stdout = iter([l.encode() + b"\n" for l in lines]) 200 + mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) 190 201 mock_proc.stderr = iter([]) 191 202 mock_proc.wait.return_value = 0 192 203 mock_proc.returncode = 0 ··· 208 219 ) 209 220 assert isinstance(result, SeedResult) 210 221 assert result.tool_calls == 0 222 + 223 + 224 + class TestSeedWorldStyleContext: 225 + """seed_world prepends style.md as a Player Preferences block.""" 226 + 227 + @patch("storied.planner.run_with_tools") 228 + def test_seed_world_includes_style_when_present( 229 + self, 230 + mock_run: MagicMock, 231 + character_world: Path, 232 + ): 233 + from storied.paths import world_path 234 + world_dir = world_path("default") 235 + world_dir.mkdir(parents=True, exist_ok=True) 236 + (world_dir / "style.md").write_text( 237 + "Grim political intrigue. No heroic fantasy. Slow-burn " 238 + "investigation with moral compromise." 239 + ) 240 + 241 + mock_run.return_value = None 242 + 243 + seed_world(world_id="default", player_id="default") 244 + 245 + mock_run.assert_called_once() 246 + user_message = mock_run.call_args.kwargs["user_message"] 247 + assert "## Player Preferences" in user_message 248 + assert "Grim political intrigue" in user_message 249 + # Preferences come before the character sheet 250 + pref_idx = user_message.index("Player Preferences") 251 + char_idx = user_message.index("Kael Stormborn") 252 + assert pref_idx < char_idx 253 + 254 + @patch("storied.planner.run_with_tools") 255 + def test_seed_world_omits_style_block_when_absent( 256 + self, 257 + mock_run: MagicMock, 258 + character_world: Path, 259 + ): 260 + mock_run.return_value = None 261 + seed_world(world_id="default", player_id="default") 262 + 263 + mock_run.assert_called_once() 264 + user_message = mock_run.call_args.kwargs["user_message"] 265 + assert "Player Preferences" not in user_message 266 + assert "Kael Stormborn" in user_message 267 + 268 + @patch("storied.planner.run_with_tools") 269 + def test_seed_world_ignores_empty_style_file( 270 + self, 271 + mock_run: MagicMock, 272 + character_world: Path, 273 + ): 274 + from storied.paths import world_path 275 + world_dir = world_path("default") 276 + world_dir.mkdir(parents=True, exist_ok=True) 277 + (world_dir / "style.md").write_text(" \n\n \n") 278 + 279 + mock_run.return_value = None 280 + seed_world(world_id="default", player_id="default") 281 + 282 + mock_run.assert_called_once() 283 + user_message = mock_run.call_args.kwargs["user_message"] 284 + assert "Player Preferences" not in user_message