A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Fix DM context loss on resume, add world tick and toolkit improvements

Turns out the DM was losing its entire system prompt after turn 1 — on
--resume without --system-prompt, claude -p falls back to the default
Claude Code system prompt (a software engineering agent!). The DM was
coasting on conversation history alone, which explains the timekeeping
drift. Now we always send --system-prompt and --model on resume, so the
DM gets fresh context (current time, character state, entities) every
turn. Tested empirically with a codeword swap to confirm.

Beyond the context fix, a bunch of changes to make the world feel more
alive and reduce the DM's bookkeeping burden:

- set_scene now returns the current time with atmosphere hints ("Day 1,
17:30 (Afternoon, fading light)") so the DM knows what time it is
- Present entities get auto-marked when set_scene is called, so the DM
doesn't need separate mark calls for NPCs in the scene
- recall falls back to fuzzy word-overlap matching when exact search
misses — "that merchant" can now find "Henrik the Merchant"
- Log context window doubled from 5 to 10 entries
- World tick system that evaluates Will triggers between sessions and
during gameplay, running in a background thread so it doesn't block
the player (the synchronous version took 128s, oops)
- Per-file locking on establish/mark for thread safety with background
ticks
- Richer seeded worlds (12-16 entities with nearby locations, regional
lore, notable items, and thread deadlines)
- /status command shows HP bar, AC, gold without burning a DM turn
- Roll notifications now show the reason: [Rolling Investigation...]
instead of [Rolling...]

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

+608 -64
+1
prompts/planner-system.md
··· 53 53 - **Weave open threads in.** If there's a mystery about missing merchants, maybe a nearby NPC heard rumors. If there's a bounty, maybe a location has clues. 54 54 - **Respect entity types.** Non-sentient things can still "want" and "know" — a bridge "wants" to collapse, a cursed ring "knows" its maker's name. Frame narrative tendency, not literal consciousness. 55 55 - **Write Was entries for backstory.** Use `mark` to give entities pre-existing history that enriches the world. 56 + - **Add deadlines to threads when natural.** "If Day 5 passes without intervention → the prisoners are moved." This creates urgency the DM can use. 56 57 - **Anchor entities to locations.** Don't say "works at the city jail" — say "works at [[Greyhaven City Jail]]". 57 58 - **Give names to everyone.** Don't create "a guard" — create "Mara, a guard". Named entities create a living world. 58 59
+12 -4
prompts/world-seed.md
··· 23 23 24 24 4. **People in the scene** — 1-2 NPCs the player can interact with immediately. A fellow traveler, a merchant, a guard. These are the first faces the player sees. 25 25 26 - 5. **Open threads** — 2-3 hooks woven into the entities: 26 + 5. **Nearby locations** — 2-3 places beyond the immediate area that NPCs might mention or the player might hear rumors about. A nearby ruin, a neighboring town, a dangerous stretch of road. These give the DM material when the player asks "what's around here?" 27 + 28 + 6. **Regional lore** — 1 lore entry establishing the broader setting. What region is this? What's the political situation? What's the prevailing mood — peaceful, tense, war-torn? This gives the DM a tonal anchor. 29 + 30 + 7. **Items of interest** — 1-2 notable items seeded into the world (not in the player's possession). A legendary weapon rumored to be in a nearby dungeon, a cursed artifact an NPC carries, a map fragment on a tavern wall. Give each Knows/Wants/Will. 31 + 32 + 8. **Open threads** — 3-4 hooks woven into the entities: 27 33 - One personal (tied to backstory — unfinished business, a rumor about someone they knew) 28 34 - One local (something happening in the immediate area — a problem, an opportunity) 29 35 - One larger (a distant rumor or sign of something bigger — war, plague, a quest) 36 + - One environmental (something wrong or strange about the area itself — crops failing, animals fleeing, unnatural weather) 30 37 31 - 6. **The opening moment** — use `set_scene` to place the player in a specific, actionable situation. Not "you're in a tavern" — something with momentum. Walking toward something, arriving somewhere, witnessing something. 38 + 9. **The opening moment** — use `set_scene` to place the player in a specific, actionable situation. Not "you're in a tavern" — something with momentum. Walking toward something, arriving somewhere, witnessing something. 32 39 33 - 7. **The clock** — include `event` and `duration` in your final `set_scene` to start the clock. Morning of Day 1. 40 + 10. **The clock** — include `event` and `duration` in your final `set_scene` to start the clock. Morning of Day 1. 34 41 35 42 ## Rules 36 43 37 44 - **Build from the character.** Everything should feel like it grew from their backstory, not from a template. A soldier's world looks different from a wizard's. 38 45 - **Use [[wikilinks]] liberally.** Every NPC should reference their location. Every location should reference who's there. Create a connected graph. 39 - - **Keep it focused.** 6-10 entities total. Seeds, not an encyclopedia. The DM and player will fill in the rest. 46 + - **Build a world worth exploring.** 12-16 entities total. Enough that the DM has material to work with, but seeds, not an encyclopedia. 40 47 - **Give names to everyone.** Not "a guard" — "Mara, a guard at the west gate." Named entities create a living world. 41 48 - **Use Knows/Wants/Will on everything.** Even locations and items. A bridge "wants" to be crossed. A sword "knows" its previous owner. 42 49 - **Anchor Will triggers to player actions.** "If the player asks about the fire → reveal that it was arson." These give the DM ready-made drama. 50 + - **Give threads deadlines when natural.** "If Day 5 passes without intervention → the prisoners are moved." Not every thread needs urgency, but 1-2 should have a ticking clock so the player feels the world won't wait. 43 51 - **Don't write narrative.** Use the tools to create world state. The DM will handle the storytelling. 44 52 - **Don't include the player character as an entity.** They already exist as a character sheet. 45 53
+61
prompts/world-tick.md
··· 1 + You are a World Architect advancing a 5e solo adventure world between sessions. Time has passed since the player last played, and you're evaluating what changed in the world while they were away. 2 + 3 + ## Your Tools 4 + 5 + | Tool | Purpose | 6 + |------|---------| 7 + | `recall` | Look up existing world content | 8 + | `establish` | Update entities that changed | 9 + | `mark` | Record events that happened off-screen | 10 + 11 + ## What You're Given 12 + 13 + - The current game time and how much time has passed since last session 14 + - All entities with **Will** triggers (conditional behaviors) 15 + - The campaign log with recent events 16 + - Open plot threads from the session state 17 + 18 + ## What to Do 19 + 20 + ### 1. Evaluate Will Triggers 21 + 22 + Scan each entity's Will section. A trigger should fire if: 23 + - Its condition is now met (based on time, events, or world state) 24 + - Enough time has passed for it to plausibly happen off-screen 25 + - It creates interesting consequences the DM can narrate 26 + 27 + When a trigger fires: 28 + 1. `mark` the entity with what happened 29 + 2. `establish` to update their state (new location, changed disposition, etc.) 30 + 3. Remove the fired trigger by updating the Will section 31 + 32 + ### 2. Check Thread Deadlines 33 + 34 + Look for threads with time pressure — explicit deadlines in entity descriptions (e.g., "Will: If Day 5 passes without intervention → the prisoners are moved") or implied urgency. Evaluate whether inaction has consequences: 35 + - A bounty might expire 36 + - A villain might advance their plan 37 + - Supplies might run out 38 + - A window of opportunity closes 39 + 40 + When a deadline passes, `mark` the thread with what happened and `establish` to update affected entities. Don't resolve major plot threads — just let the world respond to the passage of time. 41 + 42 + ### 3. Small World Motion 43 + 44 + Add 1-2 small changes that make the world feel alive: 45 + - An NPC moved to a different location 46 + - A rumor spread or changed 47 + - Weather or seasonal shift affected a location 48 + - A faction took a minor action 49 + 50 + Keep these subtle — the player should notice the world moved, not feel like they missed a chapter. 51 + 52 + ## Rules 53 + 54 + - **Don't advance major plot.** The player should drive the story. You're adding texture, not resolution. 55 + - **Don't create events involving the player character.** They weren't there. 56 + - **Don't contradict established facts.** Read existing content carefully with `recall` before changing anything. 57 + - **Keep changes proportional to time passed.** A few hours? Almost nothing changes. A few days? NPCs might move, rumors spread. A week+? Factions act, situations evolve. 58 + - **Focus on entities near the player's last location.** Don't change things far away that the player can't observe. 59 + - **Use [[wikilinks]]** when referencing entities. 60 + 61 + When you've evaluated the triggers and made appropriate changes, stop.
+5 -4
src/storied/claude.py
··· 95 95 if not persist_session: 96 96 args.append("--no-session-persistence") 97 97 98 + # Always include system prompt and model so context stays fresh on resume 99 + args.extend(["--system-prompt", system_prompt, "--model", model]) 100 + 98 101 if resume_session_id: 99 102 args.extend(["--resume", resume_session_id]) 100 - else: 101 - if session_id: 102 - args.extend(["--session-id", session_id]) 103 - args.extend(["--system-prompt", system_prompt, "--model", model]) 103 + elif session_id: 104 + args.extend(["--session-id", session_id]) 104 105 105 106 return args 106 107
+109 -1
src/storied/cli.py
··· 11 11 # Slash commands available during play 12 12 SLASH_COMMANDS = { 13 13 "/help": "Show this help message", 14 + "/status": "Show character status (HP, AC, gold)", 14 15 "/save": "Save session state (without quitting)", 15 16 "/context": "Show token usage", 16 17 "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", ··· 266 267 f"{result.elapsed:.1f}s[/dim]" 267 268 ) 268 269 console.print() 269 - 270 270 transcript_path = Path(args.transcript) if args.transcript else None 271 271 engine = DMEngine( 272 272 world_id=world_id, ··· 275 275 transcript_path=transcript_path, 276 276 ) 277 277 engine.debug = args.debug 278 + 279 + # Background ticker for mid-session world advancement 280 + ticker = None 281 + if not creation_mode: 282 + from storied.planner import BackgroundTicker 283 + 284 + ticker = BackgroundTicker( 285 + world_id=world_id, 286 + player_id=player_id, 287 + base_path=Path.cwd(), 288 + ) 289 + # Kick off initial tick in background 290 + ticker.maybe_tick(engine._campaign_log) 278 291 279 292 # If in creation mode, start the conversation 280 293 if creation_mode: ··· 398 411 console.print() 399 412 continue 400 413 414 + # Handle /status command 415 + if action.strip().lower() == "/status": 416 + from storied.character import load_character as load_char 417 + 418 + char = load_char(player_id) 419 + if char: 420 + hp = char.get("hp", {}) 421 + hp_cur = hp.get("current", "?") 422 + hp_max = hp.get("max", "?") 423 + ac = char.get("ac", "?") 424 + gold = char.get("gold", 0) 425 + level = char.get("level", 1) 426 + name = char.get("name", "Unknown") 427 + char_class = char.get("class", "") 428 + race = char.get("race", "") 429 + 430 + # HP bar 431 + if isinstance(hp_cur, (int, float)) and isinstance(hp_max, (int, float)) and hp_max > 0: 432 + bar_width = 20 433 + filled = int((hp_cur / hp_max) * bar_width) 434 + bar_color = "green" if hp_cur > hp_max * 0.5 else "yellow" if hp_cur > hp_max * 0.25 else "red" 435 + bar = f"[{bar_color}]{'█' * filled}[/{bar_color}][dim]{'░' * (bar_width - filled)}[/dim]" 436 + hp_str = f"{bar} {hp_cur}/{hp_max}" 437 + else: 438 + hp_str = f"{hp_cur}/{hp_max}" 439 + 440 + console.print() 441 + console.print(f"[bold]{name}[/bold] [dim]{race} {char_class} {level}[/dim]") 442 + console.print(f" HP {hp_str}") 443 + console.print(f" AC [cyan]{ac}[/cyan] Gold [yellow]{gold}[/yellow]") 444 + console.print() 445 + else: 446 + console.print("[dim]No character loaded.[/dim]") 447 + continue 448 + 401 449 # Handle /help command 402 450 if action.strip().lower() == "/help": 403 451 console.print() ··· 460 508 f"{engine._total_output_tokens:,} out[/dim]" 461 509 ) 462 510 511 + # Check for completed background tick 512 + if ticker: 513 + tick_result = ticker.pop_result() 514 + if tick_result and tick_result.tool_calls > 0: 515 + console.print( 516 + f"[dim]The world shifted while you considered your next move. " 517 + f"({tick_result.tool_calls} changes)[/dim]" 518 + ) 519 + # Maybe launch a new tick if the day advanced 520 + ticker.maybe_tick(engine._campaign_log) 521 + 463 522 # Check if session ended (player quit gracefully) 464 523 if engine.session_ended: 465 524 console.print( ··· 549 608 flush=True, 550 609 ) 551 610 611 + return 0 612 + 613 + 614 + def cmd_tick(args: argparse.Namespace) -> int: 615 + """Advance the world by evaluating Will triggers.""" 616 + from storied.planner import tick_world 617 + 618 + world_id = args.world or "default" 619 + player_id = args.player or "default" 620 + 621 + def on_progress(msg: str) -> None: 622 + print(msg, flush=True) 623 + 624 + result = tick_world( 625 + world_id=world_id, 626 + player_id=player_id, 627 + model=args.model, 628 + on_progress=on_progress, 629 + ) 630 + 631 + if result.entities_checked == 0: 632 + print("No entities with active Will triggers.") 633 + return 0 634 + 635 + print( 636 + f"Done — {result.tool_calls} tool calls, " 637 + f"{result.input_tokens:,} in / {result.output_tokens:,} out, " 638 + f"{result.elapsed:.1f}s", 639 + flush=True, 640 + ) 552 641 return 0 553 642 554 643 ··· 719 808 help="Show what would be enriched without calling the API", 720 809 ) 721 810 plan_parser.set_defaults(func=cmd_plan) 811 + 812 + # tick command 813 + tick_parser = subparsers.add_parser("tick", help="Advance the world by evaluating Will triggers") 814 + tick_parser.add_argument( 815 + "--world", "-w", 816 + default="default", 817 + help="World ID (default: default)", 818 + ) 819 + tick_parser.add_argument( 820 + "--player", "-p", 821 + default="default", 822 + help="Player ID (default: default)", 823 + ) 824 + tick_parser.add_argument( 825 + "--model", "-m", 826 + default="claude-opus-4-6", 827 + help="Model to use for ticking (default: claude-opus-4-6)", 828 + ) 829 + tick_parser.set_defaults(func=cmd_tick) 722 830 723 831 # seed command 724 832 seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet")
+48 -6
src/storied/content.py
··· 127 127 def search( 128 128 self, query: str, content_type: str | None = None 129 129 ) -> list[SearchResult]: 130 - """Search content by keyword. 130 + """Search content by keyword with fuzzy fallback. 131 131 132 - Searches both filenames and file contents. 132 + First tries exact substring matching on filenames and content. 133 + If nothing matches, falls back to word-overlap scoring. 133 134 134 135 Args: 135 136 query: Search term ··· 139 140 List of SearchResult objects 140 141 """ 141 142 results: list[SearchResult] = [] 142 - seen_names: set[str] = set() # Avoid duplicates from layer override 143 + seen_names: set[str] = set() 143 144 query_lower = query.lower() 144 145 145 146 for search_dir, ctype in self._search_dirs(content_type): ··· 149 150 for path in search_dir.glob("*.md"): 150 151 name = path.stem 151 152 152 - # Skip if we already found this in a higher layer 153 153 if name in seen_names: 154 154 continue 155 155 156 156 content = path.read_text() 157 157 158 - # Check filename or content match 159 158 if query_lower in name.lower() or query_lower in content.lower(): 160 - # Extract a snippet around the match 161 159 snippet = self._extract_snippet(content, query) 162 160 results.append( 163 161 SearchResult( ··· 168 166 ) 169 167 ) 170 168 seen_names.add(name) 169 + 170 + # Fuzzy fallback: word-overlap scoring when exact match fails 171 + if not results: 172 + results = self._fuzzy_search(query, content_type) 171 173 172 174 return results 175 + 176 + def _fuzzy_search( 177 + self, query: str, content_type: str | None = None 178 + ) -> list[SearchResult]: 179 + """Word-overlap fallback when exact search finds nothing.""" 180 + query_words = {w for w in query.lower().split() if len(w) > 2} 181 + if not query_words: 182 + return [] 183 + 184 + scored: list[tuple[float, SearchResult]] = [] 185 + seen_names: set[str] = set() 186 + 187 + for search_dir, ctype in self._search_dirs(content_type): 188 + if not search_dir.exists(): 189 + continue 190 + 191 + for path in search_dir.glob("*.md"): 192 + name = path.stem 193 + if name in seen_names: 194 + continue 195 + 196 + name_words = set(name.lower().replace("-", " ").split()) 197 + content = path.read_text() 198 + content_lower = content.lower() 199 + 200 + # Score: name matches count double 201 + name_hits = len(query_words & name_words) 202 + content_hits = sum(1 for w in query_words if w in content_lower) 203 + score = name_hits * 2 + content_hits 204 + 205 + if score > 0: 206 + snippet = content[:100].strip() + ("..." if len(content) > 100 else "") 207 + scored.append(( 208 + score, 209 + SearchResult(name=name, path=path, content_type=ctype, snippet=snippet), 210 + )) 211 + seen_names.add(name) 212 + 213 + scored.sort(key=lambda x: x[0], reverse=True) 214 + return [r for _, r in scored[:5]] 173 215 174 216 def _extract_snippet(self, content: str, query: str, context: int = 50) -> str: 175 217 """Extract a snippet of text around the query match."""
+26 -2
src/storied/engine.py
··· 48 48 lines.append(raw.decode(errors="replace")) 49 49 50 50 51 + def _extract_roll_reason(tool_json: str) -> str | None: 52 + """Extract the reason field from accumulated roll tool JSON.""" 53 + try: 54 + args = json.loads(tool_json) 55 + return args.get("reason") 56 + except (json.JSONDecodeError, AttributeError): 57 + return None 58 + 59 + 51 60 def _tool_notification(name: str) -> str: 52 61 """Build a friendly tool notification string from an MCP tool name. 53 62 ··· 393 402 394 403 # Parse NDJSON stream from stdout 395 404 current_tool_json = "" 405 + current_tool_name = "" 406 + deferred_notification = False 396 407 assert proc.stdout is not None 397 408 398 409 for raw_line in proc.stdout: ··· 409 420 410 421 case ToolStart(name=name): 411 422 short = name.rsplit("__", 1)[-1] if "__" in name else name 423 + current_tool_name = short 424 + current_tool_json = "" 425 + 412 426 if short == "end_session": 413 427 self.session_ended = True 414 428 if short == "create_character": 415 429 self.character_created = True 416 430 417 - if self.debug: 431 + if short == "roll" and not self.debug: 432 + deferred_notification = True 433 + elif self.debug: 418 434 yield f"\n[→ {short}(...)]\n" 435 + deferred_notification = False 419 436 else: 420 437 yield _tool_notification(name) 421 - current_tool_json = "" 438 + deferred_notification = False 422 439 423 440 case ToolInputDelta(json_fragment=fragment): 424 441 current_tool_json += fragment 425 442 426 443 case ToolStop(): 444 + if deferred_notification and current_tool_name == "roll": 445 + reason = _extract_roll_reason(current_tool_json) 446 + label = f"Rolling {reason}" if reason else "Rolling" 447 + yield f"\n[{label}...]\n" 448 + 427 449 if self.debug and current_tool_json: 428 450 truncated = current_tool_json[:200] 429 451 if len(current_tool_json) > 200: 430 452 truncated += f"...+{len(current_tool_json) - 200}" 431 453 yield f"[input: {truncated}]\n" 432 454 current_tool_json = "" 455 + current_tool_name = "" 456 + deferred_notification = False 433 457 434 458 case Result() as r: 435 459 self._session_id = r.session_id
+22 -1
src/storied/log.py
··· 57 57 else: 58 58 return "Evening" 59 59 60 + def atmosphere(self) -> str: 61 + """Return a short atmospheric hint for the time of day.""" 62 + if self.hour < 5: 63 + return "deep night" 64 + elif self.hour < 7: 65 + return "first light" 66 + elif self.hour < 12: 67 + return "morning light" 68 + elif self.hour < 14: 69 + return "high sun" 70 + elif self.hour < 17: 71 + return "afternoon" 72 + elif self.hour < 20: 73 + return "fading light" 74 + elif self.hour < 22: 75 + return "lamplight and shadow" 76 + else: 77 + return "deep night" 78 + 60 79 61 80 @dataclass 62 81 class Duration: ··· 378 397 if self.current_entries: 379 398 lines.append("") 380 399 lines.append(f"**Today (Day {self.current_day}):**") 381 - for entry in self.current_entries[-5:]: # Last 5 entries 400 + if len(self.current_entries) > 10: 401 + lines.append(f"({len(self.current_entries) - 10} earlier entries today)") 402 + for entry in self.current_entries[-10:]: 382 403 lines.append(f"- {entry.event}") 383 404 384 405 return "\n".join(lines)
+195
src/storied/planner.py
··· 393 393 seed_result.output_tokens = claude_result.usage.get("output_tokens", 0) 394 394 395 395 return seed_result 396 + 397 + 398 + @dataclass 399 + class TickResult: 400 + """Result of a tick_world run.""" 401 + 402 + entities_checked: int = 0 403 + tool_calls: int = 0 404 + input_tokens: int = 0 405 + output_tokens: int = 0 406 + elapsed: float = 0.0 407 + 408 + 409 + def _find_entities_with_will( 410 + world_id: str, 411 + base_path: Path, 412 + ) -> list[tuple[str, Path]]: 413 + """Find all entities that have Will triggers defined.""" 414 + world_dir = base_path / "worlds" / world_id 415 + if not world_dir.exists(): 416 + return [] 417 + 418 + results: list[tuple[str, Path]] = [] 419 + for etype in ("npcs", "locations", "items", "factions", "threads"): 420 + type_dir = world_dir / etype 421 + if not type_dir.exists(): 422 + continue 423 + for path in type_dir.glob("*.md"): 424 + entity = _load_entity(path) 425 + if entity and entity.get("will"): 426 + results.append((path.stem, path)) 427 + 428 + return results 429 + 430 + 431 + def build_tick_context( 432 + world_id: str, 433 + player_id: str, 434 + base_path: Path, 435 + entities: list[tuple[str, Path]], 436 + ) -> str: 437 + """Build context for the world tick agent.""" 438 + parts: list[str] = [] 439 + 440 + # Campaign log and time 441 + log = CampaignLog(world_id, base_path) 442 + current_time = log.get_current_time() 443 + parts.append(f"## Current Game Time: {current_time}") 444 + 445 + # Session state for last-played context 446 + session = load_session(player_id, base_path) 447 + if session: 448 + location = session.get("location", "unknown") 449 + parts.append(f"## Player's Last Location: {location}") 450 + body = session.get("body", "") 451 + if body: 452 + parts.append(body) 453 + 454 + # Recent events 455 + recent = log.get_recent_entries(days=3) 456 + if recent: 457 + lines = ["## Recent Events", ""] 458 + for entry in recent: 459 + lines.append(f"- {entry.anchor} | {entry.event}") 460 + parts.append("\n".join(lines)) 461 + 462 + # Entities with Will triggers 463 + if entities: 464 + parts.append("## Entities with Active Triggers") 465 + parts.append("") 466 + for name, path in entities: 467 + content = path.read_text() 468 + entity_type = path.parent.name 469 + parts.append(f"### {name} ({entity_type})") 470 + parts.append(f"File: {entity_type}/{name}.md") 471 + parts.append("") 472 + parts.append(content) 473 + parts.append("") 474 + 475 + return "\n\n".join(parts) 476 + 477 + 478 + def tick_world( 479 + world_id: str = "default", 480 + player_id: str = "default", 481 + base_path: Path | None = None, 482 + model: str = "claude-opus-4-6", 483 + on_progress: Callable[[str], None] | None = None, 484 + ) -> TickResult: 485 + """Advance the world by evaluating Will triggers and adding small changes.""" 486 + if base_path is None: 487 + base_path = Path.cwd() 488 + 489 + def progress(msg: str) -> None: 490 + if on_progress: 491 + on_progress(msg) 492 + 493 + start_time = time.monotonic() 494 + 495 + # Find entities with Will triggers 496 + entities = _find_entities_with_will(world_id, base_path) 497 + progress(f"Found {len(entities)} entities with active triggers") 498 + 499 + if not entities: 500 + return TickResult(elapsed=time.monotonic() - start_time) 501 + 502 + # Build context and run 503 + context = build_tick_context(world_id, player_id, base_path, entities) 504 + system_prompt = load_prompt("world-tick") 505 + 506 + campaign_log = CampaignLog(world_id, base_path) 507 + mcp = start_mcp_server( 508 + world_id, player_id, base_path, "planner", campaign_log, 509 + ) 510 + mcp_config = build_mcp_config(mcp.url) 511 + 512 + progress(f"Ticking with {model}...") 513 + 514 + claude_result = _run_claude_collect( 515 + system_prompt=system_prompt, 516 + user_message=context, 517 + model=model, 518 + mcp_config=mcp_config, 519 + base_path=base_path, 520 + on_progress=on_progress, 521 + ) 522 + 523 + result = TickResult( 524 + entities_checked=len(entities), 525 + elapsed=time.monotonic() - start_time, 526 + ) 527 + 528 + if claude_result: 529 + result.tool_calls = claude_result.usage.get("tool_calls", 0) 530 + result.input_tokens = claude_result.usage.get("input_tokens", 0) 531 + result.output_tokens = claude_result.usage.get("output_tokens", 0) 532 + 533 + return result 534 + 535 + 536 + class BackgroundTicker: 537 + """Runs world ticks in a background thread during gameplay. 538 + 539 + Triggers a tick when the game day advances (e.g., after a long rest or 540 + multi-hour travel). Only one tick runs at a time. 541 + """ 542 + 543 + def __init__( 544 + self, 545 + world_id: str, 546 + player_id: str, 547 + base_path: Path, 548 + model: str = "claude-opus-4-6", 549 + ): 550 + self._world_id = world_id 551 + self._player_id = player_id 552 + self._base_path = base_path 553 + self._model = model 554 + self._last_tick_day: int = 0 555 + self._thread: Thread | None = None 556 + self._result: TickResult | None = None 557 + 558 + def maybe_tick(self, campaign_log: CampaignLog) -> None: 559 + """Launch a background tick if the game day advanced.""" 560 + current_day = campaign_log.get_current_time().day 561 + if current_day <= self._last_tick_day: 562 + return 563 + if self._thread and self._thread.is_alive(): 564 + return 565 + 566 + triggers = _find_entities_with_will(self._world_id, self._base_path) 567 + if not triggers: 568 + self._last_tick_day = current_day 569 + return 570 + 571 + self._last_tick_day = current_day 572 + self._result = None 573 + self._thread = Thread(target=self._run, daemon=True) 574 + self._thread.start() 575 + 576 + def _run(self) -> None: 577 + self._result = tick_world( 578 + world_id=self._world_id, 579 + player_id=self._player_id, 580 + base_path=self._base_path, 581 + model=self._model, 582 + ) 583 + 584 + def pop_result(self) -> TickResult | None: 585 + """Return and clear the last completed tick result, if any.""" 586 + if self._thread and not self._thread.is_alive() and self._result: 587 + result = self._result 588 + self._result = None 589 + return result 590 + return None
+113 -43
src/storied/tools.py
··· 5 5 """ 6 6 7 7 import re 8 + import threading 8 9 from pathlib import Path 9 10 10 11 import yaml 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] 11 24 12 25 from storied.character import create_character as char_create 13 26 from storied.character import update_character as char_update ··· 255 268 if event and duration: 256 269 log = campaign_log or load_log(world_id, base_path) 257 270 anchor = log.append_entry(event, duration, tags=tags) 258 - parts.append(f"Logged: {anchor} | {event} | {duration}") 271 + current = log.get_current_time() 272 + parts.append( 273 + f"Logged: {anchor} | {event} | {duration} → " 274 + f"Now: {current} ({current.period_of_day()}, {current.atmosphere()})" 275 + ) 259 276 260 277 # Update session state 261 278 updates = {} ··· 271 288 if updates: 272 289 result = session_update(player_id, updates, base_path) 273 290 parts.append(result) 291 + 292 + # Auto-mark present entities with this event 293 + if event and present and world_id: 294 + marked = _auto_mark_present( 295 + present, event, world_id, base_path, campaign_log, 296 + ) 297 + if marked: 298 + parts.append(f"Auto-marked: {', '.join(marked)}") 274 299 275 300 return "; ".join(parts) if parts else "No updates" 276 301 ··· 329 354 world_dir.mkdir(parents=True, exist_ok=True) 330 355 file_path = world_dir / f"{name}.md" 331 356 332 - # Load existing content if file exists (for partial updates) 333 - existing = _load_entity(file_path) 357 + lock = _get_file_lock(file_path) 358 + with lock: 359 + # Load existing content if file exists (for partial updates) 360 + existing = _load_entity(file_path) 334 361 335 - # Merge with existing content (new values override) 336 - if description is None: 337 - description = existing.get("description", "") 338 - if location is None: 339 - location = existing.get("location", "") 340 - if knows is None: 341 - knows = existing.get("knows", []) 342 - if wants is None: 343 - wants = existing.get("wants", []) 344 - if will is None: 345 - will = existing.get("will", []) 346 - was = existing.get("was", []) # Always preserve Was 362 + # Merge with existing content (new values override) 363 + if description is None: 364 + description = existing.get("description", "") 365 + if location is None: 366 + location = existing.get("location", "") 367 + if knows is None: 368 + knows = existing.get("knows", []) 369 + if wants is None: 370 + wants = existing.get("wants", []) 371 + if will is None: 372 + will = existing.get("will", []) 373 + was = existing.get("was", []) # Always preserve Was 347 374 348 - # Build file content 349 - file_content = _format_entity(name, description, location, knows, wants, will, was) 350 - file_path.write_text(file_content) 375 + # Build file content 376 + file_content = _format_entity(name, description, location, knows, wants, will, was) 377 + file_path.write_text(file_content) 351 378 352 - action = "Updated" if existing else "Established" 379 + action = "Updated" if existing else "Established" 353 380 return f"{action} {entity_type.rstrip('s')} '{name}'" 354 381 355 382 ··· 462 489 return "\n".join(lines) 463 490 464 491 492 + def _auto_mark_present( 493 + present: list[str], 494 + event: str, 495 + world_id: str, 496 + base_path: Path | None, 497 + campaign_log: CampaignLog | None, 498 + ) -> list[str]: 499 + """Auto-mark present entities with the current event. 500 + 501 + Extracts entity names from [[wikilink]] format in the present list 502 + and appends the event to each entity's Was section. 503 + """ 504 + if base_path is None: 505 + base_path = Path.cwd() 506 + 507 + marked: list[str] = [] 508 + for ref in present: 509 + # Extract name from "[[Name]]" or "[[Name]] - description" 510 + link_match = re.search(r"\[\[([^\]]+)\]\]", ref) 511 + if not link_match: 512 + continue 513 + name = link_match.group(1) 514 + 515 + # Try each entity type directory 516 + for etype in ("npcs", "locations", "items", "factions"): 517 + file_path = base_path / "worlds" / world_id / etype / f"{name}.md" 518 + if file_path.exists(): 519 + mark( 520 + entity_type=etype, 521 + name=name, 522 + event=event, 523 + world_id=world_id, 524 + base_path=base_path, 525 + campaign_log=campaign_log, 526 + ) 527 + marked.append(name) 528 + break 529 + 530 + return marked 531 + 532 + 465 533 def mark( 466 534 entity_type: str, 467 535 name: str, ··· 502 570 if not file_path.exists(): 503 571 return f"Error: Entity '{name}' not found in {entity_type}" 504 572 505 - # Load existing entity 506 - existing = _load_entity(file_path) 507 - 508 573 # Get current game time for timestamp 509 574 if campaign_log: 510 575 timestamp = campaign_log.get_current_time().to_anchor() ··· 512 577 log = load_log(world_id, base_path) 513 578 timestamp = log.get_current_time().to_anchor() 514 579 515 - # Append to Was section 516 - was = existing.get("was", []) 517 - was.append(f"{timestamp} | {event}") 580 + lock = _get_file_lock(file_path) 581 + with lock: 582 + # Load existing entity 583 + existing = _load_entity(file_path) 518 584 519 - # Remove resolved Will items 520 - will = existing.get("will", []) 521 - resolved = [] 522 - for trigger in resolves or []: 523 - if trigger in will: 524 - will.remove(trigger) 525 - resolved.append(trigger) 585 + # Append to Was section 586 + was = existing.get("was", []) 587 + was.append(f"{timestamp} | {event}") 526 588 527 - # Rebuild and save the file 528 - file_content = _format_entity( 529 - name, 530 - existing.get("description", ""), 531 - existing.get("location", ""), 532 - existing.get("knows", []), 533 - existing.get("wants", []), 534 - will, 535 - was, 536 - ) 537 - file_path.write_text(file_content) 589 + # Remove resolved Will items 590 + will = existing.get("will", []) 591 + resolved = [] 592 + for trigger in resolves or []: 593 + if trigger in will: 594 + will.remove(trigger) 595 + resolved.append(trigger) 596 + 597 + # Rebuild and save the file 598 + file_content = _format_entity( 599 + name, 600 + existing.get("description", ""), 601 + existing.get("location", ""), 602 + existing.get("knows", []), 603 + existing.get("wants", []), 604 + will, 605 + was, 606 + ) 607 + file_path.write_text(file_content) 538 608 539 609 result = f"Marked: {event}" 540 610 if resolved:
+6 -3
tests/test_claude.py
··· 42 42 assert "--model" in args 43 43 assert "sonnet" in args 44 44 45 - def test_resume_session_skips_system_prompt(self, monkeypatch): 45 + def test_resume_session_includes_system_prompt(self, monkeypatch): 46 46 monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") 47 47 args = build_claude_args( 48 - "sonnet", "ignored", "{}", 48 + "sonnet", "You are a DM", "{}", 49 49 resume_session_id="abc-123", 50 50 ) 51 51 assert "--resume" in args 52 52 assert "abc-123" in args 53 - assert "--system-prompt" not in args 53 + assert "--system-prompt" in args 54 + assert "You are a DM" in args 55 + assert "--model" in args 56 + assert "sonnet" in args 54 57 55 58 def test_no_session_persistence_flag(self, monkeypatch): 56 59 monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
+10
tests/test_log.py
··· 70 70 assert GameTime(hour=18).period_of_day() == "Evening" 71 71 assert GameTime(hour=23).period_of_day() == "Evening" 72 72 73 + def test_atmosphere(self): 74 + assert GameTime(hour=3).atmosphere() == "deep night" 75 + assert GameTime(hour=5).atmosphere() == "first light" 76 + assert GameTime(hour=9).atmosphere() == "morning light" 77 + assert GameTime(hour=13).atmosphere() == "high sun" 78 + assert GameTime(hour=15).atmosphere() == "afternoon" 79 + assert GameTime(hour=18).atmosphere() == "fading light" 80 + assert GameTime(hour=21).atmosphere() == "lamplight and shadow" 81 + assert GameTime(hour=23).atmosphere() == "deep night" 82 + 73 83 74 84 class TestDuration: 75 85 def test_parse_minutes(self):