From 00cc662ed67969964ca4c350b40200c25392878d Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sun, 1 Feb 2026 20:47:16 -0500 Subject: [PATCH] Add world seeding, planner, and new slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- prompts/dm-system.md | 6 +- prompts/planner-system.md | 67 ++++++ prompts/world-seed.md | 63 ++++++ src/storied/cli.py | 195 ++++++++++++++++ src/storied/log.py | 13 ++ src/storied/planner.py | 456 +++++++++++++++++++++++++++++++++++++ src/storied/tools.py | 51 +++++ tests/test_planner.py | 465 ++++++++++++++++++++++++++++++++++++++ tests/test_seeder.py | 253 +++++++++++++++++++++ 9 files changed, 1567 insertions(+), 2 deletions(-) create mode 100644 prompts/planner-system.md create mode 100644 prompts/world-seed.md create mode 100644 src/storied/planner.py create mode 100644 tests/test_planner.py create mode 100644 tests/test_seeder.py diff --git a/prompts/dm-system.md b/prompts/dm-system.md index aea1300..57e0f3b 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -43,12 +43,14 @@ If this is a brand new adventure (no session state exists), skip the recap and s ## Output Format -You're running in a terminal with markdown support. Use formatting to enhance readability: +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. + +Use markdown formatting to enhance readability: - **Bold** for emphasis, names, or important terms - *Italics* for character speech or internal thoughts - Horizontal rules (---) to separate scenes -End on moments that invite player action. +Don't over-describe. One vivid detail beats three adequate ones. Trust the player's imagination. ## Narrative Rhythm diff --git a/prompts/planner-system.md b/prompts/planner-system.md new file mode 100644 index 0000000..775149f --- /dev/null +++ b/prompts/planner-system.md @@ -0,0 +1,67 @@ +You are a World Architect for a 5e solo adventure. You prepare the world so the DM can tell a richer story. + +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. + +## Your Tools + +| Tool | Purpose | +|------|---------| +| `recall` | Look up rules or existing world content | +| `establish` | Create or update entities (NPCs, locations, items, threads) | +| `mark` | Record backstory events in an entity's history | + +## What to Do + +For each thin entity you're given: + +1. **Read what exists** — use `recall` if you need context on connected entities +2. **Add Knows** — secrets, hidden truths, what isn't obvious (2-4 items) +3. **Add Wants** — nature, tendencies, inclinations (2-4 items) +4. **Add Will** — conditional triggers in "If X → Y" format (2-4 items) +5. **Connect to the world** — use [[wikilinks]] to reference other entities +6. **Weave in threads** — plant clues, rumors, or connections to open plot threads + +## Mining the Recent Events Log + +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. + +**The bar is "would the DM benefit from having this prepped?"** Establish something from the log if: + +- It's a named NPC who might recur (the bartender who gave a tip, the lookouts who spotted the party) +- It's a location the player is likely to revisit or hear about again +- It connects to an open thread or could seed a future one + +**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. + +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. + +## Seeding New Entities + +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: + +- A brief description +- 1-2 Knows/Wants/Will items +- A location via wikilink + +Prefer enriching the thin entities you were given over inventing new ones. + +## Rules + +- **Never contradict established facts.** Read existing content carefully. If an NPC already knows something, don't overwrite it — add to it. +- **Keep Knows/Wants/Will concise.** 2-4 items each. Evocative, not exhaustive. +- **Use [[wikilinks]] liberally.** Every NPC should reference their location. Every location should reference who's there. This creates a connected graph. +- **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. +- **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. +- **Write Was entries for backstory.** Use `mark` to give entities pre-existing history that enriches the world. +- **Anchor entities to locations.** Don't say "works at the city jail" — say "works at [[Greyhaven City Jail]]". +- **Give names to everyone.** Don't create "a guard" — create "Mara, a guard". Named entities create a living world. + +## What Not to Do + +- Don't advance the plot. You're setting the stage, not playing the game. +- Don't create events that involve the player character. +- Don't contradict the current session state or campaign log. +- Don't create entities far from the current location — focus on what's nearby. +- Don't write prose or narrative. Use the tools to update world state directly. + +When you've enriched the candidates and established any worthwhile log mentions, stop. diff --git a/prompts/world-seed.md b/prompts/world-seed.md new file mode 100644 index 0000000..eadcd6a --- /dev/null +++ b/prompts/world-seed.md @@ -0,0 +1,63 @@ +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. + +## Your Tools + +| Tool | Purpose | +|------|---------| +| `establish` | Create entities (NPCs, locations, items, threads, lore) | +| `set_scene` | Place the player in a specific opening moment | +| `mark_time` | Set the starting time (morning of Day 1) | + +## 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. + +## What to Build + +Work outward from the character's backstory: + +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. + +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. + +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. + +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. + +5. **Open threads** — 2-3 hooks woven into the entities: + - One personal (tied to backstory — unfinished business, a rumor about someone they knew) + - One local (something happening in the immediate area — a problem, an opportunity) + - One larger (a distant rumor or sign of something bigger — war, plague, a quest) + +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. + +7. **The clock** — use `mark_time` to establish the starting time. Morning of Day 1. + +## Rules + +- **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. +- **Use [[wikilinks]] liberally.** Every NPC should reference their location. Every location should reference who's there. Create a connected graph. +- **Keep it focused.** 6-10 entities total. Seeds, not an encyclopedia. The DM and player will fill in the rest. +- **Give names to everyone.** Not "a guard" — "Mara, a guard at the west gate." Named entities create a living world. +- **Use Knows/Wants/Will on everything.** Even locations and items. A bridge "wants" to be crossed. A sword "knows" its previous owner. +- **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. +- **Don't write narrative.** Use the tools to create world state. The DM will handle the storytelling. +- **Don't include the player character as an entity.** They already exist as a character sheet. + +## Entity Types + +Use these for the `entity_type` parameter: + +- `locations` — places (towns, buildings, roads, dungeons) +- `npcs` — people and creatures +- `items` — notable objects, artifacts, equipment +- `threads` — plot hooks and ongoing storylines +- `lore` — history, legends, world facts + +## Order of Operations + +1. First, `mark_time` to set the clock (morning of Day 1) +2. Then `establish` all entities, starting with locations, then NPCs, then threads +3. Finally, `set_scene` to place the player in the opening moment + +When you've built the world, stop. diff --git a/src/storied/cli.py b/src/storied/cli.py index 5baad53..00b0dbb 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -36,6 +36,8 @@ SLASH_COMMANDS = { "/help": "Show this help message", "/save": "Save session state (without quitting)", "/context": "Show token usage and costs", + "/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)", } @@ -263,6 +265,36 @@ def cmd_play(args: argparse.Namespace) -> int: console.print(f"[dim]World: {world_id}[/dim]") console.print() + # Seed the world if the character exists but no session yet + if not creation_mode: + 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, + ) + + pricing = MODEL_PRICING.get("claude-opus-4-5-20251101", DEFAULT_PRICING) + cost = ( + result.input_tokens * pricing["input"] / 1_000_000 + + result.output_tokens * pricing["output"] / 1_000_000 + ) + console.print( + f"[dim] Done — {result.tool_calls} tool calls, " + f"{result.elapsed:.1f}s, ${cost:.2f}[/dim]" + ) + console.print() + transcript_path = Path(args.transcript) if args.transcript else None engine = DMEngine( world_id=world_id, @@ -461,6 +493,22 @@ def cmd_play(args: argparse.Namespace) -> int: console.print("[dim]Saving session...[/dim]") 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.]" + # Handle /dm command (out-of-character message to the DM) + if action.strip().lower().startswith("/dm"): + ooc_msg = action.strip()[3:].strip() + if not ooc_msg: + console.print("[dim]Usage: /dm [/dim]") + continue + action = f"[Out-of-character from the player: {ooc_msg}]" + + # Handle /note command (add a note to the character sheet) + if action.strip().lower().startswith("/note"): + note_msg = action.strip()[5:].strip() + if not note_msg: + console.print("[dim]Usage: /note [/dim]") + continue + 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}]" + try: console.print(Rule(style="dim blue")) console.print() # Blank line before DM response @@ -556,6 +604,93 @@ def cmd_play(args: argparse.Namespace) -> int: return 0 +def cmd_plan(args: argparse.Namespace) -> int: + """Enrich thin entities near the player's current position.""" + from storied.planner import plan_world + + world_id = args.world or "default" + player_id = args.player or "default" + + print(f"Loading session for player '{player_id}' in world '{world_id}'...", flush=True) + + def on_progress(msg: str) -> None: + print(msg, flush=True) + + result = plan_world( + world_id=world_id, + player_id=player_id, + model=args.model, + threshold=args.threshold, + max_entities=args.max_entities, + dry_run=args.dry_run, + on_progress=on_progress, + ) + + if not result.candidates: + print("No thin entities found nearby. The world looks rich enough.") + return 0 + + if result.dry_run: + return 0 + + # Real run — show summary with cost + pricing = MODEL_PRICING.get(args.model, DEFAULT_PRICING) + cost = ( + result.input_tokens * pricing["input"] / 1_000_000 + + result.output_tokens * pricing["output"] / 1_000_000 + ) + + print( + f"Done — {result.tool_calls} tool calls, " + f"{result.input_tokens:,} in / {result.output_tokens:,} out, " + f"{result.elapsed:.1f}s, ${cost:.2f}", + flush=True, + ) + + return 0 + + +def cmd_seed(args: argparse.Namespace) -> int: + """Seed an empty world from a character sheet.""" + from storied.planner import seed_world + from storied.session import load_session + + world_id = args.world or "default" + player_id = args.player or "default" + + session = load_session(player_id) + if session is not None and not args.force: + print("Session already exists. Use --force to re-seed.") + return 1 + + def on_progress(msg: str) -> None: + print(msg, flush=True) + + result = seed_world( + world_id=world_id, + player_id=player_id, + model=args.model, + on_progress=on_progress, + ) + + if result.tool_calls == 0: + print("No character found. Create a character first with `storied play`.") + return 1 + + pricing = MODEL_PRICING.get(args.model, DEFAULT_PRICING) + cost = ( + result.input_tokens * pricing["input"] / 1_000_000 + + result.output_tokens * pricing["output"] / 1_000_000 + ) + print( + f"Done — {result.tool_calls} tool calls, " + f"{result.input_tokens:,} in / {result.output_tokens:,} out, " + f"{result.elapsed:.1f}s, ${cost:.2f}", + flush=True, + ) + return 0 + + def build_parser() -> argparse.ArgumentParser: """Build the argument parser.""" parser = argparse.ArgumentParser( @@ -652,6 +787,66 @@ def build_parser() -> argparse.ArgumentParser: ) reset_parser.set_defaults(func=cmd_reset) + # plan command + plan_parser = subparsers.add_parser("plan", help="Enrich thin world entities near the player") + plan_parser.add_argument( + "--world", "-w", + default="default", + help="World ID (default: default)", + ) + plan_parser.add_argument( + "--player", "-p", + default="default", + help="Player ID (default: default)", + ) + plan_parser.add_argument( + "--model", "-m", + default="claude-opus-4-5-20251101", + help="Model to use for planning (default: claude-opus-4-5-20251101)", + ) + plan_parser.add_argument( + "--threshold", + type=float, + default=0.7, + help="Richness threshold — entities below this score get enriched (default: 0.7)", + ) + plan_parser.add_argument( + "--max-entities", + type=int, + default=8, + help="Maximum entities to enrich per run (default: 8)", + ) + plan_parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be enriched without calling the API", + ) + plan_parser.set_defaults(func=cmd_plan) + + # seed command + seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet") + seed_parser.add_argument( + "--world", "-w", + default="default", + help="World ID (default: default)", + ) + seed_parser.add_argument( + "--player", "-p", + default="default", + help="Player ID (default: default)", + ) + seed_parser.add_argument( + "--model", "-m", + default="claude-opus-4-5-20251101", + help="Model to use for seeding (default: claude-opus-4-5-20251101)", + ) + seed_parser.add_argument( + "--force", "-f", + action="store_true", + help="Re-seed even if a session already exists", + ) + seed_parser.set_defaults(func=cmd_seed) + return parser diff --git a/src/storied/log.py b/src/storied/log.py index fb32b26..b57e174 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -349,6 +349,19 @@ class CampaignLog: """Return the current game time.""" return self.current_time + def get_recent_entries(self, days: int = 2) -> list[LogEntry]: + """Get full log entries from the last N days (including current). + + Unlike format_for_context() which only shows summaries of previous + days, this returns every entry — useful for scanning the log for + casually mentioned entities. + """ + entries: list[LogEntry] = [] + start_day = max(1, self.current_day - days + 1) + for day in range(start_day, self.current_day + 1): + entries.extend(self._load_day_entries(day)) + return entries + def format_for_context(self) -> str: """Format the log for inclusion in system prompt.""" lines = [f"## Campaign Time: {self.current_time}"] diff --git a/src/storied/planner.py b/src/storied/planner.py new file mode 100644 index 0000000..d822d55 --- /dev/null +++ b/src/storied/planner.py @@ -0,0 +1,456 @@ +"""World planner — enriches thin entities near the player's current position.""" + +import os +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +import anthropic + +from storied.engine import load_prompt +from storied.log import CampaignLog +from storied.session import ( + extract_wiki_links, + load_entity_content, + load_session, + resolve_wiki_link, +) +from storied.character import format_character_context, load_character +from storied.tools import ( + PLANNER_TOOL_DEFINITIONS, + SEEDER_TOOL_DEFINITIONS, + _load_entity, + planner_execute_tool, + seeder_execute_tool, +) + +MAX_TURNS = 20 + + +def entity_richness(path: Path) -> float: + """Score an entity file's completeness from 0.0 to 1.0. + + Scoring: + - Has description (0.2) + - Has Knows with items (0.2) + - Has Wants with items (0.2) + - Has Will with items (0.2) + - Has Was history (0.1) + - Has wikilinks to other entities (0.1) + """ + entity = _load_entity(path) + if not entity: + return 0.0 + + score = 0.0 + + desc = entity.get("description", "") + if desc and not desc.startswith("##"): + score += 0.2 + if entity.get("knows"): + score += 0.2 + if entity.get("wants"): + score += 0.2 + if entity.get("will"): + score += 0.2 + if entity.get("was"): + score += 0.1 + + # Check for wikilinks anywhere in the file + content = path.read_text() + if extract_wiki_links(content): + score += 0.1 + + return min(score, 1.0) + + +def find_nearby_entities( + session: dict, + world_id: str, + base_path: Path, +) -> list[tuple[str, Path]]: + """Find entities near the player by walking wikilinks. + + Starts from the current location and present entities in the session, + then follows wikilinks from each discovered entity one hop out. + Returns (name, path) pairs with no duplicates. + """ + seen: set[str] = set() + results: list[tuple[str, Path]] = [] + to_follow: list[Path] = [] + + def add_entity(name: str) -> None: + if name in seen: + return + seen.add(name) + path = resolve_wiki_link(name, world_id, base_path) + if path: + results.append((name, path)) + to_follow.append(path) + + # Seed from current location + location = session.get("location") + if location: + add_entity(location) + + # Seed from present entities in session body + body = session.get("body", "") + for name in extract_wiki_links(body): + add_entity(name) + + # Follow links from all discovered entities (breadth-first, one pass) + i = 0 + while i < len(to_follow): + content = to_follow[i].read_text() + for linked_name in extract_wiki_links(content): + add_entity(linked_name) + i += 1 + + return results + + +def build_planning_context( + world_id: str, + player_id: str, + base_path: Path, + candidates: list[tuple[str, Path]], +) -> str: + """Build the context string that the planner LLM sees. + + Includes: current session state, open threads, and candidate entities + with their current (thin) content. + """ + parts: list[str] = [] + + # Session state + session = load_session(player_id, base_path) + if session: + location = session.get("location", "unknown") + parts.append(f"## Current Location: {location}") + + body = session.get("body", "") + if body: + parts.append(body) + + # Campaign log — full recent entries so the planner can spot casual mentions + log = CampaignLog(world_id, base_path) + parts.append(f"## Campaign Time: {log.get_current_time()}") + + recent = log.get_recent_entries(days=2) + if recent: + lines = ["## Recent Events", ""] + for entry in recent: + lines.append(f"- {entry.anchor} | {entry.event}") + parts.append("\n".join(lines)) + + # Candidate entities + if candidates: + parts.append("## Entities to Enrich") + parts.append("") + for name, path in candidates: + content = path.read_text() + parts.append(f"### {name}") + parts.append(f"File: {path.parent.name}/{name}.md") + parts.append("") + parts.append(content) + parts.append("") + else: + parts.append("## No entities to enrich.") + + return "\n\n".join(parts) + + +@dataclass +class PlanResult: + """Result of a plan_world run.""" + + candidates: list[tuple[str, Path, float]] = field(default_factory=list) + tool_calls: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + elapsed: float = 0.0 + dry_run: bool = False + + +def plan_world( + world_id: str = "default", + player_id: str = "default", + base_path: Path | None = None, + model: str = "claude-opus-4-5-20251101", + threshold: float = 0.7, + max_entities: int = 8, + dry_run: bool = False, + on_progress: Callable[[str], None] | None = None, +) -> PlanResult: + """Enrich thin entities near the player's current position. + + 1. Load session state + 2. Find nearby entities via wikilinks + 3. Score each for richness + 4. Filter below threshold, take top N thinnest + 5. If not dry_run, run the model to enrich them + + on_progress is called with status messages as work happens. + """ + if base_path is None: + base_path = Path.cwd() + + def progress(msg: str) -> None: + if on_progress: + on_progress(msg) + + start_time = time.monotonic() + + # Load session + session = load_session(player_id, base_path) + if not session: + return PlanResult(dry_run=dry_run) + + location = session.get("location", "unknown") + progress(f"Current location: {location}") + + # Find and score nearby entities + nearby = find_nearby_entities(session, world_id, base_path) + progress(f"Scanning {len(nearby)} nearby entities...") + + scored: list[tuple[str, Path, float]] = [] + for name, path in nearby: + score = entity_richness(path) + label = "rich" if score >= threshold else "thin" if score < 0.3 else "moderate" + progress(f" {name}: {score:.1f} ({label}{', skipping' if score >= threshold else ''})") + if score < threshold: + scored.append((name, path, score)) + + # Sort thinnest first, take top N + scored.sort(key=lambda x: x[2]) + candidates = scored[:max_entities] + + result = PlanResult( + candidates=candidates, + dry_run=dry_run, + elapsed=time.monotonic() - start_time, + ) + + if dry_run or not candidates: + return result + + # Build context and run the agentic loop + candidate_pairs = [(name, path) for name, path, _ in candidates] + context = build_planning_context(world_id, player_id, base_path, candidate_pairs) + system_prompt = load_prompt("planner-system") + + campaign_log = CampaignLog(world_id, base_path) + + client = anthropic.Anthropic( + api_key=os.environ.get("STORIED_ANTHROPIC_API_KEY"), + ) + + messages: list[dict] = [ + {"role": "user", "content": context}, + ] + + total_input = 0 + total_output = 0 + total_tool_calls = 0 + + progress(f"Planning with {model}...") + + for turn in range(MAX_TURNS): + if turn > 0: + progress(" ...") + response = client.messages.create( + model=model, + max_tokens=4096, + system=system_prompt, + tools=PLANNER_TOOL_DEFINITIONS, + messages=messages, + ) + + total_input += response.usage.input_tokens + total_output += response.usage.output_tokens + + # Build assistant message for conversation history + assistant_content = [] + tool_uses = [] + for block in response.content: + if block.type == "text": + assistant_content.append({ + "type": "text", + "text": block.text, + }) + elif block.type == "tool_use": + assistant_content.append({ + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": block.input, + }) + tool_uses.append(block) + + if assistant_content: + messages.append({"role": "assistant", "content": assistant_content}) + + if not tool_uses: + break + + # Execute tools + tool_results = [] + for tool_use in tool_uses: + total_tool_calls += 1 + + tool_result = planner_execute_tool( + tool_use.name, + tool_use.input, + world_id=world_id, + base_path=base_path, + campaign_log=campaign_log, + ) + + progress(f" [{tool_use.name}] {tool_result}") + + tool_results.append({ + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_result or "Done", + }) + + messages.append({"role": "user", "content": tool_results}) + + if response.stop_reason == "end_turn": + break + + result.tool_calls = total_tool_calls + result.input_tokens = total_input + result.output_tokens = total_output + result.elapsed = time.monotonic() - start_time + + return result + + +@dataclass +class SeedResult: + """Result of a seed_world run.""" + + tool_calls: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + elapsed: float = 0.0 + + +def seed_world( + world_id: str = "default", + player_id: str = "default", + base_path: Path | None = None, + model: str = "claude-opus-4-5-20251101", + on_progress: Callable[[str], None] | None = None, +) -> SeedResult: + """Build the initial world from a character sheet. + + Loads the character, sends it to a capable model with the world-seed + prompt, and executes establish/set_scene/mark_time tools to populate + the world before the first session. + """ + if base_path is None: + base_path = Path.cwd() + + def progress(msg: str) -> None: + if on_progress: + on_progress(msg) + + start_time = time.monotonic() + + # Load the character — nothing to seed without one + character = load_character(player_id, base_path) + if character is None: + return SeedResult() + + # Build context from the character sheet + context = format_character_context(character) + + system_prompt = load_prompt("world-seed") + campaign_log = CampaignLog(world_id, base_path) + + client = anthropic.Anthropic( + api_key=os.environ.get("STORIED_ANTHROPIC_API_KEY"), + ) + + messages: list[dict] = [ + {"role": "user", "content": context}, + ] + + total_input = 0 + total_output = 0 + total_tool_calls = 0 + + progress(f"Seeding with {model}...") + + for turn in range(MAX_TURNS): + response = client.messages.create( + model=model, + max_tokens=4096, + system=system_prompt, + tools=SEEDER_TOOL_DEFINITIONS, + messages=messages, + ) + + total_input += response.usage.input_tokens + total_output += response.usage.output_tokens + + # Build assistant message for conversation history + assistant_content = [] + tool_uses = [] + for block in response.content: + if block.type == "text": + assistant_content.append({ + "type": "text", + "text": block.text, + }) + elif block.type == "tool_use": + assistant_content.append({ + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": block.input, + }) + tool_uses.append(block) + + if assistant_content: + messages.append({"role": "assistant", "content": assistant_content}) + + if not tool_uses: + break + + # Execute tools + tool_results = [] + for tool_use in tool_uses: + total_tool_calls += 1 + + tool_result = seeder_execute_tool( + tool_use.name, + tool_use.input, + world_id=world_id, + player_id=player_id, + base_path=base_path, + campaign_log=campaign_log, + ) + + progress(f"[{tool_use.name}] {tool_result}") + + tool_results.append({ + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_result or "Done", + }) + + messages.append({"role": "user", "content": tool_results}) + + if response.stop_reason == "end_turn": + break + + return SeedResult( + tool_calls=total_tool_calls, + input_tokens=total_input, + output_tokens=total_output, + elapsed=time.monotonic() - start_time, + ) diff --git a/src/storied/tools.py b/src/storied/tools.py index a2a9dce..0122b7e 100644 --- a/src/storied/tools.py +++ b/src/storied/tools.py @@ -1011,6 +1011,7 @@ def execute_tool( entity_type=tool_input["entity_type"], name=tool_input["name"], description=tool_input.get("description"), + location=tool_input.get("location"), knows=tool_input.get("knows"), wants=tool_input.get("wants"), will=tool_input.get("will"), @@ -1061,3 +1062,53 @@ def execute_tool( else: return f"Unknown tool: {tool_name}" + + +PLANNER_TOOLS = {"recall", "establish", "mark"} + +PLANNER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in PLANNER_TOOLS] + + +def planner_execute_tool( + tool_name: str, + tool_input: dict, + world_id: str | None = None, + base_path: Path | None = None, + campaign_log: CampaignLog | None = None, +) -> str: + """Execute a planner-allowed tool. Rejects anything outside the allowed set.""" + if tool_name not in PLANNER_TOOLS: + return f"Tool not available to planner: {tool_name}" + return execute_tool( + tool_name, + tool_input, + world_id=world_id, + base_path=base_path, + campaign_log=campaign_log, + ) + + +SEEDER_TOOLS = {"establish", "set_scene", "mark_time"} + +SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] + + +def seeder_execute_tool( + tool_name: str, + tool_input: dict, + world_id: str | None = None, + player_id: str | None = None, + base_path: Path | None = None, + campaign_log: CampaignLog | None = None, +) -> str: + """Execute a seeder-allowed tool. Rejects anything outside the allowed set.""" + if tool_name not in SEEDER_TOOLS: + return f"Tool not available to seeder: {tool_name}" + return execute_tool( + tool_name, + tool_input, + world_id=world_id, + player_id=player_id or "default", + base_path=base_path, + campaign_log=campaign_log, + ) diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 0000000..46e9a14 --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,465 @@ +"""Tests for the world planner — entity richness scoring, discovery, and context.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from storied.log import CampaignLog +from storied.planner import ( + PlanResult, + build_planning_context, + entity_richness, + find_nearby_entities, + plan_world, +) +from storied.session import save_session +from storied.tools import establish + + +@pytest.fixture +def world_base(tmp_path: Path) -> Path: + """Create a base directory with world structure.""" + world = tmp_path / "worlds" / "test-world" + world.mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def campaign_log(world_base: Path) -> CampaignLog: + """Create a campaign log for the test world.""" + return CampaignLog("test-world", world_base) + + +@pytest.fixture +def populated_world(world_base: Path) -> Path: + """Create a world with some entities for testing.""" + establish( + entity_type="locations", + name="Town Square", + description="The center of [[Millford]]. A fountain stands here, surrounded by market stalls.", + location="Central [[Millford]]", + knows=["The fountain was built by [[Old Gregor]]"], + wants=["To be a gathering place"], + will=["If market day → attract crowds"], + world_id="test-world", + base_path=world_base, + ) + establish( + entity_type="npcs", + name="Old Gregor", + description="An elderly stonemason.", + location="[[Town Square]]", + world_id="test-world", + base_path=world_base, + ) + establish( + entity_type="locations", + name="Millford", + description="A small riverside town.", + world_id="test-world", + base_path=world_base, + ) + establish( + entity_type="npcs", + name="Thin NPC", + description="Someone with no inner life.", + world_id="test-world", + base_path=world_base, + ) + return world_base + + +class TestEntityRichness: + """Tests for richness scoring.""" + + def test_empty_entity_scores_zero(self, world_base: Path): + establish( + entity_type="npcs", + name="Empty", + world_id="test-world", + base_path=world_base, + ) + path = world_base / "worlds/test-world/npcs/Empty.md" + assert entity_richness(path) == 0.0 + + def test_description_only(self, world_base: Path): + establish( + entity_type="npcs", + name="Described", + description="A tall warrior.", + world_id="test-world", + base_path=world_base, + ) + path = world_base / "worlds/test-world/npcs/Described.md" + assert entity_richness(path) == pytest.approx(0.2) + + def test_fully_rich_entity(self, world_base: Path, campaign_log: CampaignLog): + establish( + entity_type="npcs", + name="Rich NPC", + description="A fully fleshed out character in [[Town Square]].", + knows=["Secret one", "Secret two"], + wants=["Goal one"], + will=["If X → do Y"], + world_id="test-world", + base_path=world_base, + ) + from storied.tools import mark + + mark( + entity_type="npcs", + name="Rich NPC", + event="Something happened", + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + path = world_base / "worlds/test-world/npcs/Rich NPC.md" + score = entity_richness(path) + assert score == pytest.approx(1.0) + + def test_partial_richness(self, world_base: Path): + establish( + entity_type="npcs", + name="Partial", + description="Has description and knows.", + knows=["A secret"], + world_id="test-world", + base_path=world_base, + ) + path = world_base / "worlds/test-world/npcs/Partial.md" + score = entity_richness(path) + # description (0.2) + knows (0.2) = 0.4 + assert score == pytest.approx(0.4) + + def test_wikilinks_contribute(self, world_base: Path): + establish( + entity_type="npcs", + name="Linked", + description="Hangs out at [[The Tavern]] with [[Bob]].", + world_id="test-world", + base_path=world_base, + ) + path = world_base / "worlds/test-world/npcs/Linked.md" + score = entity_richness(path) + # description (0.2) + wikilinks (0.1) = 0.3 + assert score == pytest.approx(0.3) + + +class TestFindNearbyEntities: + """Tests for discovering entities near the player.""" + + def test_finds_entities_from_location_links(self, populated_world: Path): + session = { + "location": "Town Square", + "body": "", + } + nearby = find_nearby_entities(session, "test-world", populated_world) + names = {name for name, _ in nearby} + assert "Old Gregor" in names + assert "Millford" in names + + def test_finds_entities_from_session_body(self, populated_world: Path): + session = { + "location": "Town Square", + "body": "## Present\n- [[Thin NPC]]", + } + nearby = find_nearby_entities(session, "test-world", populated_world) + names = {name for name, _ in nearby} + assert "Thin NPC" in names + + def test_includes_current_location(self, populated_world: Path): + session = { + "location": "Town Square", + "body": "", + } + nearby = find_nearby_entities(session, "test-world", populated_world) + names = {name for name, _ in nearby} + assert "Town Square" in names + + def test_no_duplicates(self, populated_world: Path): + session = { + "location": "Town Square", + "body": "## Present\n- [[Old Gregor]]", + } + nearby = find_nearby_entities(session, "test-world", populated_world) + names = [name for name, _ in nearby] + assert names.count("Old Gregor") == 1 + + def test_empty_session(self, world_base: Path): + session = {"body": ""} + nearby = find_nearby_entities(session, "test-world", world_base) + assert nearby == [] + + +class TestGetRecentEntries: + """Tests for CampaignLog.get_recent_entries().""" + + def test_returns_current_day_entries(self, world_base: Path): + log = CampaignLog("test-world", world_base) + log.append_entry("Arrived at the tavern", "10 min") + log.append_entry("Spoke with bartender", "15 min") + + entries = log.get_recent_entries(days=1) + events = [e.event for e in entries] + assert "Arrived at the tavern" in events + assert "Spoke with bartender" in events + + def test_returns_previous_day_entries(self, world_base: Path): + log = CampaignLog("test-world", world_base) + log.append_entry("Morning event", "10 min") + log.append_entry("Traveled all day", "18 hours") + log.append_entry("Day 2 event", "10 min") + + entries = log.get_recent_entries(days=2) + events = [e.event for e in entries] + assert "Morning event" in events + assert "Day 2 event" in events + + def test_respects_days_limit(self, world_base: Path): + log = CampaignLog("test-world", world_base) + log.append_entry("Day 1 event", "18 hours") + log.append_entry("Day 2 event", "18 hours") + log.append_entry("Day 3 event", "1 hour") + + entries = log.get_recent_entries(days=1) + events = [e.event for e in entries] + assert "Day 3 event" in events + assert "Day 1 event" not in events + + def test_empty_log(self, world_base: Path): + log = CampaignLog("test-world", world_base) + entries = log.get_recent_entries(days=2) + assert entries == [] + + +class TestBuildPlanningContext: + """Tests for assembling the planner's context.""" + + def test_includes_session_info(self, populated_world: Path): + save_session( + "default", + { + "location": "Town Square", + "body": "## Situation\nThe player just arrived.\n\n## Open Threads\n- Find the missing cat", + }, + populated_world, + ) + context = build_planning_context( + world_id="test-world", + player_id="default", + base_path=populated_world, + candidates=[("Thin NPC", populated_world / "worlds/test-world/npcs/Thin NPC.md")], + ) + assert "Town Square" in context + assert "missing cat" in context + + def test_includes_candidate_content(self, populated_world: Path): + save_session( + "default", + {"location": "Town Square", "body": ""}, + populated_world, + ) + path = populated_world / "worlds/test-world/npcs/Thin NPC.md" + context = build_planning_context( + world_id="test-world", + player_id="default", + base_path=populated_world, + candidates=[("Thin NPC", path)], + ) + assert "Thin NPC" in context + assert "Someone with no inner life" in context + + def test_empty_candidates(self, populated_world: Path): + save_session( + "default", + {"location": "Town Square", "body": ""}, + populated_world, + ) + context = build_planning_context( + world_id="test-world", + player_id="default", + base_path=populated_world, + candidates=[], + ) + assert "No entities" in context + + def test_includes_recent_log_entries(self, populated_world: Path): + save_session( + "default", + {"location": "Town Square", "body": ""}, + populated_world, + ) + log = CampaignLog("test-world", populated_world) + log.append_entry("Fought three goblins in the clearing", "5 rounds") + log.append_entry("Spotted two lookouts near the old mill", "30 min") + + context = build_planning_context( + world_id="test-world", + player_id="default", + base_path=populated_world, + candidates=[], + ) + assert "Recent Events" in context + assert "three goblins" in context + assert "two lookouts" in context + + +class TestPlanWorld: + """Tests for the plan_world orchestrator.""" + + def test_dry_run_returns_candidates(self, populated_world: Path): + save_session( + "default", + { + "location": "Town Square", + "body": "## Present\n- [[Thin NPC]]", + }, + populated_world, + ) + result = plan_world( + world_id="test-world", + player_id="default", + base_path=populated_world, + dry_run=True, + ) + assert isinstance(result, PlanResult) + assert result.dry_run is True + assert len(result.candidates) > 0 + names = [name for name, _, _ in result.candidates] + assert "Thin NPC" in names + + def test_dry_run_respects_threshold(self, populated_world: Path): + save_session( + "default", + {"location": "Town Square", "body": ""}, + populated_world, + ) + result = plan_world( + world_id="test-world", + player_id="default", + base_path=populated_world, + dry_run=True, + threshold=0.0, + ) + assert len(result.candidates) == 0 + + def test_dry_run_respects_max_entities(self, populated_world: Path): + save_session( + "default", + {"location": "Town Square", "body": ""}, + populated_world, + ) + result = plan_world( + world_id="test-world", + player_id="default", + base_path=populated_world, + dry_run=True, + max_entities=1, + ) + assert len(result.candidates) <= 1 + + def test_no_session_returns_empty(self, world_base: Path): + result = plan_world( + world_id="test-world", + player_id="default", + base_path=world_base, + dry_run=True, + ) + assert len(result.candidates) == 0 + + @patch("storied.planner.anthropic.Anthropic") + def test_plan_world_calls_api(self, mock_anthropic_cls: MagicMock, populated_world: Path): + save_session( + "default", + { + "location": "Town Square", + "body": "## Present\n- [[Thin NPC]]", + }, + populated_world, + ) + + # Set up mock to return an end_turn response with no tool use + mock_client = MagicMock() + mock_anthropic_cls.return_value = mock_client + mock_response = MagicMock() + mock_response.stop_reason = "end_turn" + mock_response.content = [MagicMock(type="text", text="Done enriching.")] + mock_response.usage.input_tokens = 1000 + mock_response.usage.output_tokens = 200 + mock_response.usage.cache_creation_input_tokens = 0 + mock_response.usage.cache_read_input_tokens = 0 + mock_client.messages.create.return_value = mock_response + + result = plan_world( + world_id="test-world", + player_id="default", + base_path=populated_world, + model="claude-opus-4-5-20251101", + ) + + assert result.dry_run is False + assert result.input_tokens == 1000 + assert result.output_tokens == 200 + mock_client.messages.create.assert_called_once() + + @patch("storied.planner.anthropic.Anthropic") + def test_plan_world_executes_tools(self, mock_anthropic_cls: MagicMock, populated_world: Path): + save_session( + "default", + { + "location": "Town Square", + "body": "## Present\n- [[Thin NPC]]", + }, + populated_world, + ) + + mock_client = MagicMock() + mock_anthropic_cls.return_value = mock_client + + # First response: tool use (establish) + tool_block = MagicMock() + tool_block.type = "tool_use" + tool_block.id = "tool_1" + tool_block.name = "establish" + tool_block.input = { + "entity_type": "npcs", + "name": "Thin NPC", + "knows": ["Has a secret past"], + "wants": ["Find a warm meal"], + } + first_response = MagicMock() + first_response.stop_reason = "tool_use" + first_response.content = [tool_block] + first_response.usage.input_tokens = 800 + first_response.usage.output_tokens = 100 + first_response.usage.cache_creation_input_tokens = 0 + first_response.usage.cache_read_input_tokens = 0 + + # Second response: end_turn + second_response = MagicMock() + second_response.stop_reason = "end_turn" + second_response.content = [MagicMock(type="text", text="Done.")] + second_response.usage.input_tokens = 900 + second_response.usage.output_tokens = 50 + second_response.usage.cache_creation_input_tokens = 0 + second_response.usage.cache_read_input_tokens = 0 + + mock_client.messages.create.side_effect = [first_response, second_response] + + result = plan_world( + world_id="test-world", + player_id="default", + base_path=populated_world, + model="claude-opus-4-5-20251101", + ) + + assert result.tool_calls == 1 + assert result.input_tokens == 1700 + assert result.output_tokens == 150 + + # Verify the entity was actually enriched + content = (populated_world / "worlds/test-world/npcs/Thin NPC.md").read_text() + assert "Has a secret past" in content + assert "Find a warm meal" in content diff --git a/tests/test_seeder.py b/tests/test_seeder.py new file mode 100644 index 0000000..0aa401a --- /dev/null +++ b/tests/test_seeder.py @@ -0,0 +1,253 @@ +"""Tests for world seeding — empty world detection and initial worldbuilding.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from storied.character import create_character +from storied.planner import SeedResult, seed_world +from storied.tools import ( + SEEDER_TOOL_DEFINITIONS, + SEEDER_TOOLS, + seeder_execute_tool, +) + + +class TestSeederTools: + """Tests for seeder tool filtering.""" + + def test_seeder_tools_set(self): + assert SEEDER_TOOLS == {"establish", "set_scene", "mark_time"} + + def test_seeder_tool_definitions_filtered(self): + names = {t["name"] for t in SEEDER_TOOL_DEFINITIONS} + assert names == SEEDER_TOOLS + + def test_seeder_rejects_disallowed_tools(self, tmp_path: Path): + for tool_name in ["roll", "recall", "mark", "note_discovery", "end_session"]: + result = seeder_execute_tool( + tool_name, + {}, + world_id="test", + base_path=tmp_path, + ) + assert "not available to seeder" in result + + def test_seeder_allows_establish(self, tmp_path: Path): + (tmp_path / "worlds" / "test" / "npcs").mkdir(parents=True) + result = seeder_execute_tool( + "establish", + {"entity_type": "npcs", "name": "Test NPC", "description": "A test."}, + world_id="test", + base_path=tmp_path, + ) + assert "Established" in result + + def test_seeder_allows_set_scene(self, tmp_path: Path): + (tmp_path / "players" / "default").mkdir(parents=True) + result = seeder_execute_tool( + "set_scene", + {"situation": "Walking through the forest."}, + world_id="test", + player_id="default", + base_path=tmp_path, + ) + assert "Session updated" in result + + def test_seeder_allows_mark_time(self, tmp_path: Path): + result = seeder_execute_tool( + "mark_time", + {"event": "Dawn breaks", "duration": "0 min"}, + world_id="test", + base_path=tmp_path, + ) + assert "Logged" in result + + +@pytest.fixture +def character_world(tmp_path: Path) -> Path: + """Create a base directory with a character but no session.""" + create_character( + player_id="default", + name="Kael Stormborn", + race="Human", + char_class="Fighter", + level=1, + abilities={ + "strength": 16, + "dexterity": 14, + "constitution": 14, + "intelligence": 10, + "wisdom": 12, + "charisma": 8, + }, + hp_max=12, + ac=16, + background="Soldier", + gold=50, + equipment=["Longsword", "Chain mail", "Shield"], + backstory="A former soldier haunted by a battle gone wrong.", + base_path=tmp_path, + ) + return tmp_path + + +class TestSeedWorld: + """Tests for the seed_world orchestrator.""" + + @patch("storied.planner.anthropic.Anthropic") + def test_seed_world_builds_context_from_character( + self, mock_anthropic_cls: MagicMock, character_world: Path + ): + mock_client = MagicMock() + mock_anthropic_cls.return_value = mock_client + mock_response = MagicMock() + mock_response.stop_reason = "end_turn" + mock_response.content = [MagicMock(type="text", text="Done.")] + mock_response.usage.input_tokens = 500 + mock_response.usage.output_tokens = 100 + mock_client.messages.create.return_value = mock_response + + seed_world( + world_id="default", + player_id="default", + base_path=character_world, + ) + + call_kwargs = mock_client.messages.create.call_args + messages = call_kwargs.kwargs["messages"] + user_content = messages[0]["content"] + assert "Kael Stormborn" in user_content + assert "Fighter" in user_content + + @patch("storied.planner.anthropic.Anthropic") + def test_seed_world_executes_tools( + self, mock_anthropic_cls: MagicMock, character_world: Path + ): + mock_client = MagicMock() + mock_anthropic_cls.return_value = mock_client + + # First response: tool use (establish + set_scene) + establish_block = MagicMock() + establish_block.type = "tool_use" + establish_block.id = "tool_1" + establish_block.name = "establish" + establish_block.input = { + "entity_type": "locations", + "name": "Millhaven", + "description": "A farming village.", + } + scene_block = MagicMock() + scene_block.type = "tool_use" + scene_block.id = "tool_2" + scene_block.name = "set_scene" + scene_block.input = { + "situation": "Kael walks west at dawn.", + "location": "Millhaven", + } + first_response = MagicMock() + first_response.stop_reason = "tool_use" + first_response.content = [establish_block, scene_block] + first_response.usage.input_tokens = 800 + first_response.usage.output_tokens = 150 + + # Second response: end_turn + second_response = MagicMock() + second_response.stop_reason = "end_turn" + second_response.content = [MagicMock(type="text", text="Done.")] + second_response.usage.input_tokens = 900 + second_response.usage.output_tokens = 50 + + mock_client.messages.create.side_effect = [first_response, second_response] + + result = seed_world( + world_id="default", + player_id="default", + base_path=character_world, + ) + + assert result.tool_calls == 2 + + # Verify establish created the entity + entity = character_world / "worlds/default/locations/Millhaven.md" + assert entity.exists() + assert "farming village" in entity.read_text() + + # Verify set_scene created the session + session = character_world / "players/default/session.md" + assert session.exists() + assert "Kael walks west" in session.read_text() + + @patch("storied.planner.anthropic.Anthropic") + def test_seed_world_returns_result( + self, mock_anthropic_cls: MagicMock, character_world: Path + ): + mock_client = MagicMock() + mock_anthropic_cls.return_value = mock_client + mock_response = MagicMock() + mock_response.stop_reason = "end_turn" + mock_response.content = [MagicMock(type="text", text="Done.")] + mock_response.usage.input_tokens = 500 + mock_response.usage.output_tokens = 100 + mock_client.messages.create.return_value = mock_response + + result = seed_world( + world_id="default", + player_id="default", + base_path=character_world, + ) + + assert isinstance(result, SeedResult) + assert result.input_tokens == 500 + assert result.output_tokens == 100 + assert result.elapsed > 0 + + @patch("storied.planner.anthropic.Anthropic") + def test_seed_world_reports_progress( + self, mock_anthropic_cls: MagicMock, character_world: Path + ): + mock_client = MagicMock() + mock_anthropic_cls.return_value = mock_client + + establish_block = MagicMock() + establish_block.type = "tool_use" + establish_block.id = "tool_1" + establish_block.name = "establish" + establish_block.input = { + "entity_type": "npcs", + "name": "Elara", + "description": "A healer.", + } + first_response = MagicMock() + first_response.stop_reason = "tool_use" + first_response.content = [establish_block] + first_response.usage.input_tokens = 800 + first_response.usage.output_tokens = 100 + + second_response = MagicMock() + second_response.stop_reason = "end_turn" + second_response.content = [MagicMock(type="text", text="Done.")] + second_response.usage.input_tokens = 900 + second_response.usage.output_tokens = 50 + + mock_client.messages.create.side_effect = [first_response, second_response] + + progress_messages: list[str] = [] + seed_world( + world_id="default", + player_id="default", + base_path=character_world, + on_progress=progress_messages.append, + ) + + assert any("establish" in msg for msg in progress_messages) + + def test_seed_world_no_character_returns_empty(self, tmp_path: Path): + result = seed_world( + world_id="default", + player_id="default", + base_path=tmp_path, + ) + assert isinstance(result, SeedResult) + assert result.tool_calls == 0 -- 2.51.2