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 background advancement evaluator and DM notification channel

The DM has been great at storytelling but never considers leveling up the
player character — no mechanism was nudging it to think about progression.

This adds a background agent that periodically reads the campaign journal
and makes a vibes-based judgment (calibrated against the SRD XP tables)
about whether it's time to level up. When it decides yes, it sets a flag
on the character sheet and sends a notification the DM sees next turn.
From the player's perspective it's pure milestone advancement; under the
hood there's a principled evaluator keeping the pace right.

Also adds a general notification channel so background agents (planner,
ticker, advancement) can tell the foreground DM what they changed. Before
this, they'd silently modify world files and the DM had no idea.

Other changes:
- Split tools.py (1,200 lines) into a tools/ package grouped by domain
- Extracted advancement.py from planner.py
- Added log methods for scanning by tag (for finding last level-up)
- Removed `storied plan` and `storied tick` CLI commands since both now
run automatically during gameplay
- DM prompt now has instructions for handling level-ups narratively,
including the `level` tag in set_scene

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+2105 -1284
+1 -1
.loq_cache
··· 1 - {"version":1,"config_hash":4557771575092473650,"entries":{"src/storied/initiative.py":{"mtime_secs":1774751731,"mtime_nanos":975235964,"lines":524}}} 1 + {"version":1,"config_hash":4557771575092473650,"entries":{"src/storied/tools/_context.py":{"mtime_secs":1775582352,"mtime_nanos":694759924,"lines":82},"src/storied/display.py":{"mtime_secs":1774782927,"mtime_nanos":98901860,"lines":355},"src/storied/dice.py":{"mtime_secs":1767047766,"mtime_nanos":398050421,"lines":180},"src/storied/initiative.py":{"mtime_secs":1774916278,"mtime_nanos":380939471,"lines":574},"src/storied/advancement.py":{"mtime_secs":1775582572,"mtime_nanos":681291189,"lines":199},"src/storied/srd/clean.py":{"mtime_secs":1766869132,"mtime_nanos":684969729,"lines":101},"src/storied/sandbox.py":{"mtime_secs":1774959788,"mtime_nanos":500556437,"lines":147},"src/storied/content.py":{"mtime_secs":1774709737,"mtime_nanos":677509525,"lines":115},"src/storied/cli.py":{"mtime_secs":1775582667,"mtime_nanos":84277922,"lines":1090},"src/storied/tools.py":{"mtime_secs":1775581781,"mtime_nanos":567165396,"lines":1203},"src/storied/__init__.py":{"mtime_secs":1766844771,"mtime_nanos":901498410,"lines":3},"src/storied/tools/mechanics.py":{"mtime_secs":1775582379,"mtime_nanos":322091028,"lines":143},"src/storied/notifications.py":{"mtime_secs":1775581593,"mtime_nanos":188431521,"lines":49},"src/storied/planner.py":{"mtime_secs":1775582662,"mtime_nanos":80226590,"lines":519},"src/storied/tools/character.py":{"mtime_secs":1775582513,"mtime_nanos":318646536,"lines":158},"src/storied/session.py":{"mtime_secs":1768056565,"mtime_nanos":259214128,"lines":264},"src/storied/search.py":{"mtime_secs":1774713471,"mtime_nanos":353186743,"lines":408},"src/storied/tools/entities.py":{"mtime_secs":1775582467,"mtime_nanos":786135738,"lines":466},"src/storied/log.py":{"mtime_secs":1775581843,"mtime_nanos":65667046,"lines":540},"src/storied/engine.py":{"mtime_secs":1775581998,"mtime_nanos":312921199,"lines":523},"src/storied/srd/split.py":{"mtime_secs":1766849074,"mtime_nanos":657046597,"lines":336},"src/storied/srd/download.py":{"mtime_secs":1766845896,"mtime_nanos":726572037,"lines":49},"src/storied/tools/scene.py":{"mtime_secs":1775582498,"mtime_nanos":739484677,"lines":215},"src/storied/tools/__init__.py":{"mtime_secs":1775582704,"mtime_nanos":806661949,"lines":197},"src/storied/character.py":{"mtime_secs":1774487537,"mtime_nanos":474285279,"lines":403},"src/storied/claude.py":{"mtime_secs":1774458748,"mtime_nanos":52161247,"lines":377},"src/storied/srd/extract.py":{"mtime_secs":1766849840,"mtime_nanos":197357744,"lines":63},"src/storied/srd/__init__.py":{"mtime_secs":1766869155,"mtime_nanos":624121322,"lines":15},"src/storied/mcp_server.py":{"mtime_secs":1775581792,"mtime_nanos":695257450,"lines":198}}}
+18 -1
prompts/dm-system.md
··· 298 298 - **After spending/gaining coins**: `{"purse.gp": 25}` or `{"purse.sp": 10, "purse.cp": 50}` 299 299 - **After using abilities**: `{"features.0.uses": 0}` (e.g., Second Wind) 300 300 - **After gaining/losing equipment**: `{"section.Equipment": "- Longsword\n- New shield"}` 301 - - **After leveling up**: `{"level": 2, "hp.max": 20}` 301 + - **After leveling up**: `{"level": 2, "hp.max": 20, "level_since": "#d5-1430", "advancement_ready": null}` 302 302 303 303 Call update_character immediately when these changes happen, not at the end of the session. 304 + 305 + ## Level Advancement 306 + 307 + A background system evaluates whether the character has earned a level-up. When it decides they have, you'll see an `advancement_ready` field on the character sheet and a notification in "Recent World Changes." 308 + 309 + **When `advancement_ready` is set:** 310 + 1. Don't rush it — find a narratively appropriate moment (a rest, a quiet pause, after a triumph) 311 + 2. Use `recall` to look up the character's class features for the new level 312 + 3. Narrate the growth as part of the story — the character reflects on what they've learned, feels a new confidence, discovers a new ability 313 + 4. Call `update_character` with the new level, updated HP, and any new features. Clear the flag: `{"level": <N>, "hp.max": <new_max>, "level_since": "<current_time_anchor>", "advancement_ready": null}` 314 + 5. Include `level` in the `set_scene` tags for this moment: `tags=["level"]` 315 + 316 + Don't announce it mechanically ("You've reached level 5!"). Weave it into the fiction. The player should feel their character growing, not see a UI popup. 304 317 305 318 ## Session State 306 319 ··· 321 334 threads=["Warehouse investigation - Vera's job", "Merchant attacks - 50gp from Captain"] 322 335 ) 323 336 ``` 337 + 338 + ## Recent World Changes 339 + 340 + Your context may include a "Recent World Changes" section with notifications from background systems — the world planner, the advancement evaluator, or the world ticker. These are things that happened between turns. Weave them naturally into your narration when appropriate. Don't announce them as system messages. 324 341 325 342 ## The Entity Model 326 343
+2 -1
prompts/planner-system.md
··· 9 9 | `recall` | Look up rules or existing world content | 10 10 | `establish` | Create or update entities (NPCs, locations, items, threads) | 11 11 | `mark` | Record backstory events in an entity's history | 12 + | `notify_dm` | Send a brief summary of what you changed to the DM | 12 13 13 14 ## What to Do 14 15 ··· 67 68 - Don't create entities far from the current location — focus on what's nearby. 68 69 - Don't write prose or narrative. Use the tools to update world state directly. 69 70 70 - When you've enriched the candidates and established any worthwhile log mentions, stop. 71 + When you've enriched the candidates and established any worthwhile log mentions, call `notify_dm` with a brief summary of what you changed — e.g., "Enriched [[Vera Blackwater]] with smuggling connections and added [[Dockmaster Voss]] near the harbor." Keep it to one or two sentences. Then stop.
+2 -1
prompts/world-tick.md
··· 7 7 | `recall` | Look up existing world content | 8 8 | `establish` | Update entities that changed | 9 9 | `mark` | Record events that happened off-screen | 10 + | `notify_dm` | Tell the DM what changed so they can weave it in | 10 11 11 12 ## What You're Given 12 13 ··· 58 59 - **Focus on entities near the player's last location.** Don't change things far away that the player can't observe. 59 60 - **Use [[wikilinks]]** when referencing entities. 60 61 61 - When you've evaluated the triggers and made appropriate changes, stop. 62 + When you've evaluated the triggers and made appropriate changes, call `notify_dm` with a brief summary of what happened — e.g., "[[Captain Harrik]] left [[The Rusty Anchor]] to investigate the docks. A rumor about the missing merchants spread to [[Millford]]." Keep it to the things the DM might want to weave into narration. Then stop.
+85
prompts/xp-evaluator.md
··· 1 + You are a character advancement evaluator for a solo 5e adventure. You run silently in the background, reading the campaign journal to assess whether the player character has earned a level. 2 + 3 + ## Your Job 4 + 5 + Read the campaign log since the character's last level-up (or the start of the campaign if they've never leveled). Evaluate the totality of what they've accomplished — combat encounters, quests completed, discoveries made, problems solved — and decide whether it's enough to warrant advancement to the next level. 6 + 7 + You are not tracking exact XP. You are making a holistic judgment, the way a good DM does with milestone leveling. But you should use the 5e XP tables as your calibration — they tell you roughly how much adventure separates one level from the next. 8 + 9 + ## Calibration Reference 10 + 11 + **XP between levels (cumulative thresholds):** 12 + 13 + | From → To | XP Needed | Rough Equivalent | 14 + |-----------|-----------|------------------| 15 + | 1 → 2 | 300 | 2-4 easy encounters | 16 + | 2 → 3 | 600 | A short adventure arc | 17 + | 3 → 4 | 1,800 | A substantial quest line | 18 + | 4 → 5 | 3,800 | A major story arc | 19 + | 5 → 6 | 7,500 | An extended campaign chapter | 20 + | 6 → 7 | 9,000 | Multiple significant quests | 21 + | 7 → 8 | 11,000 | A major campaign milestone | 22 + | 8 → 9 | 14,000 | Extended high-stakes adventure | 23 + | 9 → 10 | 16,000 | Major campaign arc completion | 24 + 25 + **XP Budget per Character (solo, by encounter difficulty):** 26 + 27 + | Level | Low | Moderate | High | 28 + |-------|-----|----------|------| 29 + | 1 | 50 | 75 | 100 | 30 + | 2 | 100 | 150 | 200 | 31 + | 3 | 150 | 225 | 400 | 32 + | 4 | 250 | 375 | 500 | 33 + | 5 | 500 | 750 | 1,100 | 34 + | 6 | 600 | 1,000 | 1,400 | 35 + | 7 | 750 | 1,300 | 1,700 | 36 + | 8 | 1,000 | 1,700 | 2,100 | 37 + | 9 | 1,300 | 2,000 | 2,600 | 38 + | 10 | 1,600 | 2,300 | 3,100 | 39 + 40 + **What kinds of things are worth XP:** 41 + - Combat encounters (scale by difficulty — a bar fight is not the same as a boss) 42 + - Completing quest threads (minor ~100-300, moderate ~500-1,000, major ~1,500-3,000) 43 + - Significant discoveries (finding a hidden dungeon, uncovering a conspiracy — 50-200) 44 + - Clever problem-solving (bypassing an encounter through wit, diplomacy, stealth — same as defeating it) 45 + - Surviving dangerous situations (escaping a trap, navigating a hazard — 50-200) 46 + 47 + ## Pacing Check 48 + 49 + Look at the advancement history (previous `level` tags in the log). Is there a pattern? Try to maintain a rhythm that feels natural: 50 + - Too fast: leveling every 1-2 game days feels rushed. The character should earn each level. 51 + - Too slow: going 10+ game days at low levels without advancement feels stagnant. If significant accomplishments are piling up, it's time. 52 + - Just right: significant story arcs and challenges punctuate each level. The player should feel like advancement was earned through what they did. 53 + 54 + At low levels (1-4), advancement should come faster. At higher levels (5+), it should slow down. This matches the XP curve — the gaps between levels grow. 55 + 56 + ## Your Tools 57 + 58 + | Tool | Purpose | 59 + |------|---------| 60 + | `recall` | Look up rules (class features for next level, SRD content) | 61 + | `update_character` | Set `advancement_ready` on the character sheet | 62 + | `notify_dm` | Send a notification the DM will see next turn | 63 + 64 + ## What to Do 65 + 66 + 1. Read the character's current level and class 67 + 2. Use `recall` to look up the character's class in the SRD — check what features the next level grants and any general leveling guidance 68 + 3. Read the campaign log since the last `level` tag (or from the beginning) 69 + 4. Mentally tally what the character has accomplished against the calibration table 70 + 5. Consider the pacing of previous level-ups 71 + 6. Make your call: 72 + 73 + **If NOT ready:** Do nothing. Stop. Don't output anything. 74 + 75 + **If ready:** Call both tools: 76 + - `update_character({"advancement_ready": <next_level>})` 77 + - `notify_dm("The character has earned enough experience to reach level <N>. <One sentence about what earned it — e.g., 'The heist at Thornwall Manor and the investigation of the lake beast represent significant growth.'>. Level <N> <Class> gains: <key features from the SRD lookup>. When a narratively appropriate moment arises, acknowledge their advancement.")` 78 + 79 + Then stop. Don't over-explain. Don't narrate. You're a background process, not a storyteller. 80 + 81 + ## Important 82 + 83 + - Only recommend one level at a time. If they've earned multiple levels, recommend the next one. The evaluator will run again and catch the subsequent level. 84 + - If `advancement_ready` is already set on the character sheet and the DM hasn't acted on it yet, do nothing. Don't stack notifications. 85 + - Err slightly on the side of generosity. A player stuck at the same level for too long is worse than leveling one session too early. The goal is to keep the game feeling rewarding and the character's growth matching their story.
+199
src/storied/advancement.py
··· 1 + """Character advancement evaluator — background agent that decides when to level up.""" 2 + 3 + import time 4 + from collections.abc import Callable 5 + from dataclasses import dataclass 6 + from pathlib import Path 7 + from threading import Thread 8 + 9 + from storied.character import format_character_context, load_character 10 + from storied.claude import run_with_tools 11 + from storied.engine import load_prompt 12 + from storied.log import CampaignLog 13 + from storied.mcp_server import start_server as start_mcp_server 14 + from storied.session import load_session 15 + 16 + 17 + @dataclass 18 + class AdvancementResult: 19 + """Result of an advancement evaluation run.""" 20 + 21 + evaluated: bool = False 22 + tool_calls: int = 0 23 + input_tokens: int = 0 24 + output_tokens: int = 0 25 + elapsed: float = 0.0 26 + 27 + 28 + def build_advancement_context( 29 + world_id: str, 30 + player_id: str, 31 + base_path: Path, 32 + ) -> str | None: 33 + """Build context for the advancement evaluator. 34 + 35 + Returns None if there's nothing to evaluate (no character or 36 + advancement_ready already set). 37 + """ 38 + character = load_character(player_id, base_path) 39 + if character is None: 40 + return None 41 + 42 + # If advancement_ready is already set, the DM hasn't acted yet — skip 43 + if character.get("advancement_ready"): 44 + return None 45 + 46 + parts: list[str] = [] 47 + 48 + # Character summary 49 + char_context = format_character_context(character) 50 + parts.append(char_context) 51 + 52 + # Campaign log — entries since last level-up 53 + log = CampaignLog(world_id, base_path) 54 + parts.append(f"## Campaign Time: {log.get_current_time()}") 55 + 56 + entries_since_level = log.get_entries_since_tag("level") 57 + if entries_since_level: 58 + lines = ["## Campaign Log (since last level-up)", ""] 59 + for entry in entries_since_level: 60 + tag_str = f" [{', '.join(entry.tags)}]" if entry.tags else "" 61 + lines.append(f"- {entry.anchor} | {entry.event}{tag_str}") 62 + parts.append("\n".join(lines)) 63 + else: 64 + parts.append("## Campaign Log\n\nNo events recorded yet.") 65 + 66 + # Previous level-ups for pacing reference 67 + level_entries = log.find_tag_entries("level") 68 + if level_entries: 69 + lines = ["## Advancement History", ""] 70 + for entry in level_entries: 71 + lines.append(f"- {entry.anchor} | {entry.event}") 72 + parts.append("\n".join(lines)) 73 + 74 + # Session state for thread context 75 + session = load_session(player_id, base_path) 76 + if session: 77 + body = session.get("body", "") 78 + if body: 79 + parts.append(f"## Session State\n\n{body}") 80 + 81 + return "\n\n---\n\n".join(parts) 82 + 83 + 84 + def evaluate_advancement( 85 + world_id: str = "default", 86 + player_id: str = "default", 87 + base_path: Path | None = None, 88 + model: str = "claude-opus-4-6", 89 + on_progress: Callable[[str], None] | None = None, 90 + ) -> AdvancementResult: 91 + """Evaluate whether the character has earned a level-up.""" 92 + if base_path is None: 93 + base_path = Path.cwd() 94 + 95 + def progress(msg: str) -> None: 96 + if on_progress: 97 + on_progress(msg) 98 + 99 + start_time = time.monotonic() 100 + 101 + context = build_advancement_context(world_id, player_id, base_path) 102 + if context is None: 103 + progress("Skipped (no character or advancement already pending)") 104 + return AdvancementResult(elapsed=time.monotonic() - start_time) 105 + 106 + system_prompt = load_prompt("xp-evaluator") 107 + 108 + campaign_log = CampaignLog(world_id, base_path) 109 + mcp = start_mcp_server( 110 + world_id, player_id, base_path, "advancement", campaign_log, 111 + ) 112 + 113 + progress(f"Evaluating advancement with {model}...") 114 + 115 + def on_tool(name: str) -> None: 116 + if on_progress: 117 + on_progress(f" [{name}]") 118 + 119 + claude_result = run_with_tools( 120 + system_prompt=system_prompt, 121 + user_message=context, 122 + mcp_url=mcp.url, 123 + model=model, 124 + on_tool_start=on_tool, 125 + cwd=base_path, 126 + ) 127 + 128 + result = AdvancementResult( 129 + evaluated=True, 130 + elapsed=time.monotonic() - start_time, 131 + ) 132 + 133 + if claude_result: 134 + result.tool_calls = claude_result.usage.get("tool_calls", 0) 135 + result.input_tokens = claude_result.usage.get("input_tokens", 0) 136 + result.output_tokens = claude_result.usage.get("output_tokens", 0) 137 + 138 + return result 139 + 140 + 141 + class BackgroundAdvancement: 142 + """Runs advancement evaluation in a background thread during gameplay. 143 + 144 + Triggers every N player turns. Only one evaluation runs at a time. 145 + """ 146 + 147 + def __init__( 148 + self, 149 + world_id: str, 150 + player_id: str, 151 + base_path: Path, 152 + model: str = "claude-opus-4-6", 153 + interval: int = 5, 154 + ): 155 + self._world_id = world_id 156 + self._player_id = player_id 157 + self._base_path = base_path 158 + self._model = model 159 + self._interval = interval 160 + self._turn_count: int = 0 161 + self._thread: Thread | None = None 162 + self._result: AdvancementResult | None = None 163 + 164 + def on_turn(self) -> None: 165 + """Call after each player turn. Launches evaluation when interval is reached.""" 166 + self._turn_count += 1 167 + if self._turn_count < self._interval: 168 + return 169 + self._maybe_evaluate() 170 + 171 + def on_combat_end(self) -> None: 172 + """Call when initiative ends. Combat is a strong advancement signal.""" 173 + self._maybe_evaluate() 174 + 175 + def _maybe_evaluate(self) -> None: 176 + """Launch a background evaluation if one isn't already running.""" 177 + if self._thread and self._thread.is_alive(): 178 + return 179 + 180 + self._turn_count = 0 181 + self._result = None 182 + self._thread = Thread(target=self._run, daemon=True) 183 + self._thread.start() 184 + 185 + def _run(self) -> None: 186 + self._result = evaluate_advancement( 187 + world_id=self._world_id, 188 + player_id=self._player_id, 189 + base_path=self._base_path, 190 + model=self._model, 191 + ) 192 + 193 + def pop_result(self) -> AdvancementResult | None: 194 + """Return and clear the last completed evaluation result, if any.""" 195 + if self._thread and not self._thread.is_alive() and self._result: 196 + result = self._result 197 + self._result = None 198 + return result 199 + return None
+21 -123
src/storied/cli.py
··· 312 312 313 313 # Background ticker for mid-session world advancement 314 314 ticker = None 315 + advancement = None 315 316 if not creation_mode and not sandbox: 317 + from storied.advancement import BackgroundAdvancement 316 318 from storied.planner import BackgroundTicker 317 319 318 320 ticker = BackgroundTicker( ··· 322 324 ) 323 325 # Kick off initial tick in background 324 326 ticker.maybe_tick(engine._campaign_log) 327 + 328 + advancement = BackgroundAdvancement( 329 + world_id=world_id, 330 + player_id=player_id, 331 + base_path=base_path or Path.cwd(), 332 + ) 325 333 326 334 # If in creation mode, start the conversation 327 335 if creation_mode: ··· 572 580 # Maybe launch a new tick if the day advanced 573 581 ticker.maybe_tick(engine._campaign_log) 574 582 583 + # Advancement evaluator — tick the turn counter and check results 584 + if advancement: 585 + if engine.combat_ended: 586 + advancement.on_combat_end() 587 + engine.combat_ended = False 588 + else: 589 + advancement.on_turn() 590 + adv_result = advancement.pop_result() 591 + if adv_result and adv_result.tool_calls > 0: 592 + console.print( 593 + "[dim]The character reflects on recent experiences...[/dim]" 594 + ) 595 + 575 596 # Check if session ended (player quit gracefully) 576 597 if engine.session_ended: 577 598 console.print( ··· 630 651 return 0 631 652 632 653 633 - def cmd_plan(args: argparse.Namespace) -> int: 634 - """Enrich thin entities near the player's current position.""" 635 - from storied.planner import plan_world 636 - 637 - world_id = args.world or "default" 638 - player_id = args.player or "default" 639 - 640 - print(f"Loading session for player '{player_id}' in world '{world_id}'...", flush=True) 641 - 642 - def on_progress(msg: str) -> None: 643 - print(msg, flush=True) 644 - 645 - result = plan_world( 646 - world_id=world_id, 647 - player_id=player_id, 648 - model=args.model, 649 - threshold=args.threshold, 650 - max_entities=args.max_entities, 651 - dry_run=args.dry_run, 652 - on_progress=on_progress, 653 - ) 654 - 655 - if not result.candidates: 656 - print("No thin entities found nearby. The world looks rich enough.") 657 - return 0 658 - 659 - if result.dry_run: 660 - return 0 661 - 662 - print( 663 - f"Done — {result.tool_calls} tool calls, " 664 - f"{result.input_tokens:,} in / {result.output_tokens:,} out, " 665 - f"{result.elapsed:.1f}s", 666 - flush=True, 667 - ) 668 - 669 - return 0 670 - 671 - 672 - def cmd_tick(args: argparse.Namespace) -> int: 673 - """Advance the world by evaluating Will triggers.""" 674 - from storied.planner import tick_world 675 - 676 - world_id = args.world or "default" 677 - player_id = args.player or "default" 678 - 679 - def on_progress(msg: str) -> None: 680 - print(msg, flush=True) 681 - 682 - result = tick_world( 683 - world_id=world_id, 684 - player_id=player_id, 685 - model=args.model, 686 - on_progress=on_progress, 687 - ) 688 - 689 - if result.entities_checked == 0: 690 - print("No entities with active Will triggers.") 691 - return 0 692 - 693 - print( 694 - f"Done — {result.tool_calls} tool calls, " 695 - f"{result.input_tokens:,} in / {result.output_tokens:,} out, " 696 - f"{result.elapsed:.1f}s", 697 - flush=True, 698 - ) 699 - return 0 700 - 701 654 702 655 def cmd_index_srd(args: argparse.Namespace) -> int: 703 656 """Build search index for SRD content.""" ··· 922 875 help="Skip confirmation prompt", 923 876 ) 924 877 reset_parser.set_defaults(func=cmd_reset) 925 - 926 - # plan command 927 - plan_parser = subparsers.add_parser("plan", help="Enrich thin world entities near the player") 928 - plan_parser.add_argument( 929 - "--world", "-w", 930 - default="default", 931 - help="World ID (default: default)", 932 - ) 933 - plan_parser.add_argument( 934 - "--player", "-p", 935 - default="default", 936 - help="Player ID (default: default)", 937 - ) 938 - plan_parser.add_argument( 939 - "--model", "-m", 940 - default="claude-opus-4-6", 941 - help="Model to use for planning (default: claude-opus-4-6)", 942 - ) 943 - plan_parser.add_argument( 944 - "--threshold", 945 - type=float, 946 - default=0.7, 947 - help="Richness threshold — entities below this score get enriched (default: 0.7)", 948 - ) 949 - plan_parser.add_argument( 950 - "--max-entities", 951 - type=int, 952 - default=8, 953 - help="Maximum entities to enrich per run (default: 8)", 954 - ) 955 - plan_parser.add_argument( 956 - "--dry-run", 957 - action="store_true", 958 - help="Show what would be enriched without calling the API", 959 - ) 960 - plan_parser.set_defaults(func=cmd_plan) 961 - 962 - # tick command 963 - tick_parser = subparsers.add_parser("tick", help="Advance the world by evaluating Will triggers") 964 - tick_parser.add_argument( 965 - "--world", "-w", 966 - default="default", 967 - help="World ID (default: default)", 968 - ) 969 - tick_parser.add_argument( 970 - "--player", "-p", 971 - default="default", 972 - help="Player ID (default: default)", 973 - ) 974 - tick_parser.add_argument( 975 - "--model", "-m", 976 - default="claude-opus-4-6", 977 - help="Model to use for ticking (default: claude-opus-4-6)", 978 - ) 979 - tick_parser.set_defaults(func=cmd_tick) 980 878 981 879 # seed command 982 880 seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet")
+15 -1
src/storied/engine.py
··· 8 8 9 9 import yaml 10 10 11 + from storied import notifications 11 12 from storied.character import format_character_context, load_character 12 13 from storied.claude import ( 13 14 Result, ··· 107 108 self._total_input_tokens: int = 0 108 109 self._total_output_tokens: int = 0 109 110 110 - # Session end / character creation flags 111 + # Session end / character creation / combat end flags 111 112 self.session_ended: bool = False 112 113 self.character_created: bool = False 114 + self.combat_ended: bool = False 113 115 114 116 # Debug mode for verbose tool output 115 117 self.debug: bool = False ··· 255 257 parts.append(entity_context) 256 258 loaded_names.add(name) 257 259 260 + # Notifications from background agents (planner, ticker, advancement) 261 + if self.world_id: 262 + pending = notifications.drain(self.world_id, self.base_path) 263 + if pending: 264 + notif_lines = ["## Recent World Changes\n"] 265 + notif_lines.extend(f"- {msg}" for msg in pending) 266 + notif_context = "\n".join(notif_lines) 267 + self._context_parts["Notifications"] = notif_context 268 + parts.append(notif_context) 269 + 258 270 # Initiative state (injected when active so the DM never loses track) 259 271 if self._mcp.ctx.initiative.active: 260 272 initiative_context = self._mcp.ctx.initiative.format_for_context() ··· 437 449 self.session_ended = True 438 450 if short == "create_character": 439 451 self.character_created = True 452 + if short == "end_initiative": 453 + self.combat_ended = True 440 454 441 455 if short in ("roll", "run_code") and not self.debug: 442 456 # Signal the CLI to flush the renderer before we
+31
src/storied/log.py
··· 404 404 405 405 return "\n".join(lines) 406 406 407 + def get_all_entries(self) -> list[LogEntry]: 408 + """Get every log entry from day 1 through the current day.""" 409 + entries: list[LogEntry] = [] 410 + for day in range(1, self.current_day + 1): 411 + entries.extend(self._load_day_entries(day)) 412 + return entries 413 + 414 + def get_entries_since_tag(self, tag: str) -> list[LogEntry]: 415 + """Get all entries after the last occurrence of a tag. 416 + 417 + Scans backwards through the entire log to find the most recent entry 418 + with the given tag, then returns everything after it. If the tag is 419 + never found, returns all entries. 420 + """ 421 + all_entries = self.get_all_entries() 422 + 423 + # Find last occurrence of the tag 424 + last_idx = -1 425 + for i, entry in enumerate(all_entries): 426 + if tag in entry.tags: 427 + last_idx = i 428 + 429 + if last_idx == -1: 430 + return all_entries 431 + 432 + return all_entries[last_idx + 1:] 433 + 434 + def find_tag_entries(self, tag: str) -> list[LogEntry]: 435 + """Find all entries with a given tag, across all days.""" 436 + return [e for e in self.get_all_entries() if tag in e.tags] 437 + 407 438 def time_since_rest(self, rest_type: str = "short") -> Duration: 408 439 """Calculate time since last rest of given type.""" 409 440 tag = f"rest:{rest_type}"
+4
src/storied/mcp_server.py
··· 26 26 ) 27 27 from storied.sandbox import build_tool_signatures 28 28 from storied.tools import ( 29 + ADVANCEMENT_TOOL_DEFINITIONS, 29 30 EntityIndex, 30 31 PLANNER_TOOL_DEFINITIONS, 31 32 SEEDER_TOOL_DEFINITIONS, 32 33 TOOL_DEFINITIONS, 33 34 ToolContext, 35 + advancement_execute_tool, 34 36 execute_tool, 35 37 planner_execute_tool, 36 38 seeder_execute_tool, ··· 41 43 TOOL_SETS: dict[str, list[dict]] = { 42 44 "planner": PLANNER_TOOL_DEFINITIONS, 43 45 "seeder": SEEDER_TOOL_DEFINITIONS, 46 + "advancement": ADVANCEMENT_TOOL_DEFINITIONS, 44 47 } 45 48 46 49 EXECUTORS = { 47 50 "dm": execute_tool, 48 51 "planner": planner_execute_tool, 49 52 "seeder": seeder_execute_tool, 53 + "advancement": advancement_execute_tool, 50 54 } 51 55 52 56
+49
src/storied/notifications.py
··· 1 + """Notification channel for background agents to communicate with the DM. 2 + 3 + Background agents (planner, ticker, advancement evaluator) append messages. 4 + The DM engine reads and clears them each turn via _build_context. 5 + """ 6 + 7 + import threading 8 + from pathlib import Path 9 + 10 + 11 + _lock = threading.Lock() 12 + 13 + 14 + def _notifications_path(world_id: str, base_path: Path) -> Path: 15 + return base_path / "worlds" / world_id / "dm_notifications.md" 16 + 17 + 18 + def append(world_id: str, base_path: Path, message: str) -> None: 19 + """Append a notification for the DM to see next turn.""" 20 + path = _notifications_path(world_id, base_path) 21 + with _lock: 22 + path.parent.mkdir(parents=True, exist_ok=True) 23 + with path.open("a") as f: 24 + f.write(f"- {message}\n") 25 + 26 + 27 + def drain(world_id: str, base_path: Path) -> list[str]: 28 + """Read all pending notifications and clear the file. 29 + 30 + Returns a list of notification messages (without the leading "- "). 31 + """ 32 + path = _notifications_path(world_id, base_path) 33 + with _lock: 34 + if not path.exists(): 35 + return [] 36 + 37 + content = path.read_text().strip() 38 + if not content: 39 + path.unlink(missing_ok=True) 40 + return [] 41 + 42 + # Clear the file 43 + path.unlink(missing_ok=True) 44 + 45 + return [ 46 + line.lstrip("- ").strip() 47 + for line in content.splitlines() 48 + if line.strip() 49 + ]
-1
src/storied/planner.py
··· 167 167 dry_run: bool = False 168 168 169 169 170 - 171 170 def plan_world( 172 171 world_id: str = "default", 173 172 player_id: str = "default",
-1155
src/storied/tools.py
··· 1 - """DM tools for Claude to use during gameplay. 2 - 3 - These functions are exposed to Claude as tools. The docstrings become 4 - the tool descriptions that Claude sees. 5 - """ 6 - 7 - import re 8 - import threading 9 - from dataclasses import dataclass, field 10 - from pathlib import Path 11 - 12 - import yaml 13 - 14 - from storied.character import create_character as char_create 15 - from storied.initiative import ( 16 - ALL_INITIATIVE_TOOL_NAMES, 17 - InitiativeTracker, 18 - execute_initiative_tool, 19 - ) 20 - from storied.character import update_character as char_update 21 - from storied.dice import roll as dice_roll 22 - from storied.log import CampaignLog 23 - from storied.search import VectorIndex 24 - from storied.session import name_to_slug 25 - from storied.session import update_session as session_update 26 - 27 - # Per-file locks for thread-safe entity writes (establish, mark) 28 - _file_locks: dict[Path, threading.Lock] = {} 29 - _file_locks_lock = threading.Lock() 30 - 31 - 32 - def _get_file_lock(path: Path) -> threading.Lock: 33 - """Get or create a lock for the given file path.""" 34 - with _file_locks_lock: 35 - if path not in _file_locks: 36 - _file_locks[path] = threading.Lock() 37 - return _file_locks[path] 38 - 39 - 40 - class EntityIndex: 41 - """Name→path index with write-through entity cache. 42 - 43 - Built once at startup by globbing the world directory. Lookups are 44 - dict lookups instead of 6 sequential Path.exists() calls. The cache 45 - stores parsed entity dicts so repeated loads within a turn skip disk I/O. 46 - establish() and mark() update both the index and cache on write. 47 - """ 48 - 49 - def __init__(self, world_dir: Path | None = None): 50 - self._paths: dict[str, Path] = {} 51 - self._cache: dict[Path, dict] = {} 52 - if world_dir and world_dir.exists(): 53 - for md in world_dir.rglob("*.md"): 54 - self._paths[md.stem] = md 55 - 56 - def resolve(self, name: str) -> Path | None: 57 - """Look up an entity's file path by name.""" 58 - return self._paths.get(name) 59 - 60 - def register(self, name: str, path: Path) -> None: 61 - """Register or update an entity's path in the index.""" 62 - self._paths[name] = path 63 - 64 - def cache_get(self, path: Path) -> dict | None: 65 - """Get a cached parsed entity, or None if not cached.""" 66 - return self._cache.get(path) 67 - 68 - def cache_put(self, path: Path, data: dict) -> None: 69 - """Store a parsed entity in the cache.""" 70 - self._cache[path] = data 71 - 72 - 73 - @dataclass 74 - class ToolContext: 75 - """Shared infrastructure for all tool calls. 76 - 77 - Created once per MCP server and passed to every tool invocation. 78 - All fields are required — no defensive None checks in tool code. 79 - """ 80 - 81 - world_id: str 82 - player_id: str 83 - base_path: Path 84 - campaign_log: CampaignLog 85 - entity_index: EntityIndex 86 - vector_index: VectorIndex 87 - initiative: InitiativeTracker = field(default_factory=InitiativeTracker) 88 - 89 - 90 - def _sync_player_hp(target: str, ctx: ToolContext, result: str) -> str: 91 - """Auto-sync player character sheet when damage/heal targets a player.""" 92 - combatant = ctx.initiative._find(target) 93 - if combatant and combatant.is_player: 94 - char_update(ctx.player_id, {"hp.current": combatant.hp}, ctx.base_path) 95 - result += f" (character sheet synced to {combatant.hp} HP)" 96 - return result 97 - 98 - 99 - def roll(notation: str, reason: str | None = None) -> dict: 100 - """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. 101 - 102 - Use for attack rolls, skill checks, saving throws, and damage rolls. 103 - Supports: XdY, XdY+Z, XdY-Z, advantage (2d20kh1), disadvantage (2d20kl1). 104 - 105 - Args: 106 - notation: Dice notation string (e.g., "1d20+5", "2d6", "4d6kh3") 107 - reason: Brief description of what the roll is for (e.g., "Athletics", 108 - "Attack with longsword", "Wisdom save", "Fireball damage") 109 - 110 - Returns: 111 - Dict with rolls, kept dice, modifier, and total 112 - """ 113 - result = dice_roll(notation) 114 - return result.to_dict() 115 - 116 - 117 - def recall( 118 - query: str, 119 - ctx: ToolContext, 120 - scope: str = "all", 121 - content_type: str | None = None, 122 - ) -> str: 123 - """Look up rules, world content, or both. 124 - 125 - Use to recall information about: 126 - - Rules: spells, monsters, classes, items, conditions from the SRD 127 - - World: NPCs, locations, factions, lore you've established 128 - - Both: search everything (default) 129 - 130 - Args: 131 - query: What to look up (e.g., "fireball", "captain vex", "merchant guild") 132 - scope: Where to search - "rules", "world", or "all" (default) 133 - content_type: Optional type to limit search (e.g., "spells", "npcs") 134 - 135 - Returns: 136 - Content of the found item, or a message if not found 137 - """ 138 - # scope="rules" → SRD only; scope="world" → everything except SRD; 139 - # scope="all" → no filter 140 - source_filter: str | None = None 141 - if scope == "rules": 142 - source_filter = "srd" 143 - 144 - current_day = ctx.campaign_log.get_current_time().day 145 - hits = ctx.vector_index.search( 146 - query, limit=5, source_filter=source_filter, 147 - exclude_source="srd" if scope == "world" else None, 148 - decay_ref=current_day, 149 - ) 150 - if hits: 151 - if len(hits) == 1 or hits[0].score > 0.8: 152 - hit_path = Path(hits[0].path) 153 - if hit_path.exists(): 154 - return hit_path.read_text() 155 - lines = [f"Found {len(hits)} matches:"] 156 - for h in hits: 157 - lines.append(f" - {h.doc_id.split(':')[1]} ({h.source}): {h.snippet[:80]}") 158 - return "\n".join(lines) 159 - 160 - return f"Nothing found matching '{query}'" 161 - 162 - 163 - def update_character(updates: dict, ctx: ToolContext) -> str: 164 - """Update the player's character sheet to persist changes. 165 - 166 - Call this after HP changes, equipment gained/lost, coins spent, level ups, etc. 167 - This ensures progress is saved and survives between sessions. 168 - 169 - Args: 170 - updates: Fields to update. Use dot notation for nested fields. 171 - Examples: 172 - - {"hp.current": 5} - set current HP to 5 173 - - {"purse.gp": 25} - set gold to 25 174 - - {"purse.sp": 10, "purse.cp": 50} - set silver and copper 175 - - {"level": 2, "hp.max": 20} - level up 176 - For markdown sections, use "section.Name": 177 - - {"section.Equipment": "- Longsword\\n- New shield"} 178 - player_id: Player identifier (usually "default") 179 - base_path: Base path for players directory 180 - 181 - Returns: 182 - Confirmation of what was updated 183 - """ 184 - return char_update(ctx.player_id, updates, ctx.base_path) 185 - 186 - 187 - def create_character( 188 - name: str, 189 - race: str, 190 - char_class: str, 191 - level: int, 192 - abilities: dict[str, int], 193 - hp_max: int, 194 - ac: int, 195 - ctx: ToolContext, 196 - background: str | None = None, 197 - speed: int = 30, 198 - purse: dict[str, int] | None = None, 199 - equipment: list[str] | None = None, 200 - features: list[str] | None = None, 201 - proficiencies: str | None = None, 202 - backstory: str | None = None, 203 - ) -> str: 204 - """Create a new player character and save to disk. 205 - 206 - Call this when character creation is complete. Include all the mechanical 207 - details needed to play: abilities, HP, AC, equipment, and features. 208 - 209 - Args: 210 - name: Character name 211 - race: Race (e.g., "Human", "High Elf", "Hill Dwarf") 212 - char_class: Class (e.g., "Fighter", "Wizard", "Rogue") 213 - level: Starting level (usually 1) 214 - abilities: All six ability scores as a dict: 215 - {"strength": 15, "dexterity": 14, "constitution": 13, 216 - "intelligence": 12, "wisdom": 10, "charisma": 8} 217 - hp_max: Maximum hit points 218 - ac: Armor class 219 - background: Background (e.g., "Soldier", "Sage", "Criminal") 220 - speed: Movement speed in feet (default 30) 221 - purse: Starting coins by denomination: 222 - {"cp": 0, "sp": 0, "ep": 0, "gp": 15, "pp": 0} 223 - Denominations: cp (copper), sp (silver), ep (electrum), 224 - gp (gold), pp (platinum). Omit denominations for 0. 225 - equipment: List of equipment items 226 - features: List of racial and class features 227 - proficiencies: Description of proficiencies (armor, weapons, tools, saves, skills) 228 - backstory: Character backstory and personality 229 - 230 - Returns: 231 - Confirmation message 232 - """ 233 - return char_create( 234 - player_id=ctx.player_id, 235 - name=name, 236 - race=race, 237 - char_class=char_class, 238 - level=level, 239 - abilities=abilities, 240 - hp_max=hp_max, 241 - ac=ac, 242 - background=background, 243 - speed=speed, 244 - purse=purse, 245 - equipment=equipment, 246 - features=features, 247 - proficiencies=proficiencies, 248 - backstory=backstory, 249 - base_path=ctx.base_path, 250 - ) 251 - 252 - 253 - def set_scene( 254 - ctx: ToolContext, 255 - event: str | None = None, 256 - duration: str | None = None, 257 - situation: str | None = None, 258 - location: str | None = None, 259 - present: list[str] | None = None, 260 - threads: list[str] | None = None, 261 - tags: list[str] | None = None, 262 - ) -> str: 263 - """Call this after every response. Logs what happened, advances the clock, 264 - and updates the scene state. 265 - 266 - Always include event and duration — time only advances when you log it. 267 - 268 - Args: 269 - event: What happened this turn (e.g., "Spoke with the innkeeper", 270 - "Searched the warehouse", "Fought off thugs"). 271 - duration: How long it took (e.g., "10 min", "1 hour", "3 rounds", 272 - "1 day"). The clock advances by this amount. 273 - situation: Updated situation summary. Write in present tense as if 274 - briefing another DM taking over mid-session. 275 - location: New location when the player moves (e.g., "The Rusty Anchor") 276 - present: Entities currently present, using [[Name]] format 277 - (e.g., ["[[Vera Blackwater]]", "[[Henrik]] - barkeep"]) 278 - threads: Open plot threads or objectives 279 - tags: Optional tags: "combat", "rest:short", "rest:long", "travel" 280 - 281 - Returns: 282 - Confirmation of what was updated 283 - """ 284 - parts = [] 285 - 286 - if event and duration: 287 - anchor = ctx.campaign_log.append_entry(event, duration, tags=tags) 288 - current = ctx.campaign_log.get_current_time() 289 - parts.append( 290 - f"Logged: {anchor} | {event} | {duration} → " 291 - f"Now: {current} ({current.period_of_day()}, {current.atmosphere()})" 292 - ) 293 - 294 - updates = {} 295 - if situation is not None: 296 - updates["situation"] = situation 297 - if location is not None: 298 - updates["location"] = location 299 - if present is not None: 300 - updates["present"] = present 301 - if threads is not None: 302 - updates["threads"] = threads 303 - 304 - if updates: 305 - result = session_update(ctx.player_id, updates, ctx.base_path) 306 - parts.append(result) 307 - 308 - if event and present: 309 - marked = _auto_mark_present(present, event, ctx) 310 - if marked: 311 - parts.append(f"Auto-marked: {', '.join(marked)}") 312 - 313 - return "; ".join(parts) if parts else "No updates" 314 - 315 - 316 - def establish( 317 - entity_type: str, 318 - name: str, 319 - ctx: ToolContext, 320 - description: str | None = None, 321 - location: str | None = None, 322 - knows: list[str] | None = None, 323 - wants: list[str] | None = None, 324 - will: list[str] | None = None, 325 - ) -> str: 326 - """Establish or update an entity in the world. 327 - 328 - Use to create NPCs, locations, items, factions, or threads with their inner 329 - state. Everything has Knows/Wants/Will: 330 - - **Knows** = secrets, hidden truths, what isn't obvious 331 - - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") 332 - - **Will** = conditional triggers, what happens if... 333 - 334 - This isn't literal consciousness - it's narrative tendency. A bridge can "want" 335 - to collapse. Cursed gold "wants" to be spent. Frame it this way and the world 336 - feels alive. 337 - 338 - Partial updates: omit fields to preserve existing content when updating. 339 - 340 - Args: 341 - entity_type: Type of entity: npcs, locations, items, factions, threads, lore 342 - name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 343 - This becomes the filename directly (no slugification). 344 - description: Prose description for the ## Is section. Include appearance, 345 - background, current state, relationships via [[wikilinks]]. 346 - location: Where this entity is right now. Can be a simple wikilink like 347 - "[[The Rusty Anchor]]" or a verbal description like "In the basement 348 - of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". 349 - knows: List of secrets and hidden truths. Things that aren't obvious. 350 - wants: List of desires, tendencies, inclinations. The entity's nature. 351 - will: List of conditional behaviors: "If X → Y" format. 352 - 353 - Returns: 354 - Confirmation with the file path 355 - """ 356 - world_dir = ctx.base_path / "worlds" / ctx.world_id / entity_type 357 - world_dir.mkdir(parents=True, exist_ok=True) 358 - file_path = world_dir / f"{name}.md" 359 - 360 - lock = _get_file_lock(file_path) 361 - with lock: 362 - existing = _load_entity(file_path, ctx.entity_index) 363 - 364 - if description is None: 365 - description = existing.get("description", "") 366 - if location is None: 367 - location = existing.get("location", "") 368 - if knows is None: 369 - knows = existing.get("knows", []) 370 - if wants is None: 371 - wants = existing.get("wants", []) 372 - if will is None: 373 - will = existing.get("will", []) 374 - was = existing.get("was", []) 375 - 376 - data = { 377 - "description": description, "location": location, 378 - "knows": knows, "wants": wants, "will": will, "was": was, 379 - } 380 - _write_entity(file_path, name, entity_type, data, ctx) 381 - 382 - action = "Updated" if existing else "Established" 383 - return f"{action} {entity_type.rstrip('s')} '{name}'" 384 - 385 - 386 - def _load_entity(file_path: Path, entity_index: EntityIndex) -> dict: 387 - """Load an existing entity file and parse its structure.""" 388 - cached = entity_index.cache_get(file_path) 389 - if cached is not None: 390 - return cached 391 - 392 - if not file_path.exists(): 393 - return {} 394 - 395 - content = file_path.read_text() 396 - result = {} 397 - 398 - # Parse ## Is section 399 - is_match = re.search(r"## Is\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) 400 - if is_match: 401 - is_content = is_match.group(1).strip() 402 - 403 - # Extract location (line starting with **Location:**) 404 - loc_match = re.search(r"\*\*Location:\*\*\s*(.+)", is_content) 405 - if loc_match: 406 - result["location"] = loc_match.group(1).strip() 407 - 408 - # Extract description (text before first ### subsection, excluding location line) 409 - desc_match = re.match(r"(.*?)(?=\n### |\Z)", is_content, re.DOTALL) 410 - if desc_match: 411 - desc = desc_match.group(1).strip() 412 - # Remove location line from description 413 - desc = re.sub(r"\*\*Location:\*\*\s*.+\n?", "", desc).strip() 414 - result["description"] = desc 415 - 416 - # Extract ### Knows 417 - knows_match = re.search(r"### Knows\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 418 - if knows_match: 419 - result["knows"] = _parse_list_items(knows_match.group(1)) 420 - 421 - # Extract ### Wants 422 - wants_match = re.search(r"### Wants\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 423 - if wants_match: 424 - result["wants"] = _parse_list_items(wants_match.group(1)) 425 - 426 - # Extract ### Will 427 - will_match = re.search(r"### Will\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 428 - if will_match: 429 - result["will"] = _parse_list_items(will_match.group(1)) 430 - 431 - # Parse ## Was section 432 - was_match = re.search(r"## Was\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) 433 - if was_match: 434 - result["was"] = _parse_list_items(was_match.group(1)) 435 - 436 - entity_index.cache_put(file_path, result) 437 - 438 - return result 439 - 440 - 441 - def _parse_list_items(text: str) -> list[str]: 442 - """Parse markdown list items into a list of strings.""" 443 - items = [] 444 - for line in text.strip().split("\n"): 445 - line = line.strip() 446 - if line.startswith("- "): 447 - items.append(line[2:]) 448 - return items 449 - 450 - 451 - def _format_entity( 452 - name: str, 453 - description: str, 454 - location: str, 455 - knows: list[str], 456 - wants: list[str], 457 - will: list[str], 458 - was: list[str], 459 - ) -> str: 460 - """Format an entity as markdown with Is/Was structure.""" 461 - lines = [f"# {name}", "", "## Is", ""] 462 - 463 - if location: 464 - lines.append(f"**Location:** {location}") 465 - lines.append("") 466 - 467 - if description: 468 - lines.append(description) 469 - lines.append("") 470 - 471 - if knows: 472 - lines.append("### Knows") 473 - lines.append("") 474 - for item in knows: 475 - lines.append(f"- {item}") 476 - lines.append("") 477 - 478 - if wants: 479 - lines.append("### Wants") 480 - lines.append("") 481 - for item in wants: 482 - lines.append(f"- {item}") 483 - lines.append("") 484 - 485 - if will: 486 - lines.append("### Will") 487 - lines.append("") 488 - for item in will: 489 - lines.append(f"- {item}") 490 - lines.append("") 491 - 492 - lines.append("## Was") 493 - lines.append("") 494 - for item in was: 495 - lines.append(f"- {item}") 496 - lines.append("") 497 - 498 - return "\n".join(lines) 499 - 500 - 501 - def _write_entity( 502 - file_path: Path, 503 - name: str, 504 - entity_type: str, 505 - data: dict, 506 - ctx: ToolContext, 507 - ) -> None: 508 - """Write an entity to disk and update all indexes.""" 509 - file_content = _format_entity( 510 - name, data["description"], data["location"], 511 - data["knows"], data["wants"], data["will"], data["was"], 512 - ) 513 - file_path.write_text(file_content) 514 - ctx.entity_index.register(name, file_path) 515 - ctx.entity_index.cache_put(file_path, data) 516 - ctx.vector_index.upsert( 517 - f"world:{entity_type}/{name}.md:0", 518 - file_content, 519 - {"source": "world", "content_type": entity_type, 520 - "path": str(file_path), "title": name}, 521 - ) 522 - 523 - 524 - def _auto_mark_present( 525 - present: list[str], event: str, ctx: ToolContext, 526 - ) -> list[str]: 527 - """Auto-mark present entities with the current event. 528 - 529 - Extracts entity names from [[wikilink]] format in the present list 530 - and appends the event to each entity's Was section. 531 - """ 532 - marked: list[str] = [] 533 - for ref in present: 534 - link_match = re.search(r"\[\[([^\]]+)\]\]", ref) 535 - if not link_match: 536 - continue 537 - name = link_match.group(1) 538 - 539 - file_path = ctx.entity_index.resolve(name) 540 - if file_path is None: 541 - for etype in ("npcs", "locations", "items", "factions"): 542 - candidate = ctx.base_path / "worlds" / ctx.world_id / etype / f"{name}.md" 543 - if candidate.exists(): 544 - file_path = candidate 545 - break 546 - 547 - if file_path and file_path.exists(): 548 - entity_type = file_path.parent.name 549 - mark(entity_type=entity_type, name=name, event=event, ctx=ctx) 550 - marked.append(name) 551 - 552 - return marked 553 - 554 - 555 - def mark( 556 - entity_type: str, 557 - name: str, 558 - event: str, 559 - ctx: ToolContext, 560 - resolves: list[str] | None = None, 561 - ) -> str: 562 - """Record an event in an entity's history (## Was section). 563 - 564 - Use when something significant happens to or involving an entity. This builds 565 - their history and helps maintain continuity across sessions. 566 - 567 - If the event resolves a Will trigger (e.g., "Vera introduced the player to 568 - Harrik" resolves "If trusted → intro to Harrik"), provide the trigger text 569 - in `resolves` to remove it from the Will section. 570 - 571 - Args: 572 - entity_type: Type of entity: npcs, locations, items, factions, threads 573 - name: Entity name (exact filename match) 574 - event: What happened - brief description for the Was section 575 - resolves: Optional list of Will items to remove if this event fired triggers 576 - 577 - Returns: 578 - Confirmation message 579 - """ 580 - file_path = ctx.entity_index.resolve(name) 581 - if file_path is None: 582 - file_path = ctx.base_path / "worlds" / ctx.world_id / entity_type / f"{name}.md" 583 - 584 - if not file_path.exists(): 585 - return f"Error: Entity '{name}' not found in {entity_type}" 586 - 587 - timestamp = ctx.campaign_log.get_current_time().to_anchor() 588 - 589 - lock = _get_file_lock(file_path) 590 - with lock: 591 - existing = _load_entity(file_path, ctx.entity_index) 592 - 593 - was = existing.get("was", []) 594 - was.append(f"{timestamp} | {event}") 595 - 596 - will = existing.get("will", []) 597 - resolved = [] 598 - for trigger in resolves or []: 599 - if trigger in will: 600 - will.remove(trigger) 601 - resolved.append(trigger) 602 - 603 - data = { 604 - "description": existing.get("description", ""), 605 - "location": existing.get("location", ""), 606 - "knows": existing.get("knows", []), 607 - "wants": existing.get("wants", []), 608 - "will": will, 609 - "was": was, 610 - } 611 - _write_entity(file_path, name, entity_type, data, ctx) 612 - 613 - result = f"Marked: {event}" 614 - if resolved: 615 - if len(resolved) == 1: 616 - result += f" (resolved: {resolved[0]})" 617 - else: 618 - result += f" (resolved {len(resolved)} triggers)" 619 - return result 620 - 621 - 622 - def note_discovery( 623 - entity: str, 624 - content: str, 625 - ctx: ToolContext, 626 - content_type: str = "lore", 627 - tags: list[str] | None = None, 628 - ) -> str: 629 - """Record what the player has learned about something. 630 - 631 - Use when the player discovers or learns information about an NPC, 632 - location, faction, or other world element. This captures their 633 - perspective, which may be incomplete or even wrong. 634 - 635 - The player's knowledge is separate from DM truth (use `establish` 636 - for the full facts). This helps track what the player knows vs. 637 - what they haven't discovered yet. 638 - 639 - Args: 640 - entity: Name of what they learned about (e.g., "Vera Blackwater") 641 - content: What the player learned or observed 642 - content_type: Type of content - npcs, locations, factions, lore (default: lore) 643 - tags: Optional tags for categorization 644 - 645 - Returns: 646 - Confirmation message 647 - """ 648 - slug = name_to_slug(entity) 649 - 650 - knowledge_dir = ( 651 - ctx.base_path / "players" / ctx.player_id / "worlds" 652 - / ctx.world_id / content_type 653 - ) 654 - knowledge_dir.mkdir(parents=True, exist_ok=True) 655 - file_path = knowledge_dir / f"{slug}.md" 656 - 657 - frontmatter: dict = { 658 - "type": content_type.rstrip("s"), 659 - "name": entity, 660 - } 661 - if tags: 662 - frontmatter["tags"] = tags 663 - 664 - file_content = "---\n" 665 - file_content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 666 - file_content += "---\n\n" 667 - file_content += content.strip() 668 - file_content += "\n" 669 - 670 - file_path.write_text(file_content) 671 - 672 - ctx.vector_index.upsert( 673 - f"player:{content_type}/{slug}.md:0", 674 - file_content, 675 - {"source": "player", "content_type": content_type, 676 - "path": str(file_path), "title": entity}, 677 - ) 678 - 679 - return f"Noted: player learned about '{entity}'" 680 - 681 - 682 - def tune(tuning: str, ctx: ToolContext) -> str: 683 - """Update your storytelling style based on player feedback. 684 - 685 - Write the complete updated style as markdown prose. This replaces the 686 - entire current style. Incorporate existing preferences where they still 687 - apply — don't discard preferences the player hasn't contradicted. 688 - """ 689 - path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 690 - path.write_text(f"# Style\n\n{tuning}\n") 691 - return "Style updated." 692 - 693 - 694 - def end_session(situation: str, ctx: ToolContext, threads: list[str] | None = None) -> str: 695 - """End the current session, saving the game state for next time. 696 - 697 - Call this when the player indicates they want to stop playing. This saves 698 - the current situation so the next session can resume smoothly. 699 - 700 - Before calling, give a brief farewell and summary of what happened this session. 701 - 702 - Args: 703 - situation: Summary of the current state of affairs for the next session. 704 - Write as if briefing a DM who will pick up where you left off. 705 - threads: Open plot threads or objectives to carry forward 706 - 707 - Returns: 708 - Confirmation that session was saved 709 - """ 710 - updates: dict = {"situation": situation} 711 - if threads is not None: 712 - updates["threads"] = threads 713 - 714 - session_update(ctx.player_id, updates, ctx.base_path) 715 - return "SESSION_ENDED" 716 - 717 - 718 - # Tool definitions for the Anthropic API 719 - TOOL_DEFINITIONS = [ 720 - { 721 - "name": "roll", 722 - "description": roll.__doc__, 723 - "input_schema": { 724 - "type": "object", 725 - "properties": { 726 - "notation": { 727 - "type": "string", 728 - "description": "Dice notation (e.g., '1d20+5', '2d6', '4d6kh3')", 729 - }, 730 - "reason": { 731 - "type": "string", 732 - "description": "What the roll is for (e.g., 'Athletics', 'Longsword attack', 'Dex save')", 733 - }, 734 - }, 735 - "required": ["notation", "reason"], 736 - }, 737 - }, 738 - { 739 - "name": "recall", 740 - "description": recall.__doc__, 741 - "input_schema": { 742 - "type": "object", 743 - "properties": { 744 - "query": { 745 - "type": "string", 746 - "description": "What to look up (e.g., 'fireball', 'captain vex')", 747 - }, 748 - "scope": { 749 - "type": "string", 750 - "description": "Where to search: 'rules' (SRD), 'world' (established content), or 'all' (both)", 751 - "enum": ["rules", "world", "all"], 752 - }, 753 - "content_type": { 754 - "type": "string", 755 - "description": "Type to limit search (e.g., 'spells', 'npcs', 'monsters')", 756 - }, 757 - }, 758 - "required": ["query"], 759 - }, 760 - }, 761 - { 762 - "name": "update_character", 763 - "description": update_character.__doc__, 764 - "input_schema": { 765 - "type": "object", 766 - "properties": { 767 - "updates": { 768 - "type": "object", 769 - "description": "Fields to update. Use dot notation for nested (e.g., 'hp.current': 5). Use 'section.Name' for markdown sections.", 770 - }, 771 - }, 772 - "required": ["updates"], 773 - }, 774 - }, 775 - { 776 - "name": "create_character", 777 - "description": create_character.__doc__, 778 - "input_schema": { 779 - "type": "object", 780 - "properties": { 781 - "name": {"type": "string", "description": "Character name"}, 782 - "race": {"type": "string", "description": "Race (e.g., 'Human', 'High Elf')"}, 783 - "char_class": {"type": "string", "description": "Class (e.g., 'Fighter', 'Wizard')"}, 784 - "level": {"type": "integer", "description": "Starting level (usually 1)"}, 785 - "abilities": { 786 - "type": "object", 787 - "description": "All six ability scores: strength, dexterity, constitution, intelligence, wisdom, charisma", 788 - }, 789 - "hp_max": {"type": "integer", "description": "Maximum hit points"}, 790 - "ac": {"type": "integer", "description": "Armor class"}, 791 - "background": {"type": "string", "description": "Background (e.g., 'Soldier', 'Sage')"}, 792 - "speed": {"type": "integer", "description": "Movement speed in feet"}, 793 - "purse": { 794 - "type": "object", 795 - "description": "Starting coins: {cp, sp, ep, gp, pp}. Omit denominations for 0.", 796 - "properties": { 797 - "cp": {"type": "integer", "description": "Copper pieces"}, 798 - "sp": {"type": "integer", "description": "Silver pieces"}, 799 - "ep": {"type": "integer", "description": "Electrum pieces"}, 800 - "gp": {"type": "integer", "description": "Gold pieces"}, 801 - "pp": {"type": "integer", "description": "Platinum pieces"}, 802 - }, 803 - }, 804 - "equipment": { 805 - "type": "array", 806 - "items": {"type": "string"}, 807 - "description": "List of equipment items", 808 - }, 809 - "features": { 810 - "type": "array", 811 - "items": {"type": "string"}, 812 - "description": "List of racial and class features", 813 - }, 814 - "proficiencies": {"type": "string", "description": "Proficiency description"}, 815 - "backstory": {"type": "string", "description": "Character backstory and personality"}, 816 - }, 817 - "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], 818 - }, 819 - }, 820 - { 821 - "name": "set_scene", 822 - "description": set_scene.__doc__, 823 - "input_schema": { 824 - "type": "object", 825 - "properties": { 826 - "event": { 827 - "type": "string", 828 - "description": "What happened this turn (logged to campaign journal)", 829 - }, 830 - "duration": { 831 - "type": "string", 832 - "description": "How long it took (e.g., '10 min', '1 hour', '3 rounds')", 833 - }, 834 - "situation": { 835 - "type": "string", 836 - "description": "Updated situation summary in present tense", 837 - }, 838 - "location": { 839 - "type": "string", 840 - "description": "New location when the player moves", 841 - }, 842 - "present": { 843 - "type": "array", 844 - "items": {"type": "string"}, 845 - "description": "Entities present, using [[Name]] format", 846 - }, 847 - "threads": { 848 - "type": "array", 849 - "items": {"type": "string"}, 850 - "description": "Open plot threads or objectives", 851 - }, 852 - "tags": { 853 - "type": "array", 854 - "items": {"type": "string"}, 855 - "description": "Optional: 'combat', 'rest:short', 'rest:long', 'travel'", 856 - }, 857 - }, 858 - "required": ["event", "duration"], 859 - }, 860 - }, 861 - { 862 - "name": "establish", 863 - "description": establish.__doc__, 864 - "input_schema": { 865 - "type": "object", 866 - "properties": { 867 - "entity_type": { 868 - "type": "string", 869 - "description": "Type of entity", 870 - "enum": ["npcs", "locations", "items", "factions", "threads", "lore"], 871 - }, 872 - "name": { 873 - "type": "string", 874 - "description": "Display name (exact filename, e.g., 'Vera Blackwater')", 875 - }, 876 - "description": { 877 - "type": "string", 878 - "description": "Prose description with [[wikilinks]] for relationships", 879 - }, 880 - "location": { 881 - "type": "string", 882 - "description": "Current location (e.g., '[[The Rusty Anchor]]' or 'In the basement of [[The Rusty Anchor]]')", 883 - }, 884 - "knows": { 885 - "type": "array", 886 - "items": {"type": "string"}, 887 - "description": "Secrets, hidden truths - what isn't obvious", 888 - }, 889 - "wants": { 890 - "type": "array", 891 - "items": {"type": "string"}, 892 - "description": "Nature, tendencies, inclinations - even non-sentient things", 893 - }, 894 - "will": { 895 - "type": "array", 896 - "items": {"type": "string"}, 897 - "description": "Conditional behaviors in 'If X → Y' format", 898 - }, 899 - }, 900 - "required": ["entity_type", "name"], 901 - }, 902 - }, 903 - { 904 - "name": "mark", 905 - "description": mark.__doc__, 906 - "input_schema": { 907 - "type": "object", 908 - "properties": { 909 - "entity_type": { 910 - "type": "string", 911 - "description": "Type of entity", 912 - "enum": ["npcs", "locations", "items", "factions", "threads"], 913 - }, 914 - "name": { 915 - "type": "string", 916 - "description": "Entity name (exact filename match)", 917 - }, 918 - "event": { 919 - "type": "string", 920 - "description": "What happened - brief description", 921 - }, 922 - "resolves": { 923 - "type": "array", 924 - "items": {"type": "string"}, 925 - "description": "Optional: Will items to remove if this event fired triggers", 926 - }, 927 - }, 928 - "required": ["entity_type", "name", "event"], 929 - }, 930 - }, 931 - { 932 - "name": "note_discovery", 933 - "description": note_discovery.__doc__, 934 - "input_schema": { 935 - "type": "object", 936 - "properties": { 937 - "entity": { 938 - "type": "string", 939 - "description": "Name of what they learned about (e.g., 'Vera Blackwater')", 940 - }, 941 - "content": { 942 - "type": "string", 943 - "description": "What the player learned or observed", 944 - }, 945 - "content_type": { 946 - "type": "string", 947 - "description": "Type of content", 948 - "enum": ["npcs", "locations", "factions", "lore"], 949 - }, 950 - "tags": { 951 - "type": "array", 952 - "items": {"type": "string"}, 953 - "description": "Tags for categorization", 954 - }, 955 - }, 956 - "required": ["entity", "content"], 957 - }, 958 - }, 959 - { 960 - "name": "tune", 961 - "description": tune.__doc__, 962 - "input_schema": { 963 - "type": "object", 964 - "properties": { 965 - "tuning": { 966 - "type": "string", 967 - "description": "Complete updated style as markdown prose. Replaces the current style entirely.", 968 - }, 969 - }, 970 - "required": ["tuning"], 971 - }, 972 - }, 973 - { 974 - "name": "end_session", 975 - "description": end_session.__doc__, 976 - "input_schema": { 977 - "type": "object", 978 - "properties": { 979 - "situation": { 980 - "type": "string", 981 - "description": "Summary of current state for the next session", 982 - }, 983 - "threads": { 984 - "type": "array", 985 - "items": {"type": "string"}, 986 - "description": "Open plot threads or objectives to carry forward", 987 - }, 988 - }, 989 - "required": ["situation"], 990 - }, 991 - }, 992 - { 993 - "name": "run_code", 994 - "description": ( 995 - "Run Python code in a secure sandbox. Use for calculations, random " 996 - "generation, data formatting, or any computation the narrative needs.\n\n" 997 - "All your DM tools are callable as functions (see signatures below). " 998 - "Most return a str with the result. The exception is roll(), which " 999 - "returns a dict with keys: notation, rolls, kept, modifier, total — " 1000 - "use roll('2d6+3')['total'] for math, or index into rolls/kept for " 1001 - "individual dice. Use roll() for all randomness (no random module).\n\n" 1002 - "Language: variables, functions, loops, conditionals, comprehensions, " 1003 - "f-strings. Stdlib: re, json, datetime, math. No classes, no other " 1004 - "imports, no file/network access. Errors return as text.\n\n" 1005 - "Available functions:\n{tool_signatures}" 1006 - ), 1007 - "input_schema": { 1008 - "type": "object", 1009 - "properties": { 1010 - "description": { 1011 - "type": "string", 1012 - "description": "What this code does in game terms (e.g., 'Designing cave system', 'Splitting treasure', 'Generating NPC schedule')", 1013 - }, 1014 - "code": { 1015 - "type": "string", 1016 - "description": "Python code to execute", 1017 - }, 1018 - }, 1019 - "required": ["description", "code"], 1020 - }, 1021 - }, 1022 - ] 1023 - 1024 - 1025 - def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 1026 - """Execute a tool by name with the given input.""" 1027 - if tool_name in ALL_INITIATIVE_TOOL_NAMES: 1028 - result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) 1029 - if result is not None: 1030 - if tool_name in ("damage", "heal"): 1031 - result = _sync_player_hp(tool_input["target"], ctx, result) 1032 - return result 1033 - 1034 - if tool_name == "roll": 1035 - result = roll(tool_input["notation"]) 1036 - rolls_str = ", ".join(str(r) for r in result["rolls"]) 1037 - if result["kept"] != result["rolls"]: 1038 - kept_str = ", ".join(str(r) for r in result["kept"]) 1039 - return f"Rolled {result['notation']}: [{rolls_str}] → kept [{kept_str}] + {result['modifier']} = {result['total']}" 1040 - elif result["modifier"]: 1041 - return f"Rolled {result['notation']}: [{rolls_str}] + {result['modifier']} = {result['total']}" 1042 - else: 1043 - return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" 1044 - 1045 - elif tool_name == "recall": 1046 - return recall( 1047 - tool_input["query"], ctx, 1048 - scope=tool_input.get("scope", "all"), 1049 - content_type=tool_input.get("content_type"), 1050 - ) 1051 - 1052 - elif tool_name == "update_character": 1053 - return update_character(tool_input["updates"], ctx) 1054 - 1055 - elif tool_name == "create_character": 1056 - return create_character( 1057 - name=tool_input["name"], 1058 - race=tool_input["race"], 1059 - char_class=tool_input["char_class"], 1060 - level=tool_input["level"], 1061 - abilities=tool_input["abilities"], 1062 - hp_max=tool_input["hp_max"], 1063 - ac=tool_input["ac"], 1064 - ctx=ctx, 1065 - background=tool_input.get("background"), 1066 - speed=tool_input.get("speed", 30), 1067 - purse=tool_input.get("purse"), 1068 - equipment=tool_input.get("equipment"), 1069 - features=tool_input.get("features"), 1070 - proficiencies=tool_input.get("proficiencies"), 1071 - backstory=tool_input.get("backstory"), 1072 - ) 1073 - 1074 - elif tool_name == "set_scene": 1075 - return set_scene( 1076 - ctx=ctx, 1077 - event=tool_input.get("event"), 1078 - duration=tool_input.get("duration"), 1079 - situation=tool_input.get("situation"), 1080 - location=tool_input.get("location"), 1081 - present=tool_input.get("present"), 1082 - threads=tool_input.get("threads"), 1083 - tags=tool_input.get("tags"), 1084 - ) 1085 - 1086 - elif tool_name == "establish": 1087 - return establish( 1088 - entity_type=tool_input["entity_type"], 1089 - name=tool_input["name"], 1090 - ctx=ctx, 1091 - description=tool_input.get("description"), 1092 - location=tool_input.get("location"), 1093 - knows=tool_input.get("knows"), 1094 - wants=tool_input.get("wants"), 1095 - will=tool_input.get("will"), 1096 - ) 1097 - 1098 - elif tool_name == "mark": 1099 - return mark( 1100 - entity_type=tool_input["entity_type"], 1101 - name=tool_input["name"], 1102 - event=tool_input["event"], 1103 - ctx=ctx, 1104 - resolves=tool_input.get("resolves"), 1105 - ) 1106 - 1107 - elif tool_name == "note_discovery": 1108 - return note_discovery( 1109 - entity=tool_input["entity"], 1110 - content=tool_input["content"], 1111 - ctx=ctx, 1112 - content_type=tool_input.get("content_type", "lore"), 1113 - tags=tool_input.get("tags"), 1114 - ) 1115 - 1116 - elif tool_name == "tune": 1117 - return tune(tuning=tool_input["tuning"], ctx=ctx) 1118 - 1119 - elif tool_name == "end_session": 1120 - return end_session( 1121 - situation=tool_input["situation"], 1122 - ctx=ctx, 1123 - threads=tool_input.get("threads"), 1124 - ) 1125 - 1126 - elif tool_name == "run_code": 1127 - from storied.sandbox import execute as sandbox_execute 1128 - return sandbox_execute(tool_input["code"], ctx=ctx) 1129 - 1130 - else: 1131 - return f"Unknown tool: {tool_name}" 1132 - 1133 - 1134 - PLANNER_TOOLS = {"recall", "establish", "mark"} 1135 - 1136 - PLANNER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in PLANNER_TOOLS] 1137 - 1138 - 1139 - def planner_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 1140 - """Execute a planner-allowed tool. Rejects anything outside the allowed set.""" 1141 - if tool_name not in PLANNER_TOOLS: 1142 - return f"Tool not available to planner: {tool_name}" 1143 - return execute_tool(tool_name, tool_input, ctx) 1144 - 1145 - 1146 - SEEDER_TOOLS = {"establish", "set_scene"} 1147 - 1148 - SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] 1149 - 1150 - 1151 - def seeder_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 1152 - """Execute a seeder-allowed tool. Rejects anything outside the allowed set.""" 1153 - if tool_name not in SEEDER_TOOLS: 1154 - return f"Tool not available to seeder: {tool_name}" 1155 - return execute_tool(tool_name, tool_input, ctx)
+197
src/storied/tools/__init__.py
··· 1 + """DM tools for Claude to use during gameplay. 2 + 3 + Submodules group related tools by domain. This __init__ aggregates 4 + definitions and re-exports public names so existing imports continue 5 + to work unchanged. 6 + """ 7 + 8 + from storied.initiative import ( 9 + ALL_INITIATIVE_TOOL_NAMES, 10 + execute_initiative_tool, 11 + ) 12 + from storied.tools._context import ( 13 + EntityIndex, 14 + ToolContext, 15 + _get_file_lock, 16 + _sync_player_hp, 17 + ) 18 + from storied.tools.character import ( 19 + create_character, 20 + update_character, 21 + ) 22 + from storied.tools.entities import ( 23 + _auto_mark_present, 24 + _load_entity, 25 + establish, 26 + mark, 27 + note_discovery, 28 + ) 29 + from storied.tools.mechanics import ( 30 + recall, 31 + roll, 32 + ) 33 + from storied.tools.scene import ( 34 + end_session, 35 + notify_dm, 36 + set_scene, 37 + tune, 38 + ) 39 + 40 + # Merge per-module DEFINITIONS lists into the canonical flat list 41 + from storied.tools.character import DEFINITIONS as _CHAR_DEFS 42 + from storied.tools.entities import DEFINITIONS as _ENTITY_DEFS 43 + from storied.tools.mechanics import DEFINITIONS as _MECH_DEFS 44 + from storied.tools.scene import DEFINITIONS as _SCENE_DEFS 45 + 46 + TOOL_DEFINITIONS: list[dict] = ( 47 + _MECH_DEFS + _CHAR_DEFS + _SCENE_DEFS + _ENTITY_DEFS 48 + ) 49 + 50 + 51 + def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 52 + """Execute a tool by name with the given input.""" 53 + if tool_name in ALL_INITIATIVE_TOOL_NAMES: 54 + result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) 55 + if result is not None: 56 + if tool_name in ("damage", "heal"): 57 + result = _sync_player_hp(tool_input["target"], ctx, result) 58 + return result 59 + 60 + if tool_name == "roll": 61 + result = roll(tool_input["notation"]) 62 + rolls_str = ", ".join(str(r) for r in result["rolls"]) 63 + if result["kept"] != result["rolls"]: 64 + kept_str = ", ".join(str(r) for r in result["kept"]) 65 + return f"Rolled {result['notation']}: [{rolls_str}] → kept [{kept_str}] + {result['modifier']} = {result['total']}" 66 + elif result["modifier"]: 67 + return f"Rolled {result['notation']}: [{rolls_str}] + {result['modifier']} = {result['total']}" 68 + else: 69 + return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" 70 + 71 + elif tool_name == "recall": 72 + return recall( 73 + tool_input["query"], ctx, 74 + scope=tool_input.get("scope", "all"), 75 + content_type=tool_input.get("content_type"), 76 + ) 77 + 78 + elif tool_name == "update_character": 79 + return update_character(tool_input["updates"], ctx) 80 + 81 + elif tool_name == "create_character": 82 + return create_character( 83 + name=tool_input["name"], 84 + race=tool_input["race"], 85 + char_class=tool_input["char_class"], 86 + level=tool_input["level"], 87 + abilities=tool_input["abilities"], 88 + hp_max=tool_input["hp_max"], 89 + ac=tool_input["ac"], 90 + ctx=ctx, 91 + background=tool_input.get("background"), 92 + speed=tool_input.get("speed", 30), 93 + purse=tool_input.get("purse"), 94 + equipment=tool_input.get("equipment"), 95 + features=tool_input.get("features"), 96 + proficiencies=tool_input.get("proficiencies"), 97 + backstory=tool_input.get("backstory"), 98 + ) 99 + 100 + elif tool_name == "set_scene": 101 + return set_scene( 102 + ctx=ctx, 103 + event=tool_input.get("event"), 104 + duration=tool_input.get("duration"), 105 + situation=tool_input.get("situation"), 106 + location=tool_input.get("location"), 107 + present=tool_input.get("present"), 108 + threads=tool_input.get("threads"), 109 + tags=tool_input.get("tags"), 110 + ) 111 + 112 + elif tool_name == "establish": 113 + return establish( 114 + entity_type=tool_input["entity_type"], 115 + name=tool_input["name"], 116 + ctx=ctx, 117 + description=tool_input.get("description"), 118 + location=tool_input.get("location"), 119 + knows=tool_input.get("knows"), 120 + wants=tool_input.get("wants"), 121 + will=tool_input.get("will"), 122 + ) 123 + 124 + elif tool_name == "mark": 125 + return mark( 126 + entity_type=tool_input["entity_type"], 127 + name=tool_input["name"], 128 + event=tool_input["event"], 129 + ctx=ctx, 130 + resolves=tool_input.get("resolves"), 131 + ) 132 + 133 + elif tool_name == "note_discovery": 134 + return note_discovery( 135 + entity=tool_input["entity"], 136 + content=tool_input["content"], 137 + ctx=ctx, 138 + content_type=tool_input.get("content_type", "lore"), 139 + tags=tool_input.get("tags"), 140 + ) 141 + 142 + elif tool_name == "tune": 143 + return tune(tuning=tool_input["tuning"], ctx=ctx) 144 + 145 + elif tool_name == "end_session": 146 + return end_session( 147 + situation=tool_input["situation"], 148 + ctx=ctx, 149 + threads=tool_input.get("threads"), 150 + ) 151 + 152 + elif tool_name == "run_code": 153 + from storied.sandbox import execute as sandbox_execute 154 + return sandbox_execute(tool_input["code"], ctx=ctx) 155 + 156 + elif tool_name == "notify_dm": 157 + return notify_dm(message=tool_input["message"], ctx=ctx) 158 + 159 + else: 160 + return f"Unknown tool: {tool_name}" 161 + 162 + 163 + # --- Tool sets for specialized agents --- 164 + 165 + PLANNER_TOOLS = {"recall", "establish", "mark", "notify_dm"} 166 + PLANNER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in PLANNER_TOOLS] 167 + 168 + 169 + def planner_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 170 + """Execute a planner-allowed tool.""" 171 + if tool_name not in PLANNER_TOOLS: 172 + return f"Tool not available to planner: {tool_name}" 173 + return execute_tool(tool_name, tool_input, ctx) 174 + 175 + 176 + SEEDER_TOOLS = {"establish", "set_scene"} 177 + SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] 178 + 179 + 180 + def seeder_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 181 + """Execute a seeder-allowed tool.""" 182 + if tool_name not in SEEDER_TOOLS: 183 + return f"Tool not available to seeder: {tool_name}" 184 + return execute_tool(tool_name, tool_input, ctx) 185 + 186 + 187 + ADVANCEMENT_TOOLS = {"recall", "update_character", "notify_dm"} 188 + ADVANCEMENT_TOOL_DEFINITIONS = [ 189 + t for t in TOOL_DEFINITIONS if t["name"] in ADVANCEMENT_TOOLS 190 + ] 191 + 192 + 193 + def advancement_execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 194 + """Execute an advancement-evaluator-allowed tool.""" 195 + if tool_name not in ADVANCEMENT_TOOLS: 196 + return f"Tool not available to advancement evaluator: {tool_name}" 197 + return execute_tool(tool_name, tool_input, ctx)
+82
src/storied/tools/_context.py
··· 1 + """Shared infrastructure for all tool calls.""" 2 + 3 + import threading 4 + from dataclasses import dataclass, field 5 + from pathlib import Path 6 + 7 + from storied.character import update_character as char_update 8 + from storied.initiative import InitiativeTracker 9 + from storied.log import CampaignLog 10 + from storied.search import VectorIndex 11 + 12 + 13 + # Per-file locks for thread-safe entity writes (establish, mark) 14 + _file_locks: dict[Path, threading.Lock] = {} 15 + _file_locks_lock = threading.Lock() 16 + 17 + 18 + def _get_file_lock(path: Path) -> threading.Lock: 19 + """Get or create a lock for the given file path.""" 20 + with _file_locks_lock: 21 + if path not in _file_locks: 22 + _file_locks[path] = threading.Lock() 23 + return _file_locks[path] 24 + 25 + 26 + class EntityIndex: 27 + """Name->path index with write-through entity cache. 28 + 29 + Built once at startup by globbing the world directory. Lookups are 30 + dict lookups instead of 6 sequential Path.exists() calls. The cache 31 + stores parsed entity dicts so repeated loads within a turn skip disk I/O. 32 + establish() and mark() update both the index and cache on write. 33 + """ 34 + 35 + def __init__(self, world_dir: Path | None = None): 36 + self._paths: dict[str, Path] = {} 37 + self._cache: dict[Path, dict] = {} 38 + if world_dir and world_dir.exists(): 39 + for md in world_dir.rglob("*.md"): 40 + self._paths[md.stem] = md 41 + 42 + def resolve(self, name: str) -> Path | None: 43 + """Look up an entity's file path by name.""" 44 + return self._paths.get(name) 45 + 46 + def register(self, name: str, path: Path) -> None: 47 + """Register or update an entity's path in the index.""" 48 + self._paths[name] = path 49 + 50 + def cache_get(self, path: Path) -> dict | None: 51 + """Get a cached parsed entity, or None if not cached.""" 52 + return self._cache.get(path) 53 + 54 + def cache_put(self, path: Path, data: dict) -> None: 55 + """Store a parsed entity in the cache.""" 56 + self._cache[path] = data 57 + 58 + 59 + @dataclass 60 + class ToolContext: 61 + """Shared infrastructure for all tool calls. 62 + 63 + Created once per MCP server and passed to every tool invocation. 64 + All fields are required — no defensive None checks in tool code. 65 + """ 66 + 67 + world_id: str 68 + player_id: str 69 + base_path: Path 70 + campaign_log: CampaignLog 71 + entity_index: EntityIndex 72 + vector_index: VectorIndex 73 + initiative: InitiativeTracker = field(default_factory=InitiativeTracker) 74 + 75 + 76 + def _sync_player_hp(target: str, ctx: ToolContext, result: str) -> str: 77 + """Auto-sync player character sheet when damage/heal targets a player.""" 78 + combatant = ctx.initiative._find(target) 79 + if combatant and combatant.is_player: 80 + char_update(ctx.player_id, {"hp.current": combatant.hp}, ctx.base_path) 81 + result += f" (character sheet synced to {combatant.hp} HP)" 82 + return result
+158
src/storied/tools/character.py
··· 1 + """Character management tools.""" 2 + 3 + from storied.character import create_character as char_create 4 + from storied.character import update_character as char_update 5 + from storied.tools._context import ToolContext 6 + 7 + 8 + def update_character(updates: dict, ctx: ToolContext) -> str: 9 + """Update the player's character sheet to persist changes. 10 + 11 + Call this after HP changes, equipment gained/lost, coins spent, level ups, etc. 12 + This ensures progress is saved and survives between sessions. 13 + 14 + Args: 15 + updates: Fields to update. Use dot notation for nested fields. 16 + Examples: 17 + - {"hp.current": 5} - set current HP to 5 18 + - {"purse.gp": 25} - set gold to 25 19 + - {"purse.sp": 10, "purse.cp": 50} - set silver and copper 20 + - {"level": 2, "hp.max": 20} - level up 21 + For markdown sections, use "section.Name": 22 + - {"section.Equipment": "- Longsword\\n- New shield"} 23 + player_id: Player identifier (usually "default") 24 + base_path: Base path for players directory 25 + 26 + Returns: 27 + Confirmation of what was updated 28 + """ 29 + return char_update(ctx.player_id, updates, ctx.base_path) 30 + 31 + 32 + def create_character( 33 + name: str, 34 + race: str, 35 + char_class: str, 36 + level: int, 37 + abilities: dict[str, int], 38 + hp_max: int, 39 + ac: int, 40 + ctx: ToolContext, 41 + background: str | None = None, 42 + speed: int = 30, 43 + purse: dict[str, int] | None = None, 44 + equipment: list[str] | None = None, 45 + features: list[str] | None = None, 46 + proficiencies: str | None = None, 47 + backstory: str | None = None, 48 + ) -> str: 49 + """Create a new player character and save to disk. 50 + 51 + Call this when character creation is complete. Include all the mechanical 52 + details needed to play: abilities, HP, AC, equipment, and features. 53 + 54 + Args: 55 + name: Character name 56 + race: Race (e.g., "Human", "High Elf", "Hill Dwarf") 57 + char_class: Class (e.g., "Fighter", "Wizard", "Rogue") 58 + level: Starting level (usually 1) 59 + abilities: All six ability scores as a dict: 60 + {"strength": 15, "dexterity": 14, "constitution": 13, 61 + "intelligence": 12, "wisdom": 10, "charisma": 8} 62 + hp_max: Maximum hit points 63 + ac: Armor class 64 + background: Background (e.g., "Soldier", "Sage", "Criminal") 65 + speed: Movement speed in feet (default 30) 66 + purse: Starting coins by denomination: 67 + {"cp": 0, "sp": 0, "ep": 0, "gp": 15, "pp": 0} 68 + Denominations: cp (copper), sp (silver), ep (electrum), 69 + gp (gold), pp (platinum). Omit denominations for 0. 70 + equipment: List of equipment items 71 + features: List of racial and class features 72 + proficiencies: Description of proficiencies (armor, weapons, tools, saves, skills) 73 + backstory: Character backstory and personality 74 + 75 + Returns: 76 + Confirmation message 77 + """ 78 + return char_create( 79 + player_id=ctx.player_id, 80 + name=name, 81 + race=race, 82 + char_class=char_class, 83 + level=level, 84 + abilities=abilities, 85 + hp_max=hp_max, 86 + ac=ac, 87 + background=background, 88 + speed=speed, 89 + purse=purse, 90 + equipment=equipment, 91 + features=features, 92 + proficiencies=proficiencies, 93 + backstory=backstory, 94 + base_path=ctx.base_path, 95 + ) 96 + 97 + 98 + DEFINITIONS: list[dict] = [ 99 + { 100 + "name": "update_character", 101 + "description": update_character.__doc__, 102 + "input_schema": { 103 + "type": "object", 104 + "properties": { 105 + "updates": { 106 + "type": "object", 107 + "description": "Fields to update. Use dot notation for nested (e.g., 'hp.current': 5). Use 'section.Name' for markdown sections.", 108 + }, 109 + }, 110 + "required": ["updates"], 111 + }, 112 + }, 113 + { 114 + "name": "create_character", 115 + "description": create_character.__doc__, 116 + "input_schema": { 117 + "type": "object", 118 + "properties": { 119 + "name": {"type": "string", "description": "Character name"}, 120 + "race": {"type": "string", "description": "Race (e.g., 'Human', 'High Elf')"}, 121 + "char_class": {"type": "string", "description": "Class (e.g., 'Fighter', 'Wizard')"}, 122 + "level": {"type": "integer", "description": "Starting level (usually 1)"}, 123 + "abilities": { 124 + "type": "object", 125 + "description": "All six ability scores: strength, dexterity, constitution, intelligence, wisdom, charisma", 126 + }, 127 + "hp_max": {"type": "integer", "description": "Maximum hit points"}, 128 + "ac": {"type": "integer", "description": "Armor class"}, 129 + "background": {"type": "string", "description": "Background (e.g., 'Soldier', 'Sage')"}, 130 + "speed": {"type": "integer", "description": "Movement speed in feet"}, 131 + "purse": { 132 + "type": "object", 133 + "description": "Starting coins: {cp, sp, ep, gp, pp}. Omit denominations for 0.", 134 + "properties": { 135 + "cp": {"type": "integer", "description": "Copper pieces"}, 136 + "sp": {"type": "integer", "description": "Silver pieces"}, 137 + "ep": {"type": "integer", "description": "Electrum pieces"}, 138 + "gp": {"type": "integer", "description": "Gold pieces"}, 139 + "pp": {"type": "integer", "description": "Platinum pieces"}, 140 + }, 141 + }, 142 + "equipment": { 143 + "type": "array", 144 + "items": {"type": "string"}, 145 + "description": "List of equipment items", 146 + }, 147 + "features": { 148 + "type": "array", 149 + "items": {"type": "string"}, 150 + "description": "List of racial and class features", 151 + }, 152 + "proficiencies": {"type": "string", "description": "Proficiency description"}, 153 + "backstory": {"type": "string", "description": "Character backstory and personality"}, 154 + }, 155 + "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], 156 + }, 157 + }, 158 + ]
+466
src/storied/tools/entities.py
··· 1 + """World entity tools — establish, mark, note_discovery.""" 2 + 3 + import re 4 + from pathlib import Path 5 + 6 + import yaml 7 + 8 + from storied.session import name_to_slug 9 + from storied.tools._context import EntityIndex, ToolContext, _get_file_lock 10 + 11 + 12 + def establish( 13 + entity_type: str, 14 + name: str, 15 + ctx: ToolContext, 16 + description: str | None = None, 17 + location: str | None = None, 18 + knows: list[str] | None = None, 19 + wants: list[str] | None = None, 20 + will: list[str] | None = None, 21 + ) -> str: 22 + """Establish or update an entity in the world. 23 + 24 + Use to create NPCs, locations, items, factions, or threads with their inner 25 + state. Everything has Knows/Wants/Will: 26 + - **Knows** = secrets, hidden truths, what isn't obvious 27 + - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") 28 + - **Will** = conditional triggers, what happens if... 29 + 30 + This isn't literal consciousness - it's narrative tendency. A bridge can "want" 31 + to collapse. Cursed gold "wants" to be spent. Frame it this way and the world 32 + feels alive. 33 + 34 + Partial updates: omit fields to preserve existing content when updating. 35 + 36 + Args: 37 + entity_type: Type of entity: npcs, locations, items, factions, threads, lore 38 + name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 39 + This becomes the filename directly (no slugification). 40 + description: Prose description for the ## Is section. Include appearance, 41 + background, current state, relationships via [[wikilinks]]. 42 + location: Where this entity is right now. Can be a simple wikilink like 43 + "[[The Rusty Anchor]]" or a verbal description like "In the basement 44 + of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". 45 + knows: List of secrets and hidden truths. Things that aren't obvious. 46 + wants: List of desires, tendencies, inclinations. The entity's nature. 47 + will: List of conditional behaviors: "If X -> Y" format. 48 + 49 + Returns: 50 + Confirmation with the file path 51 + """ 52 + world_dir = ctx.base_path / "worlds" / ctx.world_id / entity_type 53 + world_dir.mkdir(parents=True, exist_ok=True) 54 + file_path = world_dir / f"{name}.md" 55 + 56 + lock = _get_file_lock(file_path) 57 + with lock: 58 + existing = _load_entity(file_path, ctx.entity_index) 59 + 60 + if description is None: 61 + description = existing.get("description", "") 62 + if location is None: 63 + location = existing.get("location", "") 64 + if knows is None: 65 + knows = existing.get("knows", []) 66 + if wants is None: 67 + wants = existing.get("wants", []) 68 + if will is None: 69 + will = existing.get("will", []) 70 + was = existing.get("was", []) 71 + 72 + data = { 73 + "description": description, "location": location, 74 + "knows": knows, "wants": wants, "will": will, "was": was, 75 + } 76 + _write_entity(file_path, name, entity_type, data, ctx) 77 + 78 + action = "Updated" if existing else "Established" 79 + return f"{action} {entity_type.rstrip('s')} '{name}'" 80 + 81 + 82 + def _load_entity(file_path: Path, entity_index: EntityIndex) -> dict: 83 + """Load an existing entity file and parse its structure.""" 84 + cached = entity_index.cache_get(file_path) 85 + if cached is not None: 86 + return cached 87 + 88 + if not file_path.exists(): 89 + return {} 90 + 91 + content = file_path.read_text() 92 + result = {} 93 + 94 + # Parse ## Is section 95 + is_match = re.search(r"## Is\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) 96 + if is_match: 97 + is_content = is_match.group(1).strip() 98 + 99 + loc_match = re.search(r"\*\*Location:\*\*\s*(.+)", is_content) 100 + if loc_match: 101 + result["location"] = loc_match.group(1).strip() 102 + 103 + desc_match = re.match(r"(.*?)(?=\n### |\Z)", is_content, re.DOTALL) 104 + if desc_match: 105 + desc = desc_match.group(1).strip() 106 + desc = re.sub(r"\*\*Location:\*\*\s*.+\n?", "", desc).strip() 107 + result["description"] = desc 108 + 109 + knows_match = re.search(r"### Knows\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 110 + if knows_match: 111 + result["knows"] = _parse_list_items(knows_match.group(1)) 112 + 113 + wants_match = re.search(r"### Wants\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 114 + if wants_match: 115 + result["wants"] = _parse_list_items(wants_match.group(1)) 116 + 117 + will_match = re.search(r"### Will\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 118 + if will_match: 119 + result["will"] = _parse_list_items(will_match.group(1)) 120 + 121 + # Parse ## Was section 122 + was_match = re.search(r"## Was\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) 123 + if was_match: 124 + result["was"] = _parse_list_items(was_match.group(1)) 125 + 126 + entity_index.cache_put(file_path, result) 127 + return result 128 + 129 + 130 + def _parse_list_items(text: str) -> list[str]: 131 + """Parse markdown list items into a list of strings.""" 132 + items = [] 133 + for line in text.strip().split("\n"): 134 + line = line.strip() 135 + if line.startswith("- "): 136 + items.append(line[2:]) 137 + return items 138 + 139 + 140 + def _format_entity( 141 + name: str, 142 + description: str, 143 + location: str, 144 + knows: list[str], 145 + wants: list[str], 146 + will: list[str], 147 + was: list[str], 148 + ) -> str: 149 + """Format an entity as markdown with Is/Was structure.""" 150 + lines = [f"# {name}", "", "## Is", ""] 151 + 152 + if location: 153 + lines.append(f"**Location:** {location}") 154 + lines.append("") 155 + 156 + if description: 157 + lines.append(description) 158 + lines.append("") 159 + 160 + if knows: 161 + lines.append("### Knows") 162 + lines.append("") 163 + for item in knows: 164 + lines.append(f"- {item}") 165 + lines.append("") 166 + 167 + if wants: 168 + lines.append("### Wants") 169 + lines.append("") 170 + for item in wants: 171 + lines.append(f"- {item}") 172 + lines.append("") 173 + 174 + if will: 175 + lines.append("### Will") 176 + lines.append("") 177 + for item in will: 178 + lines.append(f"- {item}") 179 + lines.append("") 180 + 181 + lines.append("## Was") 182 + lines.append("") 183 + for item in was: 184 + lines.append(f"- {item}") 185 + lines.append("") 186 + 187 + return "\n".join(lines) 188 + 189 + 190 + def _write_entity( 191 + file_path: Path, 192 + name: str, 193 + entity_type: str, 194 + data: dict, 195 + ctx: ToolContext, 196 + ) -> None: 197 + """Write an entity to disk and update all indexes.""" 198 + file_content = _format_entity( 199 + name, data["description"], data["location"], 200 + data["knows"], data["wants"], data["will"], data["was"], 201 + ) 202 + file_path.write_text(file_content) 203 + ctx.entity_index.register(name, file_path) 204 + ctx.entity_index.cache_put(file_path, data) 205 + ctx.vector_index.upsert( 206 + f"world:{entity_type}/{name}.md:0", 207 + file_content, 208 + {"source": "world", "content_type": entity_type, 209 + "path": str(file_path), "title": name}, 210 + ) 211 + 212 + 213 + def _auto_mark_present( 214 + present: list[str], event: str, ctx: ToolContext, 215 + ) -> list[str]: 216 + """Auto-mark present entities with the current event.""" 217 + marked: list[str] = [] 218 + for ref in present: 219 + link_match = re.search(r"\[\[([^\]]+)\]\]", ref) 220 + if not link_match: 221 + continue 222 + name = link_match.group(1) 223 + 224 + file_path = ctx.entity_index.resolve(name) 225 + if file_path is None: 226 + for etype in ("npcs", "locations", "items", "factions"): 227 + candidate = ctx.base_path / "worlds" / ctx.world_id / etype / f"{name}.md" 228 + if candidate.exists(): 229 + file_path = candidate 230 + break 231 + 232 + if file_path and file_path.exists(): 233 + entity_type = file_path.parent.name 234 + mark(entity_type=entity_type, name=name, event=event, ctx=ctx) 235 + marked.append(name) 236 + 237 + return marked 238 + 239 + 240 + def mark( 241 + entity_type: str, 242 + name: str, 243 + event: str, 244 + ctx: ToolContext, 245 + resolves: list[str] | None = None, 246 + ) -> str: 247 + """Record an event in an entity's history (## Was section). 248 + 249 + Use when something significant happens to or involving an entity. This builds 250 + their history and helps maintain continuity across sessions. 251 + 252 + If the event resolves a Will trigger (e.g., "Vera introduced the player to 253 + Harrik" resolves "If trusted -> intro to Harrik"), provide the trigger text 254 + in `resolves` to remove it from the Will section. 255 + 256 + Args: 257 + entity_type: Type of entity: npcs, locations, items, factions, threads 258 + name: Entity name (exact filename match) 259 + event: What happened - brief description for the Was section 260 + resolves: Optional list of Will items to remove if this event fired triggers 261 + 262 + Returns: 263 + Confirmation message 264 + """ 265 + file_path = ctx.entity_index.resolve(name) 266 + if file_path is None: 267 + file_path = ctx.base_path / "worlds" / ctx.world_id / entity_type / f"{name}.md" 268 + 269 + if not file_path.exists(): 270 + return f"Error: Entity '{name}' not found in {entity_type}" 271 + 272 + timestamp = ctx.campaign_log.get_current_time().to_anchor() 273 + 274 + lock = _get_file_lock(file_path) 275 + with lock: 276 + existing = _load_entity(file_path, ctx.entity_index) 277 + 278 + was = existing.get("was", []) 279 + was.append(f"{timestamp} | {event}") 280 + 281 + will = existing.get("will", []) 282 + resolved = [] 283 + for trigger in resolves or []: 284 + if trigger in will: 285 + will.remove(trigger) 286 + resolved.append(trigger) 287 + 288 + data = { 289 + "description": existing.get("description", ""), 290 + "location": existing.get("location", ""), 291 + "knows": existing.get("knows", []), 292 + "wants": existing.get("wants", []), 293 + "will": will, 294 + "was": was, 295 + } 296 + _write_entity(file_path, name, entity_type, data, ctx) 297 + 298 + result = f"Marked: {event}" 299 + if resolved: 300 + if len(resolved) == 1: 301 + result += f" (resolved: {resolved[0]})" 302 + else: 303 + result += f" (resolved {len(resolved)} triggers)" 304 + return result 305 + 306 + 307 + def note_discovery( 308 + entity: str, 309 + content: str, 310 + ctx: ToolContext, 311 + content_type: str = "lore", 312 + tags: list[str] | None = None, 313 + ) -> str: 314 + """Record what the player has learned about something. 315 + 316 + Use when the player discovers or learns information about an NPC, 317 + location, faction, or other world element. This captures their 318 + perspective, which may be incomplete or even wrong. 319 + 320 + The player's knowledge is separate from DM truth (use `establish` 321 + for the full facts). This helps track what the player knows vs. 322 + what they haven't discovered yet. 323 + 324 + Args: 325 + entity: Name of what they learned about (e.g., "Vera Blackwater") 326 + content: What the player learned or observed 327 + content_type: Type of content - npcs, locations, factions, lore (default: lore) 328 + tags: Optional tags for categorization 329 + 330 + Returns: 331 + Confirmation message 332 + """ 333 + slug = name_to_slug(entity) 334 + 335 + knowledge_dir = ( 336 + ctx.base_path / "players" / ctx.player_id / "worlds" 337 + / ctx.world_id / content_type 338 + ) 339 + knowledge_dir.mkdir(parents=True, exist_ok=True) 340 + file_path = knowledge_dir / f"{slug}.md" 341 + 342 + frontmatter: dict = { 343 + "type": content_type.rstrip("s"), 344 + "name": entity, 345 + } 346 + if tags: 347 + frontmatter["tags"] = tags 348 + 349 + file_content = "---\n" 350 + file_content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 351 + file_content += "---\n\n" 352 + file_content += content.strip() 353 + file_content += "\n" 354 + 355 + file_path.write_text(file_content) 356 + 357 + ctx.vector_index.upsert( 358 + f"player:{content_type}/{slug}.md:0", 359 + file_content, 360 + {"source": "player", "content_type": content_type, 361 + "path": str(file_path), "title": entity}, 362 + ) 363 + 364 + return f"Noted: player learned about '{entity}'" 365 + 366 + 367 + DEFINITIONS: list[dict] = [ 368 + { 369 + "name": "establish", 370 + "description": establish.__doc__, 371 + "input_schema": { 372 + "type": "object", 373 + "properties": { 374 + "entity_type": { 375 + "type": "string", 376 + "description": "Type of entity", 377 + "enum": ["npcs", "locations", "items", "factions", "threads", "lore"], 378 + }, 379 + "name": { 380 + "type": "string", 381 + "description": "Display name (exact filename, e.g., 'Vera Blackwater')", 382 + }, 383 + "description": { 384 + "type": "string", 385 + "description": "Prose description with [[wikilinks]] for relationships", 386 + }, 387 + "location": { 388 + "type": "string", 389 + "description": "Current location (e.g., '[[The Rusty Anchor]]' or 'In the basement of [[The Rusty Anchor]]')", 390 + }, 391 + "knows": { 392 + "type": "array", 393 + "items": {"type": "string"}, 394 + "description": "Secrets, hidden truths - what isn't obvious", 395 + }, 396 + "wants": { 397 + "type": "array", 398 + "items": {"type": "string"}, 399 + "description": "Nature, tendencies, inclinations - even non-sentient things", 400 + }, 401 + "will": { 402 + "type": "array", 403 + "items": {"type": "string"}, 404 + "description": "Conditional behaviors in 'If X -> Y' format", 405 + }, 406 + }, 407 + "required": ["entity_type", "name"], 408 + }, 409 + }, 410 + { 411 + "name": "mark", 412 + "description": mark.__doc__, 413 + "input_schema": { 414 + "type": "object", 415 + "properties": { 416 + "entity_type": { 417 + "type": "string", 418 + "description": "Type of entity", 419 + "enum": ["npcs", "locations", "items", "factions", "threads"], 420 + }, 421 + "name": { 422 + "type": "string", 423 + "description": "Entity name (exact filename match)", 424 + }, 425 + "event": { 426 + "type": "string", 427 + "description": "What happened - brief description", 428 + }, 429 + "resolves": { 430 + "type": "array", 431 + "items": {"type": "string"}, 432 + "description": "Optional: Will items to remove if this event fired triggers", 433 + }, 434 + }, 435 + "required": ["entity_type", "name", "event"], 436 + }, 437 + }, 438 + { 439 + "name": "note_discovery", 440 + "description": note_discovery.__doc__, 441 + "input_schema": { 442 + "type": "object", 443 + "properties": { 444 + "entity": { 445 + "type": "string", 446 + "description": "Name of what they learned about (e.g., 'Vera Blackwater')", 447 + }, 448 + "content": { 449 + "type": "string", 450 + "description": "What the player learned or observed", 451 + }, 452 + "content_type": { 453 + "type": "string", 454 + "description": "Type of content", 455 + "enum": ["npcs", "locations", "factions", "lore"], 456 + }, 457 + "tags": { 458 + "type": "array", 459 + "items": {"type": "string"}, 460 + "description": "Tags for categorization", 461 + }, 462 + }, 463 + "required": ["entity", "content"], 464 + }, 465 + }, 466 + ]
+143
src/storied/tools/mechanics.py
··· 1 + """Dice, rules lookup, and code execution tools.""" 2 + 3 + from pathlib import Path 4 + 5 + from storied.dice import roll as dice_roll 6 + from storied.tools._context import ToolContext 7 + 8 + 9 + def roll(notation: str, reason: str | None = None) -> dict: 10 + """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. 11 + 12 + Use for attack rolls, skill checks, saving throws, and damage rolls. 13 + Supports: XdY, XdY+Z, XdY-Z, advantage (2d20kh1), disadvantage (2d20kl1). 14 + 15 + Args: 16 + notation: Dice notation string (e.g., "1d20+5", "2d6", "4d6kh3") 17 + reason: Brief description of what the roll is for (e.g., "Athletics", 18 + "Attack with longsword", "Wisdom save", "Fireball damage") 19 + 20 + Returns: 21 + Dict with rolls, kept dice, modifier, and total 22 + """ 23 + result = dice_roll(notation) 24 + return result.to_dict() 25 + 26 + 27 + def recall( 28 + query: str, 29 + ctx: ToolContext, 30 + scope: str = "all", 31 + content_type: str | None = None, 32 + ) -> str: 33 + """Look up rules, world content, or both. 34 + 35 + Use to recall information about: 36 + - Rules: spells, monsters, classes, items, conditions from the SRD 37 + - World: NPCs, locations, factions, lore you've established 38 + - Both: search everything (default) 39 + 40 + Args: 41 + query: What to look up (e.g., "fireball", "captain vex", "merchant guild") 42 + scope: Where to search - "rules", "world", or "all" (default) 43 + content_type: Optional type to limit search (e.g., "spells", "npcs") 44 + 45 + Returns: 46 + Content of the found item, or a message if not found 47 + """ 48 + source_filter: str | None = None 49 + if scope == "rules": 50 + source_filter = "srd" 51 + 52 + current_day = ctx.campaign_log.get_current_time().day 53 + hits = ctx.vector_index.search( 54 + query, limit=5, source_filter=source_filter, 55 + exclude_source="srd" if scope == "world" else None, 56 + decay_ref=current_day, 57 + ) 58 + if hits: 59 + if len(hits) == 1 or hits[0].score > 0.8: 60 + hit_path = Path(hits[0].path) 61 + if hit_path.exists(): 62 + return hit_path.read_text() 63 + lines = [f"Found {len(hits)} matches:"] 64 + for h in hits: 65 + lines.append(f" - {h.doc_id.split(':')[1]} ({h.source}): {h.snippet[:80]}") 66 + return "\n".join(lines) 67 + 68 + return f"Nothing found matching '{query}'" 69 + 70 + 71 + DEFINITIONS: list[dict] = [ 72 + { 73 + "name": "roll", 74 + "description": roll.__doc__, 75 + "input_schema": { 76 + "type": "object", 77 + "properties": { 78 + "notation": { 79 + "type": "string", 80 + "description": "Dice notation (e.g., '1d20+5', '2d6', '4d6kh3')", 81 + }, 82 + "reason": { 83 + "type": "string", 84 + "description": "What the roll is for (e.g., 'Athletics', 'Longsword attack', 'Dex save')", 85 + }, 86 + }, 87 + "required": ["notation", "reason"], 88 + }, 89 + }, 90 + { 91 + "name": "recall", 92 + "description": recall.__doc__, 93 + "input_schema": { 94 + "type": "object", 95 + "properties": { 96 + "query": { 97 + "type": "string", 98 + "description": "What to look up (e.g., 'fireball', 'captain vex')", 99 + }, 100 + "scope": { 101 + "type": "string", 102 + "description": "Where to search: 'rules' (SRD), 'world' (established content), or 'all' (both)", 103 + "enum": ["rules", "world", "all"], 104 + }, 105 + "content_type": { 106 + "type": "string", 107 + "description": "Type to limit search (e.g., 'spells', 'npcs', 'monsters')", 108 + }, 109 + }, 110 + "required": ["query"], 111 + }, 112 + }, 113 + { 114 + "name": "run_code", 115 + "description": ( 116 + "Run Python code in a secure sandbox. Use for calculations, random " 117 + "generation, data formatting, or any computation the narrative needs.\n\n" 118 + "All your DM tools are callable as functions (see signatures below). " 119 + "Most return a str with the result. The exception is roll(), which " 120 + "returns a dict with keys: notation, rolls, kept, modifier, total — " 121 + "use roll('2d6+3')['total'] for math, or index into rolls/kept for " 122 + "individual dice. Use roll() for all randomness (no random module).\n\n" 123 + "Language: variables, functions, loops, conditionals, comprehensions, " 124 + "f-strings. Stdlib: re, json, datetime, math. No classes, no other " 125 + "imports, no file/network access. Errors return as text.\n\n" 126 + "Available functions:\n{tool_signatures}" 127 + ), 128 + "input_schema": { 129 + "type": "object", 130 + "properties": { 131 + "description": { 132 + "type": "string", 133 + "description": "What this code does in game terms (e.g., 'Designing cave system', 'Splitting treasure', 'Generating NPC schedule')", 134 + }, 135 + "code": { 136 + "type": "string", 137 + "description": "Python code to execute", 138 + }, 139 + }, 140 + "required": ["description", "code"], 141 + }, 142 + }, 143 + ]
+215
src/storied/tools/scene.py
··· 1 + """Scene management, session, style tuning, and DM notification tools.""" 2 + 3 + import re 4 + 5 + from storied import notifications 6 + from storied.session import update_session as session_update 7 + from storied.tools._context import ToolContext 8 + from storied.tools.entities import _auto_mark_present 9 + 10 + 11 + def set_scene( 12 + ctx: ToolContext, 13 + event: str | None = None, 14 + duration: str | None = None, 15 + situation: str | None = None, 16 + location: str | None = None, 17 + present: list[str] | None = None, 18 + threads: list[str] | None = None, 19 + tags: list[str] | None = None, 20 + ) -> str: 21 + """Call this after every response. Logs what happened, advances the clock, 22 + and updates the scene state. 23 + 24 + Always include event and duration — time only advances when you log it. 25 + 26 + Args: 27 + event: What happened this turn (e.g., "Spoke with the innkeeper", 28 + "Searched the warehouse", "Fought off thugs"). 29 + duration: How long it took (e.g., "10 min", "1 hour", "3 rounds", 30 + "1 day"). The clock advances by this amount. 31 + situation: Updated situation summary. Write in present tense as if 32 + briefing another DM taking over mid-session. 33 + location: New location when the player moves (e.g., "The Rusty Anchor") 34 + present: Entities currently present, using [[Name]] format 35 + (e.g., ["[[Vera Blackwater]]", "[[Henrik]] - barkeep"]) 36 + threads: Open plot threads or objectives 37 + tags: Optional tags: "combat", "rest:short", "rest:long", "travel" 38 + 39 + Returns: 40 + Confirmation of what was updated 41 + """ 42 + parts = [] 43 + 44 + if event and duration: 45 + anchor = ctx.campaign_log.append_entry(event, duration, tags=tags) 46 + current = ctx.campaign_log.get_current_time() 47 + parts.append( 48 + f"Logged: {anchor} | {event} | {duration} → " 49 + f"Now: {current} ({current.period_of_day()}, {current.atmosphere()})" 50 + ) 51 + 52 + updates = {} 53 + if situation is not None: 54 + updates["situation"] = situation 55 + if location is not None: 56 + updates["location"] = location 57 + if present is not None: 58 + updates["present"] = present 59 + if threads is not None: 60 + updates["threads"] = threads 61 + 62 + if updates: 63 + result = session_update(ctx.player_id, updates, ctx.base_path) 64 + parts.append(result) 65 + 66 + if event and present: 67 + marked = _auto_mark_present(present, event, ctx) 68 + if marked: 69 + parts.append(f"Auto-marked: {', '.join(marked)}") 70 + 71 + return "; ".join(parts) if parts else "No updates" 72 + 73 + 74 + def tune(tuning: str, ctx: ToolContext) -> str: 75 + """Update your storytelling style based on player feedback. 76 + 77 + Write the complete updated style as markdown prose. This replaces the 78 + entire current style. Incorporate existing preferences where they still 79 + apply — don't discard preferences the player hasn't contradicted. 80 + """ 81 + path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 82 + path.write_text(f"# Style\n\n{tuning}\n") 83 + return "Style updated." 84 + 85 + 86 + def end_session(situation: str, ctx: ToolContext, threads: list[str] | None = None) -> str: 87 + """End the current session, saving the game state for next time. 88 + 89 + Call this when the player indicates they want to stop playing. This saves 90 + the current situation so the next session can resume smoothly. 91 + 92 + Before calling, give a brief farewell and summary of what happened this session. 93 + 94 + Args: 95 + situation: Summary of the current state of affairs for the next session. 96 + Write as if briefing a DM who will pick up where you left off. 97 + threads: Open plot threads or objectives to carry forward 98 + 99 + Returns: 100 + Confirmation that session was saved 101 + """ 102 + updates: dict = {"situation": situation} 103 + if threads is not None: 104 + updates["threads"] = threads 105 + 106 + session_update(ctx.player_id, updates, ctx.base_path) 107 + return "SESSION_ENDED" 108 + 109 + 110 + def notify_dm(message: str, ctx: ToolContext) -> str: 111 + """Send a notification that the DM will see at the start of the next turn. 112 + 113 + Use this to communicate important background changes to the DM, 114 + such as advancement readiness or world state changes. 115 + 116 + Args: 117 + message: The notification message for the DM 118 + 119 + Returns: 120 + Confirmation that the notification was queued 121 + """ 122 + notifications.append(ctx.world_id, ctx.base_path, message) 123 + return f"Notification queued: {message}" 124 + 125 + 126 + DEFINITIONS: list[dict] = [ 127 + { 128 + "name": "set_scene", 129 + "description": set_scene.__doc__, 130 + "input_schema": { 131 + "type": "object", 132 + "properties": { 133 + "event": { 134 + "type": "string", 135 + "description": "What happened this turn (logged to campaign journal)", 136 + }, 137 + "duration": { 138 + "type": "string", 139 + "description": "How long it took (e.g., '10 min', '1 hour', '3 rounds')", 140 + }, 141 + "situation": { 142 + "type": "string", 143 + "description": "Updated situation summary in present tense", 144 + }, 145 + "location": { 146 + "type": "string", 147 + "description": "New location when the player moves", 148 + }, 149 + "present": { 150 + "type": "array", 151 + "items": {"type": "string"}, 152 + "description": "Entities present, using [[Name]] format", 153 + }, 154 + "threads": { 155 + "type": "array", 156 + "items": {"type": "string"}, 157 + "description": "Open plot threads or objectives", 158 + }, 159 + "tags": { 160 + "type": "array", 161 + "items": {"type": "string"}, 162 + "description": "Optional: 'combat', 'rest:short', 'rest:long', 'travel', 'level'", 163 + }, 164 + }, 165 + "required": ["event", "duration"], 166 + }, 167 + }, 168 + { 169 + "name": "tune", 170 + "description": tune.__doc__, 171 + "input_schema": { 172 + "type": "object", 173 + "properties": { 174 + "tuning": { 175 + "type": "string", 176 + "description": "Complete updated style as markdown prose. Replaces the current style entirely.", 177 + }, 178 + }, 179 + "required": ["tuning"], 180 + }, 181 + }, 182 + { 183 + "name": "end_session", 184 + "description": end_session.__doc__, 185 + "input_schema": { 186 + "type": "object", 187 + "properties": { 188 + "situation": { 189 + "type": "string", 190 + "description": "Summary of current state for the next session", 191 + }, 192 + "threads": { 193 + "type": "array", 194 + "items": {"type": "string"}, 195 + "description": "Open plot threads or objectives to carry forward", 196 + }, 197 + }, 198 + "required": ["situation"], 199 + }, 200 + }, 201 + { 202 + "name": "notify_dm", 203 + "description": notify_dm.__doc__, 204 + "input_schema": { 205 + "type": "object", 206 + "properties": { 207 + "message": { 208 + "type": "string", 209 + "description": "The notification message for the DM", 210 + }, 211 + }, 212 + "required": ["message"], 213 + }, 214 + }, 215 + ]
+359
tests/test_advancement.py
··· 1 + """Tests for the character advancement evaluation system.""" 2 + 3 + import json 4 + from pathlib import Path 5 + from unittest.mock import MagicMock, patch 6 + 7 + import pytest 8 + 9 + from storied.character import load_character, save_character 10 + from storied.log import CampaignLog 11 + from storied.advancement import ( 12 + AdvancementResult, 13 + BackgroundAdvancement, 14 + build_advancement_context, 15 + evaluate_advancement, 16 + ) 17 + from storied.session import save_session 18 + from storied.tools import ToolContext 19 + from storied.tools.scene import notify_dm 20 + 21 + 22 + # --- Fixtures --- 23 + 24 + 25 + @pytest.fixture 26 + def character(ctx: ToolContext) -> dict: 27 + """Create a level 3 rogue character.""" 28 + data = { 29 + "name": "Kira", 30 + "race": "Human", 31 + "class": "Rogue", 32 + "level": 3, 33 + "hp": {"current": 24, "max": 24}, 34 + "ac": 14, 35 + "speed": 30, 36 + "abilities": { 37 + "strength": 10, 38 + "dexterity": 16, 39 + "constitution": 14, 40 + "intelligence": 12, 41 + "wisdom": 10, 42 + "charisma": 14, 43 + }, 44 + "body": "## Features\n- Sneak Attack 2d6\n- Cunning Action\n- Thief subclass", 45 + } 46 + save_character("default", data, ctx.base_path) 47 + return data 48 + 49 + 50 + @pytest.fixture 51 + def campaign_with_events(ctx: ToolContext) -> CampaignLog: 52 + """Campaign log with some adventure events.""" 53 + log = ctx.campaign_log 54 + log.append_entry("Arrived in Millford", "30 min") 55 + log.append_entry("Investigated the warehouse", "1 hour") 56 + log.append_entry("Fought three thugs", "3 rounds", tags=["combat"]) 57 + log.append_entry("Discovered smuggling ring", "30 min") 58 + log.append_entry("Traveled to Thornwall", "4 hours") 59 + log.append_entry("Infiltrated the manor", "2 hours") 60 + log.append_entry("Stole the merchant's ledger", "30 min") 61 + return log 62 + 63 + 64 + # --- notify_dm tool --- 65 + 66 + 67 + class TestNotifyDM: 68 + def test_appends_notification(self, ctx: ToolContext): 69 + result = notify_dm("Test message", ctx) 70 + 71 + assert "queued" in result.lower() 72 + path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 73 + assert path.exists() 74 + assert "Test message" in path.read_text() 75 + 76 + def test_multiple_notifications(self, ctx: ToolContext): 77 + notify_dm("First", ctx) 78 + notify_dm("Second", ctx) 79 + 80 + path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 81 + content = path.read_text() 82 + assert "First" in content 83 + assert "Second" in content 84 + 85 + 86 + # --- build_advancement_context --- 87 + 88 + 89 + class TestBuildAdvancementContext: 90 + def test_returns_none_without_character(self, ctx: ToolContext): 91 + result = build_advancement_context( 92 + ctx.world_id, ctx.player_id, ctx.base_path 93 + ) 94 + assert result is None 95 + 96 + def test_returns_none_when_advancement_ready( 97 + self, ctx: ToolContext, character: dict 98 + ): 99 + character["advancement_ready"] = 4 100 + save_character("default", character, ctx.base_path) 101 + 102 + result = build_advancement_context( 103 + ctx.world_id, ctx.player_id, ctx.base_path 104 + ) 105 + assert result is None 106 + 107 + def test_includes_character_info( 108 + self, ctx: ToolContext, character: dict 109 + ): 110 + context = build_advancement_context( 111 + ctx.world_id, ctx.player_id, ctx.base_path 112 + ) 113 + assert context is not None 114 + assert "Kira" in context 115 + assert "Rogue" in context 116 + assert "Level 3" in context 117 + 118 + def test_includes_log_entries( 119 + self, 120 + ctx: ToolContext, 121 + character: dict, 122 + campaign_with_events: CampaignLog, 123 + ): 124 + context = build_advancement_context( 125 + ctx.world_id, ctx.player_id, ctx.base_path 126 + ) 127 + assert context is not None 128 + assert "warehouse" in context 129 + assert "smuggling ring" in context 130 + 131 + def test_includes_entries_since_level_tag( 132 + self, ctx: ToolContext, character: dict 133 + ): 134 + log = ctx.campaign_log 135 + log.append_entry("Old event before level-up", "1 hour") 136 + log.append_entry("Leveled up to 3", "5 min", tags=["level"]) 137 + log.append_entry("New adventure begins", "30 min") 138 + log.append_entry("Fought a dragon", "5 rounds", tags=["combat"]) 139 + 140 + context = build_advancement_context( 141 + ctx.world_id, ctx.player_id, ctx.base_path 142 + ) 143 + assert context is not None 144 + assert "Old event before level-up" not in context 145 + assert "New adventure begins" in context 146 + assert "Fought a dragon" in context 147 + 148 + def test_includes_advancement_history( 149 + self, ctx: ToolContext, character: dict 150 + ): 151 + log = ctx.campaign_log 152 + log.append_entry("Reached level 2", "5 min", tags=["level"]) 153 + log.append_entry("Adventured more", "2 hours") 154 + log.append_entry("Reached level 3", "5 min", tags=["level"]) 155 + log.append_entry("Recent events", "1 hour") 156 + 157 + context = build_advancement_context( 158 + ctx.world_id, ctx.player_id, ctx.base_path 159 + ) 160 + assert context is not None 161 + assert "Advancement History" in context 162 + assert "Reached level 2" in context 163 + assert "Reached level 3" in context 164 + 165 + def test_includes_session_state( 166 + self, ctx: ToolContext, character: dict 167 + ): 168 + save_session( 169 + "default", 170 + { 171 + "location": "Town Square", 172 + "body": "## Open Threads\n- Find the missing merchant", 173 + }, 174 + ctx.base_path, 175 + ) 176 + 177 + context = build_advancement_context( 178 + ctx.world_id, ctx.player_id, ctx.base_path 179 + ) 180 + assert context is not None 181 + assert "missing merchant" in context 182 + 183 + 184 + # --- CampaignLog tag methods --- 185 + 186 + 187 + class TestLogTagMethods: 188 + def test_get_entries_since_tag_returns_all_when_no_tag( 189 + self, ctx: ToolContext 190 + ): 191 + log = ctx.campaign_log 192 + log.append_entry("Event one", "10 min") 193 + log.append_entry("Event two", "10 min") 194 + 195 + entries = log.get_entries_since_tag("level") 196 + assert len(entries) == 2 197 + 198 + def test_get_entries_since_tag_returns_after_last_tag( 199 + self, ctx: ToolContext 200 + ): 201 + log = ctx.campaign_log 202 + log.append_entry("Before level", "10 min") 203 + log.append_entry("Level up!", "5 min", tags=["level"]) 204 + log.append_entry("After level", "10 min") 205 + 206 + entries = log.get_entries_since_tag("level") 207 + assert len(entries) == 1 208 + assert entries[0].event == "After level" 209 + 210 + def test_get_entries_since_tag_uses_last_occurrence( 211 + self, ctx: ToolContext 212 + ): 213 + log = ctx.campaign_log 214 + log.append_entry("First level", "5 min", tags=["level"]) 215 + log.append_entry("Between levels", "1 hour") 216 + log.append_entry("Second level", "5 min", tags=["level"]) 217 + log.append_entry("After second", "30 min") 218 + 219 + entries = log.get_entries_since_tag("level") 220 + assert len(entries) == 1 221 + assert entries[0].event == "After second" 222 + 223 + def test_find_tag_entries(self, ctx: ToolContext): 224 + log = ctx.campaign_log 225 + log.append_entry("Normal event", "10 min") 226 + log.append_entry("Level 2", "5 min", tags=["level"]) 227 + log.append_entry("More stuff", "1 hour") 228 + log.append_entry("Level 3", "5 min", tags=["level"]) 229 + 230 + entries = log.find_tag_entries("level") 231 + assert len(entries) == 2 232 + assert entries[0].event == "Level 2" 233 + assert entries[1].event == "Level 3" 234 + 235 + def test_find_tag_entries_empty(self, ctx: ToolContext): 236 + log = ctx.campaign_log 237 + log.append_entry("Normal event", "10 min") 238 + 239 + entries = log.find_tag_entries("level") 240 + assert entries == [] 241 + 242 + def test_get_all_entries(self, ctx: ToolContext): 243 + log = ctx.campaign_log 244 + log.append_entry("Day 1 morning", "10 min") 245 + log.append_entry("Day 1 travel", "18 hours") 246 + log.append_entry("Day 2 event", "10 min") 247 + 248 + entries = log.get_all_entries() 249 + assert len(entries) == 3 250 + assert entries[0].event == "Day 1 morning" 251 + assert entries[2].event == "Day 2 event" 252 + 253 + 254 + # --- evaluate_advancement --- 255 + 256 + 257 + class TestEvaluateAdvancement: 258 + def test_skips_without_character(self, ctx: ToolContext): 259 + result = evaluate_advancement( 260 + world_id=ctx.world_id, 261 + player_id=ctx.player_id, 262 + base_path=ctx.base_path, 263 + ) 264 + assert result.evaluated is False 265 + 266 + def test_skips_when_advancement_ready( 267 + self, ctx: ToolContext, character: dict 268 + ): 269 + character["advancement_ready"] = 4 270 + save_character("default", character, ctx.base_path) 271 + 272 + result = evaluate_advancement( 273 + world_id=ctx.world_id, 274 + player_id=ctx.player_id, 275 + base_path=ctx.base_path, 276 + ) 277 + assert result.evaluated is False 278 + 279 + @patch("storied.claude.subprocess.Popen") 280 + def test_calls_claude_when_character_exists( 281 + self, 282 + mock_popen: MagicMock, 283 + ctx: ToolContext, 284 + character: dict, 285 + campaign_with_events: CampaignLog, 286 + ): 287 + result_line = json.dumps({ 288 + "type": "result", 289 + "session_id": "sess-adv", 290 + "usage": {"input_tokens": 500, "output_tokens": 100}, 291 + "duration_ms": 2000, 292 + }) 293 + mock_proc = MagicMock() 294 + mock_proc.stdin = MagicMock() 295 + mock_proc.stdout = iter([result_line.encode() + b"\n"]) 296 + mock_proc.stderr = iter([]) 297 + mock_proc.wait.return_value = 0 298 + mock_proc.returncode = 0 299 + mock_popen.return_value = mock_proc 300 + 301 + result = evaluate_advancement( 302 + world_id=ctx.world_id, 303 + player_id=ctx.player_id, 304 + base_path=ctx.base_path, 305 + ) 306 + 307 + assert result.evaluated is True 308 + assert result.input_tokens == 500 309 + mock_popen.assert_called_once() 310 + 311 + 312 + # --- BackgroundAdvancement --- 313 + 314 + 315 + class TestBackgroundAdvancement: 316 + def test_does_not_trigger_before_interval(self): 317 + adv = BackgroundAdvancement( 318 + world_id="test", 319 + player_id="default", 320 + base_path=Path("/tmp/fake"), 321 + interval=5, 322 + ) 323 + # 4 turns should not trigger 324 + for _ in range(4): 325 + adv.on_turn() 326 + assert adv._thread is None 327 + 328 + def test_on_combat_end_triggers_immediately( 329 + self, ctx: ToolContext, character: dict 330 + ): 331 + adv = BackgroundAdvancement( 332 + world_id=ctx.world_id, 333 + player_id=ctx.player_id, 334 + base_path=ctx.base_path, 335 + interval=100, 336 + ) 337 + 338 + with patch("storied.advancement.evaluate_advancement") as mock_eval: 339 + mock_eval.return_value = AdvancementResult() 340 + adv.on_combat_end() 341 + # Give the thread a moment 342 + if adv._thread: 343 + adv._thread.join(timeout=2) 344 + mock_eval.assert_called_once() 345 + 346 + def test_turn_counter_resets_after_evaluation(self): 347 + adv = BackgroundAdvancement( 348 + world_id="test", 349 + player_id="default", 350 + base_path=Path("/tmp/fake"), 351 + interval=5, 352 + ) 353 + with patch("storied.advancement.evaluate_advancement") as mock_eval: 354 + mock_eval.return_value = AdvancementResult() 355 + for _ in range(5): 356 + adv.on_turn() 357 + if adv._thread: 358 + adv._thread.join(timeout=2) 359 + assert adv._turn_count == 0
+58
tests/test_notifications.py
··· 1 + """Tests for the DM notification channel.""" 2 + 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from storied import notifications 8 + 9 + 10 + @pytest.fixture 11 + def world(tmp_path: Path) -> tuple[str, Path]: 12 + """Return (world_id, base_path) with world directory created.""" 13 + world_dir = tmp_path / "worlds" / "test-world" 14 + world_dir.mkdir(parents=True) 15 + return "test-world", tmp_path 16 + 17 + 18 + class TestAppendAndDrain: 19 + def test_drain_empty(self, world: tuple[str, Path]): 20 + world_id, base_path = world 21 + assert notifications.drain(world_id, base_path) == [] 22 + 23 + def test_append_then_drain(self, world: tuple[str, Path]): 24 + world_id, base_path = world 25 + notifications.append(world_id, base_path, "Something happened") 26 + 27 + messages = notifications.drain(world_id, base_path) 28 + assert messages == ["Something happened"] 29 + 30 + def test_drain_clears(self, world: tuple[str, Path]): 31 + world_id, base_path = world 32 + notifications.append(world_id, base_path, "First") 33 + 34 + notifications.drain(world_id, base_path) 35 + assert notifications.drain(world_id, base_path) == [] 36 + 37 + def test_multiple_messages(self, world: tuple[str, Path]): 38 + world_id, base_path = world 39 + notifications.append(world_id, base_path, "First thing") 40 + notifications.append(world_id, base_path, "Second thing") 41 + notifications.append(world_id, base_path, "Third thing") 42 + 43 + messages = notifications.drain(world_id, base_path) 44 + assert messages == ["First thing", "Second thing", "Third thing"] 45 + 46 + def test_file_removed_after_drain(self, world: tuple[str, Path]): 47 + world_id, base_path = world 48 + notifications.append(world_id, base_path, "Temporary") 49 + 50 + notifications.drain(world_id, base_path) 51 + path = base_path / "worlds" / "test-world" / "dm_notifications.md" 52 + assert not path.exists() 53 + 54 + def test_creates_world_dir_if_missing(self, tmp_path: Path): 55 + notifications.append("new-world", tmp_path, "Hello") 56 + 57 + messages = notifications.drain("new-world", tmp_path) 58 + assert messages == ["Hello"]