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 session state, campaign log, and character persistence

The DM can now save and load game state across sessions:

- Session state (situation, location, present NPCs, open threads)
- Campaign log for time tracking with chunked day files
- Character sheet updates for HP, gold, equipment changes
- World persistence for NPCs, locations, and lore

The campaign log is the canonical clock - time only advances when
events are logged. Made time tracking and state saving "core
principles" in the DM prompt so it happens continuously rather
than requiring the player to ask.

Also added:
- Token usage display via /context command
- Game time in the CLI prompt
- Reason field for dice rolls (shows "Athletics: 1d20+5" not just the notation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+1819 -43
+127 -11
prompts/dm-system.md
··· 1 1 You are an expert D&D 5e Dungeon Master running a solo adventure. 2 2 3 + ## Output Format 4 + 5 + You're running in a terminal with markdown support. Use formatting to enhance readability: 6 + - **Bold** for emphasis, names, or important terms 7 + - *Italics* for character speech or internal thoughts 8 + - Horizontal rules (---) to separate scenes 9 + 10 + Keep responses focused and paced well - this is interactive fiction, not a novel. End on moments that invite player action. 11 + 3 12 ## Core Principle: Real Mechanics 4 13 5 14 This is a real D&D game with real dice rolls and real rules. Never narrate outcomes without rolling - if something could fail, roll for it. 6 15 16 + **You roll ALL dice** - both for enemies AND for the player. The player describes what they want to do, you handle all the mechanics. Never ask the player to roll. 17 + 18 + ## Core Principle: Track Time 19 + 20 + **Log events as they happen**, not at the end of the session. After each significant action or scene, call `log_event` immediately: 21 + 22 + - After a conversation: `log_event("Negotiated with the guard captain", "20 min")` 23 + - After combat ends: `log_event("Defeated dock thugs", "3 rounds")` 24 + - After travel: `log_event("Walked to the harbor district", "15 min")` 25 + - After investigation: `log_event("Searched the warehouse", "30 min")` 26 + - After a rest: `log_event("Short rest in alley", "1 hour", tags=["rest:short"])` 27 + 28 + The campaign log is the canonical clock. If you don't log it, time doesn't advance. Log frequently - every scene transition, every meaningful interaction, every passage of time. 29 + 30 + ## Core Principle: Save State 31 + 32 + **Update session state frequently** so the game can resume if interrupted. Call `update_session` whenever: 33 + 34 + - The player moves to a new location 35 + - A scene ends or the situation changes significantly 36 + - NPCs enter or leave the scene 37 + - New objectives emerge or old ones resolve 38 + 39 + Think of it as saving your game. If the session ended right now, would the next DM know what's happening? 40 + 7 41 ## When to Roll Dice 8 42 9 43 ALWAYS roll dice for: ··· 37 71 - DC 20: Hard (leap across a 20-foot chasm) 38 72 - DC 25: Very hard (pick an exceptional lock) 39 73 40 - State the DC and what they're rolling, then roll. 74 + Set the DC mentally, roll, then narrate the outcome. Don't announce DCs to the player. 75 + 76 + ## Immersion vs Mechanics 77 + 78 + **Outside combat**: Stay immersive. Don't mention dice, rolls, or numbers. Narrate outcomes naturally based on what you rolled - the player experiences the story, not the math. 79 + 80 + - BAD: "You rolled a 14, which beats the DC 12, so you pick the lock." 81 + - GOOD: "The lock clicks open after a moment's work - whoever made this wasn't expecting a skilled hand." 41 82 42 - ## Narrative Style 83 + **When presenting choices**: You can hint at difficulty or suggest what skills might help, but don't quote numbers. 43 84 44 - - Describe what the dice results mean in the fiction 45 - - A natural 20 is a critical hit or spectacular success 46 - - A natural 1 is a fumble or embarrassing failure 47 - - Near-misses and close calls are dramatic - "The arrow whistles past your ear" 85 + - BAD: "This would require a DC 20 Athletics check." 86 + - GOOD: "The cliff face is sheer and slick with spray - a difficult climb, even for someone strong." 87 + 88 + **In combat**: More mechanical transparency is fine. The player should understand the tactical situation - hits, misses, how wounded enemies look. But still narrate, don't just announce numbers. 48 89 49 90 ## Combat Flow 50 91 51 - In combat, track: 52 - - Initiative (have player roll, you roll for enemies) 53 - - HP for enemies (look up their stats) 54 - - Conditions and effects 92 + In combat, track internally: 93 + - Initiative order 94 + - Enemy HP and conditions 95 + - Active effects and durations 96 + 97 + Narrate each exchange with dramatic weight. A hit isn't just "8 damage" - describe the impact. Show how wounded enemies are through their behavior, not HP counts. 98 + 99 + ## Player Input 55 100 56 - Narrate each attack with its result: "You swing your longsword (rolls 17 vs AC 13) - a solid hit! (rolls 8 damage) The goblin staggers back, bloodied but still standing." 101 + When the player writes in "quotes", their character is literally saying those words. Otherwise, they're describing intentions or actions - you narrate the details and improvise any dialogue as the situation calls for. 102 + 103 + Examples: 104 + - Player: "Hello, barkeep!" → Character says exactly that 105 + - Player: I ask the barkeep about rumors → You narrate the conversation, voicing both sides 57 106 58 107 ## Player Agency 59 108 60 109 - Ask what the player wants to attempt, then determine if a roll is needed 61 110 - Failures create complications, not dead ends 62 111 - The world is reactive and consistent 112 + 113 + ## Character Management 114 + 115 + The player's character sheet is provided below. Use update_character to persist changes so progress is saved between sessions: 116 + 117 + - **After damage/healing**: `{"hp.current": 5}` 118 + - **After spending/gaining gold**: `{"gold": 25}` 119 + - **After using abilities**: `{"features.0.uses": 0}` (e.g., Second Wind) 120 + - **After gaining/losing equipment**: `{"section.Equipment": "- Longsword\n- New shield"}` 121 + - **After leveling up**: `{"level": 2, "hp.max": 20}` 122 + 123 + Call update_character immediately when these changes happen, not at the end of the session. 124 + 125 + ## Session State 126 + 127 + Use update_session to track the current situation so the game can resume naturally: 128 + 129 + - **When the player moves**: `location: "rusty-anchor"` 130 + - **When the scene changes**: Update `situation` with a brief summary 131 + - **When NPCs enter/leave**: Update `present` with `[[Name]]` format 132 + - **When goals change**: Update `threads` with open objectives 133 + 134 + Write situation summaries as if briefing another DM taking over mid-session. Focus on: where we are, what's happening, what's at stake, what the player seems to be pursuing. 135 + 136 + Example: 137 + ``` 138 + situation: "Satchmo is at The Rusty Anchor, having just accepted Vera's offer to investigate the warehouse. She's given him a key and warned him about the night watch." 139 + present: ["[[Vera Blackwater]] - waiting for results", "[[Henrik]] - pretending not to listen"] 140 + threads: ["Warehouse investigation - Vera's job", "Merchant attacks - 50gp from Captain"] 141 + ``` 142 + 143 + ## World Persistence 144 + 145 + Use save_to_world when you introduce entities worth remembering: 146 + 147 + - Named NPCs the player has interacted with meaningfully 148 + - Locations the player has visited (not just mentioned) 149 + - Significant events that affect the world state 150 + - Factions the player has learned about 151 + 152 + **Don't save** throwaway characters (random guard, unnamed merchant) or locations mentioned in passing. 153 + 154 + When saving, include useful details: appearance, personality, what they know, their goals, connections to other entities. 155 + 156 + ## Wiki References 157 + 158 + Use `[[Name]]` syntax when referencing saved entities in session state: 159 + 160 + - `[[Vera Blackwater]]` - links to the NPC file 161 + - `[[The Rusty Anchor]]` - links to the location file 162 + 163 + This helps load relevant context automatically when resuming sessions. 164 + 165 + ## Duration Guidelines 166 + 167 + When logging events, estimate realistic durations: 168 + 169 + | Activity | Typical Duration | 170 + |----------|------------------| 171 + | Brief exchange | 5 min | 172 + | Conversation | 15-30 min | 173 + | Searching a room | 10-20 min | 174 + | Combat | 1-5 rounds (~6 sec each) | 175 + | Short rest | 1 hour | 176 + | Long rest | 8 hours | 177 + | Walking across town | 15-30 min | 178 + | Travel between towns | hours to days |
+200
src/storied/character.py
··· 1 + """Player character loading and saving.""" 2 + 3 + import re 4 + from pathlib import Path 5 + 6 + import yaml 7 + 8 + 9 + def load_character(player_id: str, base_path: Path | None = None) -> dict | None: 10 + """Load a character file, returning frontmatter and body. 11 + 12 + Args: 13 + player_id: Player identifier (directory name under players/) 14 + base_path: Base path for players directory (defaults to cwd) 15 + 16 + Returns: 17 + Dict with frontmatter fields plus 'body' key, or None if not found 18 + """ 19 + if base_path is None: 20 + base_path = Path.cwd() 21 + 22 + char_path = base_path / "players" / player_id / "character.md" 23 + if not char_path.exists(): 24 + return None 25 + 26 + content = char_path.read_text() 27 + return parse_character(content) 28 + 29 + 30 + def parse_character(content: str) -> dict: 31 + """Parse character markdown with YAML frontmatter.""" 32 + if content.startswith("---"): 33 + # Find the closing --- 34 + end_match = re.search(r"\n---\s*\n", content[3:]) 35 + if end_match: 36 + frontmatter_end = end_match.start() + 3 37 + frontmatter_str = content[3:frontmatter_end] 38 + body = content[frontmatter_end + end_match.end() - end_match.start() :] 39 + 40 + result = yaml.safe_load(frontmatter_str) or {} 41 + result["body"] = body.strip() 42 + return result 43 + 44 + # No frontmatter, just body 45 + return {"body": content.strip()} 46 + 47 + 48 + def save_character( 49 + player_id: str, data: dict, base_path: Path | None = None 50 + ) -> None: 51 + """Save character data back to file. 52 + 53 + Args: 54 + player_id: Player identifier 55 + data: Character data with frontmatter fields and 'body' 56 + base_path: Base path for players directory 57 + """ 58 + if base_path is None: 59 + base_path = Path.cwd() 60 + 61 + char_path = base_path / "players" / player_id / "character.md" 62 + char_path.parent.mkdir(parents=True, exist_ok=True) 63 + 64 + # Separate body from frontmatter fields 65 + body = data.pop("body", "") 66 + frontmatter = data 67 + 68 + # Build the file content 69 + content = "---\n" 70 + content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 71 + content += "---\n\n" 72 + content += body 73 + 74 + char_path.write_text(content) 75 + 76 + # Put body back in data dict 77 + data["body"] = body 78 + 79 + 80 + def update_character( 81 + player_id: str, 82 + updates: dict, 83 + base_path: Path | None = None, 84 + ) -> str: 85 + """Update specific fields in the character sheet. 86 + 87 + Args: 88 + player_id: Player identifier 89 + updates: Dict of updates. Use dot notation for nested fields: 90 + {"hp.current": 5, "gold": 25} 91 + Use "section.Name" for markdown sections: 92 + {"section.Equipment": "- Sword\\n- Shield"} 93 + base_path: Base path for players directory 94 + 95 + Returns: 96 + Confirmation message 97 + """ 98 + data = load_character(player_id, base_path) 99 + if data is None: 100 + return f"No character found for player '{player_id}'" 101 + 102 + changes = [] 103 + 104 + for key, value in updates.items(): 105 + if key.startswith("section."): 106 + # Update a markdown section 107 + section_name = key[8:] # Remove "section." prefix 108 + data["body"] = _update_section(data.get("body", ""), section_name, value) 109 + changes.append(f"Updated {section_name} section") 110 + elif "." in key: 111 + # Nested frontmatter field (e.g., "hp.current") 112 + _set_nested(data, key, value) 113 + changes.append(f"{key} = {value}") 114 + else: 115 + # Top-level frontmatter field 116 + data[key] = value 117 + changes.append(f"{key} = {value}") 118 + 119 + save_character(player_id, data, base_path) 120 + return "Character updated: " + ", ".join(changes) 121 + 122 + 123 + def _set_nested(data: dict, key: str, value) -> None: 124 + """Set a nested value using dot notation.""" 125 + parts = key.split(".") 126 + current = data 127 + 128 + for part in parts[:-1]: 129 + if part not in current: 130 + current[part] = {} 131 + current = current[part] 132 + 133 + current[parts[-1]] = value 134 + 135 + 136 + def _update_section(body: str, section_name: str, new_content: str) -> str: 137 + """Update a markdown section by name.""" 138 + # Pattern to find ## Section Name and capture until next ## or end 139 + pattern = rf"(## {re.escape(section_name)}\n)(.*?)(?=\n## |\Z)" 140 + 141 + def replacer(match: re.Match) -> str: 142 + return match.group(1) + new_content.strip() + "\n" 143 + 144 + new_body, count = re.subn(pattern, replacer, body, flags=re.DOTALL) 145 + 146 + if count == 0: 147 + # Section doesn't exist, append it 148 + new_body = body.rstrip() + f"\n\n## {section_name}\n{new_content.strip()}\n" 149 + 150 + return new_body 151 + 152 + 153 + def format_character_context(data: dict) -> str: 154 + """Format character data for inclusion in the system prompt. 155 + 156 + Returns the full character sheet as readable text. 157 + """ 158 + lines = ["## Player Character\n"] 159 + 160 + # Format frontmatter as readable stats 161 + name = data.get("name", "Unknown") 162 + race = data.get("race", "") 163 + char_class = data.get("class", "") 164 + level = data.get("level", 1) 165 + 166 + lines.append(f"**{name}** - Level {level} {race} {char_class}\n") 167 + 168 + # HP and AC 169 + hp = data.get("hp", {}) 170 + if isinstance(hp, dict): 171 + lines.append(f"**HP:** {hp.get('current', '?')}/{hp.get('max', '?')}") 172 + else: 173 + lines.append(f"**HP:** {hp}") 174 + 175 + ac = data.get("ac", "?") 176 + speed = data.get("speed", 30) 177 + lines.append(f"**AC:** {ac} | **Speed:** {speed} ft\n") 178 + 179 + # Abilities 180 + abilities = data.get("abilities", {}) 181 + if abilities: 182 + ability_strs = [] 183 + for ability in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]: 184 + score = abilities.get(ability, 10) 185 + mod = (score - 10) // 2 186 + mod_str = f"+{mod}" if mod >= 0 else str(mod) 187 + ability_strs.append(f"{ability[:3].upper()} {score} ({mod_str})") 188 + lines.append(" | ".join(ability_strs) + "\n") 189 + 190 + # Gold 191 + gold = data.get("gold") 192 + if gold is not None: 193 + lines.append(f"**Gold:** {gold}\n") 194 + 195 + # Include the markdown body (equipment, features, notes, etc.) 196 + body = data.get("body", "") 197 + if body: 198 + lines.append(body) 199 + 200 + return "\n".join(lines)
+122 -21
src/storied/cli.py
··· 126 126 127 127 def cmd_play(args: argparse.Namespace) -> int: 128 128 """Start an interactive DM session.""" 129 - import readline # noqa: F401 - enables line editing for input() 129 + import atexit 130 + import readline 130 131 131 - from rich.console import Console 132 + from rich.console import Console, Group 133 + from rich.live import Live 132 134 from rich.markdown import Markdown 133 135 from rich.panel import Panel 136 + from rich.text import Text 134 137 135 138 from storied.engine import DMEngine 136 139 140 + # Set up readline history 141 + history_file = Path.home() / ".storied_history" 142 + try: 143 + readline.read_history_file(history_file) 144 + except FileNotFoundError: 145 + pass 146 + readline.set_history_length(1000) 147 + atexit.register(readline.write_history_file, history_file) 148 + 137 149 console = Console() 138 - world_id = args.world if args.world else None 150 + world_id = args.world if args.world else "default" 139 151 140 152 console.print(Panel.fit( 141 153 "[bold]Welcome to Storied![/bold]\n" 142 - "Type [cyan]quit[/cyan] or [cyan]exit[/cyan] to end the session.", 154 + "Type [cyan]quit[/cyan] or [cyan]exit[/cyan] to end the session.\n" 155 + "Type [cyan]/context[/cyan] to see token usage.", 143 156 title="Storied", 144 157 border_style="green", 145 158 )) 146 - if world_id: 147 - console.print(f"[dim]World: {world_id}[/dim]") 159 + console.print(f"[dim]World: {world_id}[/dim]") 148 160 console.print() 149 161 150 162 engine = DMEngine(world_id=world_id) 151 163 164 + def build_display(parts: list[tuple[str, str]]) -> Group: 165 + """Build display from ordered parts (type, content).""" 166 + renderables = [] 167 + prev_type = None 168 + for part_type, content in parts: 169 + if part_type == "tool": 170 + if prev_type == "text": 171 + renderables.append(Text("")) # Blank line before tools 172 + renderables.append(Text(content, style="dim")) 173 + elif part_type == "text" and content.strip(): 174 + if prev_type == "tool": 175 + renderables.append(Text("")) # Blank line after tools 176 + renderables.append(Markdown(content)) 177 + prev_type = part_type 178 + return Group(*renderables) 179 + 152 180 try: 153 181 while True: 154 182 try: 155 - action = input("> ") 183 + game_time = engine.get_current_time() 184 + action = input(f"[{game_time}] > ") 156 185 except EOFError: 157 186 print() 158 187 break ··· 164 193 console.print("[yellow]Farewell, adventurer![/yellow]") 165 194 break 166 195 196 + # Handle /context command 197 + if action.strip().lower() == "/context": 198 + stats = engine.get_context_stats() 199 + console.print() 200 + 201 + # Header with model, time, and usage 202 + pct = (stats["estimated_total"] / stats["model_limit"]) * 100 203 + total_k = stats["estimated_total"] / 1000 204 + limit_k = stats["model_limit"] / 1000 205 + game_time = engine.get_current_time() 206 + console.print( 207 + f"[bold]Context Usage[/bold] [dim]({game_time})[/dim] " 208 + f"[dim]{engine.model} · {total_k:.1f}k/{limit_k:.0f}k tokens ({pct:.1f}%)[/dim]" 209 + ) 210 + 211 + # Build visual bar (30 chars wide) 212 + bar_width = 30 213 + 214 + # Calculate proportions 215 + limit = stats["model_limit"] 216 + parts = [ 217 + ("DM Instructions", stats["system_prompt"], "bright_blue"), 218 + ("Character", stats["context_parts"].get("Character", 0), "green"), 219 + ("Log", stats["context_parts"].get("Log", 0), "bright_cyan"), 220 + ("Session", stats["context_parts"].get("Session", 0), "yellow"), 221 + ] 222 + # Add location if present 223 + if "Location" in stats["context_parts"]: 224 + parts.append(("Location", stats["context_parts"]["Location"], "cyan")) 225 + # Add NPCs 226 + for key, val in stats["context_parts"].items(): 227 + if key.startswith("NPC:"): 228 + parts.append((key, val, "magenta")) 229 + parts.append(("Messages", stats["messages"], "bright_magenta")) 230 + 231 + # Build the bar 232 + bar = "" 233 + legend_items = [] 234 + for name, tokens, color in parts: 235 + if tokens > 0: 236 + width = max(1, int((tokens / limit) * bar_width)) 237 + bar += f"[{color}]{'█' * width}[/{color}]" 238 + pct_part = (tokens / limit) * 100 239 + legend_items.append((name, tokens, pct_part, color)) 240 + 241 + # Fill remaining with dim blocks 242 + used_width = sum(max(1, int((t / limit) * bar_width)) for _, t, _ in parts if t > 0) 243 + remaining_width = bar_width - used_width 244 + if remaining_width > 0: 245 + bar += f"[dim]{'░' * remaining_width}[/dim]" 246 + 247 + console.print(bar) 248 + 249 + # Legend 250 + for name, tokens, pct_part, color in legend_items: 251 + tokens_str = f"{tokens:,}" if tokens < 1000 else f"{tokens/1000:.1f}k" 252 + console.print(f" [{color}]█[/{color}] {name}: [dim]{tokens_str} ({pct_part:.1f}%)[/dim]") 253 + 254 + # Free space 255 + free_pct = (stats["estimated_remaining"] / limit) * 100 256 + free_k = stats["estimated_remaining"] / 1000 257 + console.print(f" [dim]░[/dim] Free: [dim]{free_k:.1f}k ({free_pct:.1f}%)[/dim]") 258 + 259 + # Session totals if we have them 260 + if stats["total_input"] > 0: 261 + console.print() 262 + console.print("[dim]Session totals:[/dim]") 263 + console.print(f" [dim]Input: {stats['total_input']:,} · Output: {stats['total_output']:,}[/dim]") 264 + 265 + console.print() 266 + continue 267 + 167 268 try: 168 - needs_newline = False 169 - for chunk in engine.stream_action(action): 170 - # Check if this is a tool notification 171 - if chunk.startswith("\n[") or chunk.startswith("Rolled "): 172 - if needs_newline: 173 - print() # Finish current line 174 - console.print(f"[dim]{chunk.strip()}[/dim]") 175 - needs_newline = False 176 - else: 177 - # Stream text directly 178 - print(chunk, end="", flush=True) 179 - needs_newline = not chunk.endswith("\n") 269 + console.print() # Blank line before DM response 270 + parts: list[tuple[str, str]] = [] # (type, content) in order 271 + 272 + with Live(console=console, refresh_per_second=10) as live: 273 + for chunk in engine.stream_action(action): 274 + if chunk.startswith("\n[") or chunk.startswith("Rolled "): 275 + parts.append(("tool", chunk.strip())) 276 + else: 277 + # Append to last text part, or create new one 278 + if parts and parts[-1][0] == "text": 279 + parts[-1] = ("text", parts[-1][1] + chunk) 280 + else: 281 + parts.append(("text", chunk)) 282 + live.update(build_display(parts)) 180 283 181 - if needs_newline: 182 - print() # Finish final line 183 284 console.print() 184 285 except KeyboardInterrupt: 185 286 console.print("\n[red][Interrupted][/red]")
+204 -8
src/storied/engine.py
··· 5 5 6 6 import anthropic 7 7 8 + from storied.character import format_character_context, load_character 9 + from storied.content import ContentResolver 10 + from storied.log import CampaignLog 11 + from storied.session import ( 12 + extract_wiki_links, 13 + format_session_context, 14 + load_session, 15 + name_to_slug, 16 + ) 8 17 from storied.tools import TOOL_DEFINITIONS, execute_tool 9 18 10 19 ··· 21 30 22 31 def __init__( 23 32 self, 24 - world_id: str | None = None, 33 + world_id: str = "default", 34 + player_id: str = "default", 25 35 base_path: Path | None = None, 26 36 model: str = "claude-sonnet-4-20250514", 27 37 ): 28 38 """Initialize the DM engine. 29 39 30 40 Args: 31 - world_id: Optional world ID for world-specific content 41 + world_id: World ID for world-specific content (default: "default") 42 + player_id: Player identifier for character loading (default: "default") 32 43 base_path: Base path for content resolution (defaults to cwd) 33 44 model: Claude model to use 34 45 """ 35 46 self.client = anthropic.Anthropic() 36 47 self.model = model 37 48 self.world_id = world_id 49 + self.player_id = player_id 38 50 self.base_path = base_path or Path.cwd() 39 51 self.messages: list[dict] = [] 40 - self.system_prompt = load_prompt("dm-system") 52 + 53 + # Token tracking 54 + self.last_usage: dict = {"input_tokens": 0, "output_tokens": 0} 55 + self.total_input_tokens: int = 0 56 + self.total_output_tokens: int = 0 57 + 58 + # Campaign log for time tracking 59 + self._campaign_log = CampaignLog(self.player_id, self.base_path) 60 + 61 + # Build system prompt with full context 62 + self._base_prompt = load_prompt("dm-system") 63 + self._context_parts: dict[str, str] = {} 64 + self.system_prompt = self._base_prompt + "\n\n" + self._build_context() 65 + 66 + def _build_context(self) -> str: 67 + """Build context string for system prompt. 68 + 69 + Loads and formats: 70 + 1. Character sheet (always) 71 + 2. Campaign log (current time + recent events) 72 + 3. Session state if exists (situation, present, threads) 73 + 4. Current location if set 74 + 5. Present entities from [[wiki links]] 75 + """ 76 + parts = [] 77 + self._context_parts = {} 78 + 79 + # 1. Character sheet 80 + character = load_character(self.player_id, self.base_path) 81 + if character: 82 + char_context = format_character_context(character) 83 + self._context_parts["Character"] = char_context 84 + parts.append(char_context) 85 + 86 + # 2. Campaign log 87 + log_context = self._campaign_log.format_for_context() 88 + if log_context: 89 + self._context_parts["Log"] = log_context 90 + parts.append(log_context) 91 + 92 + # 3. Session state 93 + session = load_session(self.player_id, self.base_path) 94 + if session: 95 + session_context = format_session_context(session) 96 + self._context_parts["Session"] = session_context 97 + parts.append(session_context) 98 + 99 + # 4. Current location 100 + location_slug = session.get("location") 101 + if location_slug and self.world_id: 102 + location_content = self._load_world_content("locations", location_slug) 103 + if location_content: 104 + loc_context = self._format_entity("Location", location_content) 105 + self._context_parts["Location"] = loc_context 106 + parts.append(loc_context) 107 + 108 + # 5. Present entities from wiki links 109 + present_text = session.get("body", "") 110 + for name in extract_wiki_links(present_text): 111 + entity = self._find_entity(name) 112 + if entity: 113 + entity_context = self._format_entity("NPC", entity) 114 + self._context_parts[f"NPC:{name}"] = entity_context 115 + parts.append(entity_context) 116 + 117 + return "\n\n---\n\n".join(parts) 118 + 119 + def _load_world_content(self, content_type: str, name: str) -> dict | None: 120 + """Load world content by type and name.""" 121 + if not self.world_id: 122 + return None 123 + resolver = ContentResolver(base_path=self.base_path, world_id=self.world_id) 124 + return resolver.load(name, content_type=content_type) 125 + 126 + def _find_entity(self, name: str) -> dict | None: 127 + """Find an entity by name, searching NPCs then locations.""" 128 + if not self.world_id: 129 + return None 130 + resolver = ContentResolver(base_path=self.base_path, world_id=self.world_id) 131 + 132 + # Try NPCs first 133 + slug = name_to_slug(name) 134 + content = resolver.load(slug, content_type="npcs") 135 + if content: 136 + return content 137 + 138 + # Try locations 139 + content = resolver.load(slug, content_type="locations") 140 + if content: 141 + return content 142 + 143 + return None 144 + 145 + def _format_entity(self, entity_type: str, content: dict) -> str: 146 + """Format an entity for context display.""" 147 + name = content.get("name", "Unknown") 148 + body = content.get("body", "") 149 + return f"## {entity_type}: {name}\n\n{body}" 150 + 151 + @staticmethod 152 + def _estimate_tokens(text: str) -> int: 153 + """Rough token estimate (~4 chars per token).""" 154 + return len(text) // 4 155 + 156 + def get_context_stats(self) -> dict: 157 + """Get breakdown of context window usage. 158 + 159 + Returns dict with: 160 + - model_limit: context window size for model 161 + - system_prompt: base DM instructions tokens 162 + - context_parts: dict of component -> estimated tokens 163 + - messages: conversation history tokens (estimated) 164 + - last_input: actual input tokens from last API call 165 + - last_output: actual output tokens from last API call 166 + - total_input: cumulative input tokens this session 167 + - total_output: cumulative output tokens this session 168 + """ 169 + # Model context limits (approximate) 170 + model_limits = { 171 + "claude-sonnet-4-20250514": 200_000, 172 + "claude-opus-4-20250514": 200_000, 173 + } 174 + model_limit = model_limits.get(self.model, 200_000) 175 + 176 + # Estimate system prompt components 177 + base_prompt_tokens = self._estimate_tokens(self._base_prompt) 178 + 179 + context_breakdown = {} 180 + for name, content in self._context_parts.items(): 181 + context_breakdown[name] = self._estimate_tokens(content) 182 + 183 + # Estimate messages 184 + import json 185 + messages_str = json.dumps(self.messages) 186 + messages_tokens = self._estimate_tokens(messages_str) 187 + 188 + # Total estimated context 189 + context_total = base_prompt_tokens + sum(context_breakdown.values()) 190 + estimated_total = context_total + messages_tokens 191 + 192 + return { 193 + "model_limit": model_limit, 194 + "system_prompt": base_prompt_tokens, 195 + "context_parts": context_breakdown, 196 + "context_total": context_total, 197 + "messages": messages_tokens, 198 + "estimated_total": estimated_total, 199 + "estimated_remaining": model_limit - estimated_total, 200 + "last_input": self.last_usage.get("input_tokens", 0), 201 + "last_output": self.last_usage.get("output_tokens", 0), 202 + "total_input": self.total_input_tokens, 203 + "total_output": self.total_output_tokens, 204 + } 41 205 42 206 def process_action(self, player_input: str) -> str: 43 207 """Process player input and return DM narrative. ··· 109 273 # Get the final message for conversation history 110 274 final_message = stream.get_final_message() 111 275 276 + # Track token usage 277 + if final_message.usage: 278 + self.last_usage = { 279 + "input_tokens": final_message.usage.input_tokens, 280 + "output_tokens": final_message.usage.output_tokens, 281 + } 282 + self.total_input_tokens += final_message.usage.input_tokens 283 + self.total_output_tokens += final_message.usage.output_tokens 284 + 112 285 # Build assistant content for conversation history 113 286 for block in final_message.content: 114 - if block.type == "text": 287 + if block.type == "text" and block.text: 115 288 assistant_content.append({"type": "text", "text": block.text}) 116 289 elif block.type == "tool_use": 117 290 assistant_content.append( ··· 123 296 } 124 297 ) 125 298 126 - # Add assistant response to conversation 127 - self.messages.append({"role": "assistant", "content": assistant_content}) 299 + # Add assistant response to conversation (skip if empty) 300 + if assistant_content: 301 + self.messages.append({"role": "assistant", "content": assistant_content}) 128 302 129 303 # If there were tool uses, execute them and continue the loop 130 304 if tool_uses: ··· 132 306 for tool_use in tool_uses: 133 307 # Show the user what's happening 134 308 if tool_use["name"] == "roll_dice": 135 - yield f"\n[Rolling {tool_use['input'].get('notation', '?')}...]\n" 309 + reason = tool_use["input"].get("reason", "") 310 + notation = tool_use["input"].get("notation", "?") 311 + if reason: 312 + yield f"\n[{reason}: {notation}...]\n" 313 + else: 314 + yield f"\n[Rolling {notation}...]\n" 136 315 elif tool_use["name"] == "lookup_rule": 137 316 yield f"\n[Looking up: {tool_use['input'].get('query', '?')}...]\n" 138 317 elif tool_use["name"] == "query_world": 139 318 yield f"\n[Checking world: {tool_use['input'].get('query', '?')}...]\n" 319 + elif tool_use["name"] == "update_character": 320 + yield "\n[Updating character sheet...]\n" 321 + elif tool_use["name"] == "update_session": 322 + yield "\n[Updating session state...]\n" 323 + elif tool_use["name"] == "save_to_world": 324 + name = tool_use["input"].get("name", "?") 325 + yield f"\n[Saving to world: {name}...]\n" 326 + elif tool_use["name"] == "log_event": 327 + event = tool_use["input"].get("event", "?") 328 + duration = tool_use["input"].get("duration", "?") 329 + yield f"\n[Logging: {event} ({duration})]...\n" 140 330 141 331 result = execute_tool( 142 332 tool_use["name"], 143 333 tool_use["input"], 144 334 world_id=self.world_id, 335 + player_id=self.player_id, 145 336 base_path=self.base_path, 337 + campaign_log=self._campaign_log, 146 338 ) 147 339 148 340 # Show dice roll results immediately ··· 153 345 { 154 346 "type": "tool_result", 155 347 "tool_use_id": tool_use["id"], 156 - "content": result, 348 + "content": result or "Done", 157 349 } 158 350 ) 159 351 ··· 170 362 def reset(self) -> None: 171 363 """Reset the conversation history.""" 172 364 self.messages = [] 365 + 366 + def get_current_time(self) -> str: 367 + """Get the current game time as a display string.""" 368 + return str(self._campaign_log.get_current_time())
+396
src/storied/log.py
··· 1 + """Campaign log for tracking in-game time and events.""" 2 + 3 + from __future__ import annotations 4 + 5 + import re 6 + from dataclasses import dataclass, field 7 + from pathlib import Path 8 + 9 + import yaml 10 + 11 + 12 + @dataclass 13 + class GameTime: 14 + """Represents a point in game time.""" 15 + 16 + day: int = 1 17 + hour: int = 6 18 + minute: int = 0 19 + 20 + def __str__(self) -> str: 21 + return f"Day {self.day}, {self.hour:02d}:{self.minute:02d}" 22 + 23 + def to_anchor(self) -> str: 24 + """Return timestamp anchor like #d1-0600.""" 25 + return f"#d{self.day}-{self.hour:02d}{self.minute:02d}" 26 + 27 + @classmethod 28 + def from_anchor(cls, anchor: str) -> GameTime: 29 + """Parse an anchor like #d1-0600 or d1-0600.""" 30 + anchor = anchor.lstrip("#") 31 + match = re.match(r"d(\d+)-(\d{2})(\d{2})", anchor) 32 + if not match: 33 + raise ValueError(f"Invalid time anchor: {anchor}") 34 + return cls( 35 + day=int(match.group(1)), 36 + hour=int(match.group(2)), 37 + minute=int(match.group(3)), 38 + ) 39 + 40 + def add_duration(self, duration: Duration) -> GameTime: 41 + """Return a new GameTime advanced by the duration.""" 42 + total_minutes = self.hour * 60 + self.minute + duration.total_minutes 43 + days_passed, remaining_minutes = divmod(total_minutes, 24 * 60) 44 + new_hour, new_minute = divmod(remaining_minutes, 60) 45 + return GameTime( 46 + day=self.day + days_passed, 47 + hour=new_hour, 48 + minute=new_minute, 49 + ) 50 + 51 + def period_of_day(self) -> str: 52 + """Return Morning/Afternoon/Evening based on hour.""" 53 + if self.hour < 12: 54 + return "Morning" 55 + elif self.hour < 18: 56 + return "Afternoon" 57 + else: 58 + return "Evening" 59 + 60 + 61 + @dataclass 62 + class Duration: 63 + """Represents a duration of time.""" 64 + 65 + minutes: int = 0 66 + raw: str = "" 67 + 68 + @property 69 + def total_minutes(self) -> int: 70 + return self.minutes 71 + 72 + def __str__(self) -> str: 73 + return self.raw or f"{self.minutes} min" 74 + 75 + @classmethod 76 + def parse(cls, text: str) -> Duration: 77 + """Parse duration from text like '30 min', '2 hours', '3 days', '5 rounds'.""" 78 + text = text.strip().lower() 79 + 80 + # Try common patterns 81 + patterns = [ 82 + (r"(\d+)\s*(?:day|days)", lambda m: int(m.group(1)) * 24 * 60), 83 + (r"(\d+)\s*(?:hour|hours|hr|hrs|h)", lambda m: int(m.group(1)) * 60), 84 + (r"(\d+)\s*(?:minute|minutes|min|mins|m)", lambda m: int(m.group(1))), 85 + (r"(\d+)\s*(?:round|rounds|rnd|rnds|r)", lambda m: max(1, int(m.group(1)) // 10)), 86 + (r"scene", lambda m: 5), # Scene change is ~5 min 87 + ] 88 + 89 + for pattern, converter in patterns: 90 + match = re.search(pattern, text) 91 + if match: 92 + return cls(minutes=converter(match), raw=text) 93 + 94 + # Try bare number as minutes 95 + match = re.match(r"(\d+)", text) 96 + if match: 97 + return cls(minutes=int(match.group(1)), raw=text) 98 + 99 + return cls(minutes=0, raw=text) 100 + 101 + 102 + @dataclass 103 + class LogEntry: 104 + """A single entry in the campaign log.""" 105 + 106 + anchor: str 107 + event: str 108 + duration: Duration 109 + tags: list[str] = field(default_factory=list) 110 + 111 + @classmethod 112 + def parse(cls, line: str) -> LogEntry | None: 113 + """Parse a log entry line like '- #d1-0600 | Arrived | 30 min | tag'.""" 114 + line = line.strip() 115 + if not line.startswith("- #"): 116 + return None 117 + 118 + # Remove leading "- " 119 + line = line[2:] 120 + 121 + parts = [p.strip() for p in line.split("|")] 122 + if len(parts) < 3: 123 + return None 124 + 125 + anchor = parts[0] 126 + event = parts[1] 127 + duration = Duration.parse(parts[2]) 128 + tags = parts[3].split(",") if len(parts) > 3 else [] 129 + tags = [t.strip() for t in tags if t.strip()] 130 + 131 + return cls(anchor=anchor, event=event, duration=duration, tags=tags) 132 + 133 + def to_line(self) -> str: 134 + """Format as a log line.""" 135 + parts = [self.anchor, self.event, str(self.duration)] 136 + if self.tags: 137 + parts.append(", ".join(self.tags)) 138 + return "- " + " | ".join(parts) 139 + 140 + 141 + class CampaignLog: 142 + """Manages the campaign log files.""" 143 + 144 + def __init__(self, player_id: str = "default", base_path: Path | None = None): 145 + self.player_id = player_id 146 + self.base_path = base_path or Path.cwd() 147 + self.log_dir = self.base_path / "players" / player_id / "log" 148 + 149 + # Load or initialize state 150 + self._load_index() 151 + 152 + def _load_index(self) -> None: 153 + """Load the log index or initialize defaults.""" 154 + index_path = self.log_dir / "index.md" 155 + 156 + if not index_path.exists(): 157 + self.current_day = 1 158 + self.current_time = GameTime(day=1, hour=6, minute=0) 159 + self.previous_summaries: list[str] = [] 160 + self.current_entries: list[LogEntry] = [] 161 + return 162 + 163 + content = index_path.read_text() 164 + self._parse_index(content) 165 + 166 + def _parse_index(self, content: str) -> None: 167 + """Parse index.md content.""" 168 + # Defaults 169 + self.current_day = 1 170 + self.current_time = GameTime(day=1, hour=6, minute=0) 171 + body = content 172 + 173 + # Parse frontmatter if present 174 + if content.startswith("---"): 175 + end_match = re.search(r"\n---\s*\n", content[3:]) 176 + if end_match: 177 + frontmatter_end = end_match.start() + 3 178 + frontmatter_str = content[3:frontmatter_end] 179 + body = content[frontmatter_end + end_match.end() - end_match.start() :] 180 + 181 + fm = yaml.safe_load(frontmatter_str) or {} 182 + self.current_day = fm.get("current_day", 1) 183 + time_str = fm.get("current_time", "d1-0600") 184 + self.current_time = GameTime.from_anchor(time_str) 185 + 186 + # Parse previous day summaries 187 + self.previous_summaries = [] 188 + in_previous = False 189 + for line in body.split("\n"): 190 + if line.strip() == "## Previous Days": 191 + in_previous = True 192 + continue 193 + if line.startswith("## ") and in_previous: 194 + in_previous = False 195 + if in_previous and line.startswith("- "): 196 + self.previous_summaries.append(line[2:].strip()) 197 + 198 + # Parse current day entries 199 + self.current_entries = [] 200 + for line in body.split("\n"): 201 + entry = LogEntry.parse(line) 202 + if entry: 203 + self.current_entries.append(entry) 204 + 205 + def _save_index(self) -> None: 206 + """Save the current state to index.md.""" 207 + self.log_dir.mkdir(parents=True, exist_ok=True) 208 + index_path = self.log_dir / "index.md" 209 + 210 + # Build content 211 + lines = [ 212 + "---", 213 + f"current_day: {self.current_day}", 214 + f"current_time: {self.current_time.to_anchor().lstrip('#')}", 215 + "---", 216 + "", 217 + "# Campaign Log", 218 + ] 219 + 220 + # Previous days section 221 + if self.previous_summaries: 222 + lines.append("") 223 + lines.append("## Previous Days") 224 + for summary in self.previous_summaries: 225 + lines.append(f"- {summary}") 226 + 227 + # Current day section 228 + lines.append("") 229 + lines.append(f"## Day {self.current_day} (Current)") 230 + 231 + # Group entries by period 232 + periods: dict[str, list[LogEntry]] = {} 233 + for entry in self.current_entries: 234 + try: 235 + time = GameTime.from_anchor(entry.anchor) 236 + period = time.period_of_day() 237 + except ValueError: 238 + period = "Morning" 239 + if period not in periods: 240 + periods[period] = [] 241 + periods[period].append(entry) 242 + 243 + for period in ["Morning", "Afternoon", "Evening"]: 244 + if period in periods: 245 + lines.append("") 246 + lines.append(f"### {period}") 247 + for entry in periods[period]: 248 + lines.append(entry.to_line()) 249 + 250 + lines.append("") 251 + index_path.write_text("\n".join(lines)) 252 + 253 + def append_entry( 254 + self, 255 + event: str, 256 + duration: str | Duration, 257 + advance_time: bool = True, 258 + tags: list[str] | None = None, 259 + ) -> str: 260 + """Append an event to the log and return its anchor.""" 261 + if isinstance(duration, str): 262 + duration = Duration.parse(duration) 263 + 264 + anchor = self.current_time.to_anchor() 265 + entry = LogEntry( 266 + anchor=anchor, 267 + event=event, 268 + duration=duration, 269 + tags=tags or [], 270 + ) 271 + self.current_entries.append(entry) 272 + 273 + if advance_time: 274 + self.current_time = self.current_time.add_duration(duration) 275 + 276 + # Check if we crossed into a new day 277 + if self.current_time.day > self.current_day: 278 + self._roll_day() 279 + 280 + self._save_index() 281 + return anchor 282 + 283 + def _roll_day(self) -> None: 284 + """Archive current day and start a new one.""" 285 + # Create summary for current day 286 + if self.current_entries: 287 + events = [e.event for e in self.current_entries[:3]] 288 + summary = f"Day {self.current_day}: " + ", ".join(events) 289 + if len(self.current_entries) > 3: 290 + summary += ", ..." 291 + self.previous_summaries.append(summary) 292 + 293 + # Save full day log 294 + self._save_day_file(self.current_day) 295 + 296 + # Start new day 297 + self.current_day = self.current_time.day 298 + self.current_entries = [] 299 + 300 + def _save_day_file(self, day: int) -> None: 301 + """Save a full day's log to day-NNN.md.""" 302 + day_path = self.log_dir / f"day-{day:03d}.md" 303 + 304 + # Build summary 305 + events = [e.event for e in self.current_entries[:3]] 306 + summary = ", ".join(events) 307 + if len(self.current_entries) > 3: 308 + summary += ", ..." 309 + 310 + lines = [ 311 + "---", 312 + f"day: {day}", 313 + f"summary: {summary}", 314 + "---", 315 + "", 316 + f"# Day {day}", 317 + ] 318 + 319 + # Group by period 320 + periods: dict[str, list[LogEntry]] = {} 321 + for entry in self.current_entries: 322 + try: 323 + time = GameTime.from_anchor(entry.anchor) 324 + period = time.period_of_day() 325 + except ValueError: 326 + period = "Morning" 327 + if period not in periods: 328 + periods[period] = [] 329 + periods[period].append(entry) 330 + 331 + for period in ["Morning", "Afternoon", "Evening"]: 332 + if period in periods: 333 + lines.append("") 334 + lines.append(f"### {period}") 335 + for entry in periods[period]: 336 + lines.append(entry.to_line()) 337 + 338 + lines.append("") 339 + day_path.write_text("\n".join(lines)) 340 + 341 + def get_current_time(self) -> GameTime: 342 + """Return the current game time.""" 343 + return self.current_time 344 + 345 + def format_for_context(self) -> str: 346 + """Format the log index for inclusion in system prompt.""" 347 + lines = [f"## Campaign Time: {self.current_time}"] 348 + 349 + if self.previous_summaries: 350 + lines.append("") 351 + lines.append("**Previous Days:**") 352 + for summary in self.previous_summaries[-3:]: # Last 3 days 353 + lines.append(f"- {summary}") 354 + 355 + if self.current_entries: 356 + lines.append("") 357 + lines.append(f"**Today (Day {self.current_day}):**") 358 + for entry in self.current_entries[-5:]: # Last 5 entries 359 + lines.append(f"- {entry.event}") 360 + 361 + return "\n".join(lines) 362 + 363 + def time_since_rest(self, rest_type: str = "short") -> Duration: 364 + """Calculate time since last rest of given type.""" 365 + tag = f"rest:{rest_type}" 366 + 367 + # Search backwards through entries 368 + total_minutes = 0 369 + for entry in reversed(self.current_entries): 370 + if tag in entry.tags: 371 + return Duration(minutes=total_minutes) 372 + total_minutes += entry.duration.total_minutes 373 + 374 + # If not found in current day, it's been longer 375 + return Duration(minutes=total_minutes + 8 * 60) # Add 8 hours as estimate 376 + 377 + 378 + def load_log(player_id: str = "default", base_path: Path | None = None) -> CampaignLog: 379 + """Load or create a campaign log for a player.""" 380 + return CampaignLog(player_id=player_id, base_path=base_path) 381 + 382 + 383 + def log_event( 384 + event: str, 385 + duration: str, 386 + advance_time: bool = True, 387 + tags: list[str] | None = None, 388 + player_id: str = "default", 389 + base_path: Path | None = None, 390 + ) -> str: 391 + """Convenience function to append an event to the log. 392 + 393 + Returns the timestamp anchor for the event. 394 + """ 395 + log = load_log(player_id, base_path) 396 + return log.append_entry(event, duration, advance_time, tags)
+200
src/storied/session.py
··· 1 + """Session state management for persistent game state.""" 2 + 3 + import re 4 + from datetime import datetime, timezone 5 + from pathlib import Path 6 + 7 + import yaml 8 + 9 + 10 + def load_session(player_id: str, base_path: Path | None = None) -> dict | None: 11 + """Load session state for a player. 12 + 13 + Args: 14 + player_id: Player identifier (directory name under players/) 15 + base_path: Base path for players directory (defaults to cwd) 16 + 17 + Returns: 18 + Dict with frontmatter fields plus 'body' key, or None if not found 19 + """ 20 + if base_path is None: 21 + base_path = Path.cwd() 22 + 23 + session_path = base_path / "players" / player_id / "session.md" 24 + if not session_path.exists(): 25 + return None 26 + 27 + content = session_path.read_text() 28 + return parse_session(content) 29 + 30 + 31 + def parse_session(content: str) -> dict: 32 + """Parse session markdown with YAML frontmatter.""" 33 + if content.startswith("---"): 34 + end_match = re.search(r"\n---\s*\n", content[3:]) 35 + if end_match: 36 + frontmatter_end = end_match.start() + 3 37 + frontmatter_str = content[3:frontmatter_end] 38 + body = content[frontmatter_end + end_match.end() - end_match.start() :] 39 + 40 + result = yaml.safe_load(frontmatter_str) or {} 41 + result["body"] = body.strip() 42 + return result 43 + 44 + return {"body": content.strip()} 45 + 46 + 47 + def save_session(player_id: str, data: dict, base_path: Path | None = None) -> None: 48 + """Save session state to file. 49 + 50 + Args: 51 + player_id: Player identifier 52 + data: Session data with frontmatter fields and 'body' 53 + base_path: Base path for players directory 54 + """ 55 + if base_path is None: 56 + base_path = Path.cwd() 57 + 58 + session_path = base_path / "players" / player_id / "session.md" 59 + session_path.parent.mkdir(parents=True, exist_ok=True) 60 + 61 + # Update timestamp 62 + data["updated"] = datetime.now(timezone.utc).isoformat() 63 + 64 + # Separate body from frontmatter 65 + body = data.pop("body", "") 66 + frontmatter = data 67 + 68 + # Build file content 69 + content = "---\n" 70 + content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 71 + content += "---\n\n" 72 + content += body 73 + 74 + session_path.write_text(content) 75 + 76 + # Restore body to dict 77 + data["body"] = body 78 + 79 + 80 + def update_session( 81 + player_id: str, 82 + updates: dict, 83 + base_path: Path | None = None, 84 + ) -> str: 85 + """Update specific fields in the session state. 86 + 87 + Args: 88 + player_id: Player identifier 89 + updates: Dict of updates. Keys can be: 90 + - "situation": Replace the Situation section 91 + - "present": Replace the Present section (list of strings) 92 + - "threads": Replace the Open Threads section (list of strings) 93 + - "location": Update the location in frontmatter 94 + - "world": Update the world in frontmatter 95 + base_path: Base path for players directory 96 + 97 + Returns: 98 + Confirmation message 99 + """ 100 + data = load_session(player_id, base_path) 101 + if data is None: 102 + # Create new session 103 + data = {"body": ""} 104 + 105 + changes = [] 106 + body = data.get("body", "") 107 + 108 + for key, value in updates.items(): 109 + if key == "situation": 110 + body = _update_section(body, "Situation", value) 111 + changes.append("Updated situation") 112 + elif key == "present": 113 + if isinstance(value, list): 114 + value = "\n".join(f"- {item}" for item in value) 115 + body = _update_section(body, "Present", value) 116 + changes.append("Updated present NPCs/entities") 117 + elif key == "threads": 118 + if isinstance(value, list): 119 + value = "\n".join(f"- {item}" for item in value) 120 + body = _update_section(body, "Open Threads", value) 121 + changes.append("Updated open threads") 122 + elif key in ("location", "world"): 123 + data[key] = value 124 + changes.append(f"{key} = {value}") 125 + else: 126 + data[key] = value 127 + changes.append(f"{key} = {value}") 128 + 129 + data["body"] = body 130 + save_session(player_id, data, base_path) 131 + 132 + if not changes: 133 + return "No changes made to session" 134 + return "Session updated: " + ", ".join(changes) 135 + 136 + 137 + def _update_section(body: str, section_name: str, new_content: str) -> str: 138 + """Update a markdown section by name.""" 139 + pattern = rf"(## {re.escape(section_name)}\n)(.*?)(?=\n## |\Z)" 140 + 141 + def replacer(match: re.Match) -> str: 142 + return match.group(1) + new_content.strip() + "\n" 143 + 144 + new_body, count = re.subn(pattern, replacer, body, flags=re.DOTALL) 145 + 146 + if count == 0: 147 + # Section doesn't exist, append it 148 + new_body = body.rstrip() + f"\n\n## {section_name}\n{new_content.strip()}\n" 149 + 150 + return new_body 151 + 152 + 153 + def extract_wiki_links(text: str) -> list[str]: 154 + """Extract [[wiki-style]] links from text. 155 + 156 + Args: 157 + text: Text containing [[Name]] style references 158 + 159 + Returns: 160 + List of referenced names (without brackets) 161 + """ 162 + if not text: 163 + return [] 164 + return re.findall(r"\[\[([^\]]+)\]\]", text) 165 + 166 + 167 + def name_to_slug(name: str) -> str: 168 + """Convert a display name to a file slug. 169 + 170 + Args: 171 + name: Display name like "Vera Blackwater" 172 + 173 + Returns: 174 + Slug like "vera-blackwater" 175 + """ 176 + slug = name.lower() 177 + slug = re.sub(r"[^a-z0-9\s-]", "", slug) 178 + slug = re.sub(r"\s+", "-", slug) 179 + slug = re.sub(r"-+", "-", slug) 180 + return slug.strip("-") 181 + 182 + 183 + def format_session_context(data: dict) -> str: 184 + """Format session data for inclusion in the system prompt. 185 + 186 + Returns a readable summary of the current session state. 187 + """ 188 + lines = ["## Current Session\n"] 189 + 190 + # Location 191 + location = data.get("location") 192 + if location: 193 + lines.append(f"**Location:** {location}\n") 194 + 195 + # Body contains the situation, present, threads sections 196 + body = data.get("body", "") 197 + if body: 198 + lines.append(body) 199 + 200 + return "\n".join(lines)
+335 -3
src/storied/tools.py
··· 6 6 7 7 from pathlib import Path 8 8 9 + import yaml 10 + 11 + from storied.character import update_character as char_update 9 12 from storied.content import ContentResolver 10 13 from storied.dice import roll as dice_roll 14 + from storied.log import CampaignLog, load_log 15 + from storied.session import name_to_slug 16 + from storied.session import update_session as session_update 11 17 12 18 13 - def roll_dice(notation: str) -> dict: 19 + def roll_dice(notation: str, reason: str | None = None) -> dict: 14 20 """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. 15 21 16 22 Use for attack rolls, skill checks, saving throws, and damage rolls. ··· 18 24 19 25 Args: 20 26 notation: Dice notation string (e.g., "1d20+5", "2d6", "4d6kh3") 27 + reason: Brief description of what the roll is for (e.g., "Athletics", 28 + "Attack with longsword", "Wisdom save", "Fireball damage") 21 29 22 30 Returns: 23 31 Dict with rolls, kept dice, modifier, and total ··· 122 130 return "\n".join(lines) 123 131 124 132 133 + def update_character( 134 + updates: dict, 135 + player_id: str = "default", 136 + base_path: Path | None = None, 137 + ) -> str: 138 + """Update the player's character sheet to persist changes. 139 + 140 + Call this after HP changes, equipment gained/lost, gold spent, level ups, etc. 141 + This ensures progress is saved and survives between sessions. 142 + 143 + Args: 144 + updates: Fields to update. Use dot notation for nested fields. 145 + Examples: 146 + - {"hp.current": 5} - set current HP to 5 147 + - {"gold": 25} - set gold to 25 148 + - {"level": 2, "hp.max": 20} - level up 149 + For markdown sections, use "section.Name": 150 + - {"section.Equipment": "- Longsword\\n- New shield"} 151 + player_id: Player identifier (usually "default") 152 + base_path: Base path for players directory 153 + 154 + Returns: 155 + Confirmation of what was updated 156 + """ 157 + return char_update(player_id, updates, base_path) 158 + 159 + 160 + def update_session( 161 + situation: str | None = None, 162 + location: str | None = None, 163 + present: list[str] | None = None, 164 + threads: list[str] | None = None, 165 + player_id: str = "default", 166 + base_path: Path | None = None, 167 + ) -> str: 168 + """Update the current session state when the scene changes significantly. 169 + 170 + Call this when: 171 + - The player moves to a new location 172 + - A significant scene transition occurs (combat ends, time passes) 173 + - Important NPCs enter or leave the scene 174 + - The player's goals or situation change meaningfully 175 + 176 + Write situation summaries as if briefing another DM taking over mid-session. 177 + Focus on: where we are, what's happening, what's at stake. 178 + 179 + Args: 180 + situation: New situation summary (replaces current). Write in present tense, 181 + describing the current state of affairs. 182 + location: Location slug when player moves (e.g., "rusty-anchor", "docks") 183 + present: List of entities currently present, using [[Name]] format 184 + (e.g., ["[[Vera Blackwater]] - information broker", "[[Henrik]] - barkeep"]) 185 + threads: List of open plot threads or objectives 186 + (e.g., ["Investigate merchant attacks - 50gp reward", "Vera's mysterious offer"]) 187 + player_id: Player identifier 188 + base_path: Base path for players directory 189 + 190 + Returns: 191 + Confirmation of what was updated 192 + """ 193 + updates = {} 194 + if situation is not None: 195 + updates["situation"] = situation 196 + if location is not None: 197 + updates["location"] = location 198 + if present is not None: 199 + updates["present"] = present 200 + if threads is not None: 201 + updates["threads"] = threads 202 + 203 + return session_update(player_id, updates, base_path) 204 + 205 + 206 + def save_to_world( 207 + content_type: str, 208 + name: str, 209 + content: str, 210 + tags: list[str] | None = None, 211 + world_id: str | None = None, 212 + base_path: Path | None = None, 213 + **extra_frontmatter: str, 214 + ) -> str: 215 + """Save new content to the world for future reference. 216 + 217 + Use when creating persistent world content that should be remembered: 218 + - A named NPC the player has interacted with meaningfully 219 + - A location the player has visited 220 + - A significant event that affects the world state 221 + - A faction the player has learned about 222 + 223 + Don't save: 224 + - Unnamed background characters (random guard, merchant #3) 225 + - Locations mentioned but not visited 226 + - Minor events with no ongoing consequences 227 + 228 + Args: 229 + content_type: Type of content. One of: npcs, locations, factions, events, lore, items 230 + name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 231 + content: Markdown body describing the entity. Include relevant details, 232 + appearance, personality, what they know, connections, etc. 233 + tags: Optional tags for categorization (e.g., ["tavern", "social", "quest-hook"]) 234 + world_id: World to save to (required) 235 + base_path: Base path for worlds directory 236 + **extra_frontmatter: Additional frontmatter fields (e.g., disposition="friendly", 237 + location="rusty-anchor") 238 + 239 + Returns: 240 + Confirmation with the file path 241 + """ 242 + if not world_id: 243 + return "Error: No world_id specified. Cannot save world content." 244 + 245 + if base_path is None: 246 + base_path = Path.cwd() 247 + 248 + # Generate slug from name 249 + slug = name_to_slug(name) 250 + 251 + # Build file path 252 + world_dir = base_path / "worlds" / world_id / content_type 253 + world_dir.mkdir(parents=True, exist_ok=True) 254 + file_path = world_dir / f"{slug}.md" 255 + 256 + # Build frontmatter 257 + frontmatter = { 258 + "type": content_type.rstrip("s"), # npcs -> npc 259 + "name": name, 260 + } 261 + if tags: 262 + frontmatter["tags"] = tags 263 + frontmatter.update(extra_frontmatter) 264 + 265 + # Build file content 266 + file_content = "---\n" 267 + file_content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 268 + file_content += "---\n\n" 269 + file_content += content.strip() 270 + file_content += "\n" 271 + 272 + file_path.write_text(file_content) 273 + 274 + return f"Saved {content_type.rstrip('s')} '{name}' to {file_path.relative_to(base_path)}" 275 + 276 + 277 + def log_event( 278 + event: str, 279 + duration: str, 280 + advance_time: bool = True, 281 + tags: list[str] | None = None, 282 + campaign_log: CampaignLog | None = None, 283 + player_id: str = "default", 284 + base_path: Path | None = None, 285 + ) -> str: 286 + """Record an event in the campaign log with its duration. 287 + 288 + Use this to track significant happenings and advance game time: 289 + - Scene transitions: "Entered the tavern", "5 min" 290 + - Conversations: "Spoke with Vera about the job", "30 min" 291 + - Travel: "Traveled to Millford", "1 day" 292 + - Combat: "Fought warehouse guards", "4 rounds" 293 + - Rests: "Short rest", "1 hour" with tags=["rest:short"] 294 + 295 + The log is the canonical source of time in the game. Keep entries brief. 296 + 297 + Args: 298 + event: Brief description of what happened 299 + duration: How long it took (e.g., "30 min", "2 hours", "1 day", "5 rounds") 300 + advance_time: Whether to advance current time by duration (default: True) 301 + tags: Optional tags like ["combat"], ["rest:short"], ["rest:long"], ["travel"] 302 + campaign_log: Existing CampaignLog instance to use (avoids stale cache) 303 + player_id: Player identifier 304 + base_path: Base path for players directory 305 + 306 + Returns: 307 + Confirmation with the timestamp anchor (e.g., "#d1-0700") 308 + """ 309 + log = campaign_log or load_log(player_id, base_path) 310 + anchor = log.append_entry(event, duration, advance_time, tags) 311 + return f"Logged: {anchor} | {event} | {duration}" 312 + 313 + 125 314 # Tool definitions for the Anthropic API 126 315 TOOL_DEFINITIONS = [ 127 316 { ··· 133 322 "notation": { 134 323 "type": "string", 135 324 "description": "Dice notation (e.g., '1d20+5', '2d6', '4d6kh3')", 136 - } 325 + }, 326 + "reason": { 327 + "type": "string", 328 + "description": "What the roll is for (e.g., 'Athletics', 'Longsword attack', 'Dex save')", 329 + }, 137 330 }, 138 - "required": ["notation"], 331 + "required": ["notation", "reason"], 139 332 }, 140 333 }, 141 334 { ··· 191 384 "required": ["query"], 192 385 }, 193 386 }, 387 + { 388 + "name": "update_character", 389 + "description": update_character.__doc__, 390 + "input_schema": { 391 + "type": "object", 392 + "properties": { 393 + "updates": { 394 + "type": "object", 395 + "description": "Fields to update. Use dot notation for nested (e.g., 'hp.current': 5). Use 'section.Name' for markdown sections.", 396 + }, 397 + }, 398 + "required": ["updates"], 399 + }, 400 + }, 401 + { 402 + "name": "update_session", 403 + "description": update_session.__doc__, 404 + "input_schema": { 405 + "type": "object", 406 + "properties": { 407 + "situation": { 408 + "type": "string", 409 + "description": "New situation summary describing current state of affairs", 410 + }, 411 + "location": { 412 + "type": "string", 413 + "description": "Location slug when player moves (e.g., 'rusty-anchor')", 414 + }, 415 + "present": { 416 + "type": "array", 417 + "items": {"type": "string"}, 418 + "description": "Entities present, using [[Name]] format", 419 + }, 420 + "threads": { 421 + "type": "array", 422 + "items": {"type": "string"}, 423 + "description": "Open plot threads or objectives", 424 + }, 425 + }, 426 + "required": [], 427 + }, 428 + }, 429 + { 430 + "name": "save_to_world", 431 + "description": save_to_world.__doc__, 432 + "input_schema": { 433 + "type": "object", 434 + "properties": { 435 + "content_type": { 436 + "type": "string", 437 + "description": "Type of content", 438 + "enum": ["npcs", "locations", "factions", "events", "lore", "items"], 439 + }, 440 + "name": { 441 + "type": "string", 442 + "description": "Display name (e.g., 'Vera Blackwater')", 443 + }, 444 + "content": { 445 + "type": "string", 446 + "description": "Markdown body describing the entity", 447 + }, 448 + "tags": { 449 + "type": "array", 450 + "items": {"type": "string"}, 451 + "description": "Tags for categorization", 452 + }, 453 + }, 454 + "required": ["content_type", "name", "content"], 455 + }, 456 + }, 457 + { 458 + "name": "log_event", 459 + "description": log_event.__doc__, 460 + "input_schema": { 461 + "type": "object", 462 + "properties": { 463 + "event": { 464 + "type": "string", 465 + "description": "Brief description of what happened", 466 + }, 467 + "duration": { 468 + "type": "string", 469 + "description": "How long it took (e.g., '30 min', '2 hours', '1 day', '5 rounds')", 470 + }, 471 + "advance_time": { 472 + "type": "boolean", 473 + "description": "Whether to advance game time by the duration (default: true)", 474 + }, 475 + "tags": { 476 + "type": "array", 477 + "items": {"type": "string"}, 478 + "description": "Optional tags like 'combat', 'rest:short', 'rest:long', 'travel'", 479 + }, 480 + }, 481 + "required": ["event", "duration"], 482 + }, 483 + }, 194 484 ] 195 485 196 486 ··· 198 488 tool_name: str, 199 489 tool_input: dict, 200 490 world_id: str | None = None, 491 + player_id: str = "default", 201 492 base_path: Path | None = None, 493 + campaign_log: CampaignLog | None = None, 202 494 ) -> str: 203 495 """Execute a tool by name with the given input. 204 496 ··· 206 498 tool_name: Name of the tool to execute 207 499 tool_input: Tool input parameters 208 500 world_id: Current world ID for query_world 501 + player_id: Player ID for update_character 209 502 base_path: Base path for content resolution 503 + campaign_log: Campaign log instance for log_event 210 504 211 505 Returns: 212 506 Tool result as a string ··· 235 529 tool_input["query"], 236 530 content_type=tool_input.get("content_type"), 237 531 world_id=world_id, 532 + base_path=base_path, 533 + ) 534 + 535 + elif tool_name == "update_character": 536 + return update_character( 537 + tool_input["updates"], 538 + player_id=player_id, 539 + base_path=base_path, 540 + ) 541 + 542 + elif tool_name == "update_session": 543 + return update_session( 544 + situation=tool_input.get("situation"), 545 + location=tool_input.get("location"), 546 + present=tool_input.get("present"), 547 + threads=tool_input.get("threads"), 548 + player_id=player_id, 549 + base_path=base_path, 550 + ) 551 + 552 + elif tool_name == "save_to_world": 553 + return save_to_world( 554 + content_type=tool_input["content_type"], 555 + name=tool_input["name"], 556 + content=tool_input["content"], 557 + tags=tool_input.get("tags"), 558 + world_id=world_id, 559 + base_path=base_path, 560 + ) 561 + 562 + elif tool_name == "log_event": 563 + return log_event( 564 + event=tool_input["event"], 565 + duration=tool_input["duration"], 566 + advance_time=tool_input.get("advance_time", True), 567 + tags=tool_input.get("tags"), 568 + campaign_log=campaign_log, 569 + player_id=player_id, 238 570 base_path=base_path, 239 571 ) 240 572
+235
tests/test_log.py
··· 1 + """Tests for campaign log functionality.""" 2 + 3 + import pytest 4 + 5 + from storied.log import ( 6 + CampaignLog, 7 + Duration, 8 + GameTime, 9 + LogEntry, 10 + load_log, 11 + log_event, 12 + ) 13 + 14 + 15 + class TestGameTime: 16 + def test_default_values(self): 17 + t = GameTime() 18 + assert t.day == 1 19 + assert t.hour == 6 20 + assert t.minute == 0 21 + 22 + def test_str(self): 23 + assert str(GameTime(day=3, hour=14, minute=30)) == "Day 3, 14:30" 24 + 25 + def test_to_anchor(self): 26 + assert GameTime(day=1, hour=6, minute=0).to_anchor() == "#d1-0600" 27 + assert GameTime(day=3, hour=14, minute=30).to_anchor() == "#d3-1430" 28 + 29 + def test_from_anchor(self): 30 + t = GameTime.from_anchor("#d1-0600") 31 + assert t.day == 1 32 + assert t.hour == 6 33 + assert t.minute == 0 34 + 35 + t = GameTime.from_anchor("d3-1430") # Without # 36 + assert t.day == 3 37 + assert t.hour == 14 38 + assert t.minute == 30 39 + 40 + def test_from_anchor_invalid(self): 41 + with pytest.raises(ValueError): 42 + GameTime.from_anchor("invalid") 43 + 44 + def test_add_duration_minutes(self): 45 + t = GameTime(day=1, hour=6, minute=0) 46 + result = t.add_duration(Duration(minutes=30)) 47 + assert result.day == 1 48 + assert result.hour == 6 49 + assert result.minute == 30 50 + 51 + def test_add_duration_hours(self): 52 + t = GameTime(day=1, hour=6, minute=0) 53 + result = t.add_duration(Duration(minutes=120)) 54 + assert result.day == 1 55 + assert result.hour == 8 56 + assert result.minute == 0 57 + 58 + def test_add_duration_crosses_day(self): 59 + t = GameTime(day=1, hour=22, minute=0) 60 + result = t.add_duration(Duration(minutes=180)) # 3 hours 61 + assert result.day == 2 62 + assert result.hour == 1 63 + assert result.minute == 0 64 + 65 + def test_period_of_day(self): 66 + assert GameTime(hour=6).period_of_day() == "Morning" 67 + assert GameTime(hour=11).period_of_day() == "Morning" 68 + assert GameTime(hour=12).period_of_day() == "Afternoon" 69 + assert GameTime(hour=17).period_of_day() == "Afternoon" 70 + assert GameTime(hour=18).period_of_day() == "Evening" 71 + assert GameTime(hour=23).period_of_day() == "Evening" 72 + 73 + 74 + class TestDuration: 75 + def test_parse_minutes(self): 76 + d = Duration.parse("30 min") 77 + assert d.minutes == 30 78 + assert d.raw == "30 min" 79 + 80 + def test_parse_hours(self): 81 + d = Duration.parse("2 hours") 82 + assert d.minutes == 120 83 + 84 + def test_parse_days(self): 85 + d = Duration.parse("3 days") 86 + assert d.minutes == 3 * 24 * 60 87 + 88 + def test_parse_rounds(self): 89 + d = Duration.parse("5 rounds") 90 + assert d.minutes >= 0 # Rounds are very short 91 + 92 + def test_parse_scene(self): 93 + d = Duration.parse("scene") 94 + assert d.minutes == 5 95 + 96 + def test_parse_variations(self): 97 + assert Duration.parse("1 hour").minutes == 60 98 + assert Duration.parse("1 hr").minutes == 60 99 + assert Duration.parse("1h").minutes == 60 100 + assert Duration.parse("1 day").minutes == 24 * 60 101 + 102 + def test_str_with_raw(self): 103 + d = Duration.parse("30 min") 104 + assert str(d) == "30 min" 105 + 106 + def test_str_without_raw(self): 107 + d = Duration(minutes=30) 108 + assert str(d) == "30 min" 109 + 110 + 111 + class TestLogEntry: 112 + def test_parse_basic(self): 113 + entry = LogEntry.parse("- #d1-0600 | Arrived at Port Haven | 30 min") 114 + assert entry is not None 115 + assert entry.anchor == "#d1-0600" 116 + assert entry.event == "Arrived at Port Haven" 117 + assert entry.duration.minutes == 30 118 + 119 + def test_parse_with_tags(self): 120 + entry = LogEntry.parse("- #d1-0600 | Short rest | 1 hour | rest:short") 121 + assert entry is not None 122 + assert entry.tags == ["rest:short"] 123 + 124 + def test_parse_invalid(self): 125 + assert LogEntry.parse("Not a valid line") is None 126 + assert LogEntry.parse("- no anchor") is None 127 + 128 + def test_to_line(self): 129 + entry = LogEntry( 130 + anchor="#d1-0600", 131 + event="Arrived", 132 + duration=Duration.parse("30 min"), 133 + ) 134 + assert entry.to_line() == "- #d1-0600 | Arrived | 30 min" 135 + 136 + def test_to_line_with_tags(self): 137 + entry = LogEntry( 138 + anchor="#d1-0600", 139 + event="Rest", 140 + duration=Duration.parse("1 hour"), 141 + tags=["rest:short"], 142 + ) 143 + assert entry.to_line() == "- #d1-0600 | Rest | 1 hour | rest:short" 144 + 145 + 146 + class TestCampaignLog: 147 + def test_new_log(self, tmp_path): 148 + log = CampaignLog(player_id="test", base_path=tmp_path) 149 + assert log.current_day == 1 150 + assert log.current_time.hour == 6 151 + assert log.current_entries == [] 152 + 153 + def test_append_entry(self, tmp_path): 154 + log = CampaignLog(player_id="test", base_path=tmp_path) 155 + anchor = log.append_entry("Arrived at Port Haven", "30 min") 156 + 157 + assert anchor == "#d1-0600" 158 + assert len(log.current_entries) == 1 159 + assert log.current_time.minute == 30 160 + 161 + def test_append_multiple_entries(self, tmp_path): 162 + log = CampaignLog(player_id="test", base_path=tmp_path) 163 + log.append_entry("Event 1", "30 min") 164 + log.append_entry("Event 2", "1 hour") 165 + 166 + assert len(log.current_entries) == 2 167 + assert log.current_time.hour == 7 168 + assert log.current_time.minute == 30 169 + 170 + def test_persistence(self, tmp_path): 171 + log = CampaignLog(player_id="test", base_path=tmp_path) 172 + log.append_entry("Test event", "30 min") 173 + 174 + # Load fresh 175 + log2 = CampaignLog(player_id="test", base_path=tmp_path) 176 + assert len(log2.current_entries) == 1 177 + assert log2.current_entries[0].event == "Test event" 178 + 179 + def test_roll_day(self, tmp_path): 180 + log = CampaignLog(player_id="test", base_path=tmp_path) 181 + log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00 182 + log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day 183 + 184 + assert log.current_day == 2 185 + assert len(log.previous_summaries) == 1 186 + assert "Morning event" in log.previous_summaries[0] 187 + 188 + # Check day file was created 189 + day_file = tmp_path / "players" / "test" / "log" / "day-001.md" 190 + assert day_file.exists() 191 + 192 + def test_format_for_context(self, tmp_path): 193 + log = CampaignLog(player_id="test", base_path=tmp_path) 194 + log.append_entry("Arrived at tavern", "30 min") 195 + log.append_entry("Met the barkeep", "1 hour") 196 + 197 + context = log.format_for_context() 198 + assert "Day 1" in context 199 + assert "Arrived at tavern" in context 200 + assert "Met the barkeep" in context 201 + 202 + def test_time_since_rest_no_rest(self, tmp_path): 203 + log = CampaignLog(player_id="test", base_path=tmp_path) 204 + log.append_entry("Activity", "2 hours") 205 + 206 + since = log.time_since_rest("short") 207 + assert since.minutes >= 2 * 60 208 + 209 + def test_time_since_rest_with_rest(self, tmp_path): 210 + log = CampaignLog(player_id="test", base_path=tmp_path) 211 + log.append_entry("Activity", "2 hours") 212 + log.append_entry("Rest", "1 hour", tags=["rest:short"]) 213 + log.append_entry("More activity", "30 min") 214 + 215 + since = log.time_since_rest("short") 216 + assert since.minutes == 30 217 + 218 + 219 + class TestConvenienceFunctions: 220 + def test_load_log(self, tmp_path): 221 + log = load_log(player_id="test", base_path=tmp_path) 222 + assert isinstance(log, CampaignLog) 223 + 224 + def test_log_event(self, tmp_path): 225 + anchor = log_event( 226 + "Test event", 227 + "30 min", 228 + player_id="test", 229 + base_path=tmp_path, 230 + ) 231 + assert anchor == "#d1-0600" 232 + 233 + # Verify it was saved 234 + log = load_log(player_id="test", base_path=tmp_path) 235 + assert len(log.current_entries) == 1