From 92bcb78192979cd14b197def87c319c890c6d793 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 11 Apr 2026 17:03:06 -0400 Subject: [PATCH] Fixing onboarding stuff --- CLAUDE.md | 11 + design/architecture.md | 37 ++ prompts/character-creation.md | 49 -- prompts/new-game.md | 78 ++++ prompts/world-seed.md | 6 +- src/storied/character/operations.py | 42 +- src/storied/claude.py | 5 + src/storied/cli.py | 675 +++++++++++++++++----------- src/storied/engine.py | 50 ++- src/storied/mcp_server.py | 51 ++- src/storied/planner.py | 23 +- src/storied/search.py | 44 +- src/storied/tools/character.py | 29 +- tests/test_character.py | 47 +- tests/test_cli.py | 83 ++++ tests/test_mcp_server.py | 164 ++++++- tests/test_seeder.py | 86 +++- 17 files changed, 1078 insertions(+), 402 deletions(-) delete mode 100644 prompts/character-creation.md create mode 100644 prompts/new-game.md create mode 100644 tests/test_cli.py diff --git a/CLAUDE.md b/CLAUDE.md index 0175e2e..79a11f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,17 @@ reads from module getters (`worlds_path()`, `player_path(id)`, etc.). 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. +## Cold-Start Flow + +When `storied play` runs against a fresh world (no character or no `style.md`), the CLI enters a single-invocation onboarding sequence: + +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`. +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. +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. +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. + +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. + ## Content Layers Game content is organized in three layers that overlay each other, diff --git a/design/architecture.md b/design/architecture.md index c8eedbd..c79e0a0 100644 --- a/design/architecture.md +++ b/design/architecture.md @@ -92,6 +92,43 @@ trust. ## Component Responsibilities +### Cold-Start Onboarding (`cli.py` + `prompts/new-game.md`) + +When `storied play` launches against a fresh world — no character +sheet or no `style.md` — it enters an onboarding sequence rather than +the normal DM loop. The sequence runs entirely in one invocation: + +1. **Onboarding conversation.** A DM engine loads + `prompts/new-game.md` and weaves character creation with + worldbuilding preferences. The DM has two mandatory deliverables + before `end_session`: `tune()` (writes `worlds/{world}/style.md` + capturing tone, themes, pacing) and `create_character()`. The + prompt forbids `set_scene`, `establish`, and any attempt to start + the adventure — those belong to the seeder and the live DM. +2. **Artifact gate.** After the onboarding engine exits, `cmd_play` + re-checks both files. If either is missing, it prints a friendly + "onboarding incomplete" message and exits; re-running + `storied play` re-enters onboarding to fill the gap. The DM + notices what already exists in context and only works on the + missing deliverable. +3. **Synchronous planning phase.** `seed_world()` runs as a blocking + Claude subprocess using `prompts/world-seed.md`. It now reads + `style.md` and prepends it as a `## Player Preferences` block to + the seeder's user message, so the 12–16 entities it establishes + and the opening `set_scene` reflect the player's preferences — not + a lowest-common-denominator interpretation of the character + sheet alone. +4. **Live play.** A fresh `DMEngine` starts with `prompts/dm-system.md`, + `BackgroundTicker` and `BackgroundAdvancement` come online, and + the player drops into the opening scene. + +`cli.py` extracts the input/stream/ticker/advancement loop into +`_run_engine_loop` so both the onboarding engine and the play engine +share the same interactive shell. Onboarding runs with +`is_onboarding=True` and no background agents; play runs with +`is_onboarding=False` and full ticker + advancement. `--sandbox` +skips cold-start entirely and goes straight to the DM system prompt. + ### DM Engine (`engine.py`) The agentic loop: builds the turn's context, spawns Claude with the MCP diff --git a/prompts/character-creation.md b/prompts/character-creation.md deleted file mode 100644 index 81cda8a..0000000 --- a/prompts/character-creation.md +++ /dev/null @@ -1,49 +0,0 @@ -You are helping a player create a new 5e character for a solo adventure. - -## Your Role - -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. - -## The Flow - -Start by asking what kind of hero they imagine. Let their answer guide you: - -- If they have a clear concept ("a sneaky halfling thief"), help them build toward it -- If they're unsure, ask questions to discover what appeals to them -- If they want to explore options, briefly describe what's available - -## Key Decisions - -Work through these naturally (not necessarily in order): - -1. **Concept**: Who is this person? What's their deal? -2. **Race**: Human, Elf, Dwarf, Halfling, etc. Look up races for traits. -3. **Class**: Fighter, Wizard, Rogue, etc. Look up classes for features. -4. **Background**: Acolyte, Criminal, Soldier, etc. Provides skills and flavor. -5. **Ability Scores**: Roll 4d6kh3 six times, let them assign scores. -6. **Starting Equipment**: Based on class and background. -7. **Details**: Name, personality, bonds, flaws - whatever feels right. - -## Ability Scores - -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. - -## Looking Things Up - -Use `recall` freely to check: -- Racial traits (darkvision, resistances, etc.) -- Class features (hit dice, proficiencies, starting abilities) -- Background features (skills, tools, equipment) -- Spell lists if they're a caster - -Get the details right - this character will be with them for a while. - -## Finalizing - -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). - -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. - -**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. - -Don't rush. Character creation is part of the fun. Let them explore, change their mind, and discover who they want to play. diff --git a/prompts/new-game.md b/prompts/new-game.md new file mode 100644 index 0000000..00d38e7 --- /dev/null +++ b/prompts/new-game.md @@ -0,0 +1,78 @@ +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: + +1. **The kind of world and story the player wants** — recorded via `tune`. +2. **The player's character** — recorded via `create_character`. + +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. + +## Check what already exists + +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: + +- **Character sheet present, no Style section** → skip character creation entirely. Open by acknowledging their character and focus the whole session on worldbuilding preferences. +- **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. +- **Neither present** → full onboarding, both deliverables. +- **Both present** → something is wrong; thank the player, call `end_session`, and let them re-run. + +## Weaving the conversation + +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. + +Some questions to work in naturally, not as a form: + +- What kind of story excites you? Heroic, grim, political, weird, cozy, brutal? +- What tone — gritty realism, high fantasy, dark horror, comedic, sword-and-sorcery? +- Pacing — breakneck action, slow burn, investigation-heavy, travelogue? +- What *don't* you want? Things to avoid — tropes, themes, intensity levels. +- What's the hero of this story like? What are they drawn to, what do they avoid, what do they want? +- Who are they? Race, class, background, name, quirks. + +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. + +## Using `tune` + +`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: + +- **Tone** (one or two lines — grim, playful, tense, etc.) +- **Themes** (what the story is about) +- **Pacing** (breakneck, slow burn, investigation-heavy) +- **What to lean into** (moments the player wants) +- **What to avoid** (hard no's) + +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. + +## Character creation + +Work through these naturally: + +1. **Concept** — who is this person, what's their deal? +2. **Race** — look up traits with `recall`. +3. **Class** — look up features with `recall`. +4. **Background** — skills, tools, flavor. +5. **Ability scores** — roll 4d6kh3 six times, let the player assign. +6. **Starting equipment** — class + background. +7. **Details** — name, personality, bonds, flaws. + +Use `recall` freely for rules lookups. Get the details right. + +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. + +## What you MUST NOT do + +- **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. +- **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. +- **Do NOT start the adventure.** No narration, no "you find yourself in...", no opening scene. This session is setup only. + +Your surface this session is: `tune`, `create_character`, `update_character`, `add_item`, `recall`, `roll`, `end_session`. That's it. + +## Wrapping up + +When both `tune` and `create_character` have been called — and only then — give a brief summary: + +- A one-paragraph reflection on the world and story vibe you've captured. +- A quick character sheet reference (key abilities, notable features, starting gear highlights). +- A short note that when the session ends, the world will be built and their first scene will be waiting. + +Then call `end_session` with a wrap-up situation like "Ready to begin: [character] preparing to step into [vibe description]." + +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. diff --git a/prompts/world-seed.md b/prompts/world-seed.md index e4bb973..d9115d7 100644 --- a/prompts/world-seed.md +++ b/prompts/world-seed.md @@ -9,7 +9,11 @@ You are a World Architect for a solo 5e adventure. You're building the opening w ## What You're Given -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. +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. + +**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*. + +If no preferences block is present, fall back to inferring tone from the character's backstory alone. ## What to Build diff --git a/src/storied/character/operations.py b/src/storied/character/operations.py index 85c8004..46fd785 100644 --- a/src/storied/character/operations.py +++ b/src/storied/character/operations.py @@ -407,7 +407,11 @@ def adjust_coins( ) -> str: """Apply relative coin changes (positive=gain, negative=spend). - Each denomination is clamped to 0 minimum. + Atomic: if any denomination would go below zero, the entire call is + rejected with an error and the purse is not modified. The DM should + handle change in a single call (e.g. paying 5 cp from a 9 sp / 0 cp + purse is ``{"sp": -1, "cp": +5}``, not two separate calls), and + this rejection is what surfaces the mistake when they don't. """ data = load_character(player_id) if data is None: @@ -417,18 +421,42 @@ def adjust_coins( "purse", {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0} ) - changes = [] + # Validate every denomination would land ≥ 0 before touching anything. + proposed: dict[str, int] = {} + shortfalls: list[str] = [] for denom, delta in deltas.items(): if denom not in ("cp", "sp", "ep", "gp", "pp"): continue old = purse.get(denom, 0) new = old + delta if new < 0: - changes.append(f"{denom} {old} → 0 (short {-new} {denom})") - purse[denom] = 0 - else: - changes.append(f"{denom} {old} → {new}") - purse[denom] = new + shortfalls.append( + f"{denom}: have {old}, need {-delta} (short {-new})" + ) + proposed[denom] = new + + if shortfalls: + coins = [] + for denom in ("pp", "gp", "ep", "sp", "cp"): + amount = purse.get(denom, 0) + if amount: + coins.append(f"{amount} {denom}") + purse_str = ", ".join(coins) if coins else "empty" + return ( + "Cannot adjust coins — insufficient funds. " + f"{'; '.join(shortfalls)}. Purse: {purse_str}. " + "Nothing was changed. To make change, fold the payment and " + "the coins coming back into a single call: e.g. paying 5 cp " + 'from a 9 sp / 0 cp purse is {"sp": -1, "cp": 5} ' + "(1 silver out, 5 copper back). Don't call adjust_coins twice " + "to spend then make change — you'll double-charge." + ) + + changes = [] + for denom, new in proposed.items(): + old = purse.get(denom, 0) + changes.append(f"{denom} {old} → {new}") + purse[denom] = new save_character(player_id, data) diff --git a/src/storied/claude.py b/src/storied/claude.py index 2c6b1b7..25293d3 100644 --- a/src/storied/claude.py +++ b/src/storied/claude.py @@ -108,6 +108,11 @@ def _build_tool_args( "--strict-mcp-config", "--mcp-config", mcp_config, "--effort", effort, + # Keep per-machine sections (cwd, env, memory paths, git + # status) out of the system prompt. We control the entire + # prompt explicitly via --system-prompt, and the dynamic + # bits are the most plausible cross-campaign leak surface. + "--exclude-dynamic-system-prompt-sections", ] if not persist_session: diff --git a/src/storied/cli.py b/src/storied/cli.py index 83cf823..3a23820 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -5,9 +5,17 @@ import argparse import sys from pathlib import Path +from typing import TYPE_CHECKING import argcomplete +if TYPE_CHECKING: + from rich.console import Console + + from storied.advancement import BackgroundAdvancement + from storied.engine import DMEngine + from storied.planner import BackgroundTicker + # Slash commands available during play SLASH_COMMANDS = { "/help": "Show this help message", @@ -16,7 +24,10 @@ SLASH_COMMANDS = { "/save": "Save session state (without quitting)", "/context": "Show token usage", "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", - "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)", + "/note": ( + "Add a note to your character sheet " + "(e.g. /note remember the prayer words)" + ), } def _format_character_display( @@ -86,14 +97,15 @@ def cmd_srd_convert(args: argparse.Namespace) -> int: # Write metadata meta_path = output_path.parent / "meta.yaml" source_hash = get_file_hash(pdf_path) + from datetime import UTC, datetime + import yaml - from datetime import datetime, timezone meta = { "version": "5.2.1", "source_url": SRD_URL, "source_hash": source_hash, - "extracted_at": datetime.now(timezone.utc).isoformat(), + "extracted_at": datetime.now(UTC).isoformat(), } meta_path.write_text(yaml.dump(meta, sort_keys=False)) log(f"Wrote: {meta_path}") @@ -157,7 +169,6 @@ def cmd_reset(args: argparse.Namespace) -> int: """Reset player and world state to start fresh.""" import shutil - from storied import paths from storied.paths import ( configure, data_home, @@ -208,221 +219,81 @@ def cmd_reset(args: argparse.Namespace) -> int: return 0 -def cmd_play(args: argparse.Namespace) -> int: - """Start an interactive DM session.""" - import atexit - import readline - import shutil - import tempfile - - from rich.console import Console - from rich.markdown import Markdown - from rich.panel import Panel - from rich.rule import Rule - from rich.text import Text +def _is_cold_start(world_id: str, player_id: str) -> bool: + """True when the story can't run because onboarding artifacts are missing. + Cold start means either the player has no character yet, or the + world has no ``style.md`` capturing the player's worldbuilding + preferences. Either gap sends ``cmd_play`` into the onboarding + flow on launch. + """ from storied.character import load_character - from storied.engine import DMEngine + from storied.paths import world_path - # Set up readline history - history_file = Path.home() / ".storied_history" - try: - readline.read_history_file(history_file) - except FileNotFoundError: - pass - readline.set_history_length(1000) - atexit.register(readline.write_history_file, history_file) + character = load_character(player_id) + style_path = world_path(world_id) / "style.md" + return character is None or not style_path.exists() - # Set up slash command autocomplete - def slash_completer(text: str, state: int) -> str | None: - if text.startswith("/"): - matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)] - return matches[state] if state < len(matches) else None - return None - readline.set_completer_delims(readline.get_completer_delims().replace("/", "")) - readline.set_completer(slash_completer) - readline.parse_and_bind("tab: complete") - readline.parse_and_bind("set enable-bracketed-paste on") - - console = Console() - world_id = args.world if args.world else "default" - player_id = "default" - sandbox = getattr(args, "sandbox", False) - - from storied.paths import configure, data_home, resolve_data_home - - # Sandbox mode: throwaway worlds + players in a temp directory. - # User homebrew rules stay pointed at the real ~/.storied/rules/ - # so the sandbox isn't a pristine playground — it's still "your - # rules", just with a fresh world to mess around in. - if sandbox: - sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) - (sandbox_dir / "worlds" / world_id).mkdir(parents=True) - (sandbox_dir / "players" / player_id).mkdir(parents=True) - configure( - data_home=sandbox_dir, - user_rules_home=Path.home() / ".storied" / "rules", - ) - else: - configure( - data_home=resolve_data_home(getattr(args, "base_path", None)) - ) - data_home().mkdir(parents=True, exist_ok=True) - - # Export STORIED_HOME so any subprocess that re-enters storied - # (e.g. via run_code) sees the same data directory. - import os - os.environ["STORIED_HOME"] = str(data_home()) +def _run_engine_loop( + engine: "DMEngine", + *, + console: "Console", + args: argparse.Namespace, + player_id: str, + is_sandbox: bool, + is_onboarding: bool, + first_message: str | None, + ticker: "BackgroundTicker | None" = None, + advancement: "BackgroundAdvancement | None" = None, +) -> None: + """Run the interactive input/stream loop against a DMEngine. + + Three modes: + - ``is_onboarding``: cold-start flow. Exits via ``end_session`` or + Ctrl+D; the CLI re-checks artifacts after the loop returns. + - ``is_sandbox``: throwaway session with no save-on-quit. + - Normal play: saves the session on Ctrl+D / Ctrl+C. + """ + from rich.markdown import Markdown + from rich.rule import Rule - base_path = data_home() - creation_mode = False + from storied.display import StreamRenderer - if sandbox: - console.print(Panel.fit( - "[bold]Storied Sandbox[/bold]\n" - "No character, no world — just you and the DM.\n" - "Type [cyan]Ctrl+D[/cyan] to quit.", - title="Sandbox", - border_style="cyan", - )) - prompt_name = "dm-system" - else: - # Check if character exists - character = load_character(player_id) - creation_mode = character is None + terse_exit = is_sandbox or is_onboarding - if creation_mode: - console.print(Panel.fit( - "[bold]Welcome to Storied![/bold]\n" - "Let's create your character!", - title="Character Creation", - border_style="yellow", - )) - prompt_name = "character-creation" + def on_graceful_exit() -> None: + if is_sandbox: + console.print("[cyan]Sandbox session ended.[/cyan]") + elif is_onboarding: + console.print("[yellow]Onboarding interrupted.[/yellow]") else: - console.print(Panel.fit( - "[bold]Welcome to Storied![/bold]\n" - "Let the DM know when you're ready to quit.\n" - "Type [cyan]/context[/cyan] to see token usage.", - title="Storied", - border_style="green", - )) - prompt_name = "dm-system" - - console.print(f"[dim]World: {world_id}{' (sandbox)' if sandbox else ''}[/dim]") - console.print() - - # Seed the world if the character exists but no session yet - if not creation_mode and not sandbox: - from storied.session import load_session - - session = load_session(player_id) - if session is None: - from storied.planner import seed_world - - console.print(f"[dim]Seeding the world for {character['name']}...[/dim]") - - def on_seed_progress(msg: str) -> None: - console.print(f"[dim] {msg}[/dim]") - - result = seed_world( - world_id=world_id, - player_id=player_id, - on_progress=on_seed_progress, - ) - + console.print("[dim]Saving session...[/dim]") + try: + save_msg = "I need to quit now. Please save the game." + list(engine.stream_action(save_msg)) + except Exception: + pass console.print( - f"[dim] Done — {result.tool_calls} tool calls, " - f"{result.elapsed:.1f}s[/dim]" + "[yellow]Session saved. Farewell, adventurer![/yellow]" ) - console.print() - transcript_path = Path(args.transcript) if args.transcript else None - engine = DMEngine( - world_id=world_id, - player_id=player_id, - prompt_name=prompt_name, - transcript_path=transcript_path, - ) - engine.debug = args.debug - - # Debug mode: dump the base system prompt once at startup. The - # per-turn context is dumped separately before each stream_action - # call below. Between them, the user sees exactly what the DM - # subprocess is seeing each turn. - if args.debug: - console.print(Rule("System Prompt", style="dim")) - console.print(engine._base_prompt, style="dim", highlight=False) - console.print(Rule(style="dim")) - console.print() - - # Background ticker for mid-session world advancement - ticker = None - advancement = None - if not creation_mode and not sandbox: - from storied.advancement import BackgroundAdvancement - from storied.planner import BackgroundTicker - - ticker = BackgroundTicker( - world_id=world_id, - player_id=player_id, - ) - # Kick off initial tick in background - ticker.maybe_tick(engine._campaign_log) - - advancement = BackgroundAdvancement( - world_id=world_id, - player_id=player_id, - ) - - # If in creation mode, start the conversation - if creation_mode: - console.print("[dim]The DM will guide you through character creation...[/dim]") - console.print() - - from storied.display import StreamRenderer - - # Kick off the conversation with appropriate first message - if sandbox: - first_message = ( - "[Sandbox session — no character, no world. The player wants to " - "experiment freely. Jump straight into whatever they ask.]" - ) - elif creation_mode: - first_message = "Let's create a character!" - else: - first_message = "[Session starting]" try: while True: - # Get player input (or use first_message to kick off creation) if first_message: action = first_message first_message = None else: try: console.print(Rule(style="dim blue")) - if creation_mode or sandbox: + if terse_exit: action = input("> ") else: game_time = engine.get_current_time() action = input(f"[{game_time}] > ") except EOFError: console.print() - if sandbox: - console.print("[cyan]Sandbox session ended.[/cyan]") - elif not creation_mode: - console.print("[dim]Saving session...[/dim]") - try: - save_msg = "I need to quit now. Please save the game." - list(engine.stream_action(save_msg)) - except Exception: - pass - console.print( - "[yellow]Session saved. Farewell, adventurer![/yellow]" - ) - else: - console.print("[yellow]Farewell![/yellow]") + on_graceful_exit() break if not action.strip(): @@ -433,7 +304,10 @@ def cmd_play(args: argparse.Namespace) -> int: stats = engine.get_context_stats() console.print() - # Header: real API usage if available, estimate otherwise + # Header: real per-turn input from the API (cache_read + + # cache_write + new) if we have a result, else the static + # estimate. The API number is ground truth for "how much + # of the context window did this turn use." limit_k = stats["model_limit"] / 1000 game_time = engine.get_current_time() if stats["last_input"] > 0: @@ -441,13 +315,15 @@ def cmd_play(args: argparse.Namespace) -> int: usage_str = f"{input_k:.1f}k/{limit_k:.0f}k tokens (last turn)" else: context_k = stats["context_total"] / 1000 - usage_str = f"~{context_k:.1f}k/{limit_k:.0f}k system tokens (estimated)" + usage_str = ( + f"~{context_k:.1f}k/{limit_k:.0f}k " + "system tokens (estimated)" + ) console.print( f"[bold]Context Usage[/bold] [dim]({game_time})[/dim]" f" [dim]{engine.model} · {usage_str}[/dim]" ) - # Color mapping for context sections _SECTION_COLORS: dict[str, str] = { "Style": "dim", "Character": "green", @@ -459,21 +335,45 @@ def cmd_play(args: argparse.Namespace) -> int: "Initiative": "red", } - # Build section list from all context parts sections: list[tuple[str, int, str]] = [ - ("DM Instructions", stats["system_prompt"], "bright_blue"), - ("Tool Schemas", stats.get("tool_surface", 0), "bright_magenta"), + ( + "DM Instructions", + stats["system_prompt"], + "bright_blue", + ), + ( + "Tool Schemas", + stats.get("tool_surface", 0), + "bright_magenta", + ), ] for key, tokens in stats["context_parts"].items(): if key.startswith("Entity:") or key.startswith("Linked:"): label = key.split(":", 1)[1] - color = "magenta" if key.startswith("Entity:") else "dark_magenta" + color = ( + "magenta" + if key.startswith("Entity:") + else "dark_magenta" + ) else: label = key color = _SECTION_COLORS.get(key, "white") sections.append((label, tokens, color)) - # 40x5 grid (200 cells = 200k tokens, 1 cell = 1k) + # Conversation history lives in the Claude subprocess + # session, not in the per-turn system prompt. The API's + # cache reports it as part of the input total — we + # surface the gap between what the engine builds fresh + # and what the model actually saw as a "Conversation" + # bucket so the grid shows the full picture. + last_input = stats.get("last_input", 0) + if last_input > 0: + convo_tokens = max(0, last_input - stats["context_total"]) + if convo_tokens > 0: + sections.append( + ("Conversation", convo_tokens, "bright_white"), + ) + grid_w, grid_h = 40, 5 total_cells = grid_w * grid_h cells: list[str] = [] @@ -486,12 +386,10 @@ def cmd_play(args: argparse.Namespace) -> int: cells.extend([color] * n_cells) legend_items.append((name, tokens, color)) - # Pad remaining cells remaining = total_cells - len(cells) if remaining > 0: cells.extend(["dim"] * remaining) - # Render grid for row in range(grid_h): line = "" for col in range(grid_w): @@ -501,20 +399,44 @@ def cmd_play(args: argparse.Namespace) -> int: line += f"[{c}]{char}[/{c}]" console.print(line) - # Legend for name, tokens, color in legend_items: - tokens_str = f"~{tokens:,}" if tokens < 1_000 else f"~{tokens / 1_000:.1f}k" - console.print(f" [{color}]█[/{color}] {name}: [dim]{tokens_str}[/dim]") + tokens_str = ( + f"~{tokens:,}" + if tokens < 1_000 + else f"~{tokens / 1_000:.1f}k" + ) + console.print( + f" [{color}]█[/{color}] {name}: " + f"[dim]{tokens_str}[/dim]" + ) - # Session totals (actual API counts) if stats["total_input"] > 0: console.print() last_in = f"{stats['last_input']:,}" last_out = f"{stats['last_output']:,}" total_in = f"{stats['total_input']:,}" total_out = f"{stats['total_output']:,}" + # Cache breakdown — most of each turn's input is + # actually a cache read (cheap, ~10% of new-token + # cost), but it still counts toward the window. + new = stats.get("last_new", 0) + cache_read = stats.get("last_cache_read", 0) + cache_write = stats.get("last_cache_write", 0) + breakdown_parts = [] + if new: + breakdown_parts.append(f"{new:,} new") + if cache_read: + breakdown_parts.append(f"{cache_read:,} cached") + if cache_write: + breakdown_parts.append(f"{cache_write:,} cache-write") + breakdown = ( + f" ({' · '.join(breakdown_parts)})" + if breakdown_parts + else "" + ) console.print( - f" [dim]Last turn: {last_in} in · {last_out} out[/dim]" + f" [dim]Last turn: {last_in} in" + f"{breakdown} · {last_out} out[/dim]" ) console.print( f" [dim]Session: {total_in} in · {total_out} out[/dim]" @@ -547,8 +469,7 @@ def cmd_play(args: argparse.Namespace) -> int: console.print() continue - # Handle /save command — everything is already durably written - # on each set_scene; this just confirms state without a round-trip. + # Handle /save command if action.strip().lower() == "/save": from storied.session import load_session console.print() @@ -577,9 +498,7 @@ def cmd_play(args: argparse.Namespace) -> int: f"{ooc_msg}]" ) - # Handle /note command — append directly to the player's notes.md - # without a DM round-trip. The DM still sees the latest notes on - # the next turn via its character context. + # Handle /note command if action.strip().lower().startswith("/note"): note_msg = action.strip()[5:].strip() if not note_msg: @@ -601,10 +520,6 @@ def cmd_play(args: argparse.Namespace) -> int: try: console.print(Rule(style="dim blue")) - # Debug mode: dump the per-turn context before the - # response streams. `stream_action` rebuilds it - # internally, but the double-build is cheap and keeps - # the debug path a pure observer. if args.debug: console.print(Rule("Turn Context", style="dim")) console.print( @@ -627,7 +542,6 @@ def cmd_play(args: argparse.Namespace) -> int: console.print(f"[dim]{chunk}[/dim]") prev_type = "tool" elif chunk == "": - # Flush signal from engine (deferred tool starting) renderer.flush() else: if prev_type == "tool": @@ -644,7 +558,6 @@ def cmd_play(args: argparse.Namespace) -> int: renderer.flush() console.print() - # Show debug token info if enabled if args.debug and engine._last_result: r = engine._last_result in_tokens = r.usage.get("input_tokens", 0) @@ -655,19 +568,17 @@ def cmd_play(args: argparse.Namespace) -> int: f"{engine._total_output_tokens:,} out[/dim]" ) - # Check for completed background tick if ticker: tick_result = ticker.pop_result() if tick_result and tick_result.tool_calls > 0: console.print() console.print( - f"[dim]The world shifted while you considered your next move. " + "[dim]The world shifted while you considered " + f"your next move. " f"({tick_result.tool_calls} changes)[/dim]" ) - # Maybe launch a new tick if the day advanced ticker.maybe_tick(engine._campaign_log) - # Advancement evaluator — tick the turn counter and check results if advancement: if engine.combat_ended: advancement.on_combat_end() @@ -680,59 +591,271 @@ def cmd_play(args: argparse.Namespace) -> int: "[dim]The character reflects on recent experiences...[/dim]" ) - # Check if session ended (player quit gracefully) if engine.session_ended: - console.print( - "[yellow]Session saved. Farewell, adventurer![/yellow]" - ) - break - - # Check if character was just created - if creation_mode and engine.character_created: - console.print() - console.print(Panel.fit( - "[bold green]Character created![/bold green]\n" - "Run [cyan]storied play[/cyan] again to begin your adventure.", - border_style="green", - )) + if is_onboarding: + console.print( + "[green]Onboarding complete.[/green]" + ) + else: + console.print( + "[yellow]Session saved. Farewell, adventurer![/yellow]" + ) break except KeyboardInterrupt: console.print("\n[red][Interrupted][/red]") - if sandbox: - console.print("[cyan]Sandbox session ended.[/cyan]") - elif not creation_mode: - console.print("[dim]Saving session...[/dim]") - try: - save_msg = "I need to quit now. Please save the game." - list(engine.stream_action(save_msg)) - except Exception: - pass - console.print( - "[yellow]Session saved. Farewell, adventurer![/yellow]" - ) - else: - console.print("[yellow]Farewell![/yellow]") + on_graceful_exit() break except KeyboardInterrupt: console.print() + on_graceful_exit() + + +def cmd_play(args: argparse.Namespace) -> int: + """Start an interactive DM session.""" + import atexit + + # Set up readline history + import contextlib + import readline + import shutil + import tempfile + + from rich.console import Console + from rich.panel import Panel + from rich.rule import Rule + + from storied.character import load_character + from storied.engine import DMEngine + history_file = Path.home() / ".storied_history" + with contextlib.suppress(FileNotFoundError): + readline.read_history_file(history_file) + readline.set_history_length(1000) + atexit.register(readline.write_history_file, history_file) + + # Set up slash command autocomplete + def slash_completer(text: str, state: int) -> str | None: + if text.startswith("/"): + matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(text)] + return matches[state] if state < len(matches) else None + return None + + readline.set_completer_delims(readline.get_completer_delims().replace("/", "")) + readline.set_completer(slash_completer) + readline.parse_and_bind("tab: complete") + readline.parse_and_bind("set enable-bracketed-paste on") + + console = Console() + world_id = args.world if args.world else "default" + player_id = "default" + sandbox = getattr(args, "sandbox", False) + sandbox_dir: Path | None = None + + from storied.paths import configure, data_home, resolve_data_home + + # Sandbox mode: throwaway worlds + players in a temp directory. + # User homebrew rules stay pointed at the real ~/.storied/rules/ + # so the sandbox isn't a pristine playground — it's still "your + # rules", just with a fresh world to mess around in. + if sandbox: + sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) + (sandbox_dir / "worlds" / world_id).mkdir(parents=True) + (sandbox_dir / "players" / player_id).mkdir(parents=True) + configure( + data_home=sandbox_dir, + user_rules_home=Path.home() / ".storied" / "rules", + ) + else: + configure( + data_home=resolve_data_home(getattr(args, "base_path", None)) + ) + data_home().mkdir(parents=True, exist_ok=True) + + # Export STORIED_HOME so any subprocess that re-enters storied + # (e.g. via run_code) sees the same data directory. + import os + os.environ["STORIED_HOME"] = str(data_home()) + + from storied.paths import world_path + + transcript_path = Path(args.transcript) if args.transcript else None + + try: + # Cold-start detection: if either the character or the world's + # style.md is missing, we can't run the story — enter onboarding. + # Sandbox skips this entirely; it starts fresh every time by design. + cold_start = not sandbox and _is_cold_start(world_id, player_id) + + # Onboarding phase: DM captures worldbuilding preferences (via + # tune) and creates the character (via create_character) in a + # single woven conversation. When both artifacts land and the + # DM calls end_session, we fall through to the normal seed + + # play path in the same invocation. + if cold_start: + console.print(Panel.fit( + "[bold]Welcome to Storied![/bold]\n" + "Let's figure out the kind of adventure you want\n" + "and build your character.", + title="New Campaign", + border_style="yellow", + )) + console.print(f"[dim]World: {world_id}[/dim]") + console.print() + + onboarding_engine = DMEngine( + world_id=world_id, + player_id=player_id, + prompt_name="new-game", + transcript_path=transcript_path, + ) + onboarding_engine.debug = args.debug + + if args.debug: + console.print(Rule("System Prompt", style="dim")) + console.print( + onboarding_engine._base_prompt, + style="dim", + highlight=False, + ) + console.print(Rule(style="dim")) + console.print() + + _run_engine_loop( + onboarding_engine, + console=console, + args=args, + player_id=player_id, + is_sandbox=False, + is_onboarding=True, + first_message="Let's get started.", + ) + + # Re-check artifacts after the onboarding engine exits. + character = load_character(player_id) + style_exists = (world_path(world_id) / "style.md").exists() + missing = [] + if character is None: + missing.append("character") + if not style_exists: + missing.append("world style") + if missing: + console.print() + console.print(Panel.fit( + f"[yellow]Onboarding incomplete — missing: " + f"{', '.join(missing)}.[/yellow]\n" + f"Run [cyan]storied play[/cyan] again to continue.", + border_style="yellow", + )) + return 0 + + # Welcome panel for continuing play (skipped when we just + # finished onboarding — the transition into seeding + play + # speaks for itself). if sandbox: - console.print("[cyan]Sandbox session ended.[/cyan]") - elif not creation_mode: - console.print("[dim]Saving session...[/dim]") - try: - save_msg = "I need to quit now. Please save the game." - list(engine.stream_action(save_msg)) - except Exception: - pass - console.print( - "[yellow]Session saved. Farewell, adventurer![/yellow]" + console.print(Panel.fit( + "[bold]Storied Sandbox[/bold]\n" + "No character, no world — just you and the DM.\n" + "Type [cyan]Ctrl+D[/cyan] to quit.", + title="Sandbox", + border_style="cyan", + )) + console.print(f"[dim]World: {world_id} (sandbox)[/dim]") + console.print() + elif not cold_start: + console.print(Panel.fit( + "[bold]Welcome to Storied![/bold]\n" + "Let the DM know when you're ready to quit.\n" + "Type [cyan]/context[/cyan] to see token usage.", + title="Storied", + border_style="green", + )) + console.print(f"[dim]World: {world_id}[/dim]") + console.print() + + # Seed the world if there's no session yet. After cold-start + # onboarding, style.md is now on disk and seed_world reads it + # so the world it builds reflects the player's preferences. + if not sandbox: + character = load_character(player_id) + from storied.session import load_session + + session = load_session(player_id) + if session is None and character is not None: + from storied.planner import seed_world + + console.print( + f"[dim]Seeding the world for {character['name']}...[/dim]" + ) + + def on_seed_progress(msg: str) -> None: + console.print(f"[dim] {msg}[/dim]") + + result = seed_world( + world_id=world_id, + player_id=player_id, + on_progress=on_seed_progress, + ) + + console.print( + f"[dim] Done — {result.tool_calls} tool calls, " + f"{result.elapsed:.1f}s[/dim]" + ) + console.print() + + # Build the play engine. + engine = DMEngine( + world_id=world_id, + player_id=player_id, + prompt_name="dm-system", + transcript_path=transcript_path, + ) + engine.debug = args.debug + + if args.debug: + console.print(Rule("System Prompt", style="dim")) + console.print(engine._base_prompt, style="dim", highlight=False) + console.print(Rule(style="dim")) + console.print() + + # Background agents only run during live play, not sandbox. + ticker = None + advancement = None + if not sandbox: + from storied.advancement import BackgroundAdvancement + from storied.planner import BackgroundTicker + + ticker = BackgroundTicker( + world_id=world_id, + player_id=player_id, ) - else: - console.print("[yellow]Farewell![/yellow]") + ticker.maybe_tick(engine._campaign_log) + + advancement = BackgroundAdvancement( + world_id=world_id, + player_id=player_id, + ) + + first_message = ( + "[Sandbox session — no character, no world. The player wants to " + "experiment freely. Jump straight into whatever they ask.]" + if sandbox + else "[Session starting]" + ) + + _run_engine_loop( + engine, + console=console, + args=args, + player_id=player_id, + is_sandbox=sandbox, + is_onboarding=False, + first_message=first_message, + ticker=ticker, + advancement=advancement, + ) finally: - if sandbox_dir and sandbox_dir.exists(): + if sandbox_dir is not None and sandbox_dir.exists(): shutil.rmtree(sandbox_dir, ignore_errors=True) return 0 @@ -895,7 +1018,9 @@ def build_parser() -> argparse.ArgumentParser: # srd command group srd_parser = subparsers.add_parser("srd", help="SRD processing commands") - srd_subparsers = srd_parser.add_subparsers(dest="srd_command", help="SRD subcommands") + srd_subparsers = srd_parser.add_subparsers( + dest="srd_command", help="SRD subcommands" + ) # srd download download_parser = srd_subparsers.add_parser("download", help="Download SRD PDF") @@ -916,7 +1041,9 @@ def build_parser() -> argparse.ArgumentParser: download_parser.set_defaults(func=cmd_srd_download) # srd convert - convert_parser = srd_subparsers.add_parser("convert", help="Convert SRD PDF to markdown") + convert_parser = srd_subparsers.add_parser( + "convert", help="Convert SRD PDF to markdown" + ) convert_parser.add_argument( "--pdf", help="Path to PDF (default: rules/sources/SRD_CC_v5.2.1.pdf)", @@ -928,7 +1055,9 @@ def build_parser() -> argparse.ArgumentParser: convert_parser.set_defaults(func=cmd_srd_convert) # srd split - split_parser = srd_subparsers.add_parser("split", help="Split SRD markdown into sections") + split_parser = srd_subparsers.add_parser( + "split", help="Split SRD markdown into sections" + ) split_parser.add_argument( "--input", "-i", help="Input markdown file (default: rules/srd-5.2.1/srd.md)", @@ -940,7 +1069,9 @@ def build_parser() -> argparse.ArgumentParser: split_parser.set_defaults(func=cmd_srd_split) # srd clean - clean_parser = srd_subparsers.add_parser("clean", help="Clean up extracted markdown files") + clean_parser = srd_subparsers.add_parser( + "clean", help="Clean up extracted markdown files" + ) clean_parser.add_argument( "--dir", "-d", help="Sections directory (default: rules/srd-5.2.1/sections)", @@ -1003,7 +1134,9 @@ def build_parser() -> argparse.ArgumentParser: reset_parser.set_defaults(func=cmd_reset) # seed command - seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet") + seed_parser = subparsers.add_parser( + "seed", help="Seed an empty world from a character sheet" + ) seed_parser.add_argument( "--world", "-w", default="default", @@ -1074,7 +1207,9 @@ def main(argv: list[str] | None = None) -> int: parser.print_help() return 0 - if args.command in ("srd", "index") and not getattr(args, f"{args.command}_command", None): + if args.command in ("srd", "index") and not getattr( + args, f"{args.command}_command", None + ): for action in parser._subparsers._actions: if isinstance(action, argparse._SubParsersAction): sub = action.choices.get(args.command) diff --git a/src/storied/engine.py b/src/storied/engine.py index 36456a7..ab5992f 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -10,12 +10,6 @@ import yaml from storied import notifications from storied.character import format_character_context, load_character -from storied.notification_formatters import ( - DEFERRED_FORMATTERS, - TOOL_LABELS, - _extract_json_field, - _extract_roll_reason, -) from storied.claude import ( Result, TextDelta, @@ -24,9 +18,15 @@ from storied.claude import ( ToolStop, stream_with_tools, ) -from storied.mcp_server import start_server as start_mcp_server from storied.content import ContentResolver from storied.log import CampaignLog, TranscriptLog +from storied.mcp_server import start_server as start_mcp_server +from storied.notification_formatters import ( + DEFERRED_FORMATTERS, + TOOL_LABELS, + _extract_json_field, + _extract_roll_reason, +) from storied.paths import data_home, player_path, world_path from storied.session import ( extract_wiki_links, @@ -441,12 +441,21 @@ class DMEngine: + sum(context_breakdown.values()) ) - # Usage from last result event - last_input = 0 + # Usage from the last result event. The Anthropic API splits + # input across three buckets: new tokens, cache reads, and cache + # writes. The "real" per-turn input — which equals the size of + # the model's context window for that turn — is the sum. + last_new = 0 + last_cache_read = 0 + last_cache_write = 0 last_output = 0 if self._last_result: - last_input = self._last_result.usage.get("input_tokens", 0) - last_output = self._last_result.usage.get("output_tokens", 0) + usage = self._last_result.usage + last_new = usage.get("input_tokens", 0) + last_cache_read = usage.get("cache_read_input_tokens", 0) + last_cache_write = usage.get("cache_creation_input_tokens", 0) + last_output = usage.get("output_tokens", 0) + last_input = last_new + last_cache_read + last_cache_write return { "model_limit": model_limit, @@ -455,6 +464,9 @@ class DMEngine: "context_parts": context_breakdown, "context_total": context_total, "last_input": last_input, + "last_new": last_new, + "last_cache_read": last_cache_read, + "last_cache_write": last_cache_write, "last_output": last_output, "total_input": self._total_input_tokens, "total_output": self._total_output_tokens, @@ -546,7 +558,10 @@ class DMEngine: desc = _extract_json_field(current_tool_json, "description") label = desc if desc else "Running code" yield f"[{label}...]" - elif deferred_notification and current_tool_name in DEFERRED_FORMATTERS: + elif ( + deferred_notification + and current_tool_name in DEFERRED_FORMATTERS + ): formatter = DEFERRED_FORMATTERS[current_tool_name] yield f"[{formatter(current_tool_json)}...]" @@ -562,7 +577,16 @@ class DMEngine: case Result() as r: self._session_id = r.session_id self._last_result = r - self._total_input_tokens += r.usage.get("input_tokens", 0) + # Total input the model actually processed = new + # uncached input + cache reads + cache writes. The + # raw `input_tokens` field is just the new chunk; + # the bulk of each turn lives behind cache_read. + real_in = ( + r.usage.get("input_tokens", 0) + + r.usage.get("cache_read_input_tokens", 0) + + r.usage.get("cache_creation_input_tokens", 0) + ) + self._total_input_tokens += real_in self._total_output_tokens += r.usage.get("output_tokens", 0) self._log_transcript("result", { "session_id": r.session_id, diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py index 241b0e0..9ee37d6 100644 --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -69,14 +69,17 @@ def _populate_index( vi: VectorIndex, srd_root: Path | None = None, ) -> None: - """Auto-populate an empty index from all three content layers. + """Idempotently populate the index from all three content layers. - Populates in priority-inverse order so higher-priority layers are - indexed last (letting the SRD seed fast-path do its job first): + Safe to call repeatedly: the SRD layer is skipped once + ``vi.has_source("srd")`` is true, and the user/world reindex passes + are mtime-aware. Populates in priority-inverse order so higher- + priority layers are indexed last (letting the SRD seed fast-path do + its job first): 1. Shipped SRD — ``/srd-5.2.1/`` via the prebuilt ``search.db`` seed if present, else reindex the sections dir. - Tagged ``source="srd"``. + Tagged ``source="srd"``. Skipped when already seeded. 2. User homebrew — ``/`` if the directory exists. Tagged ``source="user"``. 3. World content — ``/``. Tagged ``source="world"``. @@ -85,25 +88,32 @@ def _populate_index( the shipped layer at a tmp path. In production it resolves to ``paths.shipped_rules_path() / "srd-5.2.1"``. """ - # 1. Shipped SRD - if srd_root is None: - srd_root = paths.shipped_rules_path() / "srd-5.2.1" - srd_seed = srd_root / "search.db" - if srd_seed.exists(): - vi.reseed(srd_seed) - else: - srd_dir = srd_root / "sections" - if srd_dir.exists(): - vi.reindex_directory(srd_dir, source="srd") + # 1. Shipped SRD — skip if already seeded. `reseed` replaces the + # entire db file, so calling it when the SRD is already present + # would wipe any world/transcript rows that have accumulated. + if not vi.has_source("srd"): + if srd_root is None: + srd_root = paths.shipped_rules_path() / "srd-5.2.1" + srd_seed = srd_root / "search.db" + if srd_seed.exists(): + vi.reseed(srd_seed) + else: + srd_dir = srd_root / "sections" + if srd_dir.exists(): + vi.reindex_directory(srd_dir, source="srd") # 2. User homebrew — flat layout, source="user" user_rules = paths.user_rules_path() if user_rules.exists(): vi.reindex_directory(user_rules, source="user") - # 3. World content — flat layout, source="world" + # 3. World content — flat layout, source="world". Skip the + # transcripts/ subdirectory: those files are indexed separately + # by the engine after each turn with source="transcript". if world_dir.exists(): - vi.reindex_directory(world_dir, source="world") + vi.reindex_directory( + world_dir, source="world", skip_subdirs=frozenset({"transcripts"}), + ) async def _compose_server(role: str) -> FastMCP: @@ -187,10 +197,11 @@ def start_server( # pragma: no cover world_dir = paths.world_path(world_id) - vector_index = VectorIndex( - world_dir / "search.db", - on_empty=lambda vi: _populate_index(world_dir, vi), - ) + vector_index = VectorIndex(world_dir / "search.db") + # Populate eagerly so the first recall never races the transcript + # upsert at turn end. `_populate_index` is idempotent — subsequent + # calls skip the SRD reseed and mtime-check the user/world layers. + _populate_index(world_dir, vector_index) ctx = init_ctx( world_id=world_id, diff --git a/src/storied/planner.py b/src/storied/planner.py index c0bf884..bd1dce3 100644 --- a/src/storied/planner.py +++ b/src/storied/planner.py @@ -7,11 +7,11 @@ from pathlib import Path from threading import Thread from storied.character import format_character_context, load_character -from storied.claude import Result, run_with_tools +from storied.claude import run_with_tools from storied.engine import load_prompt from storied.log import CampaignLog from storied.mcp_server import start_server as start_mcp_server -from storied.paths import data_home +from storied.paths import data_home, world_path from storied.session import ( extract_wiki_links, load_session, @@ -61,7 +61,7 @@ def entity_richness(path: Path) -> float: def find_nearby_entities( - session: dict, + session: dict[str, object], world_id: str, ) -> list[tuple[str, Path]]: """Find entities near the player by walking wikilinks. @@ -280,8 +280,21 @@ def seed_world( if character is None: return SeedResult() - # Build context from the character sheet - context = format_character_context(character) + # Build context from the character sheet, prefixed with any style + # preferences captured during onboarding so the seeded world reflects + # the tone/genre/pacing the player asked for. + style_block = "" + style_path = world_path(world_id) / "style.md" + if style_path.exists(): + style_text = style_path.read_text().strip() + if style_text: + style_block = ( + "## Player Preferences\n\n" + f"{style_text}\n\n" + "---\n\n" + ) + + context = style_block + format_character_context(character) system_prompt = load_prompt("world-seed") campaign_log = CampaignLog(world_id) diff --git a/src/storied/search.py b/src/storied/search.py index 147e91d..2ddf91a 100644 --- a/src/storied/search.py +++ b/src/storied/search.py @@ -7,9 +7,9 @@ The index lives as a single search.db file in the world directory. import re import shutil import struct +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Callable import pysqlite3 as sqlite3 import sqlite_vec @@ -151,11 +151,9 @@ class VectorIndex: def __init__( self, db_path: Path, - on_empty: Callable[["VectorIndex"], None] | None = None, ): self._db_path = db_path self._embed_fn: Callable[[list[str]], list[list[float]]] = _default_embed - self._on_empty = on_empty self._conn = self._open_or_recreate() @staticmethod @@ -174,6 +172,14 @@ class VectorIndex: shutil.copy2(seed_path, self._db_path) self._conn = self._open_or_recreate() + def has_source(self, source: str) -> bool: + """True if at least one document is already indexed from ``source``.""" + row = self._conn.execute( + "SELECT 1 FROM documents WHERE source = ? LIMIT 1", + (source,), + ).fetchone() + return row is not None + def close(self) -> None: """Close the database connection.""" self._conn.close() @@ -289,15 +295,6 @@ class VectorIndex: decay_ref: Current game day for age-decay on transcripts. If None, no decay is applied. """ - # Auto-populate if the index is empty (fires at most once) - if self._on_empty: - count = self._conn.execute( - "SELECT count(*) FROM documents" - ).fetchone()[0] - if count == 0: - self._on_empty(self) - self._on_empty = None - # Normalize source_filter to a set for O(1) membership checks. allowed_sources: set[str] | None if source_filter is None: @@ -332,9 +329,12 @@ class VectorIndex: score = 1.0 / (1.0 + distance) - if decay_ref is not None and game_day is not None: - if source in ("transcript", "player"): - score *= age_decay(decay_ref, game_day) + if ( + decay_ref is not None + and game_day is not None + and source in ("transcript", "player") + ): + score *= age_decay(decay_ref, game_day) hits.append(SearchHit( doc_id=doc_id, @@ -348,11 +348,21 @@ class VectorIndex: hits.sort(key=lambda h: h.score, reverse=True) return hits[:limit] - def reindex_directory(self, directory: Path, source: str) -> int: + def reindex_directory( + self, + directory: Path, + source: str, + skip_subdirs: frozenset[str] | None = None, + ) -> int: """Index all .md files under a directory. Skips files whose mtime hasn't changed since last index. Returns the number of documents indexed (counting chunks). + + ``skip_subdirs`` is a set of top-level subdirectory names to + exclude from the walk. Used when indexing a world_dir to skip + ``transcripts/`` — those files are indexed separately by the + engine under ``source="transcript"``. """ existing = {} for row in self._conn.execute( @@ -366,6 +376,8 @@ class VectorIndex: for md_file in sorted(directory.rglob("*.md")): rel = md_file.relative_to(directory) + if skip_subdirs and rel.parts and rel.parts[0] in skip_subdirs: + continue content = md_file.read_text() mtime = md_file.stat().st_mtime content_type = rel.parts[0] if len(rel.parts) > 1 else "" diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index 3ad1e5b..6d4b8f9 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -294,15 +294,36 @@ def adjust_coins( ) -> str: """Adjust the player's coins by relative amounts. - Use negative values to spend, positive to gain. Omit any denomination - you don't want to change. Coins are clamped to zero on the underlying purse. + Negative values spend, positive values gain. Omit any denomination + you're not changing. The call is **atomic**: if any denomination + would go below zero, the whole call is rejected and nothing on the + sheet changes — fix the deltas and call again. + + **Express each transaction as a single call with the NET delta to + the purse.** If the player can't pay exact change, fold the + payment AND the change you're handing back into one call. Don't + spend first and then call again to "make change" — that's how you + end up double-charging. + + Examples: + spend 5 gp: {"gp": -5} + loot 10 gp + 5 sp: {"gp": 10, "sp": 5} + sell an item for 12 sp: {"sp": 12} + + pay 5 cp from a 9 sp / 0 cp purse (1 silver in, 5 cp change back): + {"sp": -1, "cp": 5} + pay 7 sp from a 1 gp / 0 sp purse (1 gold in, 3 sp change back): + {"gp": -1, "sp": 3} + pay 80 gp with 1 platinum: {"pp": -1, "gp": 20} + + The math: 1 pp = 10 gp, 1 gp = 10 sp = 2 ep, 1 sp = 10 cp. Args: deltas: Coin changes per denomination (cp/sp/ep/gp/pp). - Example: spend 5 gp → {"gp": -5}; gain 10 gp + 5 sp → {"gp": 10, "sp": 5} Returns: - Summary of changes and new purse balance + Summary of changes and new purse balance, or an error message + explaining the shortfall if the call was rejected. """ # Drop zero-deltas before passing to the underlying op so the result # message only mentions denominations the DM actually touched. diff --git a/tests/test_character.py b/tests/test_character.py index fe79d15..e505708 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -1143,13 +1143,54 @@ class TestCoins: assert data["state"]["purse"]["gp"] == 53 assert data["state"]["purse"]["sp"] == 5 - def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path): + def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path): + before = load_character("test-player")["state"]["purse"]["gp"] result = adjust_coins( "test-player", {"gp": -100} ) data = load_character("test-player") - assert data["state"]["purse"]["gp"] == 0 - assert "short" in result.lower() + # Rejected — purse is unchanged. + assert data["state"]["purse"]["gp"] == before + assert "cannot adjust" in result.lower() + assert "insufficient" in result.lower() + + def test_adjust_coins_rejects_partial_underflow( + self, mira: dict, player_dir: Path, + ): + # Mira has gp but not enough cp. The mixed delta should be + # rejected as a whole — neither denomination should change. + before = load_character("test-player")["state"]["purse"] + result = adjust_coins( + "test-player", {"gp": -1, "cp": -100} + ) + data = load_character("test-player") + assert data["state"]["purse"]["gp"] == before["gp"] + assert data["state"]["purse"]["cp"] == before["cp"] + assert "cannot adjust" in result.lower() + + def test_adjust_coins_making_change(self, player_dir: Path): + from storied.character.data import create_character + create_character( + player_id="test-player", + name="Coin Test", + race="Human", + char_class="Fighter", + level=1, + abilities={ + "strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + }, + hp_max=10, + ac=10, + purse={"sp": 9}, + ) + # Paying 5 cp from a 9 sp / 0 cp purse must work as one atomic call + # with the silver going out and the change coming back together. + result = adjust_coins("test-player", {"sp": -1, "cp": 5}) + data = load_character("test-player") + assert data["state"]["purse"]["sp"] == 8 + assert data["state"]["purse"]["cp"] == 5 + assert "cannot" not in result.lower() class TestNotes: diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..125547b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,83 @@ +"""Tests for CLI helpers — cold-start detection and onboarding artifacts.""" + +import pytest + +from storied.character import create_character +from storied.cli import _is_cold_start +from storied.paths import world_path + + +@pytest.fixture +def _minimal_character() -> None: + """Create a minimal character so load_character finds one.""" + create_character( + player_id="default", + name="Test", + race="Human", + char_class="Fighter", + level=1, + abilities={ + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 10, + "charisma": 10, + }, + hp_max=10, + ac=10, + ) + + +@pytest.fixture +def _world_style() -> None: + """Write a non-empty style.md for the default world.""" + world_dir = world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text("Grim. Slow-burn.\n") + + +class TestColdStartDetection: + def test_cold_start_when_nothing_exists(self): + assert _is_cold_start("default", "default") is True + + def test_cold_start_when_only_character_exists( + self, + _minimal_character: None, + ): + assert _is_cold_start("default", "default") is True + + def test_cold_start_when_only_style_exists( + self, + _world_style: None, + ): + assert _is_cold_start("default", "default") is True + + def test_not_cold_start_when_both_exist( + self, + _minimal_character: None, + _world_style: None, + ): + assert _is_cold_start("default", "default") is False + + def test_not_cold_start_ignores_other_worlds( + self, + _minimal_character: None, + ): + # Style lives in a different world — doesn't count for this one. + world_dir = world_path("other") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text("A vibe.\n") + + assert _is_cold_start("default", "default") is True + + +class TestOnboardingPromptExists: + def test_new_game_prompt_file_exists(self): + from storied.engine import load_prompt + + content = load_prompt("new-game") + # Sanity check: the prompt mentions both onboarding deliverables. + assert "tune" in content + assert "create_character" in content + assert "end_session" in content diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3589259..f3d6954 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -11,9 +11,8 @@ import asyncio import pytest -from storied.initiative import Combatant from storied.mcp_server import _compose_server -from storied.tools import ToolContext, _context +from storied.tools import ToolContext from storied.tools.character import refresh_advancement_visibility from storied.tools.combat import _flip_into_combat, _flip_out_of_combat @@ -296,6 +295,7 @@ class TestPopulateIndex: from storied.mcp_server import _populate_index vi = MagicMock() + vi.has_source.return_value = False _populate_index( tmp_path / "worlds" / "missing", vi, @@ -313,10 +313,14 @@ class TestPopulateIndex: world_dir = tmp_path / "worlds" / "test" world_dir.mkdir(parents=True) vi = MagicMock() + vi.has_source.return_value = False _populate_index( world_dir, vi, srd_root=tmp_path / "srd-missing", ) - vi.reindex_directory.assert_called_once_with(world_dir, source="world") + vi.reindex_directory.assert_called_once_with( + world_dir, source="world", + skip_subdirs=frozenset({"transcripts"}), + ) def test_srd_sections_dir(self, tmp_path): from unittest.mock import MagicMock @@ -328,6 +332,7 @@ class TestPopulateIndex: srd_dir.mkdir(parents=True) world_dir = tmp_path / "worlds" / "test" vi = MagicMock() + vi.has_source.return_value = False _populate_index(world_dir, vi, srd_root=srd_root) # SRD sections present → reindex SRD; no user layer, no world dir assert vi.reindex_directory.call_count == 1 @@ -336,7 +341,7 @@ class TestPopulateIndex: def test_user_layer_indexed(self, tmp_path): """When the user homebrew directory exists, _populate_index reindexes it with source='user' after the shipped SRD.""" - from unittest.mock import MagicMock + from unittest.mock import MagicMock, call from storied.mcp_server import _populate_index @@ -350,14 +355,39 @@ class TestPopulateIndex: world_dir.mkdir(parents=True) vi = MagicMock() + vi.has_source.return_value = False _populate_index( world_dir, vi, srd_root=tmp_path / "srd-missing", ) # Should have indexed user and world, in that order - calls = vi.reindex_directory.call_args_list - assert len(calls) == 2 - assert calls[0] == ((user_dir,), {"source": "user"}) - assert calls[1] == ((world_dir,), {"source": "world"}) + assert vi.reindex_directory.call_args_list == [ + call(user_dir, source="user"), + call( + world_dir, + source="world", + skip_subdirs=frozenset({"transcripts"}), + ), + ] + + def test_populate_index_is_idempotent(self, tmp_path): + """Once the SRD is seeded, subsequent calls must not reseed + (which would wipe world/transcript rows by file-copying the SRD + db over the live one).""" + from unittest.mock import MagicMock + + from storied.mcp_server import _populate_index + + srd_root = tmp_path / "srd-5.2.1" + srd_root.mkdir(parents=True) + srd_seed = srd_root / "search.db" + srd_seed.write_bytes(b"sqlite stub") + world_dir = tmp_path / "worlds" / "test" + world_dir.mkdir(parents=True) + + vi = MagicMock() + vi.has_source.return_value = True # SRD already seeded + _populate_index(world_dir, vi, srd_root=srd_root) + vi.reseed.assert_not_called() def test_flip_helpers_no_op_when_root_unset(self): """The combat-tag flip helpers must not crash when no top-level @@ -390,7 +420,125 @@ class TestPopulateIndex: (srd_root / "sections").mkdir() world_dir = tmp_path / "worlds" / "test" vi = MagicMock() + vi.has_source.return_value = False _populate_index(world_dir, vi, srd_root=srd_root) vi.reseed.assert_called_once_with(srd_seed) # When the seed exists, we don't also reindex SRD sections vi.reindex_directory.assert_not_called() + + +class TestRulesLookupRace: + """Regression test for the transcript-upsert-vs-first-search race. + + Before the fix, the lazy ``on_empty`` seeding would miss its window + whenever the DM's first turn didn't call ``recall``: the engine would + upsert the transcript at turn end, making the db non-empty, and the + next search would skip seeding because ``count > 0``. Rules lookups + would then return nothing. The fix eagerly populates in + ``start_server``, and ``_populate_index`` is idempotent via + ``has_source("srd")``. + """ + + def test_srd_stays_available_after_transcript_upsert_race( + self, tmp_path, monkeypatch, + ): + from storied import paths + from storied.mcp_server import _populate_index + from storied.search import VectorIndex + + # Minimal shipped SRD seed the populate helper can copy from. + srd_root = tmp_path / "shipped" / "srd-5.2.1" + srd_root.mkdir(parents=True) + seed_db = srd_root / "search.db" + seed_index = VectorIndex(seed_db) + seed_index.upsert( + "srd:character-origins.md:0", + "# Character Origins\n\n**Half-Elf** gets +2 Charisma.", + { + "source": "srd", + "content_type": "rules", + "path": str(srd_root / "sections" / "character-origins.md"), + "title": "Character Origins", + }, + ) + seed_index.close() + + monkeypatch.setattr( + paths, "shipped_rules_path", lambda: tmp_path / "shipped", + ) + + world_dir = paths.world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + db_path = world_dir / "search.db" + + # Eager populate the way start_server now does. + vi = VectorIndex(db_path) + _populate_index(world_dir, vi) + assert vi.has_source("srd") + + # Turn 1 ends without any recall — engine upserts the transcript. + (world_dir / "transcripts").mkdir(exist_ok=True) + day_path = world_dir / "transcripts" / "day+001.md" + day_path.write_text("### Day 1\n\nHey, welcome.") + vi.upsert( + "transcript:transcripts/day+001.md:0", + day_path.read_text(), + { + "source": "transcript", + "content_type": "transcripts", + "path": str(day_path), + "title": "Day 1", + "game_day": 1, + }, + ) + + # Turn 2: the DM finally calls recall. SRD must still be there. + hits = vi.search( + "half-elf charisma", + limit=3, + source_filter=["srd", "user", "world"], + ) + assert len(hits) >= 1 + assert any(h.source == "srd" for h in hits) + + def test_populate_is_idempotent_on_repeated_start_server( + self, tmp_path, monkeypatch, + ): + """A second start_server (onboarding → play handoff) must not + wipe the world/transcript rows by re-copying the SRD seed.""" + from storied import paths + from storied.mcp_server import _populate_index + from storied.search import VectorIndex + + srd_root = tmp_path / "shipped" / "srd-5.2.1" + srd_root.mkdir(parents=True) + seed_db = srd_root / "search.db" + seed_index = VectorIndex(seed_db) + seed_index.upsert( + "srd:x.md:0", "# X", {"source": "srd", "path": "x.md"}, + ) + seed_index.close() + + monkeypatch.setattr( + paths, "shipped_rules_path", lambda: tmp_path / "shipped", + ) + + world_dir = paths.world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + db_path = world_dir / "search.db" + + vi = VectorIndex(db_path) + _populate_index(world_dir, vi) + + # Upsert a transcript row to represent prior session state. + vi.upsert( + "transcript:transcripts/day+001.md:0", + "turn content", + {"source": "transcript", "path": "transcripts/day+001.md"}, + ) + assert vi.has_source("transcript") + + # Second populate — must not wipe transcripts via reseed. + _populate_index(world_dir, vi) + assert vi.has_source("srd") + assert vi.has_source("transcript") diff --git a/tests/test_seeder.py b/tests/test_seeder.py index e4a093d..38e273f 100644 --- a/tests/test_seeder.py +++ b/tests/test_seeder.py @@ -10,7 +10,6 @@ import pytest from storied.character import create_character from storied.mcp_server import _compose_server from storied.planner import SeedResult, seed_world -from storied.tools import ToolContext class TestSeederTools: @@ -101,7 +100,11 @@ class TestSeedWorld: "event": { "type": "content_block_start", "index": 0, - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__establish"}, + "content_block": { + "type": "tool_use", + "id": "t1", + "name": "mcp__storied__establish", + }, }, }), json.dumps({ @@ -109,7 +112,11 @@ class TestSeedWorld: "event": { "type": "content_block_start", "index": 1, - "content_block": {"type": "tool_use", "id": "t2", "name": "mcp__storied__set_scene"}, + "content_block": { + "type": "tool_use", + "id": "t2", + "name": "mcp__storied__set_scene", + }, }, }), json.dumps({ @@ -122,7 +129,7 @@ class TestSeedWorld: mock_proc = MagicMock() mock_proc.stdin = MagicMock() - mock_proc.stdout = iter([l.encode() + b"\n" for l in lines]) + mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 mock_proc.returncode = 0 @@ -173,7 +180,11 @@ class TestSeedWorld: "event": { "type": "content_block_start", "index": 0, - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__establish"}, + "content_block": { + "type": "tool_use", + "id": "t1", + "name": "mcp__storied__establish", + }, }, }), json.dumps({ @@ -186,7 +197,7 @@ class TestSeedWorld: mock_proc = MagicMock() mock_proc.stdin = MagicMock() - mock_proc.stdout = iter([l.encode() + b"\n" for l in lines]) + mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 mock_proc.returncode = 0 @@ -208,3 +219,66 @@ class TestSeedWorld: ) assert isinstance(result, SeedResult) assert result.tool_calls == 0 + + +class TestSeedWorldStyleContext: + """seed_world prepends style.md as a Player Preferences block.""" + + @patch("storied.planner.run_with_tools") + def test_seed_world_includes_style_when_present( + self, + mock_run: MagicMock, + character_world: Path, + ): + from storied.paths import world_path + world_dir = world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text( + "Grim political intrigue. No heroic fantasy. Slow-burn " + "investigation with moral compromise." + ) + + mock_run.return_value = None + + seed_world(world_id="default", player_id="default") + + mock_run.assert_called_once() + user_message = mock_run.call_args.kwargs["user_message"] + assert "## Player Preferences" in user_message + assert "Grim political intrigue" in user_message + # Preferences come before the character sheet + pref_idx = user_message.index("Player Preferences") + char_idx = user_message.index("Kael Stormborn") + assert pref_idx < char_idx + + @patch("storied.planner.run_with_tools") + def test_seed_world_omits_style_block_when_absent( + self, + mock_run: MagicMock, + character_world: Path, + ): + mock_run.return_value = None + seed_world(world_id="default", player_id="default") + + mock_run.assert_called_once() + user_message = mock_run.call_args.kwargs["user_message"] + assert "Player Preferences" not in user_message + assert "Kael Stormborn" in user_message + + @patch("storied.planner.run_with_tools") + def test_seed_world_ignores_empty_style_file( + self, + mock_run: MagicMock, + character_world: Path, + ): + from storied.paths import world_path + world_dir = world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text(" \n\n \n") + + mock_run.return_value = None + seed_world(world_id="default", player_id="default") + + mock_run.assert_called_once() + user_message = mock_run.call_args.kwargs["user_message"] + assert "Player Preferences" not in user_message -- 2.51.2