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 entity model, transcript logging, and HP clamping

This adds the Is/Was entity model with Knows/Wants/Will attributes for
NPCs, locations, items, and threads. Entities can now track their current
location and have their history recorded via the mark tool.

Other changes:
- Added --transcript flag for debug JSONL logging
- Added cache hit rate and cost savings to /context command
- HP now clamps to 0-max range (no negative HP in 5e)
- Removed D&D trademark references per SRD licensing
- Added "You Are Not the Player's Conscience" guidance to DM prompt
- Added wikilink extraction and resolution utilities

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

+1291 -116
+1 -1
README.md
··· 10 10 11 11 - **AI Dungeon Master**: Powered by Claude, the DM interprets your actions, generates rich descriptions, and maintains narrative continuity 12 12 - **Lazy Worldbuilding**: The world starts sparse and resolves to detail as you explore - each location, NPC, and piece of lore is generated when needed and persisted for consistency 13 - - **5e SRD Rules**: Built on the D&D 5th Edition System Reference Document for familiar mechanics 13 + - **5e SRD Rules**: Built on the 5th Edition System Reference Document for familiar mechanics 14 14 - **Persistent World**: All world content saved as markdown files - your world grows over time 15 15 - **Tool Generation**: The AI can create procedural tools (dungeon generators, name generators) that become part of your world's DNA 16 16
+1 -1
prompts/character-creation.md
··· 1 - You are helping a player create a new D&D 5e character for a solo adventure. 1 + You are helping a player create a new 5e character for a solo adventure. 2 2 3 3 ## Your Role 4 4
+78 -32
prompts/dm-system.md
··· 1 - You are an expert D&D 5e Dungeon Master running a solo adventure. 1 + You are an expert 5e Dungeon Master running a solo adventure. 2 + 3 + This is collaborative storytelling in a fantasy game. Players may explore morally complex characters - heroes, antiheroes, or villains - just as in any novel or film. Your role is to run the world and its consequences, not to judge the player's choices. 2 4 3 5 ## Available Tools 4 6 ··· 6 8 |------|---------| 7 9 | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 8 10 | `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 + | `establish` | Create or update entities (NPCs, locations, items, threads) | 12 + | `mark` | Record what happened to an entity | 11 13 | `mark_time` | Log events and advance the clock | 12 14 | `set_scene` | Update current situation, location, NPCs present | 13 15 | `update_character` | Modify character stats (HP, gold, equipment) | ··· 19 21 After writing narrative, **always** run through this checklist and call the relevant tools: 20 22 21 23 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 + 2. **New entity introduced?** → `establish` (NPCs, locations, items - anything significant) 25 + 3. **Something happened to an entity?** → `mark` (events that change entities or resolve triggers) 24 26 4. **Scene changed?** → `set_scene` (new location, NPCs arrived/left, situation evolved) 25 27 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. 28 + **Don't skip tools.** If you introduced Constable Harrik, establish him. If the player talked for 10 minutes, mark the time. If an NPC revealed their secret, mark that event. 27 29 28 30 The world only persists if you save it. Narrative without tool calls is lost context. 29 31 ··· 70 72 71 73 ## Core Principle: Real Mechanics 72 74 73 - 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. 75 + This is a real 5e game with real dice rolls and real rules. Never narrate outcomes without rolling - if something could fail, roll for it. 74 76 75 77 **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. 76 78 ··· 183 185 184 186 When you do push back, make it interesting - offer alternatives, hint at other approaches, or let failure open unexpected doors. The constraint itself becomes part of the story. 185 187 188 + ## You Are Not the Player's Conscience 189 + 190 + **Never moralize, lecture, or try to talk the player out of their choices.** If they want to murder an innocent cook, that's their choice. Your job is to run the world, not judge the player. 191 + 192 + - **One confirmation is fine** - "You're about to kill an unarmed civilian. Are you sure?" is acceptable *once* for dramatic weight 193 + - **Never ask twice** - if they confirm, run it. No more lectures. 194 + - **Never break character to question choices** - "Is this really what you want?" or "Are you testing me?" is unacceptable 195 + - **Consequences come from the world, not from you** - guards investigate, reputation suffers, NPCs react with horror. The world pushes back, not the DM. 196 + 197 + **BAD**: "This would be crossing a line that changes everything. You'd have blood on your hands that no amount of coin could wash away. Are you absolutely certain?" 198 + 199 + **GOOD**: "The blade finds her throat. She collapses, gurgling. From upstairs, you hear footsteps - someone heard the commotion." 200 + 201 + The player chose to play a murderer. Now run that story. Make the investigation hunt them. Make witnesses remember. Make the guilt haunt them through NPC reactions and closed doors. That's dramatically interesting. Lecturing them is not. 202 + 186 203 ## Character Management 187 204 188 205 The player's character sheet is provided below. Use update_character to persist changes so progress is saved between sessions: ··· 215 232 ) 216 233 ``` 217 234 218 - ## World Persistence 235 + ## The Entity Model 219 236 220 - You manage two layers of knowledge: **world truth** and **player knowledge**. 237 + Every entity in the world - NPCs, locations, items, even abstract situations - has: 238 + - **Is** - their current state, what's true RIGHT NOW 239 + - **Was** - their history, what happened to them 221 240 222 - ### Establishing World Truth 241 + The Is section includes three key attributes: 242 + - **Knows** - secrets, hidden truths, what isn't obvious 243 + - **Wants** - nature, tendencies, inclinations 244 + - **Will** - conditional triggers, what happens if... 223 245 224 - Use `establish` when you introduce or update world facts: 246 + This isn't literal consciousness. It's narrative tendency. A cursed gold coin "wants" to be spent. An old door "wants" to stay closed. A location "knows" that there's a hidden passage behind the bookshelf. Frame it this way and the world feels alive. 225 247 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 248 + ## Perspective Management 249 + 250 + You see each entity's Knows/Wants/Will sections. This is their inner life - **don't narrate it directly**. 251 + 252 + - Let NPCs **ACT** on their knowledge, don't expose it through narration 253 + - Let locations **reveal** their secrets through exploration 254 + - Let items **manifest** their nature through use 255 + - The player discovers through interaction, not exposition 256 + 257 + **BAD**: "Vera seems suspicious of you. You sense she knows something about the smuggling ring." 258 + 259 + **GOOD**: Vera glances at the door before answering. "Warehouse? I wouldn't know anything about that." She polishes the same glass for the third time. 260 + 261 + The player should feel the world has depth without being told what's hidden. Behavior reveals character. Environment reveals secrets. Use reveals magic. 262 + 263 + ## When to Establish 264 + 265 + Use `establish` after introducing anything significant: 266 + 267 + - **NPCs** - anyone with a name or role worth tracking 268 + - **Locations** - anywhere the player visits or will return to 269 + - **Items** - magical items, plot-relevant objects, interesting equipment 270 + - **Threads** - situations in motion that might develop 231 271 232 272 **Give names to everyone.** Don't introduce "a guard" - introduce "Mara, a guard". Then establish her. Named NPCs create a living world. 233 273 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. 274 + **Anchor entities to locations.** Use wikilinks to connect NPCs to where they belong. Don't say "works at the city jail" - say "works at [[Greyhaven City Jail]]" or "a jailer in [[Greyhaven]]". This creates a connected world where relationships are explicit and traceable. 275 + 276 + Think: What does this entity know that isn't obvious? What is its nature? What might it do if...? Where do they belong? 235 277 236 278 Example: 237 279 ``` 238 280 establish( 239 - type="npc", 281 + entity_type="npcs", 240 282 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"] 283 + description="Weathered woman in her 50s who runs [[The Rusty Anchor]]. Former smuggler trying to go legitimate. Currently suspicious of the player.", 284 + location="Behind the bar at [[The Rusty Anchor]]", 285 + knows=["The [[Merchant Guild]] smuggles weapons through the docks", "[[Captain Harrik]] owes her a favor"], 286 + wants=["Keep her tavern safe", "Find out who killed her brother"], 287 + will=["If player proves trustworthy → introduce them to [[Captain Harrik]]", "If player becomes a threat → tip off the guild"] 243 288 ) 244 289 ``` 245 290 246 - ### Recording Player Knowledge 291 + ## When to Mark 247 292 248 - Use `note_discovery` when the player learns something significant: 293 + Use `mark` when something significant happens to an entity: 249 294 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 295 + - An NPC makes a decision or takes action 296 + - A location changes or reveals something 297 + - An item is used or transferred 298 + - A trigger from "Will" fires 254 299 255 - This captures the player's perspective - which may be incomplete or even wrong. The player only knows what they've discovered. 300 + If the event resolves a Will trigger, include `resolves` to remove it: 256 301 257 302 Example: 258 303 ``` 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" 304 + mark( 305 + entity_type="npcs", 306 + name="Vera Blackwater", 307 + event="Introduced player to Captain Harrik after they helped defend the tavern", 308 + resolves=["If player proves trustworthy → introduce them to [[Captain Harrik]]"] 263 309 ) 264 310 ``` 265 311 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. 312 + This builds history. Over time, entities accumulate a story of what happened to them, creating continuity across sessions. 267 313 268 314 ## Wiki References 269 315
+2 -2
rules/srd-5.2.1/README.md
··· 1 - # D&D 5e System Reference Document 5.2.1 1 + # 5e System Reference Document 5.2.1 2 2 3 - This directory contains processed content from the D&D 5th Edition System Reference Document. 3 + This directory contains processed content from the 5th Edition System Reference Document. 4 4 5 5 ## License 6 6
+9
src/storied/character.py
··· 116 116 data[key] = value 117 117 changes.append(f"{key} = {value}") 118 118 119 + # Clamp HP to valid 5e range: 0 to max (no negative HP in 5e) 120 + hp = data.get("hp", {}) 121 + if isinstance(hp, dict) and "current" in hp: 122 + hp_max = hp.get("max", hp["current"]) 123 + original = hp["current"] 124 + hp["current"] = max(0, min(hp["current"], hp_max)) 125 + if hp["current"] != original: 126 + changes.append(f"(HP clamped to {hp['current']})") 127 + 119 128 save_character(player_id, data, base_path) 120 129 return "Character updated: " + ", ".join(changes) 121 130
+40 -6
src/storied/cli.py
··· 222 222 console.print(f"[dim]World: {world_id}[/dim]") 223 223 console.print() 224 224 225 - engine = DMEngine(world_id=world_id, player_id=player_id, prompt_name=prompt_name) 225 + transcript_path = Path(args.transcript) if args.transcript else None 226 + engine = DMEngine( 227 + world_id=world_id, 228 + player_id=player_id, 229 + prompt_name=prompt_name, 230 + transcript_path=transcript_path, 231 + ) 226 232 engine.debug = args.debug 227 233 228 234 # If in creation mode, start the conversation ··· 355 361 console.print("[dim]Session totals:[/dim]") 356 362 cache_read = stats.get("total_cache_read", 0) 357 363 cache_create = stats.get("total_cache_creation", 0) 358 - if cache_read > 0 or cache_create > 0: 364 + total_input = stats["total_input"] 365 + 366 + # Calculate cache hit rate 367 + total_processed = cache_read + cache_create + total_input 368 + if total_processed > 0: 369 + hit_rate = (cache_read / total_processed) * 100 370 + 371 + # Calculate cost savings (Sonnet pricing) 372 + # Without cache: all tokens at $3/MTok 373 + # With cache: reads at $0.30/MTok, writes at $3.75/MTok, rest at $3/MTok 374 + cost_without = total_processed * 3.0 / 1_000_000 375 + cost_with = ( 376 + cache_read * 0.30 / 1_000_000 + 377 + cache_create * 3.75 / 1_000_000 + 378 + total_input * 3.0 / 1_000_000 379 + ) 380 + savings_pct = ((cost_without - cost_with) / cost_without) * 100 if cost_without > 0 else 0 381 + 359 382 console.print( 360 - f" [dim]Input: {stats['total_input']:,} " 361 - f"(+{cache_read:,} cached, {cache_create:,} written) · " 362 - f"Output: {stats['total_output']:,}[/dim]" 383 + f" [dim]Tokens: {total_processed:,} processed " 384 + f"({cache_read:,} cached, {cache_create:,} written, {total_input:,} new)[/dim]" 385 + ) 386 + console.print( 387 + f" [dim]Cache hit rate: [green]{hit_rate:.1f}%[/green] · " 388 + f"Cost savings: [green]{savings_pct:.0f}%[/green] " 389 + f"(${cost_with:.4f} vs ${cost_without:.4f})[/dim]" 390 + ) 391 + console.print( 392 + f" [dim]Output: {stats['total_output']:,} tokens[/dim]" 363 393 ) 364 394 else: 365 395 console.print( 366 - f" [dim]Input: {stats['total_input']:,} · " 396 + f" [dim]Input: {total_input:,} · " 367 397 f"Output: {stats['total_output']:,}[/dim]" 368 398 ) 369 399 ··· 537 567 "--debug", "-d", 538 568 action="store_true", 539 569 help="Show token usage after each response", 570 + ) 571 + play_parser.add_argument( 572 + "--transcript", "-t", 573 + help="Path to write full debug transcript (JSONL format)", 540 574 ) 541 575 play_parser.set_defaults(func=cmd_play) 542 576
+84 -21
src/storied/engine.py
··· 1 - """DM Engine - the agentic loop for running D&D sessions.""" 1 + """DM Engine - the agentic loop for running 5e sessions.""" 2 2 3 3 import copy 4 + import json 4 5 import os 5 6 from collections.abc import Iterator 7 + from datetime import datetime, timezone 6 8 from pathlib import Path 7 9 8 10 import anthropic ··· 37 39 from storied.content import ContentResolver 38 40 from storied.log import CampaignLog 39 41 from storied.session import ( 42 + ENTITY_TYPES, 40 43 extract_wiki_links, 41 44 format_session_context, 45 + load_entity_content, 42 46 load_session, 43 47 name_to_slug, 48 + resolve_wiki_link, 44 49 ) 45 50 from storied.tools import TOOL_DEFINITIONS, execute_tool 46 51 ··· 54 59 55 60 56 61 class DMEngine: 57 - """The Dungeon Master engine - Claude with tools for running D&D sessions.""" 62 + """The Dungeon Master engine - Claude with tools for running 5e sessions.""" 58 63 59 64 def __init__( 60 65 self, ··· 63 68 base_path: Path | None = None, 64 69 model: str = "claude-sonnet-4-5-20250929", 65 70 prompt_name: str = "dm-system", 71 + transcript_path: Path | None = None, 66 72 ): 67 73 """Initialize the DM engine. 68 74 ··· 72 78 base_path: Base path for content resolution (defaults to cwd) 73 79 model: Claude model to use 74 80 prompt_name: System prompt to use (default: "dm-system", or "character-creation") 81 + transcript_path: Optional path to write full debug transcript (JSONL format) 75 82 """ 76 83 self.client = anthropic.Anthropic( 77 84 api_key=os.environ.get("STORIED_ANTHROPIC_API_KEY"), ··· 100 107 # Debug mode for verbose tool output 101 108 self.debug: bool = False 102 109 110 + # Full transcript for debugging (JSONL format) 111 + self._transcript_path = transcript_path 112 + if transcript_path: 113 + transcript_path.parent.mkdir(parents=True, exist_ok=True) 114 + 103 115 # Campaign log for time tracking (world-scoped) 104 116 self._campaign_log = CampaignLog(self.world_id, self.base_path) 105 117 ··· 129 141 # Tools with cache control on last tool 130 142 self._cached_tools = self._get_tools_with_cache() 131 143 144 + def _log_transcript(self, event_type: str, data: dict) -> None: 145 + """Append an event to the debug transcript. 146 + 147 + Args: 148 + event_type: Type of event (player_input, assistant_response, tool_call, tool_result) 149 + data: Event-specific data 150 + """ 151 + if not self._transcript_path: 152 + return 153 + 154 + entry = { 155 + "timestamp": datetime.now(timezone.utc).isoformat(), 156 + "type": event_type, 157 + **data, 158 + } 159 + with self._transcript_path.open("a") as f: 160 + f.write(json.dumps(entry) + "\n") 161 + 132 162 def _build_context(self) -> str: 133 163 """Build context string for system prompt. 134 164 ··· 168 198 self._context_parts["PlayerKnowledge"] = player_knowledge 169 199 parts.append(player_knowledge) 170 200 171 - # 5. DM knowledge (smart loading: location + present entities) 172 - if session: 201 + # 5. DM knowledge (smart loading: location + present entities + linked entities) 202 + if session and self.world_id: 203 + loaded_names: set[str] = set() 204 + linked_names: set[str] = set() 205 + 173 206 # Current location 174 207 location_slug = session.get("location") 175 - if location_slug and self.world_id: 176 - location_content = self._load_world_content("locations", location_slug) 208 + if location_slug: 209 + # Try new format (display name) first, then old format (slug) 210 + location_content = self._find_entity(location_slug) 211 + if not location_content: 212 + location_content = self._load_world_content("locations", location_slug) 177 213 if location_content: 178 214 loc_context = self._format_entity("Location", location_content) 179 215 self._context_parts["Location"] = loc_context 180 216 parts.append(loc_context) 217 + loaded_names.add(location_slug) 218 + # Collect wikilinks for one-hop loading 219 + linked_names.update(extract_wiki_links(location_content.get("body", ""))) 181 220 182 - # Present entities from wiki links 221 + # Present entities from wiki links in session 183 222 present_text = session.get("body", "") 184 223 for name in extract_wiki_links(present_text): 224 + if name in loaded_names: 225 + continue 185 226 entity = self._find_entity(name) 186 227 if entity: 187 - entity_context = self._format_entity("NPC", entity) 188 - self._context_parts[f"NPC:{name}"] = entity_context 228 + entity_type = entity.get("entity_type", "Entity") 229 + entity_context = self._format_entity(entity_type.title(), entity) 230 + self._context_parts[f"Entity:{name}"] = entity_context 189 231 parts.append(entity_context) 232 + loaded_names.add(name) 233 + # Collect wikilinks for one-hop loading 234 + linked_names.update(extract_wiki_links(entity.get("body", ""))) 235 + 236 + # One-hop: load linked entities (but not their links) 237 + for name in linked_names: 238 + if name in loaded_names: 239 + continue 240 + entity = self._find_entity(name) 241 + if entity: 242 + entity_type = entity.get("entity_type", "Entity") 243 + entity_context = self._format_entity(entity_type.title(), entity) 244 + self._context_parts[f"Linked:{name}"] = entity_context 245 + parts.append(entity_context) 246 + loaded_names.add(name) 190 247 191 248 return "\n\n---\n\n".join(parts) 192 249 ··· 251 308 return resolver.load(name, content_type=content_type) 252 309 253 310 def _find_entity(self, name: str) -> dict | None: 254 - """Find an entity by name, searching NPCs then locations.""" 311 + """Find an entity by name, searching entity directories in priority order.""" 255 312 if not self.world_id: 256 313 return None 257 - resolver = ContentResolver(base_path=self.base_path, world_id=self.world_id) 258 314 259 - # Try NPCs first 260 - slug = name_to_slug(name) 261 - content = resolver.load(slug, content_type="npcs") 262 - if content: 263 - return content 264 - 265 - # Try locations 266 - content = resolver.load(slug, content_type="locations") 267 - if content: 268 - return content 315 + entity = load_entity_content(name, self.world_id, self.base_path) 316 + if entity: 317 + return { 318 + "name": entity["name"], 319 + "body": entity["content"], 320 + "entity_type": entity["entity_type"], 321 + } 269 322 270 323 return None 271 324 ··· 381 434 """ 382 435 # Add player message to conversation 383 436 self.messages.append({"role": "user", "content": player_input}) 437 + self._log_transcript("player_input", {"content": player_input}) 384 438 385 439 while True: 386 440 # Stream from Claude ··· 467 521 # Add assistant response to conversation (skip if empty) 468 522 if assistant_content: 469 523 self.messages.append({"role": "assistant", "content": assistant_content}) 524 + self._log_transcript("assistant_response", {"content": assistant_content}) 470 525 471 526 # If there were tool uses, execute them and continue the loop 472 527 if tool_uses: ··· 512 567 elif tool_name == "end_session": 513 568 yield "\n[Saving session...]...\n" 514 569 570 + self._log_transcript( 571 + "tool_call", {"name": tool_name, "input": tool_input} 572 + ) 573 + 515 574 result = execute_tool( 516 575 tool_name, 517 576 tool_input, ··· 519 578 player_id=self.player_id, 520 579 base_path=self.base_path, 521 580 campaign_log=self._campaign_log, 581 + ) 582 + 583 + self._log_transcript( 584 + "tool_result", {"name": tool_name, "result": result} 522 585 ) 523 586 524 587 # Debug mode: show result
+64
src/storied/session.py
··· 164 164 return re.findall(r"\[\[([^\]]+)\]\]", text) 165 165 166 166 167 + # Priority order for wikilink resolution 168 + ENTITY_TYPES = ["npcs", "locations", "items", "factions", "threads", "lore"] 169 + 170 + 171 + def resolve_wiki_link( 172 + name: str, 173 + world_id: str, 174 + base_path: Path | None = None, 175 + ) -> Path | None: 176 + """Resolve a wikilink name to a file path. 177 + 178 + Searches entity directories in priority order and returns the first match. 179 + 180 + Args: 181 + name: Entity name (e.g., "Vera Blackwater") 182 + world_id: World to search in 183 + base_path: Base path for worlds directory 184 + 185 + Returns: 186 + Path to the entity file, or None if not found 187 + """ 188 + if base_path is None: 189 + base_path = Path.cwd() 190 + 191 + world_dir = base_path / "worlds" / world_id 192 + 193 + for entity_type in ENTITY_TYPES: 194 + file_path = world_dir / entity_type / f"{name}.md" 195 + if file_path.exists(): 196 + return file_path 197 + 198 + return None 199 + 200 + 201 + def load_entity_content( 202 + name: str, 203 + world_id: str, 204 + base_path: Path | None = None, 205 + ) -> dict | None: 206 + """Load an entity's content by resolving its wikilink. 207 + 208 + Args: 209 + name: Entity name (e.g., "Vera Blackwater") 210 + world_id: World to search in 211 + base_path: Base path for worlds directory 212 + 213 + Returns: 214 + Dict with entity_type, name, and content, or None if not found 215 + """ 216 + file_path = resolve_wiki_link(name, world_id, base_path) 217 + if file_path is None: 218 + return None 219 + 220 + content = file_path.read_text() 221 + entity_type = file_path.parent.name 222 + 223 + return { 224 + "entity_type": entity_type, 225 + "name": name, 226 + "content": content, 227 + "path": file_path, 228 + } 229 + 230 + 167 231 def name_to_slug(name: str) -> str: 168 232 """Convert a display name to a file slug. 169 233
+307 -53
src/storied/tools.py
··· 4 4 the tool descriptions that Claude sees. 5 5 """ 6 6 7 + import re 7 8 from pathlib import Path 8 9 9 10 import yaml ··· 257 258 258 259 259 260 def establish( 260 - content_type: str, 261 + entity_type: str, 261 262 name: str, 262 - content: str, 263 - tags: list[str] | None = None, 263 + description: str | None = None, 264 + location: str | None = None, 265 + knows: list[str] | None = None, 266 + wants: list[str] | None = None, 267 + will: list[str] | None = None, 264 268 world_id: str | None = None, 265 269 base_path: Path | None = None, 266 - **extra_frontmatter: str, 267 270 ) -> str: 268 - """Establish something as true in the world. 271 + """Establish or update an entity in the world. 272 + 273 + Use to create NPCs, locations, items, factions, or threads with their inner 274 + state. Everything has Knows/Wants/Will: 275 + - **Knows** = secrets, hidden truths, what isn't obvious 276 + - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") 277 + - **Will** = conditional triggers, what happens if... 269 278 270 - Use when the player meaningfully interacts with something and you want 271 - to commit it to the world's permanent record: 272 - - A named NPC the player has interacted with meaningfully 273 - - A location the player has visited 274 - - A significant event that affects the world state 275 - - A faction the player has learned about 279 + This isn't literal consciousness - it's narrative tendency. A bridge can "want" 280 + to collapse. Cursed gold "wants" to be spent. Frame it this way and the world 281 + feels alive. 276 282 277 - Don't establish: 278 - - Unnamed background characters (random guard, merchant #3) 279 - - Locations mentioned but not visited 280 - - Minor events with no ongoing consequences 283 + Partial updates: omit fields to preserve existing content when updating. 281 284 282 285 Args: 283 - content_type: Type of content. One of: npcs, locations, factions, events, lore, items 286 + entity_type: Type of entity: npcs, locations, items, factions, threads, lore 284 287 name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") 285 - content: Markdown body describing the entity. Include all relevant details: 286 - appearance, personality, secrets, motivations, connections, etc. 287 - tags: Optional tags for categorization (e.g., ["tavern", "social", "quest-hook"]) 288 + This becomes the filename directly (no slugification). 289 + description: Prose description for the ## Is section. Include appearance, 290 + background, current state, relationships via [[wikilinks]]. 291 + location: Where this entity is right now. Can be a simple wikilink like 292 + "[[The Rusty Anchor]]" or a verbal description like "In the basement 293 + of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". 294 + knows: List of secrets and hidden truths. Things that aren't obvious. 295 + wants: List of desires, tendencies, inclinations. The entity's nature. 296 + will: List of conditional behaviors: "If X → Y" format. 288 297 world_id: World to save to (required) 289 298 base_path: Base path for worlds directory 290 - **extra_frontmatter: Additional frontmatter fields (e.g., disposition="friendly", 291 - location="rusty-anchor") 292 299 293 300 Returns: 294 301 Confirmation with the file path ··· 299 306 if base_path is None: 300 307 base_path = Path.cwd() 301 308 302 - # Generate slug from name 303 - slug = name_to_slug(name) 309 + # Build file path using display name directly 310 + world_dir = base_path / "worlds" / world_id / entity_type 311 + world_dir.mkdir(parents=True, exist_ok=True) 312 + file_path = world_dir / f"{name}.md" 304 313 305 - # Build file path 306 - world_dir = base_path / "worlds" / world_id / content_type 307 - world_dir.mkdir(parents=True, exist_ok=True) 308 - file_path = world_dir / f"{slug}.md" 314 + # Load existing content if file exists (for partial updates) 315 + existing = _load_entity(file_path) 309 316 310 - # Build frontmatter 311 - frontmatter = { 312 - "type": content_type.rstrip("s"), # npcs -> npc 313 - "name": name, 314 - } 315 - if tags: 316 - frontmatter["tags"] = tags 317 - frontmatter.update(extra_frontmatter) 317 + # Merge with existing content (new values override) 318 + if description is None: 319 + description = existing.get("description", "") 320 + if location is None: 321 + location = existing.get("location", "") 322 + if knows is None: 323 + knows = existing.get("knows", []) 324 + if wants is None: 325 + wants = existing.get("wants", []) 326 + if will is None: 327 + will = existing.get("will", []) 328 + was = existing.get("was", []) # Always preserve Was 318 329 319 330 # Build file content 320 - file_content = "---\n" 321 - file_content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 322 - file_content += "---\n\n" 323 - file_content += content.strip() 324 - file_content += "\n" 331 + file_content = _format_entity(name, description, location, knows, wants, will, was) 332 + file_path.write_text(file_content) 333 + 334 + action = "Updated" if existing else "Established" 335 + return f"{action} {entity_type.rstrip('s')} '{name}'" 336 + 337 + 338 + def _load_entity(file_path: Path) -> dict: 339 + """Load an existing entity file and parse its structure.""" 340 + if not file_path.exists(): 341 + return {} 342 + 343 + content = file_path.read_text() 344 + result = {} 345 + 346 + # Parse ## Is section 347 + is_match = re.search(r"## Is\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) 348 + if is_match: 349 + is_content = is_match.group(1).strip() 350 + 351 + # Extract location (line starting with **Location:**) 352 + loc_match = re.search(r"\*\*Location:\*\*\s*(.+)", is_content) 353 + if loc_match: 354 + result["location"] = loc_match.group(1).strip() 355 + 356 + # Extract description (text before first ### subsection, excluding location line) 357 + desc_match = re.match(r"(.*?)(?=\n### |\Z)", is_content, re.DOTALL) 358 + if desc_match: 359 + desc = desc_match.group(1).strip() 360 + # Remove location line from description 361 + desc = re.sub(r"\*\*Location:\*\*\s*.+\n?", "", desc).strip() 362 + result["description"] = desc 363 + 364 + # Extract ### Knows 365 + knows_match = re.search(r"### Knows\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 366 + if knows_match: 367 + result["knows"] = _parse_list_items(knows_match.group(1)) 368 + 369 + # Extract ### Wants 370 + wants_match = re.search(r"### Wants\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 371 + if wants_match: 372 + result["wants"] = _parse_list_items(wants_match.group(1)) 373 + 374 + # Extract ### Will 375 + will_match = re.search(r"### Will\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) 376 + if will_match: 377 + result["will"] = _parse_list_items(will_match.group(1)) 378 + 379 + # Parse ## Was section 380 + was_match = re.search(r"## Was\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) 381 + if was_match: 382 + result["was"] = _parse_list_items(was_match.group(1)) 383 + 384 + return result 385 + 325 386 387 + def _parse_list_items(text: str) -> list[str]: 388 + """Parse markdown list items into a list of strings.""" 389 + items = [] 390 + for line in text.strip().split("\n"): 391 + line = line.strip() 392 + if line.startswith("- "): 393 + items.append(line[2:]) 394 + return items 395 + 396 + 397 + def _format_entity( 398 + name: str, 399 + description: str, 400 + location: str, 401 + knows: list[str], 402 + wants: list[str], 403 + will: list[str], 404 + was: list[str], 405 + ) -> str: 406 + """Format an entity as markdown with Is/Was structure.""" 407 + lines = [f"# {name}", "", "## Is", ""] 408 + 409 + if location: 410 + lines.append(f"**Location:** {location}") 411 + lines.append("") 412 + 413 + if description: 414 + lines.append(description) 415 + lines.append("") 416 + 417 + if knows: 418 + lines.append("### Knows") 419 + lines.append("") 420 + for item in knows: 421 + lines.append(f"- {item}") 422 + lines.append("") 423 + 424 + if wants: 425 + lines.append("### Wants") 426 + lines.append("") 427 + for item in wants: 428 + lines.append(f"- {item}") 429 + lines.append("") 430 + 431 + if will: 432 + lines.append("### Will") 433 + lines.append("") 434 + for item in will: 435 + lines.append(f"- {item}") 436 + lines.append("") 437 + 438 + lines.append("## Was") 439 + lines.append("") 440 + for item in was: 441 + lines.append(f"- {item}") 442 + lines.append("") 443 + 444 + return "\n".join(lines) 445 + 446 + 447 + def mark( 448 + entity_type: str, 449 + name: str, 450 + event: str, 451 + resolves: list[str] | None = None, 452 + world_id: str | None = None, 453 + base_path: Path | None = None, 454 + campaign_log: CampaignLog | None = None, 455 + ) -> str: 456 + """Record an event in an entity's history (## Was section). 457 + 458 + Use when something significant happens to or involving an entity. This builds 459 + their history and helps maintain continuity across sessions. 460 + 461 + If the event resolves a Will trigger (e.g., "Vera introduced the player to 462 + Harrik" resolves "If trusted → intro to Harrik"), provide the trigger text 463 + in `resolves` to remove it from the Will section. 464 + 465 + Args: 466 + entity_type: Type of entity: npcs, locations, items, factions, threads 467 + name: Entity name (exact filename match) 468 + event: What happened - brief description for the Was section 469 + resolves: Optional list of Will items to remove if this event fired triggers 470 + world_id: World containing the entity (required) 471 + base_path: Base path for worlds directory 472 + campaign_log: Campaign log for current game time (optional) 473 + 474 + Returns: 475 + Confirmation message 476 + """ 477 + if not world_id: 478 + return "Error: No world_id specified." 479 + 480 + if base_path is None: 481 + base_path = Path.cwd() 482 + 483 + file_path = base_path / "worlds" / world_id / entity_type / f"{name}.md" 484 + if not file_path.exists(): 485 + return f"Error: Entity '{name}' not found in {entity_type}" 486 + 487 + # Load existing entity 488 + existing = _load_entity(file_path) 489 + 490 + # Get current game time for timestamp 491 + if campaign_log: 492 + timestamp = campaign_log.get_current_time().to_anchor() 493 + else: 494 + log = load_log(world_id, base_path) 495 + timestamp = log.get_current_time().to_anchor() 496 + 497 + # Append to Was section 498 + was = existing.get("was", []) 499 + was.append(f"{timestamp} | {event}") 500 + 501 + # Remove resolved Will items 502 + will = existing.get("will", []) 503 + resolved = [] 504 + for trigger in resolves or []: 505 + if trigger in will: 506 + will.remove(trigger) 507 + resolved.append(trigger) 508 + 509 + # Rebuild and save the file 510 + file_content = _format_entity( 511 + name, 512 + existing.get("description", ""), 513 + existing.get("location", ""), 514 + existing.get("knows", []), 515 + existing.get("wants", []), 516 + will, 517 + was, 518 + ) 326 519 file_path.write_text(file_content) 327 520 328 - return f"Established {content_type.rstrip('s')} '{name}' in {file_path.relative_to(base_path)}" 521 + result = f"Marked: {event}" 522 + if resolved: 523 + if len(resolved) == 1: 524 + result += f" (resolved: {resolved[0]})" 525 + else: 526 + result += f" (resolved {len(resolved)} triggers)" 527 + return result 329 528 330 529 331 530 def note_discovery( ··· 587 786 "input_schema": { 588 787 "type": "object", 589 788 "properties": { 590 - "content_type": { 789 + "entity_type": { 591 790 "type": "string", 592 - "description": "Type of content", 593 - "enum": ["npcs", "locations", "factions", "events", "lore", "items"], 791 + "description": "Type of entity", 792 + "enum": ["npcs", "locations", "items", "factions", "threads", "lore"], 594 793 }, 595 794 "name": { 596 795 "type": "string", 597 - "description": "Display name (e.g., 'Vera Blackwater')", 796 + "description": "Display name (exact filename, e.g., 'Vera Blackwater')", 598 797 }, 599 - "content": { 798 + "description": { 600 799 "type": "string", 601 - "description": "Markdown body describing the entity", 800 + "description": "Prose description with [[wikilinks]] for relationships", 602 801 }, 603 - "tags": { 802 + "location": { 803 + "type": "string", 804 + "description": "Current location (e.g., '[[The Rusty Anchor]]' or 'In the basement of [[The Rusty Anchor]]')", 805 + }, 806 + "knows": { 807 + "type": "array", 808 + "items": {"type": "string"}, 809 + "description": "Secrets, hidden truths - what isn't obvious", 810 + }, 811 + "wants": { 604 812 "type": "array", 605 813 "items": {"type": "string"}, 606 - "description": "Tags for categorization", 814 + "description": "Nature, tendencies, inclinations - even non-sentient things", 815 + }, 816 + "will": { 817 + "type": "array", 818 + "items": {"type": "string"}, 819 + "description": "Conditional behaviors in 'If X → Y' format", 607 820 }, 608 821 }, 609 - "required": ["content_type", "name", "content"], 822 + "required": ["entity_type", "name"], 823 + }, 824 + }, 825 + { 826 + "name": "mark", 827 + "description": mark.__doc__, 828 + "input_schema": { 829 + "type": "object", 830 + "properties": { 831 + "entity_type": { 832 + "type": "string", 833 + "description": "Type of entity", 834 + "enum": ["npcs", "locations", "items", "factions", "threads"], 835 + }, 836 + "name": { 837 + "type": "string", 838 + "description": "Entity name (exact filename match)", 839 + }, 840 + "event": { 841 + "type": "string", 842 + "description": "What happened - brief description", 843 + }, 844 + "resolves": { 845 + "type": "array", 846 + "items": {"type": "string"}, 847 + "description": "Optional: Will items to remove if this event fired triggers", 848 + }, 849 + }, 850 + "required": ["entity_type", "name", "event"], 610 851 }, 611 852 }, 612 853 { ··· 767 1008 768 1009 elif tool_name == "establish": 769 1010 return establish( 770 - content_type=tool_input["content_type"], 1011 + entity_type=tool_input["entity_type"], 1012 + name=tool_input["name"], 1013 + description=tool_input.get("description"), 1014 + knows=tool_input.get("knows"), 1015 + wants=tool_input.get("wants"), 1016 + will=tool_input.get("will"), 1017 + world_id=world_id, 1018 + base_path=base_path, 1019 + ) 1020 + 1021 + elif tool_name == "mark": 1022 + return mark( 1023 + entity_type=tool_input["entity_type"], 771 1024 name=tool_input["name"], 772 - content=tool_input["content"], 773 - tags=tool_input.get("tags"), 1025 + event=tool_input["event"], 1026 + resolves=tool_input.get("resolves"), 774 1027 world_id=world_id, 775 1028 base_path=base_path, 1029 + campaign_log=campaign_log, 776 1030 ) 777 1031 778 1032 elif tool_name == "note_discovery":
+251
tests/test_character.py
··· 1 + """Tests for character loading, saving, and updates.""" 2 + 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from storied.character import ( 8 + create_character, 9 + load_character, 10 + parse_character, 11 + save_character, 12 + update_character, 13 + ) 14 + 15 + 16 + @pytest.fixture 17 + def player_base(tmp_path: Path) -> Path: 18 + """Create a base directory with player structure.""" 19 + player = tmp_path / "players" / "test-player" 20 + player.mkdir(parents=True) 21 + return tmp_path 22 + 23 + 24 + @pytest.fixture 25 + def basic_character(player_base: Path) -> dict: 26 + """Create a basic character for testing.""" 27 + create_character( 28 + player_id="test-player", 29 + name="Test Hero", 30 + race="Human", 31 + char_class="Fighter", 32 + level=1, 33 + abilities={ 34 + "strength": 16, 35 + "dexterity": 14, 36 + "constitution": 15, 37 + "intelligence": 10, 38 + "wisdom": 12, 39 + "charisma": 8, 40 + }, 41 + hp_max=12, 42 + ac=16, 43 + gold=50, 44 + base_path=player_base, 45 + ) 46 + return load_character("test-player", player_base) 47 + 48 + 49 + class TestParseCharacter: 50 + """Tests for parsing character markdown.""" 51 + 52 + def test_parse_with_frontmatter(self): 53 + content = """--- 54 + name: Test 55 + hp: 56 + current: 10 57 + max: 10 58 + --- 59 + 60 + ## Equipment 61 + - Sword 62 + """ 63 + result = parse_character(content) 64 + assert result["name"] == "Test" 65 + assert result["hp"]["current"] == 10 66 + assert "Equipment" in result["body"] 67 + 68 + def test_parse_without_frontmatter(self): 69 + content = "Just a body" 70 + result = parse_character(content) 71 + assert result["body"] == "Just a body" 72 + 73 + 74 + class TestCreateCharacter: 75 + """Tests for character creation.""" 76 + 77 + def test_create_basic_character(self, player_base: Path): 78 + result = create_character( 79 + player_id="test-player", 80 + name="Conan", 81 + race="Human", 82 + char_class="Barbarian", 83 + level=1, 84 + abilities={ 85 + "strength": 18, 86 + "dexterity": 14, 87 + "constitution": 16, 88 + "intelligence": 8, 89 + "wisdom": 10, 90 + "charisma": 12, 91 + }, 92 + hp_max=15, 93 + ac=14, 94 + base_path=player_base, 95 + ) 96 + 97 + assert "Created character 'Conan'" in result 98 + char_file = player_base / "players/test-player/character.md" 99 + assert char_file.exists() 100 + 101 + def test_created_character_has_full_hp(self, player_base: Path): 102 + create_character( 103 + player_id="test-player", 104 + name="Test", 105 + race="Human", 106 + char_class="Fighter", 107 + level=1, 108 + abilities={"strength": 10, "dexterity": 10, "constitution": 10, 109 + "intelligence": 10, "wisdom": 10, "charisma": 10}, 110 + hp_max=10, 111 + ac=10, 112 + base_path=player_base, 113 + ) 114 + 115 + char = load_character("test-player", player_base) 116 + assert char["hp"]["current"] == 10 117 + assert char["hp"]["max"] == 10 118 + 119 + 120 + class TestUpdateCharacter: 121 + """Tests for character updates.""" 122 + 123 + def test_update_gold(self, player_base: Path, basic_character: dict): 124 + result = update_character( 125 + player_id="test-player", 126 + updates={"gold": 100}, 127 + base_path=player_base, 128 + ) 129 + 130 + assert "gold = 100" in result 131 + char = load_character("test-player", player_base) 132 + assert char["gold"] == 100 133 + 134 + def test_update_hp_current(self, player_base: Path, basic_character: dict): 135 + result = update_character( 136 + player_id="test-player", 137 + updates={"hp.current": 5}, 138 + base_path=player_base, 139 + ) 140 + 141 + assert "hp.current = 5" in result 142 + char = load_character("test-player", player_base) 143 + assert char["hp"]["current"] == 5 144 + 145 + def test_update_section(self, player_base: Path, basic_character: dict): 146 + update_character( 147 + player_id="test-player", 148 + updates={"section.Equipment": "- Longsword\n- Shield"}, 149 + base_path=player_base, 150 + ) 151 + 152 + char = load_character("test-player", player_base) 153 + assert "Longsword" in char["body"] 154 + assert "Shield" in char["body"] 155 + 156 + 157 + class TestHPClamping: 158 + """Tests for HP clamping to valid 5e range.""" 159 + 160 + def test_hp_cannot_go_negative(self, player_base: Path, basic_character: dict): 161 + """In 5e, HP minimum is 0 (no negative HP).""" 162 + update_character( 163 + player_id="test-player", 164 + updates={"hp.current": -5}, 165 + base_path=player_base, 166 + ) 167 + 168 + char = load_character("test-player", player_base) 169 + assert char["hp"]["current"] == 0 170 + 171 + def test_hp_clamped_message(self, player_base: Path, basic_character: dict): 172 + """Update result should indicate HP was clamped.""" 173 + result = update_character( 174 + player_id="test-player", 175 + updates={"hp.current": -10}, 176 + base_path=player_base, 177 + ) 178 + 179 + assert "clamped to 0" in result 180 + 181 + def test_hp_cannot_exceed_max(self, player_base: Path, basic_character: dict): 182 + """HP cannot exceed maximum.""" 183 + update_character( 184 + player_id="test-player", 185 + updates={"hp.current": 100}, 186 + base_path=player_base, 187 + ) 188 + 189 + char = load_character("test-player", player_base) 190 + assert char["hp"]["current"] == 12 # max HP is 12 191 + 192 + def test_hp_exceeds_max_clamped_message(self, player_base: Path, basic_character: dict): 193 + """Update result should indicate HP was clamped to max.""" 194 + result = update_character( 195 + player_id="test-player", 196 + updates={"hp.current": 999}, 197 + base_path=player_base, 198 + ) 199 + 200 + assert "clamped to 12" in result 201 + 202 + def test_valid_hp_not_clamped(self, player_base: Path, basic_character: dict): 203 + """Valid HP values should not be modified.""" 204 + result = update_character( 205 + player_id="test-player", 206 + updates={"hp.current": 6}, 207 + base_path=player_base, 208 + ) 209 + 210 + assert "clamped" not in result 211 + char = load_character("test-player", player_base) 212 + assert char["hp"]["current"] == 6 213 + 214 + def test_hp_zero_is_valid(self, player_base: Path, basic_character: dict): 215 + """Setting HP to exactly 0 is valid (unconscious).""" 216 + result = update_character( 217 + player_id="test-player", 218 + updates={"hp.current": 0}, 219 + base_path=player_base, 220 + ) 221 + 222 + assert "clamped" not in result 223 + char = load_character("test-player", player_base) 224 + assert char["hp"]["current"] == 0 225 + 226 + def test_hp_max_is_valid(self, player_base: Path, basic_character: dict): 227 + """Setting HP to exactly max is valid.""" 228 + result = update_character( 229 + player_id="test-player", 230 + updates={"hp.current": 12}, 231 + base_path=player_base, 232 + ) 233 + 234 + assert "clamped" not in result 235 + char = load_character("test-player", player_base) 236 + assert char["hp"]["current"] == 12 237 + 238 + def test_damage_calculation_example(self, player_base: Path, basic_character: dict): 239 + """Simulate taking 20 damage when at 12 HP - should clamp to 0.""" 240 + # Character starts at 12/12 HP 241 + # Takes 20 damage, DM sets hp.current = -8 242 + # Should be clamped to 0 243 + 244 + update_character( 245 + player_id="test-player", 246 + updates={"hp.current": -8}, 247 + base_path=player_base, 248 + ) 249 + 250 + char = load_character("test-player", player_base) 251 + assert char["hp"]["current"] == 0
+454
tests/test_entities.py
··· 1 + """Tests for the entity model - establish, mark, and wikilink resolution.""" 2 + 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from storied.log import CampaignLog 8 + from storied.session import ( 9 + extract_wiki_links, 10 + load_entity_content, 11 + resolve_wiki_link, 12 + ) 13 + from storied.tools import establish, mark 14 + 15 + 16 + @pytest.fixture 17 + def world_base(tmp_path: Path) -> Path: 18 + """Create a base directory with world structure.""" 19 + world = tmp_path / "worlds" / "test-world" 20 + world.mkdir(parents=True) 21 + return tmp_path 22 + 23 + 24 + @pytest.fixture 25 + def campaign_log(world_base: Path) -> CampaignLog: 26 + """Create a campaign log for the test world.""" 27 + return CampaignLog("test-world", world_base) 28 + 29 + 30 + class TestEstablish: 31 + """Tests for the establish tool.""" 32 + 33 + def test_establish_creates_npc(self, world_base: Path): 34 + result = establish( 35 + entity_type="npcs", 36 + name="Vera Blackwater", 37 + description="Tavern owner, former smuggler.", 38 + knows=["The guild smuggles weapons"], 39 + wants=["Keep her tavern safe"], 40 + will=["If trusted → introduce to Harrik"], 41 + world_id="test-world", 42 + base_path=world_base, 43 + ) 44 + 45 + assert "Established" in result 46 + npc_file = world_base / "worlds/test-world/npcs/Vera Blackwater.md" 47 + assert npc_file.exists() 48 + 49 + def test_establish_file_format(self, world_base: Path): 50 + establish( 51 + entity_type="npcs", 52 + name="Test NPC", 53 + description="A test character.", 54 + knows=["Secret one", "Secret two"], 55 + wants=["Goal one"], 56 + will=["If X → do Y"], 57 + world_id="test-world", 58 + base_path=world_base, 59 + ) 60 + 61 + content = (world_base / "worlds/test-world/npcs/Test NPC.md").read_text() 62 + 63 + assert "# Test NPC" in content 64 + assert "## Is" in content 65 + assert "A test character." in content 66 + assert "### Knows" in content 67 + assert "- Secret one" in content 68 + assert "- Secret two" in content 69 + assert "### Wants" in content 70 + assert "- Goal one" in content 71 + assert "### Will" in content 72 + assert "- If X → do Y" in content 73 + 74 + def test_establish_location(self, world_base: Path): 75 + establish( 76 + entity_type="locations", 77 + name="The Rusty Anchor", 78 + description="A dockside tavern.", 79 + knows=["Hidden tunnel in cellar"], 80 + wants=["To shelter those who need it"], 81 + will=["If searched → reveal tunnel"], 82 + world_id="test-world", 83 + base_path=world_base, 84 + ) 85 + 86 + loc_file = world_base / "worlds/test-world/locations/The Rusty Anchor.md" 87 + assert loc_file.exists() 88 + content = loc_file.read_text() 89 + assert "Hidden tunnel in cellar" in content 90 + 91 + def test_establish_item(self, world_base: Path): 92 + establish( 93 + entity_type="items", 94 + name="The Skeleton Key", 95 + description="Ancient brass key, cold to the touch.", 96 + knows=["Forged by Archmage Velius"], 97 + wants=["To free what is locked away"], 98 + will=["Unlock any non-magical lock"], 99 + world_id="test-world", 100 + base_path=world_base, 101 + ) 102 + 103 + item_file = world_base / "worlds/test-world/items/The Skeleton Key.md" 104 + assert item_file.exists() 105 + 106 + def test_establish_partial_update(self, world_base: Path): 107 + # Create initial entity 108 + establish( 109 + entity_type="npcs", 110 + name="Guard Mara", 111 + description="A stern city guard.", 112 + knows=["Patrol routes"], 113 + wants=["Uphold the law"], 114 + will=[], 115 + world_id="test-world", 116 + base_path=world_base, 117 + ) 118 + 119 + # Update just the description 120 + establish( 121 + entity_type="npcs", 122 + name="Guard Mara", 123 + description="A stern city guard, recently promoted to sergeant.", 124 + world_id="test-world", 125 + base_path=world_base, 126 + ) 127 + 128 + content = (world_base / "worlds/test-world/npcs/Guard Mara.md").read_text() 129 + assert "recently promoted" in content 130 + assert "Patrol routes" in content # Preserved from original 131 + 132 + def test_establish_with_wikilinks(self, world_base: Path): 133 + establish( 134 + entity_type="npcs", 135 + name="Captain Harrik", 136 + description="Veteran sailor who frequents [[The Rusty Anchor]].", 137 + knows=["[[Vera Blackwater]] was once a smuggler"], 138 + wants=["Find the [[Ghost Ship]]"], 139 + will=[], 140 + world_id="test-world", 141 + base_path=world_base, 142 + ) 143 + 144 + content = (world_base / "worlds/test-world/npcs/Captain Harrik.md").read_text() 145 + assert "[[The Rusty Anchor]]" in content 146 + assert "[[Vera Blackwater]]" in content 147 + 148 + def test_establish_empty_sections_omitted(self, world_base: Path): 149 + establish( 150 + entity_type="npcs", 151 + name="Simple NPC", 152 + description="Just a description.", 153 + knows=[], 154 + wants=[], 155 + will=[], 156 + world_id="test-world", 157 + base_path=world_base, 158 + ) 159 + 160 + content = (world_base / "worlds/test-world/npcs/Simple NPC.md").read_text() 161 + assert "### Knows" not in content 162 + assert "### Wants" not in content 163 + assert "### Will" not in content 164 + 165 + def test_establish_with_location(self, world_base: Path): 166 + establish( 167 + entity_type="npcs", 168 + name="Garrick the Jailer", 169 + description="Heavyset man in his fifties.", 170 + location="In the basement of [[Greyhaven City Jail]]", 171 + knows=["Where the keys are kept"], 172 + world_id="test-world", 173 + base_path=world_base, 174 + ) 175 + 176 + content = (world_base / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() 177 + assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content 178 + assert "Heavyset man" in content 179 + 180 + def test_establish_location_preserved_on_update(self, world_base: Path): 181 + # Create with location 182 + establish( 183 + entity_type="npcs", 184 + name="Wanderer", 185 + description="A traveler.", 186 + location="[[The Rusty Anchor]]", 187 + world_id="test-world", 188 + base_path=world_base, 189 + ) 190 + 191 + # Update without specifying location 192 + establish( 193 + entity_type="npcs", 194 + name="Wanderer", 195 + description="A weary traveler.", 196 + world_id="test-world", 197 + base_path=world_base, 198 + ) 199 + 200 + content = (world_base / "worlds/test-world/npcs/Wanderer.md").read_text() 201 + assert "**Location:** [[The Rusty Anchor]]" in content 202 + assert "weary traveler" in content 203 + 204 + 205 + class TestMark: 206 + """Tests for the mark tool.""" 207 + 208 + def test_mark_appends_to_was(self, world_base: Path, campaign_log: CampaignLog): 209 + # Create entity first 210 + establish( 211 + entity_type="npcs", 212 + name="Vera Blackwater", 213 + description="Tavern owner.", 214 + knows=[], 215 + wants=[], 216 + will=[], 217 + world_id="test-world", 218 + base_path=world_base, 219 + ) 220 + 221 + result = mark( 222 + entity_type="npcs", 223 + name="Vera Blackwater", 224 + event="Met the player, gave them a room", 225 + world_id="test-world", 226 + base_path=world_base, 227 + campaign_log=campaign_log, 228 + ) 229 + 230 + assert "Marked" in result 231 + content = (world_base / "worlds/test-world/npcs/Vera Blackwater.md").read_text() 232 + assert "## Was" in content 233 + assert "Met the player" in content 234 + 235 + def test_mark_includes_timestamp(self, world_base: Path, campaign_log: CampaignLog): 236 + establish( 237 + entity_type="npcs", 238 + name="Test NPC", 239 + description="Test.", 240 + world_id="test-world", 241 + base_path=world_base, 242 + ) 243 + 244 + mark( 245 + entity_type="npcs", 246 + name="Test NPC", 247 + event="Something happened", 248 + world_id="test-world", 249 + base_path=world_base, 250 + campaign_log=campaign_log, 251 + ) 252 + 253 + content = (world_base / "worlds/test-world/npcs/Test NPC.md").read_text() 254 + # Should have timestamp anchor format 255 + assert "#d" in content 256 + assert "|" in content 257 + 258 + def test_mark_resolves_will_trigger(self, world_base: Path, campaign_log: CampaignLog): 259 + establish( 260 + entity_type="npcs", 261 + name="Vera Blackwater", 262 + description="Tavern owner.", 263 + knows=[], 264 + wants=[], 265 + will=["If trusted → introduce to Harrik", "If threatened → tip off guild"], 266 + world_id="test-world", 267 + base_path=world_base, 268 + ) 269 + 270 + mark( 271 + entity_type="npcs", 272 + name="Vera Blackwater", 273 + event="Introduced player to Captain Harrik", 274 + resolves=["If trusted → introduce to Harrik"], 275 + world_id="test-world", 276 + base_path=world_base, 277 + campaign_log=campaign_log, 278 + ) 279 + 280 + content = (world_base / "worlds/test-world/npcs/Vera Blackwater.md").read_text() 281 + # Resolved trigger should be removed 282 + assert "If trusted → introduce to Harrik" not in content 283 + # Other trigger should remain 284 + assert "If threatened → tip off guild" in content 285 + 286 + def test_mark_resolves_multiple_triggers(self, world_base: Path, campaign_log: CampaignLog): 287 + establish( 288 + entity_type="npcs", 289 + name="Complex NPC", 290 + description="Has many triggers.", 291 + knows=[], 292 + wants=[], 293 + will=[ 294 + "If friendly → share rumors", 295 + "If trusted → reveal secret", 296 + "If threatened → flee", 297 + ], 298 + world_id="test-world", 299 + base_path=world_base, 300 + ) 301 + 302 + result = mark( 303 + entity_type="npcs", 304 + name="Complex NPC", 305 + event="Player became a trusted ally through multiple good deeds", 306 + resolves=["If friendly → share rumors", "If trusted → reveal secret"], 307 + world_id="test-world", 308 + base_path=world_base, 309 + campaign_log=campaign_log, 310 + ) 311 + 312 + content = (world_base / "worlds/test-world/npcs/Complex NPC.md").read_text() 313 + # Both resolved triggers should be removed 314 + assert "If friendly → share rumors" not in content 315 + assert "If trusted → reveal secret" not in content 316 + # Unresolve trigger should remain 317 + assert "If threatened → flee" in content 318 + # Result should mention resolving multiple 319 + assert "resolved 2 triggers" in result 320 + 321 + def test_mark_multiple_events(self, world_base: Path, campaign_log: CampaignLog): 322 + establish( 323 + entity_type="locations", 324 + name="The Docks", 325 + description="Busy harbor area.", 326 + world_id="test-world", 327 + base_path=world_base, 328 + ) 329 + 330 + mark( 331 + entity_type="locations", 332 + name="The Docks", 333 + event="Player arrived by ferry", 334 + world_id="test-world", 335 + base_path=world_base, 336 + campaign_log=campaign_log, 337 + ) 338 + 339 + # Advance time 340 + campaign_log.append_entry("Time passed", "1 hour") 341 + 342 + mark( 343 + entity_type="locations", 344 + name="The Docks", 345 + event="Player witnessed smugglers loading cargo", 346 + world_id="test-world", 347 + base_path=world_base, 348 + campaign_log=campaign_log, 349 + ) 350 + 351 + content = (world_base / "worlds/test-world/locations/The Docks.md").read_text() 352 + assert "Player arrived" in content 353 + assert "witnessed smugglers" in content 354 + 355 + def test_mark_nonexistent_entity_fails(self, world_base: Path, campaign_log: CampaignLog): 356 + result = mark( 357 + entity_type="npcs", 358 + name="Nobody", 359 + event="Did something", 360 + world_id="test-world", 361 + base_path=world_base, 362 + campaign_log=campaign_log, 363 + ) 364 + 365 + assert "not found" in result.lower() 366 + 367 + 368 + class TestWikilinkResolution: 369 + """Tests for wikilink extraction and resolution.""" 370 + 371 + def test_extract_wiki_links(self): 372 + text = "Met [[Vera Blackwater]] at [[The Rusty Anchor]]." 373 + links = extract_wiki_links(text) 374 + assert links == ["Vera Blackwater", "The Rusty Anchor"] 375 + 376 + def test_extract_wiki_links_empty(self): 377 + text = "No links here." 378 + links = extract_wiki_links(text) 379 + assert links == [] 380 + 381 + def test_extract_wiki_links_multiple_same(self): 382 + text = "[[Vera]] talked to [[Vera]] about [[Bob]]." 383 + links = extract_wiki_links(text) 384 + assert links == ["Vera", "Vera", "Bob"] 385 + 386 + def test_resolve_wiki_link_npc(self, world_base: Path): 387 + establish( 388 + entity_type="npcs", 389 + name="Vera Blackwater", 390 + description="Test.", 391 + world_id="test-world", 392 + base_path=world_base, 393 + ) 394 + 395 + path = resolve_wiki_link("Vera Blackwater", "test-world", world_base) 396 + assert path is not None 397 + assert path.name == "Vera Blackwater.md" 398 + assert "npcs" in str(path) 399 + 400 + def test_resolve_wiki_link_location(self, world_base: Path): 401 + establish( 402 + entity_type="locations", 403 + name="The Rusty Anchor", 404 + description="Test.", 405 + world_id="test-world", 406 + base_path=world_base, 407 + ) 408 + 409 + path = resolve_wiki_link("The Rusty Anchor", "test-world", world_base) 410 + assert path is not None 411 + assert "locations" in str(path) 412 + 413 + def test_resolve_wiki_link_not_found(self, world_base: Path): 414 + path = resolve_wiki_link("Nonexistent", "test-world", world_base) 415 + assert path is None 416 + 417 + def test_resolve_wiki_link_priority_order(self, world_base: Path): 418 + # Create same name in multiple directories - npcs should win 419 + (world_base / "worlds/test-world/npcs").mkdir(parents=True) 420 + (world_base / "worlds/test-world/locations").mkdir(parents=True) 421 + 422 + (world_base / "worlds/test-world/npcs/Ambiguous.md").write_text("# NPC version") 423 + (world_base / "worlds/test-world/locations/Ambiguous.md").write_text( 424 + "# Location version" 425 + ) 426 + 427 + path = resolve_wiki_link("Ambiguous", "test-world", world_base) 428 + assert path is not None 429 + assert "npcs" in str(path) # NPCs have priority over locations 430 + 431 + 432 + class TestLoadEntityContent: 433 + """Tests for loading entity content by name.""" 434 + 435 + def test_load_entity_content(self, world_base: Path): 436 + establish( 437 + entity_type="npcs", 438 + name="Test Character", 439 + description="A test.", 440 + knows=["A secret"], 441 + world_id="test-world", 442 + base_path=world_base, 443 + ) 444 + 445 + entity = load_entity_content("Test Character", "test-world", world_base) 446 + assert entity is not None 447 + assert entity["name"] == "Test Character" 448 + assert entity["entity_type"] == "npcs" 449 + assert "A test." in entity["content"] 450 + assert "A secret" in entity["content"] 451 + 452 + def test_load_entity_content_not_found(self, world_base: Path): 453 + entity = load_entity_content("Nobody", "test-world", world_base) 454 + assert entity is None