A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Add world seeding, planner, and new slash commands

When a player creates a character and starts `storied play`, the DM used
to improvise from nothing — always the same generic opening. Now we
detect empty worlds (character exists, no session) and run a seeding
pass with Opus before play begins. The seeder builds outward from the
character's backstory: locations, NPCs, threads, and an opening moment.

Also adds `storied plan` to enrich thin entities near the player between
sessions, and `storied seed` for manual world seeding.

New slash commands:
- `/dm` for out-of-character messages to the DM
- `/note` to add notes to the character sheet mid-session

The DM system prompt now encourages shorter responses — a few paragraphs
instead of walls of text.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1567 -2
+4 -2
prompts/dm-system.md
··· 43 43 44 44 ## Output Format 45 45 46 - You're running in a terminal with markdown support. Use formatting to enhance readability: 46 + You're running in a terminal. Keep responses **short** — a few paragraphs at most. The player is typing one line at a time; don't bury them in text. Say what matters, then stop and let them act. A great response is often 2-4 sentences of narration ending on a moment that invites action. 47 + 48 + Use markdown formatting to enhance readability: 47 49 - **Bold** for emphasis, names, or important terms 48 50 - *Italics* for character speech or internal thoughts 49 51 - Horizontal rules (---) to separate scenes 50 52 51 - End on moments that invite player action. 53 + Don't over-describe. One vivid detail beats three adequate ones. Trust the player's imagination. 52 54 53 55 ## Narrative Rhythm 54 56
+67
prompts/planner-system.md
··· 1 + You are a World Architect for a 5e solo adventure. You prepare the world so the DM can tell a richer story. 2 + 3 + You're given the current scene, open plot threads, and a set of thin entities that need enrichment. Your job is to deepen them — add inner life, plant seeds for future story, and weave threads into the fabric of the world. 4 + 5 + ## Your Tools 6 + 7 + | Tool | Purpose | 8 + |------|---------| 9 + | `recall` | Look up rules or existing world content | 10 + | `establish` | Create or update entities (NPCs, locations, items, threads) | 11 + | `mark` | Record backstory events in an entity's history | 12 + 13 + ## What to Do 14 + 15 + For each thin entity you're given: 16 + 17 + 1. **Read what exists** — use `recall` if you need context on connected entities 18 + 2. **Add Knows** — secrets, hidden truths, what isn't obvious (2-4 items) 19 + 3. **Add Wants** — nature, tendencies, inclinations (2-4 items) 20 + 4. **Add Will** — conditional triggers in "If X → Y" format (2-4 items) 21 + 5. **Connect to the world** — use [[wikilinks]] to reference other entities 22 + 6. **Weave in threads** — plant clues, rumors, or connections to open plot threads 23 + 24 + ## Mining the Recent Events Log 25 + 26 + You're also given a "Recent Events" section with the full campaign log from the last couple of days. Scan it for casually mentioned people, places, or things that were never established as entities but could make the world richer if they were. 27 + 28 + **The bar is "would the DM benefit from having this prepped?"** Establish something from the log if: 29 + 30 + - It's a named NPC who might recur (the bartender who gave a tip, the lookouts who spotted the party) 31 + - It's a location the player is likely to revisit or hear about again 32 + - It connects to an open thread or could seed a future one 33 + 34 + **Don't establish** things that are generic, disposable, or far from the action — random goblins from a one-off fight, passing travelers, a bridge the party crossed once. If it wouldn't make the DM's job easier to have it prepped, skip it. 35 + 36 + When you do establish from a log mention, keep it light — a description, a location, and 1-2 Knows/Wants/Will items. These are seeds, not full portraits. 37 + 38 + ## Seeding New Entities 39 + 40 + When enriching an existing entity, you may reference entities that don't exist yet. If a reference demands it, create them with `establish` — but keep them light: 41 + 42 + - A brief description 43 + - 1-2 Knows/Wants/Will items 44 + - A location via wikilink 45 + 46 + Prefer enriching the thin entities you were given over inventing new ones. 47 + 48 + ## Rules 49 + 50 + - **Never contradict established facts.** Read existing content carefully. If an NPC already knows something, don't overwrite it — add to it. 51 + - **Keep Knows/Wants/Will concise.** 2-4 items each. Evocative, not exhaustive. 52 + - **Use [[wikilinks]] liberally.** Every NPC should reference their location. Every location should reference who's there. This creates a connected graph. 53 + - **Weave open threads in.** If there's a mystery about missing merchants, maybe a nearby NPC heard rumors. If there's a bounty, maybe a location has clues. 54 + - **Respect entity types.** Non-sentient things can still "want" and "know" — a bridge "wants" to collapse, a cursed ring "knows" its maker's name. Frame narrative tendency, not literal consciousness. 55 + - **Write Was entries for backstory.** Use `mark` to give entities pre-existing history that enriches the world. 56 + - **Anchor entities to locations.** Don't say "works at the city jail" — say "works at [[Greyhaven City Jail]]". 57 + - **Give names to everyone.** Don't create "a guard" — create "Mara, a guard". Named entities create a living world. 58 + 59 + ## What Not to Do 60 + 61 + - Don't advance the plot. You're setting the stage, not playing the game. 62 + - Don't create events that involve the player character. 63 + - Don't contradict the current session state or campaign log. 64 + - Don't create entities far from the current location — focus on what's nearby. 65 + - Don't write prose or narrative. Use the tools to update world state directly. 66 + 67 + When you've enriched the candidates and established any worthwhile log mentions, stop.
+63
prompts/world-seed.md
··· 1 + You are a World Architect for a solo 5e adventure. You're building the opening world from a character sheet so the DM has rich material to work with from the first moment of play. 2 + 3 + ## Your Tools 4 + 5 + | Tool | Purpose | 6 + |------|---------| 7 + | `establish` | Create entities (NPCs, locations, items, threads, lore) | 8 + | `set_scene` | Place the player in a specific opening moment | 9 + | `mark_time` | Set the starting time (morning of Day 1) | 10 + 11 + ## What You're Given 12 + 13 + 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. 14 + 15 + ## What to Build 16 + 17 + Work outward from the character's backstory: 18 + 19 + 1. **Where they came from** — the place they grew up, trained, or just left. Establish it as a location with personality: what it knows, what it wants, what it will do. 20 + 21 + 2. **People from their past** — 2-3 NPCs connected to the backstory. A mentor, a rival, a friend left behind. Give each Knows/Wants/Will so the DM can bring them in later. 22 + 23 + 3. **Where they are RIGHT NOW** — the immediate location. A road, a village, a crossroads. This is where play begins — make it vivid and specific. 24 + 25 + 4. **People in the scene** — 1-2 NPCs the player can interact with immediately. A fellow traveler, a merchant, a guard. These are the first faces the player sees. 26 + 27 + 5. **Open threads** — 2-3 hooks woven into the entities: 28 + - One personal (tied to backstory — unfinished business, a rumor about someone they knew) 29 + - One local (something happening in the immediate area — a problem, an opportunity) 30 + - One larger (a distant rumor or sign of something bigger — war, plague, a quest) 31 + 32 + 6. **The opening moment** — use `set_scene` to place the player in a specific, actionable situation. Not "you're in a tavern" — something with momentum. Walking toward something, arriving somewhere, witnessing something. 33 + 34 + 7. **The clock** — use `mark_time` to establish the starting time. Morning of Day 1. 35 + 36 + ## Rules 37 + 38 + - **Build from the character.** Everything should feel like it grew from their backstory, not from a template. A soldier's world looks different from a wizard's. 39 + - **Use [[wikilinks]] liberally.** Every NPC should reference their location. Every location should reference who's there. Create a connected graph. 40 + - **Keep it focused.** 6-10 entities total. Seeds, not an encyclopedia. The DM and player will fill in the rest. 41 + - **Give names to everyone.** Not "a guard" — "Mara, a guard at the west gate." Named entities create a living world. 42 + - **Use Knows/Wants/Will on everything.** Even locations and items. A bridge "wants" to be crossed. A sword "knows" its previous owner. 43 + - **Anchor Will triggers to player actions.** "If the player asks about the fire → reveal that it was arson." These give the DM ready-made drama. 44 + - **Don't write narrative.** Use the tools to create world state. The DM will handle the storytelling. 45 + - **Don't include the player character as an entity.** They already exist as a character sheet. 46 + 47 + ## Entity Types 48 + 49 + Use these for the `entity_type` parameter: 50 + 51 + - `locations` — places (towns, buildings, roads, dungeons) 52 + - `npcs` — people and creatures 53 + - `items` — notable objects, artifacts, equipment 54 + - `threads` — plot hooks and ongoing storylines 55 + - `lore` — history, legends, world facts 56 + 57 + ## Order of Operations 58 + 59 + 1. First, `mark_time` to set the clock (morning of Day 1) 60 + 2. Then `establish` all entities, starting with locations, then NPCs, then threads 61 + 3. Finally, `set_scene` to place the player in the opening moment 62 + 63 + When you've built the world, stop.
+195
src/storied/cli.py
··· 36 36 "/help": "Show this help message", 37 37 "/save": "Save session state (without quitting)", 38 38 "/context": "Show token usage and costs", 39 + "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", 40 + "/note": "Add a note to your character sheet (e.g. /note remember the prayer words)", 39 41 } 40 42 41 43 ··· 263 265 console.print(f"[dim]World: {world_id}[/dim]") 264 266 console.print() 265 267 268 + # Seed the world if the character exists but no session yet 269 + if not creation_mode: 270 + from storied.session import load_session 271 + 272 + session = load_session(player_id) 273 + if session is None: 274 + from storied.planner import seed_world 275 + 276 + console.print(f"[dim]Seeding the world for {character['name']}...[/dim]") 277 + 278 + def on_seed_progress(msg: str) -> None: 279 + console.print(f"[dim] {msg}[/dim]") 280 + 281 + result = seed_world( 282 + world_id=world_id, 283 + player_id=player_id, 284 + on_progress=on_seed_progress, 285 + ) 286 + 287 + pricing = MODEL_PRICING.get("claude-opus-4-5-20251101", DEFAULT_PRICING) 288 + cost = ( 289 + result.input_tokens * pricing["input"] / 1_000_000 290 + + result.output_tokens * pricing["output"] / 1_000_000 291 + ) 292 + console.print( 293 + f"[dim] Done — {result.tool_calls} tool calls, " 294 + f"{result.elapsed:.1f}s, ${cost:.2f}[/dim]" 295 + ) 296 + console.print() 297 + 266 298 transcript_path = Path(args.transcript) if args.transcript else None 267 299 engine = DMEngine( 268 300 world_id=world_id, ··· 461 493 console.print("[dim]Saving session...[/dim]") 462 494 action = "[System: Player requested a session save. Please save the current game state using set_scene, but do not end the session. Briefly confirm what was saved.]" 463 495 496 + # Handle /dm command (out-of-character message to the DM) 497 + if action.strip().lower().startswith("/dm"): 498 + ooc_msg = action.strip()[3:].strip() 499 + if not ooc_msg: 500 + console.print("[dim]Usage: /dm <message>[/dim]") 501 + continue 502 + action = f"[Out-of-character from the player: {ooc_msg}]" 503 + 504 + # Handle /note command (add a note to the character sheet) 505 + if action.strip().lower().startswith("/note"): 506 + note_msg = action.strip()[5:].strip() 507 + if not note_msg: 508 + console.print("[dim]Usage: /note <what to remember>[/dim]") 509 + continue 510 + action = f"[System: The player wants to add a note to their character sheet. Use update_character with section.Notes to append this note, preserving any existing notes. Note to add: {note_msg}]" 511 + 464 512 try: 465 513 console.print(Rule(style="dim blue")) 466 514 console.print() # Blank line before DM response ··· 556 604 return 0 557 605 558 606 607 + def cmd_plan(args: argparse.Namespace) -> int: 608 + """Enrich thin entities near the player's current position.""" 609 + from storied.planner import plan_world 610 + 611 + world_id = args.world or "default" 612 + player_id = args.player or "default" 613 + 614 + print(f"Loading session for player '{player_id}' in world '{world_id}'...", flush=True) 615 + 616 + def on_progress(msg: str) -> None: 617 + print(msg, flush=True) 618 + 619 + result = plan_world( 620 + world_id=world_id, 621 + player_id=player_id, 622 + model=args.model, 623 + threshold=args.threshold, 624 + max_entities=args.max_entities, 625 + dry_run=args.dry_run, 626 + on_progress=on_progress, 627 + ) 628 + 629 + if not result.candidates: 630 + print("No thin entities found nearby. The world looks rich enough.") 631 + return 0 632 + 633 + if result.dry_run: 634 + return 0 635 + 636 + # Real run — show summary with cost 637 + pricing = MODEL_PRICING.get(args.model, DEFAULT_PRICING) 638 + cost = ( 639 + result.input_tokens * pricing["input"] / 1_000_000 640 + + result.output_tokens * pricing["output"] / 1_000_000 641 + ) 642 + 643 + print( 644 + f"Done — {result.tool_calls} tool calls, " 645 + f"{result.input_tokens:,} in / {result.output_tokens:,} out, " 646 + f"{result.elapsed:.1f}s, ${cost:.2f}", 647 + flush=True, 648 + ) 649 + 650 + return 0 651 + 652 + 653 + def cmd_seed(args: argparse.Namespace) -> int: 654 + """Seed an empty world from a character sheet.""" 655 + from storied.planner import seed_world 656 + from storied.session import load_session 657 + 658 + world_id = args.world or "default" 659 + player_id = args.player or "default" 660 + 661 + session = load_session(player_id) 662 + if session is not None and not args.force: 663 + print("Session already exists. Use --force to re-seed.") 664 + return 1 665 + 666 + def on_progress(msg: str) -> None: 667 + print(msg, flush=True) 668 + 669 + result = seed_world( 670 + world_id=world_id, 671 + player_id=player_id, 672 + model=args.model, 673 + on_progress=on_progress, 674 + ) 675 + 676 + if result.tool_calls == 0: 677 + print("No character found. Create a character first with `storied play`.") 678 + return 1 679 + 680 + pricing = MODEL_PRICING.get(args.model, DEFAULT_PRICING) 681 + cost = ( 682 + result.input_tokens * pricing["input"] / 1_000_000 683 + + result.output_tokens * pricing["output"] / 1_000_000 684 + ) 685 + print( 686 + f"Done — {result.tool_calls} tool calls, " 687 + f"{result.input_tokens:,} in / {result.output_tokens:,} out, " 688 + f"{result.elapsed:.1f}s, ${cost:.2f}", 689 + flush=True, 690 + ) 691 + return 0 692 + 693 + 559 694 def build_parser() -> argparse.ArgumentParser: 560 695 """Build the argument parser.""" 561 696 parser = argparse.ArgumentParser( ··· 651 786 help="Skip confirmation prompt", 652 787 ) 653 788 reset_parser.set_defaults(func=cmd_reset) 789 + 790 + # plan command 791 + plan_parser = subparsers.add_parser("plan", help="Enrich thin world entities near the player") 792 + plan_parser.add_argument( 793 + "--world", "-w", 794 + default="default", 795 + help="World ID (default: default)", 796 + ) 797 + plan_parser.add_argument( 798 + "--player", "-p", 799 + default="default", 800 + help="Player ID (default: default)", 801 + ) 802 + plan_parser.add_argument( 803 + "--model", "-m", 804 + default="claude-opus-4-5-20251101", 805 + help="Model to use for planning (default: claude-opus-4-5-20251101)", 806 + ) 807 + plan_parser.add_argument( 808 + "--threshold", 809 + type=float, 810 + default=0.7, 811 + help="Richness threshold — entities below this score get enriched (default: 0.7)", 812 + ) 813 + plan_parser.add_argument( 814 + "--max-entities", 815 + type=int, 816 + default=8, 817 + help="Maximum entities to enrich per run (default: 8)", 818 + ) 819 + plan_parser.add_argument( 820 + "--dry-run", 821 + action="store_true", 822 + help="Show what would be enriched without calling the API", 823 + ) 824 + plan_parser.set_defaults(func=cmd_plan) 825 + 826 + # seed command 827 + seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet") 828 + seed_parser.add_argument( 829 + "--world", "-w", 830 + default="default", 831 + help="World ID (default: default)", 832 + ) 833 + seed_parser.add_argument( 834 + "--player", "-p", 835 + default="default", 836 + help="Player ID (default: default)", 837 + ) 838 + seed_parser.add_argument( 839 + "--model", "-m", 840 + default="claude-opus-4-5-20251101", 841 + help="Model to use for seeding (default: claude-opus-4-5-20251101)", 842 + ) 843 + seed_parser.add_argument( 844 + "--force", "-f", 845 + action="store_true", 846 + help="Re-seed even if a session already exists", 847 + ) 848 + seed_parser.set_defaults(func=cmd_seed) 654 849 655 850 return parser 656 851
+13
src/storied/log.py
··· 349 349 """Return the current game time.""" 350 350 return self.current_time 351 351 352 + def get_recent_entries(self, days: int = 2) -> list[LogEntry]: 353 + """Get full log entries from the last N days (including current). 354 + 355 + Unlike format_for_context() which only shows summaries of previous 356 + days, this returns every entry — useful for scanning the log for 357 + casually mentioned entities. 358 + """ 359 + entries: list[LogEntry] = [] 360 + start_day = max(1, self.current_day - days + 1) 361 + for day in range(start_day, self.current_day + 1): 362 + entries.extend(self._load_day_entries(day)) 363 + return entries 364 + 352 365 def format_for_context(self) -> str: 353 366 """Format the log for inclusion in system prompt.""" 354 367 lines = [f"## Campaign Time: {self.current_time}"]
+456
src/storied/planner.py
··· 1 + """World planner — enriches thin entities near the player's current position.""" 2 + 3 + import os 4 + import time 5 + from collections.abc import Callable 6 + from dataclasses import dataclass, field 7 + from pathlib import Path 8 + 9 + import anthropic 10 + 11 + from storied.engine import load_prompt 12 + from storied.log import CampaignLog 13 + from storied.session import ( 14 + extract_wiki_links, 15 + load_entity_content, 16 + load_session, 17 + resolve_wiki_link, 18 + ) 19 + from storied.character import format_character_context, load_character 20 + from storied.tools import ( 21 + PLANNER_TOOL_DEFINITIONS, 22 + SEEDER_TOOL_DEFINITIONS, 23 + _load_entity, 24 + planner_execute_tool, 25 + seeder_execute_tool, 26 + ) 27 + 28 + MAX_TURNS = 20 29 + 30 + 31 + def entity_richness(path: Path) -> float: 32 + """Score an entity file's completeness from 0.0 to 1.0. 33 + 34 + Scoring: 35 + - Has description (0.2) 36 + - Has Knows with items (0.2) 37 + - Has Wants with items (0.2) 38 + - Has Will with items (0.2) 39 + - Has Was history (0.1) 40 + - Has wikilinks to other entities (0.1) 41 + """ 42 + entity = _load_entity(path) 43 + if not entity: 44 + return 0.0 45 + 46 + score = 0.0 47 + 48 + desc = entity.get("description", "") 49 + if desc and not desc.startswith("##"): 50 + score += 0.2 51 + if entity.get("knows"): 52 + score += 0.2 53 + if entity.get("wants"): 54 + score += 0.2 55 + if entity.get("will"): 56 + score += 0.2 57 + if entity.get("was"): 58 + score += 0.1 59 + 60 + # Check for wikilinks anywhere in the file 61 + content = path.read_text() 62 + if extract_wiki_links(content): 63 + score += 0.1 64 + 65 + return min(score, 1.0) 66 + 67 + 68 + def find_nearby_entities( 69 + session: dict, 70 + world_id: str, 71 + base_path: Path, 72 + ) -> list[tuple[str, Path]]: 73 + """Find entities near the player by walking wikilinks. 74 + 75 + Starts from the current location and present entities in the session, 76 + then follows wikilinks from each discovered entity one hop out. 77 + Returns (name, path) pairs with no duplicates. 78 + """ 79 + seen: set[str] = set() 80 + results: list[tuple[str, Path]] = [] 81 + to_follow: list[Path] = [] 82 + 83 + def add_entity(name: str) -> None: 84 + if name in seen: 85 + return 86 + seen.add(name) 87 + path = resolve_wiki_link(name, world_id, base_path) 88 + if path: 89 + results.append((name, path)) 90 + to_follow.append(path) 91 + 92 + # Seed from current location 93 + location = session.get("location") 94 + if location: 95 + add_entity(location) 96 + 97 + # Seed from present entities in session body 98 + body = session.get("body", "") 99 + for name in extract_wiki_links(body): 100 + add_entity(name) 101 + 102 + # Follow links from all discovered entities (breadth-first, one pass) 103 + i = 0 104 + while i < len(to_follow): 105 + content = to_follow[i].read_text() 106 + for linked_name in extract_wiki_links(content): 107 + add_entity(linked_name) 108 + i += 1 109 + 110 + return results 111 + 112 + 113 + def build_planning_context( 114 + world_id: str, 115 + player_id: str, 116 + base_path: Path, 117 + candidates: list[tuple[str, Path]], 118 + ) -> str: 119 + """Build the context string that the planner LLM sees. 120 + 121 + Includes: current session state, open threads, and candidate entities 122 + with their current (thin) content. 123 + """ 124 + parts: list[str] = [] 125 + 126 + # Session state 127 + session = load_session(player_id, base_path) 128 + if session: 129 + location = session.get("location", "unknown") 130 + parts.append(f"## Current Location: {location}") 131 + 132 + body = session.get("body", "") 133 + if body: 134 + parts.append(body) 135 + 136 + # Campaign log — full recent entries so the planner can spot casual mentions 137 + log = CampaignLog(world_id, base_path) 138 + parts.append(f"## Campaign Time: {log.get_current_time()}") 139 + 140 + recent = log.get_recent_entries(days=2) 141 + if recent: 142 + lines = ["## Recent Events", ""] 143 + for entry in recent: 144 + lines.append(f"- {entry.anchor} | {entry.event}") 145 + parts.append("\n".join(lines)) 146 + 147 + # Candidate entities 148 + if candidates: 149 + parts.append("## Entities to Enrich") 150 + parts.append("") 151 + for name, path in candidates: 152 + content = path.read_text() 153 + parts.append(f"### {name}") 154 + parts.append(f"File: {path.parent.name}/{name}.md") 155 + parts.append("") 156 + parts.append(content) 157 + parts.append("") 158 + else: 159 + parts.append("## No entities to enrich.") 160 + 161 + return "\n\n".join(parts) 162 + 163 + 164 + @dataclass 165 + class PlanResult: 166 + """Result of a plan_world run.""" 167 + 168 + candidates: list[tuple[str, Path, float]] = field(default_factory=list) 169 + tool_calls: int = 0 170 + input_tokens: int = 0 171 + output_tokens: int = 0 172 + elapsed: float = 0.0 173 + dry_run: bool = False 174 + 175 + 176 + def plan_world( 177 + world_id: str = "default", 178 + player_id: str = "default", 179 + base_path: Path | None = None, 180 + model: str = "claude-opus-4-5-20251101", 181 + threshold: float = 0.7, 182 + max_entities: int = 8, 183 + dry_run: bool = False, 184 + on_progress: Callable[[str], None] | None = None, 185 + ) -> PlanResult: 186 + """Enrich thin entities near the player's current position. 187 + 188 + 1. Load session state 189 + 2. Find nearby entities via wikilinks 190 + 3. Score each for richness 191 + 4. Filter below threshold, take top N thinnest 192 + 5. If not dry_run, run the model to enrich them 193 + 194 + on_progress is called with status messages as work happens. 195 + """ 196 + if base_path is None: 197 + base_path = Path.cwd() 198 + 199 + def progress(msg: str) -> None: 200 + if on_progress: 201 + on_progress(msg) 202 + 203 + start_time = time.monotonic() 204 + 205 + # Load session 206 + session = load_session(player_id, base_path) 207 + if not session: 208 + return PlanResult(dry_run=dry_run) 209 + 210 + location = session.get("location", "unknown") 211 + progress(f"Current location: {location}") 212 + 213 + # Find and score nearby entities 214 + nearby = find_nearby_entities(session, world_id, base_path) 215 + progress(f"Scanning {len(nearby)} nearby entities...") 216 + 217 + scored: list[tuple[str, Path, float]] = [] 218 + for name, path in nearby: 219 + score = entity_richness(path) 220 + label = "rich" if score >= threshold else "thin" if score < 0.3 else "moderate" 221 + progress(f" {name}: {score:.1f} ({label}{', skipping' if score >= threshold else ''})") 222 + if score < threshold: 223 + scored.append((name, path, score)) 224 + 225 + # Sort thinnest first, take top N 226 + scored.sort(key=lambda x: x[2]) 227 + candidates = scored[:max_entities] 228 + 229 + result = PlanResult( 230 + candidates=candidates, 231 + dry_run=dry_run, 232 + elapsed=time.monotonic() - start_time, 233 + ) 234 + 235 + if dry_run or not candidates: 236 + return result 237 + 238 + # Build context and run the agentic loop 239 + candidate_pairs = [(name, path) for name, path, _ in candidates] 240 + context = build_planning_context(world_id, player_id, base_path, candidate_pairs) 241 + system_prompt = load_prompt("planner-system") 242 + 243 + campaign_log = CampaignLog(world_id, base_path) 244 + 245 + client = anthropic.Anthropic( 246 + api_key=os.environ.get("STORIED_ANTHROPIC_API_KEY"), 247 + ) 248 + 249 + messages: list[dict] = [ 250 + {"role": "user", "content": context}, 251 + ] 252 + 253 + total_input = 0 254 + total_output = 0 255 + total_tool_calls = 0 256 + 257 + progress(f"Planning with {model}...") 258 + 259 + for turn in range(MAX_TURNS): 260 + if turn > 0: 261 + progress(" ...") 262 + response = client.messages.create( 263 + model=model, 264 + max_tokens=4096, 265 + system=system_prompt, 266 + tools=PLANNER_TOOL_DEFINITIONS, 267 + messages=messages, 268 + ) 269 + 270 + total_input += response.usage.input_tokens 271 + total_output += response.usage.output_tokens 272 + 273 + # Build assistant message for conversation history 274 + assistant_content = [] 275 + tool_uses = [] 276 + for block in response.content: 277 + if block.type == "text": 278 + assistant_content.append({ 279 + "type": "text", 280 + "text": block.text, 281 + }) 282 + elif block.type == "tool_use": 283 + assistant_content.append({ 284 + "type": "tool_use", 285 + "id": block.id, 286 + "name": block.name, 287 + "input": block.input, 288 + }) 289 + tool_uses.append(block) 290 + 291 + if assistant_content: 292 + messages.append({"role": "assistant", "content": assistant_content}) 293 + 294 + if not tool_uses: 295 + break 296 + 297 + # Execute tools 298 + tool_results = [] 299 + for tool_use in tool_uses: 300 + total_tool_calls += 1 301 + 302 + tool_result = planner_execute_tool( 303 + tool_use.name, 304 + tool_use.input, 305 + world_id=world_id, 306 + base_path=base_path, 307 + campaign_log=campaign_log, 308 + ) 309 + 310 + progress(f" [{tool_use.name}] {tool_result}") 311 + 312 + tool_results.append({ 313 + "type": "tool_result", 314 + "tool_use_id": tool_use.id, 315 + "content": tool_result or "Done", 316 + }) 317 + 318 + messages.append({"role": "user", "content": tool_results}) 319 + 320 + if response.stop_reason == "end_turn": 321 + break 322 + 323 + result.tool_calls = total_tool_calls 324 + result.input_tokens = total_input 325 + result.output_tokens = total_output 326 + result.elapsed = time.monotonic() - start_time 327 + 328 + return result 329 + 330 + 331 + @dataclass 332 + class SeedResult: 333 + """Result of a seed_world run.""" 334 + 335 + tool_calls: int = 0 336 + input_tokens: int = 0 337 + output_tokens: int = 0 338 + elapsed: float = 0.0 339 + 340 + 341 + def seed_world( 342 + world_id: str = "default", 343 + player_id: str = "default", 344 + base_path: Path | None = None, 345 + model: str = "claude-opus-4-5-20251101", 346 + on_progress: Callable[[str], None] | None = None, 347 + ) -> SeedResult: 348 + """Build the initial world from a character sheet. 349 + 350 + Loads the character, sends it to a capable model with the world-seed 351 + prompt, and executes establish/set_scene/mark_time tools to populate 352 + the world before the first session. 353 + """ 354 + if base_path is None: 355 + base_path = Path.cwd() 356 + 357 + def progress(msg: str) -> None: 358 + if on_progress: 359 + on_progress(msg) 360 + 361 + start_time = time.monotonic() 362 + 363 + # Load the character — nothing to seed without one 364 + character = load_character(player_id, base_path) 365 + if character is None: 366 + return SeedResult() 367 + 368 + # Build context from the character sheet 369 + context = format_character_context(character) 370 + 371 + system_prompt = load_prompt("world-seed") 372 + campaign_log = CampaignLog(world_id, base_path) 373 + 374 + client = anthropic.Anthropic( 375 + api_key=os.environ.get("STORIED_ANTHROPIC_API_KEY"), 376 + ) 377 + 378 + messages: list[dict] = [ 379 + {"role": "user", "content": context}, 380 + ] 381 + 382 + total_input = 0 383 + total_output = 0 384 + total_tool_calls = 0 385 + 386 + progress(f"Seeding with {model}...") 387 + 388 + for turn in range(MAX_TURNS): 389 + response = client.messages.create( 390 + model=model, 391 + max_tokens=4096, 392 + system=system_prompt, 393 + tools=SEEDER_TOOL_DEFINITIONS, 394 + messages=messages, 395 + ) 396 + 397 + total_input += response.usage.input_tokens 398 + total_output += response.usage.output_tokens 399 + 400 + # Build assistant message for conversation history 401 + assistant_content = [] 402 + tool_uses = [] 403 + for block in response.content: 404 + if block.type == "text": 405 + assistant_content.append({ 406 + "type": "text", 407 + "text": block.text, 408 + }) 409 + elif block.type == "tool_use": 410 + assistant_content.append({ 411 + "type": "tool_use", 412 + "id": block.id, 413 + "name": block.name, 414 + "input": block.input, 415 + }) 416 + tool_uses.append(block) 417 + 418 + if assistant_content: 419 + messages.append({"role": "assistant", "content": assistant_content}) 420 + 421 + if not tool_uses: 422 + break 423 + 424 + # Execute tools 425 + tool_results = [] 426 + for tool_use in tool_uses: 427 + total_tool_calls += 1 428 + 429 + tool_result = seeder_execute_tool( 430 + tool_use.name, 431 + tool_use.input, 432 + world_id=world_id, 433 + player_id=player_id, 434 + base_path=base_path, 435 + campaign_log=campaign_log, 436 + ) 437 + 438 + progress(f"[{tool_use.name}] {tool_result}") 439 + 440 + tool_results.append({ 441 + "type": "tool_result", 442 + "tool_use_id": tool_use.id, 443 + "content": tool_result or "Done", 444 + }) 445 + 446 + messages.append({"role": "user", "content": tool_results}) 447 + 448 + if response.stop_reason == "end_turn": 449 + break 450 + 451 + return SeedResult( 452 + tool_calls=total_tool_calls, 453 + input_tokens=total_input, 454 + output_tokens=total_output, 455 + elapsed=time.monotonic() - start_time, 456 + )
+51
src/storied/tools.py
··· 1011 1011 entity_type=tool_input["entity_type"], 1012 1012 name=tool_input["name"], 1013 1013 description=tool_input.get("description"), 1014 + location=tool_input.get("location"), 1014 1015 knows=tool_input.get("knows"), 1015 1016 wants=tool_input.get("wants"), 1016 1017 will=tool_input.get("will"), ··· 1061 1062 1062 1063 else: 1063 1064 return f"Unknown tool: {tool_name}" 1065 + 1066 + 1067 + PLANNER_TOOLS = {"recall", "establish", "mark"} 1068 + 1069 + PLANNER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in PLANNER_TOOLS] 1070 + 1071 + 1072 + def planner_execute_tool( 1073 + tool_name: str, 1074 + tool_input: dict, 1075 + world_id: str | None = None, 1076 + base_path: Path | None = None, 1077 + campaign_log: CampaignLog | None = None, 1078 + ) -> str: 1079 + """Execute a planner-allowed tool. Rejects anything outside the allowed set.""" 1080 + if tool_name not in PLANNER_TOOLS: 1081 + return f"Tool not available to planner: {tool_name}" 1082 + return execute_tool( 1083 + tool_name, 1084 + tool_input, 1085 + world_id=world_id, 1086 + base_path=base_path, 1087 + campaign_log=campaign_log, 1088 + ) 1089 + 1090 + 1091 + SEEDER_TOOLS = {"establish", "set_scene", "mark_time"} 1092 + 1093 + SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] 1094 + 1095 + 1096 + def seeder_execute_tool( 1097 + tool_name: str, 1098 + tool_input: dict, 1099 + world_id: str | None = None, 1100 + player_id: str | None = None, 1101 + base_path: Path | None = None, 1102 + campaign_log: CampaignLog | None = None, 1103 + ) -> str: 1104 + """Execute a seeder-allowed tool. Rejects anything outside the allowed set.""" 1105 + if tool_name not in SEEDER_TOOLS: 1106 + return f"Tool not available to seeder: {tool_name}" 1107 + return execute_tool( 1108 + tool_name, 1109 + tool_input, 1110 + world_id=world_id, 1111 + player_id=player_id or "default", 1112 + base_path=base_path, 1113 + campaign_log=campaign_log, 1114 + )
+465
tests/test_planner.py
··· 1 + """Tests for the world planner — entity richness scoring, discovery, and context.""" 2 + 3 + from pathlib import Path 4 + from unittest.mock import MagicMock, patch 5 + 6 + import pytest 7 + 8 + from storied.log import CampaignLog 9 + from storied.planner import ( 10 + PlanResult, 11 + build_planning_context, 12 + entity_richness, 13 + find_nearby_entities, 14 + plan_world, 15 + ) 16 + from storied.session import save_session 17 + from storied.tools import establish 18 + 19 + 20 + @pytest.fixture 21 + def world_base(tmp_path: Path) -> Path: 22 + """Create a base directory with world structure.""" 23 + world = tmp_path / "worlds" / "test-world" 24 + world.mkdir(parents=True) 25 + return tmp_path 26 + 27 + 28 + @pytest.fixture 29 + def campaign_log(world_base: Path) -> CampaignLog: 30 + """Create a campaign log for the test world.""" 31 + return CampaignLog("test-world", world_base) 32 + 33 + 34 + @pytest.fixture 35 + def populated_world(world_base: Path) -> Path: 36 + """Create a world with some entities for testing.""" 37 + establish( 38 + entity_type="locations", 39 + name="Town Square", 40 + description="The center of [[Millford]]. A fountain stands here, surrounded by market stalls.", 41 + location="Central [[Millford]]", 42 + knows=["The fountain was built by [[Old Gregor]]"], 43 + wants=["To be a gathering place"], 44 + will=["If market day → attract crowds"], 45 + world_id="test-world", 46 + base_path=world_base, 47 + ) 48 + establish( 49 + entity_type="npcs", 50 + name="Old Gregor", 51 + description="An elderly stonemason.", 52 + location="[[Town Square]]", 53 + world_id="test-world", 54 + base_path=world_base, 55 + ) 56 + establish( 57 + entity_type="locations", 58 + name="Millford", 59 + description="A small riverside town.", 60 + world_id="test-world", 61 + base_path=world_base, 62 + ) 63 + establish( 64 + entity_type="npcs", 65 + name="Thin NPC", 66 + description="Someone with no inner life.", 67 + world_id="test-world", 68 + base_path=world_base, 69 + ) 70 + return world_base 71 + 72 + 73 + class TestEntityRichness: 74 + """Tests for richness scoring.""" 75 + 76 + def test_empty_entity_scores_zero(self, world_base: Path): 77 + establish( 78 + entity_type="npcs", 79 + name="Empty", 80 + world_id="test-world", 81 + base_path=world_base, 82 + ) 83 + path = world_base / "worlds/test-world/npcs/Empty.md" 84 + assert entity_richness(path) == 0.0 85 + 86 + def test_description_only(self, world_base: Path): 87 + establish( 88 + entity_type="npcs", 89 + name="Described", 90 + description="A tall warrior.", 91 + world_id="test-world", 92 + base_path=world_base, 93 + ) 94 + path = world_base / "worlds/test-world/npcs/Described.md" 95 + assert entity_richness(path) == pytest.approx(0.2) 96 + 97 + def test_fully_rich_entity(self, world_base: Path, campaign_log: CampaignLog): 98 + establish( 99 + entity_type="npcs", 100 + name="Rich NPC", 101 + description="A fully fleshed out character in [[Town Square]].", 102 + knows=["Secret one", "Secret two"], 103 + wants=["Goal one"], 104 + will=["If X → do Y"], 105 + world_id="test-world", 106 + base_path=world_base, 107 + ) 108 + from storied.tools import mark 109 + 110 + mark( 111 + entity_type="npcs", 112 + name="Rich NPC", 113 + event="Something happened", 114 + world_id="test-world", 115 + base_path=world_base, 116 + campaign_log=campaign_log, 117 + ) 118 + path = world_base / "worlds/test-world/npcs/Rich NPC.md" 119 + score = entity_richness(path) 120 + assert score == pytest.approx(1.0) 121 + 122 + def test_partial_richness(self, world_base: Path): 123 + establish( 124 + entity_type="npcs", 125 + name="Partial", 126 + description="Has description and knows.", 127 + knows=["A secret"], 128 + world_id="test-world", 129 + base_path=world_base, 130 + ) 131 + path = world_base / "worlds/test-world/npcs/Partial.md" 132 + score = entity_richness(path) 133 + # description (0.2) + knows (0.2) = 0.4 134 + assert score == pytest.approx(0.4) 135 + 136 + def test_wikilinks_contribute(self, world_base: Path): 137 + establish( 138 + entity_type="npcs", 139 + name="Linked", 140 + description="Hangs out at [[The Tavern]] with [[Bob]].", 141 + world_id="test-world", 142 + base_path=world_base, 143 + ) 144 + path = world_base / "worlds/test-world/npcs/Linked.md" 145 + score = entity_richness(path) 146 + # description (0.2) + wikilinks (0.1) = 0.3 147 + assert score == pytest.approx(0.3) 148 + 149 + 150 + class TestFindNearbyEntities: 151 + """Tests for discovering entities near the player.""" 152 + 153 + def test_finds_entities_from_location_links(self, populated_world: Path): 154 + session = { 155 + "location": "Town Square", 156 + "body": "", 157 + } 158 + nearby = find_nearby_entities(session, "test-world", populated_world) 159 + names = {name for name, _ in nearby} 160 + assert "Old Gregor" in names 161 + assert "Millford" in names 162 + 163 + def test_finds_entities_from_session_body(self, populated_world: Path): 164 + session = { 165 + "location": "Town Square", 166 + "body": "## Present\n- [[Thin NPC]]", 167 + } 168 + nearby = find_nearby_entities(session, "test-world", populated_world) 169 + names = {name for name, _ in nearby} 170 + assert "Thin NPC" in names 171 + 172 + def test_includes_current_location(self, populated_world: Path): 173 + session = { 174 + "location": "Town Square", 175 + "body": "", 176 + } 177 + nearby = find_nearby_entities(session, "test-world", populated_world) 178 + names = {name for name, _ in nearby} 179 + assert "Town Square" in names 180 + 181 + def test_no_duplicates(self, populated_world: Path): 182 + session = { 183 + "location": "Town Square", 184 + "body": "## Present\n- [[Old Gregor]]", 185 + } 186 + nearby = find_nearby_entities(session, "test-world", populated_world) 187 + names = [name for name, _ in nearby] 188 + assert names.count("Old Gregor") == 1 189 + 190 + def test_empty_session(self, world_base: Path): 191 + session = {"body": ""} 192 + nearby = find_nearby_entities(session, "test-world", world_base) 193 + assert nearby == [] 194 + 195 + 196 + class TestGetRecentEntries: 197 + """Tests for CampaignLog.get_recent_entries().""" 198 + 199 + def test_returns_current_day_entries(self, world_base: Path): 200 + log = CampaignLog("test-world", world_base) 201 + log.append_entry("Arrived at the tavern", "10 min") 202 + log.append_entry("Spoke with bartender", "15 min") 203 + 204 + entries = log.get_recent_entries(days=1) 205 + events = [e.event for e in entries] 206 + assert "Arrived at the tavern" in events 207 + assert "Spoke with bartender" in events 208 + 209 + def test_returns_previous_day_entries(self, world_base: Path): 210 + log = CampaignLog("test-world", world_base) 211 + log.append_entry("Morning event", "10 min") 212 + log.append_entry("Traveled all day", "18 hours") 213 + log.append_entry("Day 2 event", "10 min") 214 + 215 + entries = log.get_recent_entries(days=2) 216 + events = [e.event for e in entries] 217 + assert "Morning event" in events 218 + assert "Day 2 event" in events 219 + 220 + def test_respects_days_limit(self, world_base: Path): 221 + log = CampaignLog("test-world", world_base) 222 + log.append_entry("Day 1 event", "18 hours") 223 + log.append_entry("Day 2 event", "18 hours") 224 + log.append_entry("Day 3 event", "1 hour") 225 + 226 + entries = log.get_recent_entries(days=1) 227 + events = [e.event for e in entries] 228 + assert "Day 3 event" in events 229 + assert "Day 1 event" not in events 230 + 231 + def test_empty_log(self, world_base: Path): 232 + log = CampaignLog("test-world", world_base) 233 + entries = log.get_recent_entries(days=2) 234 + assert entries == [] 235 + 236 + 237 + class TestBuildPlanningContext: 238 + """Tests for assembling the planner's context.""" 239 + 240 + def test_includes_session_info(self, populated_world: Path): 241 + save_session( 242 + "default", 243 + { 244 + "location": "Town Square", 245 + "body": "## Situation\nThe player just arrived.\n\n## Open Threads\n- Find the missing cat", 246 + }, 247 + populated_world, 248 + ) 249 + context = build_planning_context( 250 + world_id="test-world", 251 + player_id="default", 252 + base_path=populated_world, 253 + candidates=[("Thin NPC", populated_world / "worlds/test-world/npcs/Thin NPC.md")], 254 + ) 255 + assert "Town Square" in context 256 + assert "missing cat" in context 257 + 258 + def test_includes_candidate_content(self, populated_world: Path): 259 + save_session( 260 + "default", 261 + {"location": "Town Square", "body": ""}, 262 + populated_world, 263 + ) 264 + path = populated_world / "worlds/test-world/npcs/Thin NPC.md" 265 + context = build_planning_context( 266 + world_id="test-world", 267 + player_id="default", 268 + base_path=populated_world, 269 + candidates=[("Thin NPC", path)], 270 + ) 271 + assert "Thin NPC" in context 272 + assert "Someone with no inner life" in context 273 + 274 + def test_empty_candidates(self, populated_world: Path): 275 + save_session( 276 + "default", 277 + {"location": "Town Square", "body": ""}, 278 + populated_world, 279 + ) 280 + context = build_planning_context( 281 + world_id="test-world", 282 + player_id="default", 283 + base_path=populated_world, 284 + candidates=[], 285 + ) 286 + assert "No entities" in context 287 + 288 + def test_includes_recent_log_entries(self, populated_world: Path): 289 + save_session( 290 + "default", 291 + {"location": "Town Square", "body": ""}, 292 + populated_world, 293 + ) 294 + log = CampaignLog("test-world", populated_world) 295 + log.append_entry("Fought three goblins in the clearing", "5 rounds") 296 + log.append_entry("Spotted two lookouts near the old mill", "30 min") 297 + 298 + context = build_planning_context( 299 + world_id="test-world", 300 + player_id="default", 301 + base_path=populated_world, 302 + candidates=[], 303 + ) 304 + assert "Recent Events" in context 305 + assert "three goblins" in context 306 + assert "two lookouts" in context 307 + 308 + 309 + class TestPlanWorld: 310 + """Tests for the plan_world orchestrator.""" 311 + 312 + def test_dry_run_returns_candidates(self, populated_world: Path): 313 + save_session( 314 + "default", 315 + { 316 + "location": "Town Square", 317 + "body": "## Present\n- [[Thin NPC]]", 318 + }, 319 + populated_world, 320 + ) 321 + result = plan_world( 322 + world_id="test-world", 323 + player_id="default", 324 + base_path=populated_world, 325 + dry_run=True, 326 + ) 327 + assert isinstance(result, PlanResult) 328 + assert result.dry_run is True 329 + assert len(result.candidates) > 0 330 + names = [name for name, _, _ in result.candidates] 331 + assert "Thin NPC" in names 332 + 333 + def test_dry_run_respects_threshold(self, populated_world: Path): 334 + save_session( 335 + "default", 336 + {"location": "Town Square", "body": ""}, 337 + populated_world, 338 + ) 339 + result = plan_world( 340 + world_id="test-world", 341 + player_id="default", 342 + base_path=populated_world, 343 + dry_run=True, 344 + threshold=0.0, 345 + ) 346 + assert len(result.candidates) == 0 347 + 348 + def test_dry_run_respects_max_entities(self, populated_world: Path): 349 + save_session( 350 + "default", 351 + {"location": "Town Square", "body": ""}, 352 + populated_world, 353 + ) 354 + result = plan_world( 355 + world_id="test-world", 356 + player_id="default", 357 + base_path=populated_world, 358 + dry_run=True, 359 + max_entities=1, 360 + ) 361 + assert len(result.candidates) <= 1 362 + 363 + def test_no_session_returns_empty(self, world_base: Path): 364 + result = plan_world( 365 + world_id="test-world", 366 + player_id="default", 367 + base_path=world_base, 368 + dry_run=True, 369 + ) 370 + assert len(result.candidates) == 0 371 + 372 + @patch("storied.planner.anthropic.Anthropic") 373 + def test_plan_world_calls_api(self, mock_anthropic_cls: MagicMock, populated_world: Path): 374 + save_session( 375 + "default", 376 + { 377 + "location": "Town Square", 378 + "body": "## Present\n- [[Thin NPC]]", 379 + }, 380 + populated_world, 381 + ) 382 + 383 + # Set up mock to return an end_turn response with no tool use 384 + mock_client = MagicMock() 385 + mock_anthropic_cls.return_value = mock_client 386 + mock_response = MagicMock() 387 + mock_response.stop_reason = "end_turn" 388 + mock_response.content = [MagicMock(type="text", text="Done enriching.")] 389 + mock_response.usage.input_tokens = 1000 390 + mock_response.usage.output_tokens = 200 391 + mock_response.usage.cache_creation_input_tokens = 0 392 + mock_response.usage.cache_read_input_tokens = 0 393 + mock_client.messages.create.return_value = mock_response 394 + 395 + result = plan_world( 396 + world_id="test-world", 397 + player_id="default", 398 + base_path=populated_world, 399 + model="claude-opus-4-5-20251101", 400 + ) 401 + 402 + assert result.dry_run is False 403 + assert result.input_tokens == 1000 404 + assert result.output_tokens == 200 405 + mock_client.messages.create.assert_called_once() 406 + 407 + @patch("storied.planner.anthropic.Anthropic") 408 + def test_plan_world_executes_tools(self, mock_anthropic_cls: MagicMock, populated_world: Path): 409 + save_session( 410 + "default", 411 + { 412 + "location": "Town Square", 413 + "body": "## Present\n- [[Thin NPC]]", 414 + }, 415 + populated_world, 416 + ) 417 + 418 + mock_client = MagicMock() 419 + mock_anthropic_cls.return_value = mock_client 420 + 421 + # First response: tool use (establish) 422 + tool_block = MagicMock() 423 + tool_block.type = "tool_use" 424 + tool_block.id = "tool_1" 425 + tool_block.name = "establish" 426 + tool_block.input = { 427 + "entity_type": "npcs", 428 + "name": "Thin NPC", 429 + "knows": ["Has a secret past"], 430 + "wants": ["Find a warm meal"], 431 + } 432 + first_response = MagicMock() 433 + first_response.stop_reason = "tool_use" 434 + first_response.content = [tool_block] 435 + first_response.usage.input_tokens = 800 436 + first_response.usage.output_tokens = 100 437 + first_response.usage.cache_creation_input_tokens = 0 438 + first_response.usage.cache_read_input_tokens = 0 439 + 440 + # Second response: end_turn 441 + second_response = MagicMock() 442 + second_response.stop_reason = "end_turn" 443 + second_response.content = [MagicMock(type="text", text="Done.")] 444 + second_response.usage.input_tokens = 900 445 + second_response.usage.output_tokens = 50 446 + second_response.usage.cache_creation_input_tokens = 0 447 + second_response.usage.cache_read_input_tokens = 0 448 + 449 + mock_client.messages.create.side_effect = [first_response, second_response] 450 + 451 + result = plan_world( 452 + world_id="test-world", 453 + player_id="default", 454 + base_path=populated_world, 455 + model="claude-opus-4-5-20251101", 456 + ) 457 + 458 + assert result.tool_calls == 1 459 + assert result.input_tokens == 1700 460 + assert result.output_tokens == 150 461 + 462 + # Verify the entity was actually enriched 463 + content = (populated_world / "worlds/test-world/npcs/Thin NPC.md").read_text() 464 + assert "Has a secret past" in content 465 + assert "Find a warm meal" in content
+253
tests/test_seeder.py
··· 1 + """Tests for world seeding — empty world detection and initial worldbuilding.""" 2 + 3 + from pathlib import Path 4 + from unittest.mock import MagicMock, patch 5 + 6 + import pytest 7 + 8 + from storied.character import create_character 9 + from storied.planner import SeedResult, seed_world 10 + from storied.tools import ( 11 + SEEDER_TOOL_DEFINITIONS, 12 + SEEDER_TOOLS, 13 + seeder_execute_tool, 14 + ) 15 + 16 + 17 + class TestSeederTools: 18 + """Tests for seeder tool filtering.""" 19 + 20 + def test_seeder_tools_set(self): 21 + assert SEEDER_TOOLS == {"establish", "set_scene", "mark_time"} 22 + 23 + def test_seeder_tool_definitions_filtered(self): 24 + names = {t["name"] for t in SEEDER_TOOL_DEFINITIONS} 25 + assert names == SEEDER_TOOLS 26 + 27 + def test_seeder_rejects_disallowed_tools(self, tmp_path: Path): 28 + for tool_name in ["roll", "recall", "mark", "note_discovery", "end_session"]: 29 + result = seeder_execute_tool( 30 + tool_name, 31 + {}, 32 + world_id="test", 33 + base_path=tmp_path, 34 + ) 35 + assert "not available to seeder" in result 36 + 37 + def test_seeder_allows_establish(self, tmp_path: Path): 38 + (tmp_path / "worlds" / "test" / "npcs").mkdir(parents=True) 39 + result = seeder_execute_tool( 40 + "establish", 41 + {"entity_type": "npcs", "name": "Test NPC", "description": "A test."}, 42 + world_id="test", 43 + base_path=tmp_path, 44 + ) 45 + assert "Established" in result 46 + 47 + def test_seeder_allows_set_scene(self, tmp_path: Path): 48 + (tmp_path / "players" / "default").mkdir(parents=True) 49 + result = seeder_execute_tool( 50 + "set_scene", 51 + {"situation": "Walking through the forest."}, 52 + world_id="test", 53 + player_id="default", 54 + base_path=tmp_path, 55 + ) 56 + assert "Session updated" in result 57 + 58 + def test_seeder_allows_mark_time(self, tmp_path: Path): 59 + result = seeder_execute_tool( 60 + "mark_time", 61 + {"event": "Dawn breaks", "duration": "0 min"}, 62 + world_id="test", 63 + base_path=tmp_path, 64 + ) 65 + assert "Logged" in result 66 + 67 + 68 + @pytest.fixture 69 + def character_world(tmp_path: Path) -> Path: 70 + """Create a base directory with a character but no session.""" 71 + create_character( 72 + player_id="default", 73 + name="Kael Stormborn", 74 + race="Human", 75 + char_class="Fighter", 76 + level=1, 77 + abilities={ 78 + "strength": 16, 79 + "dexterity": 14, 80 + "constitution": 14, 81 + "intelligence": 10, 82 + "wisdom": 12, 83 + "charisma": 8, 84 + }, 85 + hp_max=12, 86 + ac=16, 87 + background="Soldier", 88 + gold=50, 89 + equipment=["Longsword", "Chain mail", "Shield"], 90 + backstory="A former soldier haunted by a battle gone wrong.", 91 + base_path=tmp_path, 92 + ) 93 + return tmp_path 94 + 95 + 96 + class TestSeedWorld: 97 + """Tests for the seed_world orchestrator.""" 98 + 99 + @patch("storied.planner.anthropic.Anthropic") 100 + def test_seed_world_builds_context_from_character( 101 + self, mock_anthropic_cls: MagicMock, character_world: Path 102 + ): 103 + mock_client = MagicMock() 104 + mock_anthropic_cls.return_value = mock_client 105 + mock_response = MagicMock() 106 + mock_response.stop_reason = "end_turn" 107 + mock_response.content = [MagicMock(type="text", text="Done.")] 108 + mock_response.usage.input_tokens = 500 109 + mock_response.usage.output_tokens = 100 110 + mock_client.messages.create.return_value = mock_response 111 + 112 + seed_world( 113 + world_id="default", 114 + player_id="default", 115 + base_path=character_world, 116 + ) 117 + 118 + call_kwargs = mock_client.messages.create.call_args 119 + messages = call_kwargs.kwargs["messages"] 120 + user_content = messages[0]["content"] 121 + assert "Kael Stormborn" in user_content 122 + assert "Fighter" in user_content 123 + 124 + @patch("storied.planner.anthropic.Anthropic") 125 + def test_seed_world_executes_tools( 126 + self, mock_anthropic_cls: MagicMock, character_world: Path 127 + ): 128 + mock_client = MagicMock() 129 + mock_anthropic_cls.return_value = mock_client 130 + 131 + # First response: tool use (establish + set_scene) 132 + establish_block = MagicMock() 133 + establish_block.type = "tool_use" 134 + establish_block.id = "tool_1" 135 + establish_block.name = "establish" 136 + establish_block.input = { 137 + "entity_type": "locations", 138 + "name": "Millhaven", 139 + "description": "A farming village.", 140 + } 141 + scene_block = MagicMock() 142 + scene_block.type = "tool_use" 143 + scene_block.id = "tool_2" 144 + scene_block.name = "set_scene" 145 + scene_block.input = { 146 + "situation": "Kael walks west at dawn.", 147 + "location": "Millhaven", 148 + } 149 + first_response = MagicMock() 150 + first_response.stop_reason = "tool_use" 151 + first_response.content = [establish_block, scene_block] 152 + first_response.usage.input_tokens = 800 153 + first_response.usage.output_tokens = 150 154 + 155 + # Second response: end_turn 156 + second_response = MagicMock() 157 + second_response.stop_reason = "end_turn" 158 + second_response.content = [MagicMock(type="text", text="Done.")] 159 + second_response.usage.input_tokens = 900 160 + second_response.usage.output_tokens = 50 161 + 162 + mock_client.messages.create.side_effect = [first_response, second_response] 163 + 164 + result = seed_world( 165 + world_id="default", 166 + player_id="default", 167 + base_path=character_world, 168 + ) 169 + 170 + assert result.tool_calls == 2 171 + 172 + # Verify establish created the entity 173 + entity = character_world / "worlds/default/locations/Millhaven.md" 174 + assert entity.exists() 175 + assert "farming village" in entity.read_text() 176 + 177 + # Verify set_scene created the session 178 + session = character_world / "players/default/session.md" 179 + assert session.exists() 180 + assert "Kael walks west" in session.read_text() 181 + 182 + @patch("storied.planner.anthropic.Anthropic") 183 + def test_seed_world_returns_result( 184 + self, mock_anthropic_cls: MagicMock, character_world: Path 185 + ): 186 + mock_client = MagicMock() 187 + mock_anthropic_cls.return_value = mock_client 188 + mock_response = MagicMock() 189 + mock_response.stop_reason = "end_turn" 190 + mock_response.content = [MagicMock(type="text", text="Done.")] 191 + mock_response.usage.input_tokens = 500 192 + mock_response.usage.output_tokens = 100 193 + mock_client.messages.create.return_value = mock_response 194 + 195 + result = seed_world( 196 + world_id="default", 197 + player_id="default", 198 + base_path=character_world, 199 + ) 200 + 201 + assert isinstance(result, SeedResult) 202 + assert result.input_tokens == 500 203 + assert result.output_tokens == 100 204 + assert result.elapsed > 0 205 + 206 + @patch("storied.planner.anthropic.Anthropic") 207 + def test_seed_world_reports_progress( 208 + self, mock_anthropic_cls: MagicMock, character_world: Path 209 + ): 210 + mock_client = MagicMock() 211 + mock_anthropic_cls.return_value = mock_client 212 + 213 + establish_block = MagicMock() 214 + establish_block.type = "tool_use" 215 + establish_block.id = "tool_1" 216 + establish_block.name = "establish" 217 + establish_block.input = { 218 + "entity_type": "npcs", 219 + "name": "Elara", 220 + "description": "A healer.", 221 + } 222 + first_response = MagicMock() 223 + first_response.stop_reason = "tool_use" 224 + first_response.content = [establish_block] 225 + first_response.usage.input_tokens = 800 226 + first_response.usage.output_tokens = 100 227 + 228 + second_response = MagicMock() 229 + second_response.stop_reason = "end_turn" 230 + second_response.content = [MagicMock(type="text", text="Done.")] 231 + second_response.usage.input_tokens = 900 232 + second_response.usage.output_tokens = 50 233 + 234 + mock_client.messages.create.side_effect = [first_response, second_response] 235 + 236 + progress_messages: list[str] = [] 237 + seed_world( 238 + world_id="default", 239 + player_id="default", 240 + base_path=character_world, 241 + on_progress=progress_messages.append, 242 + ) 243 + 244 + assert any("establish" in msg for msg in progress_messages) 245 + 246 + def test_seed_world_no_character_returns_empty(self, tmp_path: Path): 247 + result = seed_world( 248 + world_id="default", 249 + player_id="default", 250 + base_path=tmp_path, 251 + ) 252 + assert isinstance(result, SeedResult) 253 + assert result.tool_calls == 0