A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Refactor world state architecture and tool semantics

Big rework of how the DM tracks and persists game state. The campaign log
now lives in worlds/{world}/log/ (world-scoped for future multiplayer)
with separate day files (day+001.md, day-001.md for backstory support).

Renamed tools to be more action-oriented:
- roll_dice → roll
- log_event → mark_time
- update_session → set_scene
- save_to_world → establish
- Combined lookup_rule + query_world → recall

Added note_discovery tool for tracking player knowledge separately from
DM truth - players/{player}/worlds/{world}/ now holds what the character
has learned, while worlds/{world}/ holds the full DM knowledge.

Also fixed the dice parser to support compound notation like 2d8+1d6
(for spells like Chaos Bolt), and added --debug mode tool call logging
that shows truncated params and results inline.

Strengthened the DM prompt with an explicit "after every response"
checklist to remind it to call mark_time, establish, note_discovery,
and set_scene consistently.

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

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

+695 -352
+4 -2
prompts/character-creation.md
··· 30 30 31 31 ## Looking Things Up 32 32 33 - Use `lookup_rule` freely to check: 33 + Use `recall` freely to check: 34 34 - Racial traits (darkvision, resistances, etc.) 35 35 - Class features (hit dice, proficiencies, starting abilities) 36 36 - Background features (skills, tools, equipment) ··· 40 40 41 41 ## Finalizing 42 42 43 - Once you have everything, call `create_character` with the full character data. This saves their character sheet and you can transition to the adventure. 43 + Once you have everything, call `create_character` with the full character data. This saves their character sheet. 44 + 45 + **IMPORTANT**: After calling `create_character`, give a brief summary of the character (a "quick reference" with key abilities, spells if any, and notable features) but do NOT start the adventure. The session will end and the player will start fresh with `storied play` using the full DM system. 44 46 45 47 Don't rush. Character creation is part of the fun. Let them explore, change their mind, and discover who they want to play.
+97 -22
prompts/dm-system.md
··· 1 1 You are an expert D&D 5e Dungeon Master running a solo adventure. 2 2 3 + ## Available Tools 4 + 5 + | Tool | Purpose | 6 + |------|---------| 7 + | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 8 + | `recall` | Look up rules or world content | 9 + | `establish` | Save DM truth (NPCs, locations, factions, lore) | 10 + | `note_discovery` | Record what the player has learned | 11 + | `mark_time` | Log events and advance the clock | 12 + | `set_scene` | Update current situation, location, NPCs present | 13 + | `update_character` | Modify character stats (HP, gold, equipment) | 14 + | `create_character` | Create a new character | 15 + | `end_session` | Gracefully end the session | 16 + 17 + ## After Every Response: Tool Checklist 18 + 19 + After writing narrative, **always** run through this checklist and call the relevant tools: 20 + 21 + 1. **Time passed?** → `mark_time` (conversations, travel, combat, investigation) 22 + 2. **New named NPC?** → `establish` (guards, merchants, anyone with a name or title) 23 + 3. **Player learned something?** → `note_discovery` (facts, rumors, NPC info, locations) 24 + 4. **Scene changed?** → `set_scene` (new location, NPCs arrived/left, situation evolved) 25 + 26 + **Don't skip tools.** If you introduced Constable Harrik, establish him. If the player talked for 10 minutes, mark the time. If they learned about Borand the missing merchant, note that discovery. 27 + 28 + The world only persists if you save it. Narrative without tool calls is lost context. 29 + 3 30 ## Starting a Session 4 31 5 32 When you see `[Session starting]`, begin with a brief "previously on..." recap: ··· 49 76 50 77 ## Core Principle: Track Time 51 78 52 - **Log events as they happen**, not at the end of the session. After each significant action or scene, call `log_event` immediately: 79 + **Call `mark_time` after every response where time passes.** This is most responses! 53 80 54 - - After a conversation: `log_event("Negotiated with the guard captain", "20 min")` 55 - - After combat ends: `log_event("Defeated dock thugs", "3 rounds")` 56 - - After travel: `log_event("Walked to the harbor district", "15 min")` 57 - - After investigation: `log_event("Searched the warehouse", "30 min")` 58 - - After a rest: `log_event("Short rest in alley", "1 hour", tags=["rest:short"])` 81 + - Conversation at the gate: `mark_time("Spoke with guards about bandits", "10 min")` 82 + - Explaining to the constable: `mark_time("Briefed Constable Harrik on bandit camp", "5 min")` 83 + - Combat: `mark_time("Fought off thugs", "3 rounds")` 84 + - Travel: `mark_time("Walked to the harbor district", "15 min")` 85 + - Investigation: `mark_time("Searched the warehouse", "30 min")` 86 + - Rest: `mark_time("Short rest in alley", "1 hour", tags=["rest:short"])` 59 87 60 - 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. 88 + The campaign log is the canonical clock. **If you don't log it, time doesn't advance.** A conversation that takes 10 minutes in-world should be logged, or the clock stays frozen. 61 89 62 90 ## Core Principle: Save State 63 91 64 - **Update session state frequently** so the game can resume if interrupted. Call `update_session` whenever: 92 + **Update session state frequently** so the game can resume if interrupted. Call `set_scene` whenever: 65 93 66 94 - The player moves to a new location 67 95 - A scene ends or the situation changes significantly ··· 85 113 2. You: Roll 1d20+5 (attack) → if it beats AC, roll 1d8+3 (damage) 86 114 3. Narrate the result based on the actual numbers 87 115 88 - ## When to Look Up Rules 116 + ## When to Look Things Up 89 117 90 - Use lookup_rule liberally: 118 + Use `recall` liberally to look up rules or world content: 119 + 120 + **Rules** (scope: "rules"): 91 121 - Before resolving spells - check the actual spell text 92 122 - When a player tries something unusual - check if there's a rule 93 123 - For monster stats - look up AC, HP, attacks, abilities 94 124 - For conditions - what exactly does "grappled" or "prone" do? 95 125 96 - Don't guess at rules. Look them up. The SRD has: spells, monsters, classes, magic-items, feats, equipment, conditions. 126 + **World** (scope: "world"): 127 + - NPC details when they appear in a scene 128 + - Location descriptions when the player arrives somewhere 129 + - Faction information when it becomes relevant 130 + - Previous events or lore that affects the current situation 131 + 132 + Don't guess at rules or forget established world details. Look them up. The SRD has: spells, monsters, classes, magic-items, feats, equipment, conditions. 97 133 98 134 ## Setting DCs 99 135 ··· 161 197 162 198 ## Session State 163 199 164 - Use update_session to track the current situation so the game can resume naturally: 200 + Use `set_scene` to track the current situation so the game can resume naturally: 165 201 166 202 - **When the player moves**: `location: "rusty-anchor"` 167 203 - **When the scene changes**: Update `situation` with a brief summary ··· 172 208 173 209 Example: 174 210 ``` 175 - 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." 176 - present: ["[[Vera Blackwater]] - waiting for results", "[[Henrik]] - pretending not to listen"] 177 - threads: ["Warehouse investigation - Vera's job", "Merchant attacks - 50gp from Captain"] 211 + set_scene( 212 + 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.", 213 + present=["[[Vera Blackwater]] - waiting for results", "[[Henrik]] - pretending not to listen"], 214 + threads=["Warehouse investigation - Vera's job", "Merchant attacks - 50gp from Captain"] 215 + ) 178 216 ``` 179 217 180 218 ## World Persistence 181 219 182 - Use save_to_world when you introduce entities worth remembering: 220 + You manage two layers of knowledge: **world truth** and **player knowledge**. 221 + 222 + ### Establishing World Truth 223 + 224 + Use `establish` when you introduce or update world facts: 225 + 226 + - **Any NPC with a name** - Guard Mara, Constable Harrik, Borand the merchant 227 + - **Any NPC with a role** - "the innkeeper", "the town guard" (give them a name!) 228 + - **Locations the player visits** - the town gate, the constable's office, the tavern 229 + - **Factions, organizations** - the town guard, the merchant guild 230 + - **Significant events** - the bandit attack, the missing merchant 231 + 232 + **Give names to everyone.** Don't introduce "a guard" - introduce "Mara, a guard". Then establish her. Named NPCs create a living world. 233 + 234 + When saving, include: appearance, personality, role, what they know, connections to other entities. This is DM knowledge - include secrets and hidden motivations the player doesn't know yet. 235 + 236 + Example: 237 + ``` 238 + establish( 239 + type="npc", 240 + name="Vera Blackwater", 241 + content="Tavern owner with connections to the smuggling ring. Knows about the warehouse but won't reveal her involvement.", 242 + tags=["rusty-anchor", "smugglers"] 243 + ) 244 + ``` 245 + 246 + ### Recording Player Knowledge 247 + 248 + Use `note_discovery` when the player learns something significant: 249 + 250 + - Information an NPC reveals to them 251 + - A location's secrets they uncover 252 + - Lore or history they piece together 253 + - Facts they learn through investigation 183 254 184 - - Named NPCs the player has interacted with meaningfully 185 - - Locations the player has visited (not just mentioned) 186 - - Significant events that affect the world state 187 - - Factions the player has learned about 255 + This captures the player's perspective - which may be incomplete or even wrong. The player only knows what they've discovered. 188 256 189 - **Don't save** throwaway characters (random guard, unnamed merchant) or locations mentioned in passing. 257 + Example: 258 + ``` 259 + note_discovery( 260 + entity="Vera Blackwater", 261 + content="Owns The Rusty Anchor. Seems to know about the warehouse - gave us a key. Claims she just wants the thefts to stop.", 262 + type="npc" 263 + ) 264 + ``` 190 265 191 - When saving, include useful details: appearance, personality, what they know, their goals, connections to other entities. 266 + **Save on meaningful interaction**, not every mention. When the player asks about something, investigates, or has a real exchange - that's when knowledge is gained. 192 267 193 268 ## Wiki References 194 269
+1
src/storied/cli.py
··· 223 223 console.print() 224 224 225 225 engine = DMEngine(world_id=world_id, player_id=player_id, prompt_name=prompt_name) 226 + engine.debug = args.debug 226 227 227 228 # If in creation mode, start the conversation 228 229 if creation_mode:
+102 -47
src/storied/dice.py
··· 2 2 3 3 import random 4 4 import re 5 - from dataclasses import dataclass 5 + from dataclasses import dataclass, field 6 6 7 7 8 8 @dataclass 9 - class DiceRoll: 10 - """Parsed dice notation.""" 9 + class DiceGroup: 10 + """A single dice group (e.g., 2d6, 4d6kh3).""" 11 11 12 12 count: int 13 13 sides: int 14 - modifier: int = 0 15 14 keep_highest: int | None = None 16 15 keep_lowest: int | None = None 16 + sign: int = 1 # +1 or -1 17 + 18 + 19 + @dataclass 20 + class DiceRoll: 21 + """Parsed dice notation with multiple groups.""" 22 + 23 + groups: list[DiceGroup] = field(default_factory=list) 24 + modifier: int = 0 25 + 26 + # Legacy single-group properties for backward compatibility 27 + @property 28 + def count(self) -> int: 29 + return self.groups[0].count if self.groups else 0 30 + 31 + @property 32 + def sides(self) -> int: 33 + return self.groups[0].sides if self.groups else 0 34 + 35 + @property 36 + def keep_highest(self) -> int | None: 37 + return self.groups[0].keep_highest if self.groups else None 38 + 39 + @property 40 + def keep_lowest(self) -> int | None: 41 + return self.groups[0].keep_lowest if self.groups else None 17 42 18 43 19 44 @dataclass ··· 37 62 } 38 63 39 64 40 - # Pattern: XdY, optional kh/kl N, optional +/- modifier 41 - DICE_PATTERN = re.compile( 65 + # Pattern for a single dice group: XdY with optional kh/kl 66 + DICE_GROUP_PATTERN = re.compile( 42 67 r""" 43 - ^\s* 44 - (\d+)\s*[dD]\s*(\d+) # XdY 68 + (\d+)\s*[dD]\s*(\d+) # XdY 45 69 (?:\s*[kK]([hHlL])(\d+))? # optional keep highest/lowest 46 - (?:\s*([+-])\s*(\d+))? # optional modifier 47 - \s*$ 48 70 """, 49 71 re.VERBOSE, 50 72 ) 51 73 52 74 53 75 def parse_notation(notation: str) -> DiceRoll: 54 - """Parse dice notation like '2d6+3' or '4d6kh3'. 76 + """Parse dice notation like '2d6+3', '4d6kh3', or '2d8+1d6'. 55 77 56 78 Supports: 57 79 - Basic: XdY (e.g., 1d20, 3d6) ··· 59 81 - Keep highest: XdYkhN (e.g., 4d6kh3 for ability scores) 60 82 - Keep lowest: XdYklN (e.g., 2d20kl1 for disadvantage) 61 83 - Combined: XdYkhN+Z (e.g., 4d6kh3+2) 84 + - Compound: XdY+AdB (e.g., 2d8+1d6, 1d6+1d4+2) 62 85 """ 63 - match = DICE_PATTERN.match(notation) 64 - if not match: 86 + notation = notation.strip() 87 + if not notation: 65 88 raise ValueError(f"Invalid dice notation: {notation!r}") 66 89 67 - count = int(match.group(1)) 68 - sides = int(match.group(2)) 90 + groups: list[DiceGroup] = [] 91 + modifier = 0 92 + 93 + # Split by + and - while preserving the operator 94 + # First, normalize the notation by adding a + at the start if it doesn't have a sign 95 + if not notation.startswith(("+", "-")): 96 + notation = "+" + notation 97 + 98 + # Find all terms (sign + value) 99 + terms = re.findall(r"([+-])\s*([^+-]+)", notation) 100 + 101 + for sign_str, term in terms: 102 + term = term.strip() 103 + sign = 1 if sign_str == "+" else -1 104 + 105 + # Try to parse as a dice group 106 + dice_match = DICE_GROUP_PATTERN.match(term) 107 + if dice_match: 108 + count = int(dice_match.group(1)) 109 + sides = int(dice_match.group(2)) 110 + 111 + keep_highest = None 112 + keep_lowest = None 113 + if dice_match.group(3): 114 + keep_type = dice_match.group(3).lower() 115 + keep_count = int(dice_match.group(4)) 116 + if keep_type == "h": 117 + keep_highest = keep_count 118 + else: 119 + keep_lowest = keep_count 69 120 70 - keep_highest = None 71 - keep_lowest = None 72 - if match.group(3): 73 - keep_type = match.group(3).lower() 74 - keep_count = int(match.group(4)) 75 - if keep_type == "h": 76 - keep_highest = keep_count 121 + groups.append( 122 + DiceGroup( 123 + count=count, 124 + sides=sides, 125 + keep_highest=keep_highest, 126 + keep_lowest=keep_lowest, 127 + sign=sign, 128 + ) 129 + ) 130 + elif term.isdigit(): 131 + # It's a flat modifier 132 + modifier += sign * int(term) 77 133 else: 78 - keep_lowest = keep_count 134 + raise ValueError(f"Invalid dice notation: {notation!r}") 79 135 80 - modifier = 0 81 - if match.group(5): 82 - sign = 1 if match.group(5) == "+" else -1 83 - modifier = sign * int(match.group(6)) 136 + if not groups and modifier == 0: 137 + raise ValueError(f"Invalid dice notation: {notation!r}") 84 138 85 - return DiceRoll( 86 - count=count, 87 - sides=sides, 88 - modifier=modifier, 89 - keep_highest=keep_highest, 90 - keep_lowest=keep_lowest, 91 - ) 139 + return DiceRoll(groups=groups, modifier=modifier) 92 140 93 141 94 142 def roll(notation: str, seed: int | None = None) -> RollResult: 95 143 """Roll dice using standard notation. 96 144 97 145 Args: 98 - notation: Dice notation like '2d6+3' or '4d6kh3' 146 + notation: Dice notation like '2d6+3', '4d6kh3', or '2d8+1d6' 99 147 seed: Optional random seed for deterministic results 100 148 101 149 Returns: ··· 104 152 parsed = parse_notation(notation) 105 153 106 154 rng = random.Random(seed) 107 - rolls = [rng.randint(1, parsed.sides) for _ in range(parsed.count)] 155 + all_rolls: list[int] = [] 156 + all_kept: list[int] = [] 157 + total = parsed.modifier 158 + 159 + for group in parsed.groups: 160 + rolls = [rng.randint(1, group.sides) for _ in range(group.count)] 161 + all_rolls.extend(rolls) 108 162 109 - # Determine which dice to keep 110 - if parsed.keep_highest: 111 - kept = sorted(rolls, reverse=True)[: parsed.keep_highest] 112 - elif parsed.keep_lowest: 113 - kept = sorted(rolls)[: parsed.keep_lowest] 114 - else: 115 - kept = rolls.copy() 163 + # Determine which dice to keep 164 + if group.keep_highest: 165 + kept = sorted(rolls, reverse=True)[: group.keep_highest] 166 + elif group.keep_lowest: 167 + kept = sorted(rolls)[: group.keep_lowest] 168 + else: 169 + kept = rolls.copy() 116 170 117 - total = sum(kept) + parsed.modifier 171 + all_kept.extend(kept) 172 + total += group.sign * sum(kept) 118 173 119 174 return RollResult( 120 - notation=notation, 121 - rolls=rolls, 122 - kept=kept, 175 + notation=notation.strip(), 176 + rolls=all_rolls, 177 + kept=all_kept, 123 178 modifier=parsed.modifier, 124 179 total=total, 125 180 )
+143 -38
src/storied/engine.py
··· 7 7 8 8 import anthropic 9 9 10 + 11 + def _truncate(value: object, max_len: int = 60, max_items: int = 3) -> str: 12 + """Truncate a value for debug display.""" 13 + if isinstance(value, str): 14 + if len(value) <= max_len: 15 + return repr(value) 16 + return repr(value[:max_len]) + f"...+{len(value) - max_len} chars" 17 + 18 + if isinstance(value, list): 19 + if len(value) <= max_items: 20 + return "[" + ", ".join(_truncate(v, 30, 2) for v in value) + "]" 21 + items = [_truncate(v, 30, 2) for v in value[:max_items]] 22 + return "[" + ", ".join(items) + f", ...+{len(value) - max_items} more]" 23 + 24 + if isinstance(value, dict): 25 + if len(value) <= max_items: 26 + pairs = [f"{k!r}: {_truncate(v, 30, 2)}" for k, v in value.items()] 27 + return "{" + ", ".join(pairs) + "}" 28 + pairs = [f"{k!r}: {_truncate(v, 30, 2)}" for k, v in list(value.items())[:max_items]] 29 + return "{" + ", ".join(pairs) + f", ...+{len(value) - max_items} more" + "}" 30 + 31 + s = repr(value) 32 + if len(s) <= max_len: 33 + return s 34 + return s[:max_len] + f"...+{len(s) - max_len} chars" 35 + 10 36 from storied.character import format_character_context, load_character 11 37 from storied.content import ContentResolver 12 38 from storied.log import CampaignLog ··· 71 97 # Session end flag (set when end_session tool is called) 72 98 self.session_ended: bool = False 73 99 74 - # Campaign log for time tracking 75 - self._campaign_log = CampaignLog(self.player_id, self.base_path) 100 + # Debug mode for verbose tool output 101 + self.debug: bool = False 102 + 103 + # Campaign log for time tracking (world-scoped) 104 + self._campaign_log = CampaignLog(self.world_id, self.base_path) 76 105 77 106 # Build system prompt with full context 78 107 self._prompt_name = prompt_name ··· 107 136 1. Character sheet (always) 108 137 2. Campaign log (current time + recent events) 109 138 3. Session state if exists (situation, present, threads) 110 - 4. Current location if set 111 - 5. Present entities from [[wiki links]] 139 + 4. Player knowledge (what the player has learned) 140 + 5. DM knowledge: current location + present entities (smart loading) 112 141 """ 113 142 parts = [] 114 143 self._context_parts = {} ··· 133 162 self._context_parts["Session"] = session_context 134 163 parts.append(session_context) 135 164 136 - # 4. Current location 165 + # 4. Player knowledge (what the player has learned about this world) 166 + player_knowledge = self._load_player_knowledge() 167 + if player_knowledge: 168 + self._context_parts["PlayerKnowledge"] = player_knowledge 169 + parts.append(player_knowledge) 170 + 171 + # 5. DM knowledge (smart loading: location + present entities) 172 + if session: 173 + # Current location 137 174 location_slug = session.get("location") 138 175 if location_slug and self.world_id: 139 176 location_content = self._load_world_content("locations", location_slug) ··· 142 179 self._context_parts["Location"] = loc_context 143 180 parts.append(loc_context) 144 181 145 - # 5. Present entities from wiki links 182 + # Present entities from wiki links 146 183 present_text = session.get("body", "") 147 184 for name in extract_wiki_links(present_text): 148 185 entity = self._find_entity(name) ··· 153 190 154 191 return "\n\n---\n\n".join(parts) 155 192 193 + def _load_player_knowledge(self) -> str | None: 194 + """Load player's known entities from players/{player}/worlds/{world}/.""" 195 + if not self.world_id: 196 + return None 197 + 198 + knowledge_dir = ( 199 + self.base_path / "players" / self.player_id / "worlds" / self.world_id 200 + ) 201 + if not knowledge_dir.exists(): 202 + return None 203 + 204 + parts = ["## What You Know\n"] 205 + 206 + for content_type in ["npcs", "locations", "factions", "lore"]: 207 + type_dir = knowledge_dir / content_type 208 + if not type_dir.exists(): 209 + continue 210 + 211 + for file in sorted(type_dir.glob("*.md")): 212 + content = self._parse_knowledge_file(file) 213 + if content: 214 + name = content.get("name", file.stem) 215 + body = content.get("body", "") 216 + parts.append(f"### {name}\n{body}\n") 217 + 218 + return "\n".join(parts) if len(parts) > 1 else None 219 + 220 + def _parse_knowledge_file(self, file_path: Path) -> dict | None: 221 + """Parse a player knowledge file with YAML frontmatter.""" 222 + import re 223 + 224 + import yaml 225 + 226 + content = file_path.read_text() 227 + if not content.startswith("---"): 228 + return {"body": content} 229 + 230 + # Parse frontmatter 231 + match = re.search(r"\n---\s*\n", content[3:]) 232 + if not match: 233 + return {"body": content} 234 + 235 + frontmatter_end = match.start() + 3 236 + frontmatter_str = content[3:frontmatter_end] 237 + body = content[frontmatter_end + match.end() - match.start() :].strip() 238 + 239 + try: 240 + frontmatter = yaml.safe_load(frontmatter_str) or {} 241 + except yaml.YAMLError: 242 + frontmatter = {} 243 + 244 + return {"body": body, **frontmatter} 245 + 156 246 def _load_world_content(self, content_type: str, name: str) -> dict | None: 157 247 """Load world content by type and name.""" 158 248 if not self.world_id: ··· 382 472 if tool_uses: 383 473 tool_results = [] 384 474 for tool_use in tool_uses: 385 - # Show the user what's happening 386 - if tool_use["name"] == "roll_dice": 387 - reason = tool_use["input"].get("reason", "") 388 - notation = tool_use["input"].get("notation", "?") 389 - if reason: 390 - yield f"\n[{reason}: {notation}...]\n" 391 - else: 392 - yield f"\n[Rolling {notation}...]\n" 393 - elif tool_use["name"] == "lookup_rule": 394 - yield f"\n[Looking up: {tool_use['input'].get('query', '?')}...]\n" 395 - elif tool_use["name"] == "query_world": 396 - yield f"\n[Checking world: {tool_use['input'].get('query', '?')}...]\n" 397 - elif tool_use["name"] == "update_character": 398 - yield "\n[Updating character sheet...]\n" 399 - elif tool_use["name"] == "create_character": 400 - name = tool_use["input"].get("name", "character") 401 - yield f"\n[Creating {name}...]\n" 402 - elif tool_use["name"] == "update_session": 403 - yield "\n[Updating session state...]\n" 404 - elif tool_use["name"] == "save_to_world": 405 - name = tool_use["input"].get("name", "?") 406 - yield f"\n[Saving to world: {name}...]\n" 407 - elif tool_use["name"] == "log_event": 408 - event = tool_use["input"].get("event", "?") 409 - duration = tool_use["input"].get("duration", "?") 410 - yield f"\n[Logging: {event} ({duration})]...\n" 411 - elif tool_use["name"] == "end_session": 412 - yield "\n[Saving session...]...\n" 475 + tool_name = tool_use["name"] 476 + tool_input = tool_use["input"] 477 + 478 + # Debug mode: show full tool call with truncated params 479 + if self.debug: 480 + params = ", ".join( 481 + f"{k}={_truncate(v)}" for k, v in tool_input.items() 482 + ) 483 + yield f"\n[→ {tool_name}({params})]\n" 484 + else: 485 + # Normal mode: show friendly messages 486 + if tool_name == "roll": 487 + reason = tool_input.get("reason", "") 488 + notation = tool_input.get("notation", "?") 489 + if reason: 490 + yield f"\n[{reason}: {notation}...]\n" 491 + else: 492 + yield f"\n[Rolling {notation}...]\n" 493 + elif tool_name == "recall": 494 + yield f"\n[Recalling: {tool_input.get('query', '?')}...]\n" 495 + elif tool_name == "update_character": 496 + yield "\n[Updating character sheet...]\n" 497 + elif tool_name == "create_character": 498 + name = tool_input.get("name", "character") 499 + yield f"\n[Creating {name}...]\n" 500 + elif tool_name == "set_scene": 501 + yield "\n[Setting scene...]\n" 502 + elif tool_name == "establish": 503 + name = tool_input.get("name", "?") 504 + yield f"\n[Establishing: {name}...]\n" 505 + elif tool_name == "note_discovery": 506 + entity = tool_input.get("entity", "?") 507 + yield f"\n[Noting: player learned about {entity}...]\n" 508 + elif tool_name == "mark_time": 509 + event = tool_input.get("event", "?") 510 + duration = tool_input.get("duration", "?") 511 + yield f"\n[{event} ({duration})]...\n" 512 + elif tool_name == "end_session": 513 + yield "\n[Saving session...]...\n" 413 514 414 515 result = execute_tool( 415 - tool_use["name"], 416 - tool_use["input"], 516 + tool_name, 517 + tool_input, 417 518 world_id=self.world_id, 418 519 player_id=self.player_id, 419 520 base_path=self.base_path, 420 521 campaign_log=self._campaign_log, 421 522 ) 523 + 524 + # Debug mode: show result 525 + if self.debug: 526 + yield f"[⇐ {_truncate(result, max_len=100)}]\n\n" 422 527 423 528 # Check if session ended 424 529 if result == "SESSION_ENDED": 425 530 self.session_ended = True 426 531 result = "Session saved. Farewell!" 427 532 428 - # Show dice roll results immediately 429 - if tool_use["name"] == "roll_dice": 533 + # Show dice roll results immediately (non-debug mode) 534 + if tool_name == "roll" and not self.debug: 430 535 yield f"{result}\n" 431 536 432 537 tool_results.append(
+82 -75
src/storied/log.py
··· 139 139 140 140 141 141 class CampaignLog: 142 - """Manages the campaign log files.""" 142 + """Manages the campaign log files. 143 143 144 - def __init__(self, player_id: str = "default", base_path: Path | None = None): 145 - self.player_id = player_id 144 + The campaign log is world-scoped (lives in worlds/{world_id}/log/) since it 145 + represents the canonical timeline of events in the world. 146 + 147 + File structure: 148 + - index.md: metadata (current day, time) + summaries of previous days 149 + - day-001.md: entries for day 1 150 + - day-002.md: entries for day 2 151 + - etc. 152 + """ 153 + 154 + def __init__(self, world_id: str = "default", base_path: Path | None = None): 155 + self.world_id = world_id 146 156 self.base_path = base_path or Path.cwd() 147 - self.log_dir = self.base_path / "players" / player_id / "log" 157 + self.log_dir = self.base_path / "worlds" / world_id / "log" 148 158 149 159 # Load or initialize state 150 - self._load_index() 160 + self._load_state() 151 161 152 - def _load_index(self) -> None: 153 - """Load the log index or initialize defaults.""" 162 + def _load_state(self) -> None: 163 + """Load the log state from index.md and current day file.""" 154 164 index_path = self.log_dir / "index.md" 155 165 156 166 if not index_path.exists(): ··· 160 170 self.current_entries: list[LogEntry] = [] 161 171 return 162 172 173 + # Parse index for metadata and summaries 163 174 content = index_path.read_text() 164 175 self._parse_index(content) 165 176 177 + # Load current day's entries from day file 178 + self.current_entries = self._load_day_entries(self.current_day) 179 + 166 180 def _parse_index(self, content: str) -> None: 167 - """Parse index.md content.""" 168 - # Defaults 181 + """Parse index.md for metadata and previous day summaries.""" 169 182 self.current_day = 1 170 183 self.current_time = GameTime(day=1, hour=6, minute=0) 171 - body = content 184 + self.previous_summaries = [] 172 185 173 - # Parse frontmatter if present 186 + # Parse frontmatter 174 187 if content.startswith("---"): 175 188 end_match = re.search(r"\n---\s*\n", content[3:]) 176 189 if end_match: ··· 182 195 self.current_day = fm.get("current_day", 1) 183 196 time_str = fm.get("current_time", "d1-0600") 184 197 self.current_time = GameTime.from_anchor(time_str) 198 + else: 199 + body = content 200 + else: 201 + body = content 185 202 186 203 # Parse previous day summaries 187 - self.previous_summaries = [] 188 204 in_previous = False 189 205 for line in body.split("\n"): 190 206 if line.strip() == "## Previous Days": 191 207 in_previous = True 192 208 continue 193 209 if line.startswith("## ") and in_previous: 194 - in_previous = False 210 + break 195 211 if in_previous and line.startswith("- "): 196 212 self.previous_summaries.append(line[2:].strip()) 197 213 198 - # Parse current day entries 199 - self.current_entries = [] 200 - for line in body.split("\n"): 214 + def _day_filename(self, day: int) -> str: 215 + """Generate filename for a day: day+001.md or day-001.md.""" 216 + if day >= 0: 217 + return f"day+{day:03d}.md" 218 + else: 219 + return f"day{day:04d}.md" # e.g., day-001.md (sign included in format) 220 + 221 + def _load_day_entries(self, day: int) -> list[LogEntry]: 222 + """Load entries from a day file.""" 223 + day_path = self.log_dir / self._day_filename(day) 224 + if not day_path.exists(): 225 + return [] 226 + 227 + entries = [] 228 + for line in day_path.read_text().split("\n"): 201 229 entry = LogEntry.parse(line) 202 230 if entry: 203 - self.current_entries.append(entry) 231 + entries.append(entry) 232 + return entries 204 233 205 234 def _save_index(self) -> None: 206 - """Save the current state to index.md.""" 235 + """Save metadata and summaries to index.md.""" 207 236 self.log_dir.mkdir(parents=True, exist_ok=True) 208 237 index_path = self.log_dir / "index.md" 209 238 210 - # Build content 211 239 lines = [ 212 240 "---", 213 241 f"current_day: {self.current_day}", ··· 217 245 "# Campaign Log", 218 246 ] 219 247 220 - # Previous days section 221 248 if self.previous_summaries: 222 249 lines.append("") 223 250 lines.append("## Previous Days") 224 251 for summary in self.previous_summaries: 225 252 lines.append(f"- {summary}") 226 253 227 - # Current day section 228 254 lines.append("") 229 - lines.append(f"## Day {self.current_day} (Current)") 255 + index_path.write_text("\n".join(lines)) 256 + 257 + def _save_day_file(self, day: int, entries: list[LogEntry]) -> None: 258 + """Save entries to a day file.""" 259 + self.log_dir.mkdir(parents=True, exist_ok=True) 260 + day_path = self.log_dir / self._day_filename(day) 261 + 262 + # Build summary from first few events 263 + if entries: 264 + events = [e.event for e in entries[:3]] 265 + summary = ", ".join(events) 266 + if len(entries) > 3: 267 + summary += ", ..." 268 + else: 269 + summary = "" 230 270 231 - # Group entries by period 271 + lines = [ 272 + "---", 273 + f"day: {day}", 274 + f"summary: {summary}", 275 + "---", 276 + "", 277 + f"# Day {day}", 278 + ] 279 + 280 + # Group by period 232 281 periods: dict[str, list[LogEntry]] = {} 233 - for entry in self.current_entries: 282 + for entry in entries: 234 283 try: 235 284 time = GameTime.from_anchor(entry.anchor) 236 285 period = time.period_of_day() ··· 248 297 lines.append(entry.to_line()) 249 298 250 299 lines.append("") 251 - index_path.write_text("\n".join(lines)) 300 + day_path.write_text("\n".join(lines)) 252 301 253 302 def append_entry( 254 303 self, ··· 277 326 if self.current_time.day > self.current_day: 278 327 self._roll_day() 279 328 329 + # Save current day entries 330 + self._save_day_file(self.current_day, self.current_entries) 280 331 self._save_index() 281 332 return anchor 282 333 ··· 290 341 summary += ", ..." 291 342 self.previous_summaries.append(summary) 292 343 293 - # Save full day log 294 - self._save_day_file(self.current_day) 295 - 296 344 # Start new day 297 345 self.current_day = self.current_time.day 298 346 self.current_entries = [] 299 347 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 348 def get_current_time(self) -> GameTime: 342 349 """Return the current game time.""" 343 350 return self.current_time 344 351 345 352 def format_for_context(self) -> str: 346 - """Format the log index for inclusion in system prompt.""" 353 + """Format the log for inclusion in system prompt.""" 347 354 lines = [f"## Campaign Time: {self.current_time}"] 348 355 349 356 if self.previous_summaries: ··· 375 382 return Duration(minutes=total_minutes + 8 * 60) # Add 8 hours as estimate 376 383 377 384 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) 385 + def load_log(world_id: str = "default", base_path: Path | None = None) -> CampaignLog: 386 + """Load or create a campaign log for a world.""" 387 + return CampaignLog(world_id=world_id, base_path=base_path) 381 388 382 389 383 390 def log_event( ··· 385 392 duration: str, 386 393 advance_time: bool = True, 387 394 tags: list[str] | None = None, 388 - player_id: str = "default", 395 + world_id: str = "default", 389 396 base_path: Path | None = None, 390 397 ) -> str: 391 398 """Convenience function to append an event to the log. 392 399 393 400 Returns the timestamp anchor for the event. 394 401 """ 395 - log = load_log(player_id, base_path) 402 + log = load_log(world_id, base_path) 396 403 return log.append_entry(event, duration, advance_time, tags)
+212 -155
src/storied/tools.py
··· 17 17 from storied.session import update_session as session_update 18 18 19 19 20 - def roll_dice(notation: str, reason: str | None = None) -> dict: 20 + def roll(notation: str, reason: str | None = None) -> dict: 21 21 """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. 22 22 23 23 Use for attack rolls, skill checks, saving throws, and damage rolls. ··· 35 35 return result.to_dict() 36 36 37 37 38 - def lookup_rule( 38 + def recall( 39 39 query: str, 40 - category: str | None = None, 40 + scope: str = "all", 41 + content_type: str | None = None, 42 + world_id: str | None = None, 41 43 base_path: Path | None = None, 42 44 ) -> str: 43 - """Search the D&D 5e SRD for rules, spells, monsters, items, or conditions. 45 + """Look up rules, world content, or both. 44 46 45 - Use when you need to verify how an ability or spell works, look up monster 46 - stats or item properties, or check condition effects. 47 + Use to recall information about: 48 + - Rules: spells, monsters, classes, items, conditions from the SRD 49 + - World: NPCs, locations, factions, lore you've established 50 + - Both: search everything (default) 47 51 48 52 Args: 49 - query: Search term (e.g., "fireball", "grappled", "ancient red dragon") 50 - category: Optional category to limit search. One of: spells, monsters, 51 - classes, magic-items, feats, or None to search all. 53 + query: What to look up (e.g., "fireball", "captain vex", "merchant guild") 54 + scope: Where to search - "rules", "world", or "all" (default) 55 + content_type: Optional type to limit search (e.g., "spells", "npcs") 56 + world_id: World to query (required if scope includes world) 52 57 base_path: Base path for content resolution (for testing) 53 58 54 59 Returns: 55 - Content of the found rule, or a message if not found 60 + Content of the found item, or a message if not found 56 61 """ 57 - resolver = ContentResolver(base_path=base_path) 62 + results_parts = [] 58 63 59 - # Try exact match first 60 - content = resolver.load(query, content_type=category) 61 - if content: 62 - return content["body"] 63 - 64 - # Fall back to search 65 - results = resolver.search(query, content_type=category) 66 - if not results: 67 - return f"No rules found matching '{query}'" 68 - 69 - if len(results) == 1: 70 - # Single result - return full content 71 - content = resolver.load(results[0].name, content_type=results[0].content_type) 64 + # Search rules if scope includes it 65 + if scope in ("rules", "all"): 66 + resolver = ContentResolver(base_path=base_path) 67 + content = resolver.load(query, content_type=content_type) 72 68 if content: 73 69 return content["body"] 74 70 75 - # Multiple results - return list 76 - lines = [f"Found {len(results)} matches for '{query}':"] 77 - for r in results[:10]: # Limit to 10 results 78 - lines.append(f"- {r.name} ({r.content_type})") 79 - if len(results) > 10: 80 - lines.append(f"... and {len(results) - 10} more") 81 - return "\n".join(lines) 82 - 83 - 84 - def query_world( 85 - query: str, 86 - content_type: str | None = None, 87 - world_id: str | None = None, 88 - base_path: Path | None = None, 89 - ) -> str: 90 - """Query the current world state for locations, NPCs, or established facts. 91 - 92 - Use when describing locations, recalling NPC details, checking what the 93 - player has learned, or maintaining consistency with previous events. 94 - 95 - Args: 96 - query: What to look up (e.g., "tavern", "captain vex", "merchant guild") 97 - content_type: Optional type to limit search. One of: locations, npcs, 98 - factions, monsters, magic-items, lore, events, or None. 99 - world_id: The world to query (required for world-specific content) 100 - base_path: Base path for content resolution (for testing) 71 + results = resolver.search(query, content_type=content_type) 72 + if results: 73 + if len(results) == 1: 74 + content = resolver.load( 75 + results[0].name, content_type=results[0].content_type 76 + ) 77 + if content: 78 + return content["body"] 79 + else: 80 + results_parts.append(("rules", results)) 101 81 102 - Returns: 103 - Content of the found world element, or a message if not found 104 - """ 105 - if not world_id: 106 - return "No world specified. Use lookup_rule for base game content." 82 + # Search world if scope includes it and world_id is provided 83 + if scope in ("world", "all") and world_id: 84 + resolver = ContentResolver(base_path=base_path, world_id=world_id) 85 + content = resolver.load(query, content_type=content_type) 86 + if content: 87 + return content["body"] 107 88 108 - resolver = ContentResolver(base_path=base_path, world_id=world_id) 89 + results = resolver.search(query, content_type=content_type) 90 + if results: 91 + if len(results) == 1: 92 + content = resolver.load( 93 + results[0].name, content_type=results[0].content_type 94 + ) 95 + if content: 96 + return content["body"] 97 + else: 98 + results_parts.append(("world", results)) 109 99 110 - # Try exact match first 111 - content = resolver.load(query, content_type=content_type) 112 - if content: 113 - return content["body"] 100 + # No exact match - return list of results if any 101 + if results_parts: 102 + lines = [] 103 + for source, results in results_parts: 104 + lines.append(f"Found {len(results)} matches in {source}:") 105 + for r in results[:5]: 106 + lines.append(f" - {r.name} ({r.content_type})") 107 + if len(results) > 5: 108 + lines.append(f" ... and {len(results) - 5} more") 109 + return "\n".join(lines) 114 110 115 - # Fall back to search 116 - results = resolver.search(query, content_type=content_type) 117 - if not results: 111 + # Nothing found 112 + if scope == "rules": 113 + return f"No rules found matching '{query}'" 114 + elif scope == "world": 115 + if not world_id: 116 + return "No world specified. Use scope='rules' for base game content." 118 117 return f"Nothing found in the world matching '{query}'" 119 - 120 - if len(results) == 1: 121 - content = resolver.load(results[0].name, content_type=results[0].content_type) 122 - if content: 123 - return content["body"] 124 - 125 - # Multiple results 126 - lines = [f"Found {len(results)} matches for '{query}':"] 127 - for r in results[:10]: 128 - lines.append(f"- {r.name} ({r.content_type})") 129 - if len(results) > 10: 130 - lines.append(f"... and {len(results) - 10} more") 131 - return "\n".join(lines) 118 + else: 119 + return f"Nothing found matching '{query}'" 132 120 133 121 134 122 def update_character( ··· 222 210 ) 223 211 224 212 225 - def update_session( 213 + def set_scene( 226 214 situation: str | None = None, 227 215 location: str | None = None, 228 216 present: list[str] | None = None, ··· 230 218 player_id: str = "default", 231 219 base_path: Path | None = None, 232 220 ) -> str: 233 - """Update the current session state when the scene changes significantly. 221 + """Update the current scene when the situation changes significantly. 234 222 235 223 Call this when: 236 224 - The player moves to a new location ··· 268 256 return session_update(player_id, updates, base_path) 269 257 270 258 271 - def save_to_world( 259 + def establish( 272 260 content_type: str, 273 261 name: str, 274 262 content: str, ··· 277 265 base_path: Path | None = None, 278 266 **extra_frontmatter: str, 279 267 ) -> str: 280 - """Save new content to the world for future reference. 268 + """Establish something as true in the world. 281 269 282 - Use when creating persistent world content that should be remembered: 270 + Use when the player meaningfully interacts with something and you want 271 + to commit it to the world's permanent record: 283 272 - A named NPC the player has interacted with meaningfully 284 273 - A location the player has visited 285 274 - A significant event that affects the world state 286 275 - A faction the player has learned about 287 276 288 - Don't save: 277 + Don't establish: 289 278 - Unnamed background characters (random guard, merchant #3) 290 279 - Locations mentioned but not visited 291 280 - Minor events with no ongoing consequences ··· 293 282 Args: 294 283 content_type: Type of content. One of: npcs, locations, factions, events, lore, items 295 284 name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 296 - content: Markdown body describing the entity. Include relevant details, 297 - appearance, personality, what they know, connections, etc. 285 + content: Markdown body describing the entity. Include all relevant details: 286 + appearance, personality, secrets, motivations, connections, etc. 298 287 tags: Optional tags for categorization (e.g., ["tavern", "social", "quest-hook"]) 299 288 world_id: World to save to (required) 300 289 base_path: Base path for worlds directory ··· 336 325 337 326 file_path.write_text(file_content) 338 327 339 - return f"Saved {content_type.rstrip('s')} '{name}' to {file_path.relative_to(base_path)}" 328 + return f"Established {content_type.rstrip('s')} '{name}' in {file_path.relative_to(base_path)}" 329 + 330 + 331 + def note_discovery( 332 + entity: str, 333 + content: str, 334 + content_type: str = "lore", 335 + tags: list[str] | None = None, 336 + world_id: str | None = None, 337 + player_id: str = "default", 338 + base_path: Path | None = None, 339 + ) -> str: 340 + """Record what the player has learned about something. 341 + 342 + Use when the player discovers or learns information about an NPC, 343 + location, faction, or other world element. This captures their 344 + perspective, which may be incomplete or even wrong. 345 + 346 + The player's knowledge is separate from DM truth (use `establish` 347 + for the full facts). This helps track what the player knows vs. 348 + what they haven't discovered yet. 349 + 350 + Args: 351 + entity: Name of what they learned about (e.g., "Vera Blackwater") 352 + content: What the player learned or observed 353 + content_type: Type of content - npcs, locations, factions, lore (default: lore) 354 + tags: Optional tags for categorization 355 + world_id: World this knowledge is about (required) 356 + player_id: Player who learned this 357 + base_path: Base path for players directory 358 + 359 + Returns: 360 + Confirmation message 361 + """ 362 + if not world_id: 363 + return "Error: No world_id specified. Cannot record player knowledge." 364 + 365 + if base_path is None: 366 + base_path = Path.cwd() 340 367 368 + # Generate slug from entity name 369 + slug = name_to_slug(entity) 341 370 342 - def log_event( 371 + # Player knowledge path: players/{player}/worlds/{world}/{type}/ 372 + knowledge_dir = base_path / "players" / player_id / "worlds" / world_id / content_type 373 + knowledge_dir.mkdir(parents=True, exist_ok=True) 374 + file_path = knowledge_dir / f"{slug}.md" 375 + 376 + # Build frontmatter 377 + frontmatter = { 378 + "type": content_type.rstrip("s"), 379 + "name": entity, 380 + } 381 + if tags: 382 + frontmatter["tags"] = tags 383 + 384 + # Build file content 385 + file_content = "---\n" 386 + file_content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 387 + file_content += "---\n\n" 388 + file_content += content.strip() 389 + file_content += "\n" 390 + 391 + file_path.write_text(file_content) 392 + 393 + return f"Noted: player learned about '{entity}'" 394 + 395 + 396 + def mark_time( 343 397 event: str, 344 398 duration: str, 345 399 advance_time: bool = True, 346 400 tags: list[str] | None = None, 347 401 campaign_log: CampaignLog | None = None, 348 - player_id: str = "default", 402 + world_id: str = "default", 349 403 base_path: Path | None = None, 350 404 ) -> str: 351 - """Record an event in the campaign log with its duration. 405 + """Record an event and advance game time. 352 406 353 - Use this to track significant happenings and advance game time: 407 + Use to track significant happenings and the passage of time: 354 408 - Scene transitions: "Entered the tavern", "5 min" 355 409 - Conversations: "Spoke with Vera about the job", "30 min" 356 410 - Travel: "Traveled to Millford", "1 day" ··· 365 419 advance_time: Whether to advance current time by duration (default: True) 366 420 tags: Optional tags like ["combat"], ["rest:short"], ["rest:long"], ["travel"] 367 421 campaign_log: Existing CampaignLog instance to use (avoids stale cache) 368 - player_id: Player identifier 369 - base_path: Base path for players directory 422 + world_id: World identifier (log is world-scoped) 423 + base_path: Base path for worlds directory 370 424 371 425 Returns: 372 426 Confirmation with the timestamp anchor (e.g., "#d1-0700") 373 427 """ 374 - log = campaign_log or load_log(player_id, base_path) 428 + log = campaign_log or load_log(world_id, base_path) 375 429 anchor = log.append_entry(event, duration, advance_time, tags) 376 430 return f"Logged: {anchor} | {event} | {duration}" 377 431 ··· 410 464 # Tool definitions for the Anthropic API 411 465 TOOL_DEFINITIONS = [ 412 466 { 413 - "name": "roll_dice", 414 - "description": roll_dice.__doc__, 467 + "name": "roll", 468 + "description": roll.__doc__, 415 469 "input_schema": { 416 470 "type": "object", 417 471 "properties": { ··· 428 482 }, 429 483 }, 430 484 { 431 - "name": "lookup_rule", 432 - "description": lookup_rule.__doc__, 485 + "name": "recall", 486 + "description": recall.__doc__, 433 487 "input_schema": { 434 488 "type": "object", 435 489 "properties": { 436 490 "query": { 437 491 "type": "string", 438 - "description": "Search term for the rule", 439 - }, 440 - "category": { 441 - "type": "string", 442 - "description": "Category to search: spells, monsters, classes, magic-items, feats", 443 - "enum": [ 444 - "spells", 445 - "monsters", 446 - "classes", 447 - "magic-items", 448 - "feats", 449 - "animals", 450 - ], 492 + "description": "What to look up (e.g., 'fireball', 'captain vex')", 451 493 }, 452 - }, 453 - "required": ["query"], 454 - }, 455 - }, 456 - { 457 - "name": "query_world", 458 - "description": query_world.__doc__, 459 - "input_schema": { 460 - "type": "object", 461 - "properties": { 462 - "query": { 494 + "scope": { 463 495 "type": "string", 464 - "description": "What to look up in the world", 496 + "description": "Where to search: 'rules' (SRD), 'world' (established content), or 'all' (both)", 497 + "enum": ["rules", "world", "all"], 465 498 }, 466 499 "content_type": { 467 500 "type": "string", 468 - "description": "Type of content: locations, npcs, factions, monsters, magic-items, lore, events", 469 - "enum": [ 470 - "locations", 471 - "npcs", 472 - "factions", 473 - "monsters", 474 - "magic-items", 475 - "lore", 476 - "events", 477 - ], 501 + "description": "Type to limit search (e.g., 'spells', 'npcs', 'monsters')", 478 502 }, 479 503 }, 480 504 "required": ["query"], ··· 530 554 }, 531 555 }, 532 556 { 533 - "name": "update_session", 534 - "description": update_session.__doc__, 557 + "name": "set_scene", 558 + "description": set_scene.__doc__, 535 559 "input_schema": { 536 560 "type": "object", 537 561 "properties": { ··· 558 582 }, 559 583 }, 560 584 { 561 - "name": "save_to_world", 562 - "description": save_to_world.__doc__, 585 + "name": "establish", 586 + "description": establish.__doc__, 563 587 "input_schema": { 564 588 "type": "object", 565 589 "properties": { ··· 586 610 }, 587 611 }, 588 612 { 589 - "name": "log_event", 590 - "description": log_event.__doc__, 613 + "name": "note_discovery", 614 + "description": note_discovery.__doc__, 615 + "input_schema": { 616 + "type": "object", 617 + "properties": { 618 + "entity": { 619 + "type": "string", 620 + "description": "Name of what they learned about (e.g., 'Vera Blackwater')", 621 + }, 622 + "content": { 623 + "type": "string", 624 + "description": "What the player learned or observed", 625 + }, 626 + "content_type": { 627 + "type": "string", 628 + "description": "Type of content", 629 + "enum": ["npcs", "locations", "factions", "lore"], 630 + }, 631 + "tags": { 632 + "type": "array", 633 + "items": {"type": "string"}, 634 + "description": "Tags for categorization", 635 + }, 636 + }, 637 + "required": ["entity", "content"], 638 + }, 639 + }, 640 + { 641 + "name": "mark_time", 642 + "description": mark_time.__doc__, 591 643 "input_schema": { 592 644 "type": "object", 593 645 "properties": { ··· 655 707 Returns: 656 708 Tool result as a string 657 709 """ 658 - if tool_name == "roll_dice": 659 - result = roll_dice(tool_input["notation"]) 710 + if tool_name == "roll": 711 + result = roll(tool_input["notation"]) 660 712 # Format nicely for the DM 661 713 rolls_str = ", ".join(str(r) for r in result["rolls"]) 662 714 if result["kept"] != result["rolls"]: ··· 667 719 else: 668 720 return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" 669 721 670 - elif tool_name == "lookup_rule": 671 - return lookup_rule( 672 - tool_input["query"], 673 - category=tool_input.get("category"), 674 - base_path=base_path, 675 - ) 676 - 677 - elif tool_name == "query_world": 678 - return query_world( 722 + elif tool_name == "recall": 723 + return recall( 679 724 tool_input["query"], 725 + scope=tool_input.get("scope", "all"), 680 726 content_type=tool_input.get("content_type"), 681 727 world_id=world_id, 682 728 base_path=base_path, ··· 709 755 base_path=base_path, 710 756 ) 711 757 712 - elif tool_name == "update_session": 713 - return update_session( 758 + elif tool_name == "set_scene": 759 + return set_scene( 714 760 situation=tool_input.get("situation"), 715 761 location=tool_input.get("location"), 716 762 present=tool_input.get("present"), ··· 719 765 base_path=base_path, 720 766 ) 721 767 722 - elif tool_name == "save_to_world": 723 - return save_to_world( 768 + elif tool_name == "establish": 769 + return establish( 724 770 content_type=tool_input["content_type"], 725 771 name=tool_input["name"], 726 772 content=tool_input["content"], ··· 729 775 base_path=base_path, 730 776 ) 731 777 732 - elif tool_name == "log_event": 733 - return log_event( 778 + elif tool_name == "note_discovery": 779 + return note_discovery( 780 + entity=tool_input["entity"], 781 + content=tool_input["content"], 782 + content_type=tool_input.get("content_type", "lore"), 783 + tags=tool_input.get("tags"), 784 + world_id=world_id, 785 + player_id=player_id, 786 + base_path=base_path, 787 + ) 788 + 789 + elif tool_name == "mark_time": 790 + return mark_time( 734 791 event=tool_input["event"], 735 792 duration=tool_input["duration"], 736 793 advance_time=tool_input.get("advance_time", True), 737 794 tags=tool_input.get("tags"), 738 795 campaign_log=campaign_log, 739 - player_id=player_id, 796 + world_id=world_id, 740 797 base_path=base_path, 741 798 ) 742 799
+41
tests/test_dice.py
··· 151 151 result2 = roll("3d6", seed=42) 152 152 assert result1.rolls == result2.rolls 153 153 assert result1.total == result2.total 154 + 155 + 156 + class TestCompoundNotation: 157 + """Tests for compound dice notation (e.g., 2d8+1d6).""" 158 + 159 + def test_two_dice_groups(self): 160 + result = roll("2d8+1d6", seed=42) 161 + assert len(result.rolls) == 3 # 2 d8s + 1 d6 162 + assert result.total == sum(result.rolls) 163 + 164 + def test_three_dice_groups(self): 165 + result = roll("1d8+1d6+1d4", seed=42) 166 + assert len(result.rolls) == 3 167 + assert result.total == sum(result.rolls) 168 + 169 + def test_compound_with_modifier(self): 170 + result = roll("2d6+1d4+2", seed=42) 171 + assert len(result.rolls) == 3 # 2 d6s + 1 d4 172 + assert result.modifier == 2 173 + assert result.total == sum(result.rolls) + 2 174 + 175 + def test_subtraction_dice_group(self): 176 + result = roll("2d6-1d4", seed=42) 177 + # First two are d6s, last is d4 178 + d6_total = sum(result.rolls[:2]) 179 + d4_total = result.rolls[2] 180 + assert result.total == d6_total - d4_total 181 + 182 + def test_chaos_bolt_notation(self): 183 + # Chaos Bolt does 2d8 + 1d6 damage 184 + result = roll("2d8+1d6", seed=123) 185 + assert len(result.rolls) == 3 186 + assert all(1 <= r <= 8 for r in result.rolls[:2]) 187 + assert 1 <= result.rolls[2] <= 6 188 + 189 + def test_complex_compound(self): 190 + # e.g., 1d6+1d4+1d8+3 191 + result = roll("1d6+1d4+1d8+3", seed=42) 192 + assert len(result.rolls) == 3 193 + assert result.modifier == 3 194 + assert result.total == sum(result.rolls) + 3
+13 -13
tests/test_log.py
··· 145 145 146 146 class TestCampaignLog: 147 147 def test_new_log(self, tmp_path): 148 - log = CampaignLog(player_id="test", base_path=tmp_path) 148 + log = CampaignLog(world_id="test", base_path=tmp_path) 149 149 assert log.current_day == 1 150 150 assert log.current_time.hour == 6 151 151 assert log.current_entries == [] 152 152 153 153 def test_append_entry(self, tmp_path): 154 - log = CampaignLog(player_id="test", base_path=tmp_path) 154 + log = CampaignLog(world_id="test", base_path=tmp_path) 155 155 anchor = log.append_entry("Arrived at Port Haven", "30 min") 156 156 157 157 assert anchor == "#d1-0600" ··· 159 159 assert log.current_time.minute == 30 160 160 161 161 def test_append_multiple_entries(self, tmp_path): 162 - log = CampaignLog(player_id="test", base_path=tmp_path) 162 + log = CampaignLog(world_id="test", base_path=tmp_path) 163 163 log.append_entry("Event 1", "30 min") 164 164 log.append_entry("Event 2", "1 hour") 165 165 ··· 168 168 assert log.current_time.minute == 30 169 169 170 170 def test_persistence(self, tmp_path): 171 - log = CampaignLog(player_id="test", base_path=tmp_path) 171 + log = CampaignLog(world_id="test", base_path=tmp_path) 172 172 log.append_entry("Test event", "30 min") 173 173 174 174 # Load fresh 175 - log2 = CampaignLog(player_id="test", base_path=tmp_path) 175 + log2 = CampaignLog(world_id="test", base_path=tmp_path) 176 176 assert len(log2.current_entries) == 1 177 177 assert log2.current_entries[0].event == "Test event" 178 178 179 179 def test_roll_day(self, tmp_path): 180 - log = CampaignLog(player_id="test", base_path=tmp_path) 180 + log = CampaignLog(world_id="test", base_path=tmp_path) 181 181 log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00 182 182 log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day 183 183 ··· 186 186 assert "Morning event" in log.previous_summaries[0] 187 187 188 188 # Check day file was created 189 - day_file = tmp_path / "players" / "test" / "log" / "day-001.md" 189 + day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md" 190 190 assert day_file.exists() 191 191 192 192 def test_format_for_context(self, tmp_path): 193 - log = CampaignLog(player_id="test", base_path=tmp_path) 193 + log = CampaignLog(world_id="test", base_path=tmp_path) 194 194 log.append_entry("Arrived at tavern", "30 min") 195 195 log.append_entry("Met the barkeep", "1 hour") 196 196 ··· 200 200 assert "Met the barkeep" in context 201 201 202 202 def test_time_since_rest_no_rest(self, tmp_path): 203 - log = CampaignLog(player_id="test", base_path=tmp_path) 203 + log = CampaignLog(world_id="test", base_path=tmp_path) 204 204 log.append_entry("Activity", "2 hours") 205 205 206 206 since = log.time_since_rest("short") 207 207 assert since.minutes >= 2 * 60 208 208 209 209 def test_time_since_rest_with_rest(self, tmp_path): 210 - log = CampaignLog(player_id="test", base_path=tmp_path) 210 + log = CampaignLog(world_id="test", base_path=tmp_path) 211 211 log.append_entry("Activity", "2 hours") 212 212 log.append_entry("Rest", "1 hour", tags=["rest:short"]) 213 213 log.append_entry("More activity", "30 min") ··· 218 218 219 219 class TestConvenienceFunctions: 220 220 def test_load_log(self, tmp_path): 221 - log = load_log(player_id="test", base_path=tmp_path) 221 + log = load_log(world_id="test", base_path=tmp_path) 222 222 assert isinstance(log, CampaignLog) 223 223 224 224 def test_log_event(self, tmp_path): 225 225 anchor = log_event( 226 226 "Test event", 227 227 "30 min", 228 - player_id="test", 228 + world_id="test", 229 229 base_path=tmp_path, 230 230 ) 231 231 assert anchor == "#d1-0600" 232 232 233 233 # Verify it was saved 234 - log = load_log(player_id="test", base_path=tmp_path) 234 + log = load_log(world_id="test", base_path=tmp_path) 235 235 assert len(log.current_entries) == 1