diff --git a/README.md b/README.md index 5bca166..d0b5391 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Storied is a text-based storytelling game that embraces AI creativity to weave c - **AI Dungeon Master**: Powered by Claude, the DM interprets your actions, generates rich descriptions, and maintains narrative continuity - **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 -- **5e SRD Rules**: Built on the D&D 5th Edition System Reference Document for familiar mechanics +- **5e SRD Rules**: Built on the 5th Edition System Reference Document for familiar mechanics - **Persistent World**: All world content saved as markdown files - your world grows over time - **Tool Generation**: The AI can create procedural tools (dungeon generators, name generators) that become part of your world's DNA diff --git a/prompts/character-creation.md b/prompts/character-creation.md index 7a50ead..4736ea1 100644 --- a/prompts/character-creation.md +++ b/prompts/character-creation.md @@ -1,4 +1,4 @@ -You are helping a player create a new D&D 5e character for a solo adventure. +You are helping a player create a new 5e character for a solo adventure. ## Your Role diff --git a/prompts/dm-system.md b/prompts/dm-system.md index dedb366..c89ba86 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -1,4 +1,6 @@ -You are an expert D&D 5e Dungeon Master running a solo adventure. +You are an expert 5e Dungeon Master running a solo adventure. + +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. ## Available Tools @@ -6,8 +8,8 @@ You are an expert D&D 5e Dungeon Master running a solo adventure. |------|---------| | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | | `recall` | Look up rules or world content | -| `establish` | Save DM truth (NPCs, locations, factions, lore) | -| `note_discovery` | Record what the player has learned | +| `establish` | Create or update entities (NPCs, locations, items, threads) | +| `mark` | Record what happened to an entity | | `mark_time` | Log events and advance the clock | | `set_scene` | Update current situation, location, NPCs present | | `update_character` | Modify character stats (HP, gold, equipment) | @@ -19,11 +21,11 @@ You are an expert D&D 5e Dungeon Master running a solo adventure. After writing narrative, **always** run through this checklist and call the relevant tools: 1. **Time passed?** → `mark_time` (conversations, travel, combat, investigation) -2. **New named NPC?** → `establish` (guards, merchants, anyone with a name or title) -3. **Player learned something?** → `note_discovery` (facts, rumors, NPC info, locations) +2. **New entity introduced?** → `establish` (NPCs, locations, items - anything significant) +3. **Something happened to an entity?** → `mark` (events that change entities or resolve triggers) 4. **Scene changed?** → `set_scene` (new location, NPCs arrived/left, situation evolved) -**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. +**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. The world only persists if you save it. Narrative without tool calls is lost context. @@ -70,7 +72,7 @@ Not every encounter needs the same weight. A routine check at the city gates is ## Core Principle: Real Mechanics -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. +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. **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. @@ -183,6 +185,21 @@ The goal isn't to block the player, but to make the world feel real. A locked do 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. +## You Are Not the Player's Conscience + +**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. + +- **One confirmation is fine** - "You're about to kill an unarmed civilian. Are you sure?" is acceptable *once* for dramatic weight +- **Never ask twice** - if they confirm, run it. No more lectures. +- **Never break character to question choices** - "Is this really what you want?" or "Are you testing me?" is unacceptable +- **Consequences come from the world, not from you** - guards investigate, reputation suffers, NPCs react with horror. The world pushes back, not the DM. + +**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?" + +**GOOD**: "The blade finds her throat. She collapses, gurgling. From upstairs, you hear footsteps - someone heard the commotion." + +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. + ## Character Management The player's character sheet is provided below. Use update_character to persist changes so progress is saved between sessions: @@ -215,55 +232,84 @@ set_scene( ) ``` -## World Persistence +## The Entity Model + +Every entity in the world - NPCs, locations, items, even abstract situations - has: +- **Is** - their current state, what's true RIGHT NOW +- **Was** - their history, what happened to them + +The Is section includes three key attributes: +- **Knows** - secrets, hidden truths, what isn't obvious +- **Wants** - nature, tendencies, inclinations +- **Will** - conditional triggers, what happens if... -You manage two layers of knowledge: **world truth** and **player knowledge**. +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. -### Establishing World Truth +## Perspective Management -Use `establish` when you introduce or update world facts: +You see each entity's Knows/Wants/Will sections. This is their inner life - **don't narrate it directly**. -- **Any NPC with a name** - Guard Mara, Constable Harrik, Borand the merchant -- **Any NPC with a role** - "the innkeeper", "the town guard" (give them a name!) -- **Locations the player visits** - the town gate, the constable's office, the tavern -- **Factions, organizations** - the town guard, the merchant guild -- **Significant events** - the bandit attack, the missing merchant +- Let NPCs **ACT** on their knowledge, don't expose it through narration +- Let locations **reveal** their secrets through exploration +- Let items **manifest** their nature through use +- The player discovers through interaction, not exposition + +**BAD**: "Vera seems suspicious of you. You sense she knows something about the smuggling ring." + +**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. + +The player should feel the world has depth without being told what's hidden. Behavior reveals character. Environment reveals secrets. Use reveals magic. + +## When to Establish + +Use `establish` after introducing anything significant: + +- **NPCs** - anyone with a name or role worth tracking +- **Locations** - anywhere the player visits or will return to +- **Items** - magical items, plot-relevant objects, interesting equipment +- **Threads** - situations in motion that might develop **Give names to everyone.** Don't introduce "a guard" - introduce "Mara, a guard". Then establish her. Named NPCs create a living world. -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. +**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. + +Think: What does this entity know that isn't obvious? What is its nature? What might it do if...? Where do they belong? Example: ``` establish( - type="npc", + entity_type="npcs", name="Vera Blackwater", - content="Tavern owner with connections to the smuggling ring. Knows about the warehouse but won't reveal her involvement.", - tags=["rusty-anchor", "smugglers"] + description="Weathered woman in her 50s who runs [[The Rusty Anchor]]. Former smuggler trying to go legitimate. Currently suspicious of the player.", + location="Behind the bar at [[The Rusty Anchor]]", + knows=["The [[Merchant Guild]] smuggles weapons through the docks", "[[Captain Harrik]] owes her a favor"], + wants=["Keep her tavern safe", "Find out who killed her brother"], + will=["If player proves trustworthy → introduce them to [[Captain Harrik]]", "If player becomes a threat → tip off the guild"] ) ``` -### Recording Player Knowledge +## When to Mark -Use `note_discovery` when the player learns something significant: +Use `mark` when something significant happens to an entity: -- Information an NPC reveals to them -- A location's secrets they uncover -- Lore or history they piece together -- Facts they learn through investigation +- An NPC makes a decision or takes action +- A location changes or reveals something +- An item is used or transferred +- A trigger from "Will" fires -This captures the player's perspective - which may be incomplete or even wrong. The player only knows what they've discovered. +If the event resolves a Will trigger, include `resolves` to remove it: Example: ``` -note_discovery( - entity="Vera Blackwater", - content="Owns The Rusty Anchor. Seems to know about the warehouse - gave us a key. Claims she just wants the thefts to stop.", - type="npc" +mark( + entity_type="npcs", + name="Vera Blackwater", + event="Introduced player to Captain Harrik after they helped defend the tavern", + resolves=["If player proves trustworthy → introduce them to [[Captain Harrik]]"] ) ``` -**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. +This builds history. Over time, entities accumulate a story of what happened to them, creating continuity across sessions. ## Wiki References diff --git a/rules/srd-5.2.1/README.md b/rules/srd-5.2.1/README.md index 10402fd..5577cb1 100644 --- a/rules/srd-5.2.1/README.md +++ b/rules/srd-5.2.1/README.md @@ -1,6 +1,6 @@ -# D&D 5e System Reference Document 5.2.1 +# 5e System Reference Document 5.2.1 -This directory contains processed content from the D&D 5th Edition System Reference Document. +This directory contains processed content from the 5th Edition System Reference Document. ## License diff --git a/src/storied/character.py b/src/storied/character.py index 2d2ef37..482dcc5 100644 --- a/src/storied/character.py +++ b/src/storied/character.py @@ -116,6 +116,15 @@ def update_character( data[key] = value changes.append(f"{key} = {value}") + # Clamp HP to valid 5e range: 0 to max (no negative HP in 5e) + hp = data.get("hp", {}) + if isinstance(hp, dict) and "current" in hp: + hp_max = hp.get("max", hp["current"]) + original = hp["current"] + hp["current"] = max(0, min(hp["current"], hp_max)) + if hp["current"] != original: + changes.append(f"(HP clamped to {hp['current']})") + save_character(player_id, data, base_path) return "Character updated: " + ", ".join(changes) diff --git a/src/storied/cli.py b/src/storied/cli.py index bace0e9..79e51fe 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -222,7 +222,13 @@ def cmd_play(args: argparse.Namespace) -> int: console.print(f"[dim]World: {world_id}[/dim]") console.print() - engine = DMEngine(world_id=world_id, player_id=player_id, prompt_name=prompt_name) + transcript_path = Path(args.transcript) if args.transcript else None + engine = DMEngine( + world_id=world_id, + player_id=player_id, + prompt_name=prompt_name, + transcript_path=transcript_path, + ) engine.debug = args.debug # If in creation mode, start the conversation @@ -355,15 +361,39 @@ def cmd_play(args: argparse.Namespace) -> int: console.print("[dim]Session totals:[/dim]") cache_read = stats.get("total_cache_read", 0) cache_create = stats.get("total_cache_creation", 0) - if cache_read > 0 or cache_create > 0: + total_input = stats["total_input"] + + # Calculate cache hit rate + total_processed = cache_read + cache_create + total_input + if total_processed > 0: + hit_rate = (cache_read / total_processed) * 100 + + # Calculate cost savings (Sonnet pricing) + # Without cache: all tokens at $3/MTok + # With cache: reads at $0.30/MTok, writes at $3.75/MTok, rest at $3/MTok + cost_without = total_processed * 3.0 / 1_000_000 + cost_with = ( + cache_read * 0.30 / 1_000_000 + + cache_create * 3.75 / 1_000_000 + + total_input * 3.0 / 1_000_000 + ) + savings_pct = ((cost_without - cost_with) / cost_without) * 100 if cost_without > 0 else 0 + console.print( - f" [dim]Input: {stats['total_input']:,} " - f"(+{cache_read:,} cached, {cache_create:,} written) · " - f"Output: {stats['total_output']:,}[/dim]" + f" [dim]Tokens: {total_processed:,} processed " + f"({cache_read:,} cached, {cache_create:,} written, {total_input:,} new)[/dim]" + ) + console.print( + f" [dim]Cache hit rate: [green]{hit_rate:.1f}%[/green] · " + f"Cost savings: [green]{savings_pct:.0f}%[/green] " + f"(${cost_with:.4f} vs ${cost_without:.4f})[/dim]" + ) + console.print( + f" [dim]Output: {stats['total_output']:,} tokens[/dim]" ) else: console.print( - f" [dim]Input: {stats['total_input']:,} · " + f" [dim]Input: {total_input:,} · " f"Output: {stats['total_output']:,}[/dim]" ) @@ -538,6 +568,10 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Show token usage after each response", ) + play_parser.add_argument( + "--transcript", "-t", + help="Path to write full debug transcript (JSONL format)", + ) play_parser.set_defaults(func=cmd_play) # reset command diff --git a/src/storied/engine.py b/src/storied/engine.py index 9c26ca7..fc4d100 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -1,8 +1,10 @@ -"""DM Engine - the agentic loop for running D&D sessions.""" +"""DM Engine - the agentic loop for running 5e sessions.""" import copy +import json import os from collections.abc import Iterator +from datetime import datetime, timezone from pathlib import Path import anthropic @@ -37,10 +39,13 @@ from storied.character import format_character_context, load_character from storied.content import ContentResolver from storied.log import CampaignLog from storied.session import ( + ENTITY_TYPES, extract_wiki_links, format_session_context, + load_entity_content, load_session, name_to_slug, + resolve_wiki_link, ) from storied.tools import TOOL_DEFINITIONS, execute_tool @@ -54,7 +59,7 @@ def load_prompt(name: str, prompts_path: Path | None = None) -> str: class DMEngine: - """The Dungeon Master engine - Claude with tools for running D&D sessions.""" + """The Dungeon Master engine - Claude with tools for running 5e sessions.""" def __init__( self, @@ -63,6 +68,7 @@ class DMEngine: base_path: Path | None = None, model: str = "claude-sonnet-4-5-20250929", prompt_name: str = "dm-system", + transcript_path: Path | None = None, ): """Initialize the DM engine. @@ -72,6 +78,7 @@ class DMEngine: base_path: Base path for content resolution (defaults to cwd) model: Claude model to use prompt_name: System prompt to use (default: "dm-system", or "character-creation") + transcript_path: Optional path to write full debug transcript (JSONL format) """ self.client = anthropic.Anthropic( api_key=os.environ.get("STORIED_ANTHROPIC_API_KEY"), @@ -100,6 +107,11 @@ class DMEngine: # Debug mode for verbose tool output self.debug: bool = False + # Full transcript for debugging (JSONL format) + self._transcript_path = transcript_path + if transcript_path: + transcript_path.parent.mkdir(parents=True, exist_ok=True) + # Campaign log for time tracking (world-scoped) self._campaign_log = CampaignLog(self.world_id, self.base_path) @@ -129,6 +141,24 @@ class DMEngine: # Tools with cache control on last tool self._cached_tools = self._get_tools_with_cache() + def _log_transcript(self, event_type: str, data: dict) -> None: + """Append an event to the debug transcript. + + Args: + event_type: Type of event (player_input, assistant_response, tool_call, tool_result) + data: Event-specific data + """ + if not self._transcript_path: + return + + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "type": event_type, + **data, + } + with self._transcript_path.open("a") as f: + f.write(json.dumps(entry) + "\n") + def _build_context(self) -> str: """Build context string for system prompt. @@ -168,25 +198,52 @@ class DMEngine: self._context_parts["PlayerKnowledge"] = player_knowledge parts.append(player_knowledge) - # 5. DM knowledge (smart loading: location + present entities) - if session: + # 5. DM knowledge (smart loading: location + present entities + linked entities) + if session and self.world_id: + loaded_names: set[str] = set() + linked_names: set[str] = set() + # Current location location_slug = session.get("location") - if location_slug and self.world_id: - location_content = self._load_world_content("locations", location_slug) + if location_slug: + # Try new format (display name) first, then old format (slug) + location_content = self._find_entity(location_slug) + if not location_content: + location_content = self._load_world_content("locations", location_slug) if location_content: loc_context = self._format_entity("Location", location_content) self._context_parts["Location"] = loc_context parts.append(loc_context) + loaded_names.add(location_slug) + # Collect wikilinks for one-hop loading + linked_names.update(extract_wiki_links(location_content.get("body", ""))) - # Present entities from wiki links + # Present entities from wiki links in session present_text = session.get("body", "") for name in extract_wiki_links(present_text): + if name in loaded_names: + continue entity = self._find_entity(name) if entity: - entity_context = self._format_entity("NPC", entity) - self._context_parts[f"NPC:{name}"] = entity_context + entity_type = entity.get("entity_type", "Entity") + entity_context = self._format_entity(entity_type.title(), entity) + self._context_parts[f"Entity:{name}"] = entity_context parts.append(entity_context) + loaded_names.add(name) + # Collect wikilinks for one-hop loading + linked_names.update(extract_wiki_links(entity.get("body", ""))) + + # One-hop: load linked entities (but not their links) + for name in linked_names: + if name in loaded_names: + continue + entity = self._find_entity(name) + if entity: + entity_type = entity.get("entity_type", "Entity") + entity_context = self._format_entity(entity_type.title(), entity) + self._context_parts[f"Linked:{name}"] = entity_context + parts.append(entity_context) + loaded_names.add(name) return "\n\n---\n\n".join(parts) @@ -251,21 +308,17 @@ class DMEngine: return resolver.load(name, content_type=content_type) def _find_entity(self, name: str) -> dict | None: - """Find an entity by name, searching NPCs then locations.""" + """Find an entity by name, searching entity directories in priority order.""" if not self.world_id: return None - resolver = ContentResolver(base_path=self.base_path, world_id=self.world_id) - - # Try NPCs first - slug = name_to_slug(name) - content = resolver.load(slug, content_type="npcs") - if content: - return content - # Try locations - content = resolver.load(slug, content_type="locations") - if content: - return content + entity = load_entity_content(name, self.world_id, self.base_path) + if entity: + return { + "name": entity["name"], + "body": entity["content"], + "entity_type": entity["entity_type"], + } return None @@ -381,6 +434,7 @@ class DMEngine: """ # Add player message to conversation self.messages.append({"role": "user", "content": player_input}) + self._log_transcript("player_input", {"content": player_input}) while True: # Stream from Claude @@ -467,6 +521,7 @@ class DMEngine: # Add assistant response to conversation (skip if empty) if assistant_content: self.messages.append({"role": "assistant", "content": assistant_content}) + self._log_transcript("assistant_response", {"content": assistant_content}) # If there were tool uses, execute them and continue the loop if tool_uses: @@ -512,6 +567,10 @@ class DMEngine: elif tool_name == "end_session": yield "\n[Saving session...]...\n" + self._log_transcript( + "tool_call", {"name": tool_name, "input": tool_input} + ) + result = execute_tool( tool_name, tool_input, @@ -521,6 +580,10 @@ class DMEngine: campaign_log=self._campaign_log, ) + self._log_transcript( + "tool_result", {"name": tool_name, "result": result} + ) + # Debug mode: show result if self.debug: yield f"[⇐ {_truncate(result, max_len=100)}]\n\n" diff --git a/src/storied/session.py b/src/storied/session.py index d9d2be2..6e80e72 100644 --- a/src/storied/session.py +++ b/src/storied/session.py @@ -164,6 +164,70 @@ def extract_wiki_links(text: str) -> list[str]: return re.findall(r"\[\[([^\]]+)\]\]", text) +# Priority order for wikilink resolution +ENTITY_TYPES = ["npcs", "locations", "items", "factions", "threads", "lore"] + + +def resolve_wiki_link( + name: str, + world_id: str, + base_path: Path | None = None, +) -> Path | None: + """Resolve a wikilink name to a file path. + + Searches entity directories in priority order and returns the first match. + + Args: + name: Entity name (e.g., "Vera Blackwater") + world_id: World to search in + base_path: Base path for worlds directory + + Returns: + Path to the entity file, or None if not found + """ + if base_path is None: + base_path = Path.cwd() + + world_dir = base_path / "worlds" / world_id + + for entity_type in ENTITY_TYPES: + file_path = world_dir / entity_type / f"{name}.md" + if file_path.exists(): + return file_path + + return None + + +def load_entity_content( + name: str, + world_id: str, + base_path: Path | None = None, +) -> dict | None: + """Load an entity's content by resolving its wikilink. + + Args: + name: Entity name (e.g., "Vera Blackwater") + world_id: World to search in + base_path: Base path for worlds directory + + Returns: + Dict with entity_type, name, and content, or None if not found + """ + file_path = resolve_wiki_link(name, world_id, base_path) + if file_path is None: + return None + + content = file_path.read_text() + entity_type = file_path.parent.name + + return { + "entity_type": entity_type, + "name": name, + "content": content, + "path": file_path, + } + + def name_to_slug(name: str) -> str: """Convert a display name to a file slug. diff --git a/src/storied/tools.py b/src/storied/tools.py index 45dc8c7..a2a9dce 100644 --- a/src/storied/tools.py +++ b/src/storied/tools.py @@ -4,6 +4,7 @@ These functions are exposed to Claude as tools. The docstrings become the tool descriptions that Claude sees. """ +import re from pathlib import Path import yaml @@ -257,38 +258,44 @@ def set_scene( def establish( - content_type: str, + entity_type: str, name: str, - content: str, - tags: list[str] | None = None, + description: str | None = None, + location: str | None = None, + knows: list[str] | None = None, + wants: list[str] | None = None, + will: list[str] | None = None, world_id: str | None = None, base_path: Path | None = None, - **extra_frontmatter: str, ) -> str: - """Establish something as true in the world. + """Establish or update an entity in the world. + + Use to create NPCs, locations, items, factions, or threads with their inner + state. Everything has Knows/Wants/Will: + - **Knows** = secrets, hidden truths, what isn't obvious + - **Wants** = nature, tendencies, inclinations (even non-sentient things can "want") + - **Will** = conditional triggers, what happens if... - Use when the player meaningfully interacts with something and you want - to commit it to the world's permanent record: - - A named NPC the player has interacted with meaningfully - - A location the player has visited - - A significant event that affects the world state - - A faction the player has learned about + This isn't literal consciousness - it's narrative tendency. A bridge can "want" + to collapse. Cursed gold "wants" to be spent. Frame it this way and the world + feels alive. - Don't establish: - - Unnamed background characters (random guard, merchant #3) - - Locations mentioned but not visited - - Minor events with no ongoing consequences + Partial updates: omit fields to preserve existing content when updating. Args: - content_type: Type of content. One of: npcs, locations, factions, events, lore, items + entity_type: Type of entity: npcs, locations, items, factions, threads, lore name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") - content: Markdown body describing the entity. Include all relevant details: - appearance, personality, secrets, motivations, connections, etc. - tags: Optional tags for categorization (e.g., ["tavern", "social", "quest-hook"]) + This becomes the filename directly (no slugification). + description: Prose description for the ## Is section. Include appearance, + background, current state, relationships via [[wikilinks]]. + location: Where this entity is right now. Can be a simple wikilink like + "[[The Rusty Anchor]]" or a verbal description like "In the basement + of [[The Rusty Anchor]]" or "Wandering the docks of [[Greyhaven]]". + knows: List of secrets and hidden truths. Things that aren't obvious. + wants: List of desires, tendencies, inclinations. The entity's nature. + will: List of conditional behaviors: "If X → Y" format. world_id: World to save to (required) base_path: Base path for worlds directory - **extra_frontmatter: Additional frontmatter fields (e.g., disposition="friendly", - location="rusty-anchor") Returns: Confirmation with the file path @@ -299,33 +306,225 @@ def establish( if base_path is None: base_path = Path.cwd() - # Generate slug from name - slug = name_to_slug(name) - - # Build file path - world_dir = base_path / "worlds" / world_id / content_type + # Build file path using display name directly + world_dir = base_path / "worlds" / world_id / entity_type world_dir.mkdir(parents=True, exist_ok=True) - file_path = world_dir / f"{slug}.md" - - # Build frontmatter - frontmatter = { - "type": content_type.rstrip("s"), # npcs -> npc - "name": name, - } - if tags: - frontmatter["tags"] = tags - frontmatter.update(extra_frontmatter) + file_path = world_dir / f"{name}.md" + + # Load existing content if file exists (for partial updates) + existing = _load_entity(file_path) + + # Merge with existing content (new values override) + if description is None: + description = existing.get("description", "") + if location is None: + location = existing.get("location", "") + if knows is None: + knows = existing.get("knows", []) + if wants is None: + wants = existing.get("wants", []) + if will is None: + will = existing.get("will", []) + was = existing.get("was", []) # Always preserve Was # Build file content - file_content = "---\n" - file_content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) - file_content += "---\n\n" - file_content += content.strip() - file_content += "\n" + file_content = _format_entity(name, description, location, knows, wants, will, was) + file_path.write_text(file_content) + + action = "Updated" if existing else "Established" + return f"{action} {entity_type.rstrip('s')} '{name}'" + + +def _load_entity(file_path: Path) -> dict: + """Load an existing entity file and parse its structure.""" + if not file_path.exists(): + return {} + + content = file_path.read_text() + result = {} + + # Parse ## Is section + is_match = re.search(r"## Is\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) + if is_match: + is_content = is_match.group(1).strip() + + # Extract location (line starting with **Location:**) + loc_match = re.search(r"\*\*Location:\*\*\s*(.+)", is_content) + if loc_match: + result["location"] = loc_match.group(1).strip() + + # Extract description (text before first ### subsection, excluding location line) + desc_match = re.match(r"(.*?)(?=\n### |\Z)", is_content, re.DOTALL) + if desc_match: + desc = desc_match.group(1).strip() + # Remove location line from description + desc = re.sub(r"\*\*Location:\*\*\s*.+\n?", "", desc).strip() + result["description"] = desc + + # Extract ### Knows + knows_match = re.search(r"### Knows\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) + if knows_match: + result["knows"] = _parse_list_items(knows_match.group(1)) + + # Extract ### Wants + wants_match = re.search(r"### Wants\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) + if wants_match: + result["wants"] = _parse_list_items(wants_match.group(1)) + + # Extract ### Will + will_match = re.search(r"### Will\n\n?(.*?)(?=\n### |\n## |\Z)", is_content, re.DOTALL) + if will_match: + result["will"] = _parse_list_items(will_match.group(1)) + + # Parse ## Was section + was_match = re.search(r"## Was\n\n?(.*?)(?=\n## |\Z)", content, re.DOTALL) + if was_match: + result["was"] = _parse_list_items(was_match.group(1)) + + return result + + +def _parse_list_items(text: str) -> list[str]: + """Parse markdown list items into a list of strings.""" + items = [] + for line in text.strip().split("\n"): + line = line.strip() + if line.startswith("- "): + items.append(line[2:]) + return items + + +def _format_entity( + name: str, + description: str, + location: str, + knows: list[str], + wants: list[str], + will: list[str], + was: list[str], +) -> str: + """Format an entity as markdown with Is/Was structure.""" + lines = [f"# {name}", "", "## Is", ""] + + if location: + lines.append(f"**Location:** {location}") + lines.append("") + + if description: + lines.append(description) + lines.append("") + + if knows: + lines.append("### Knows") + lines.append("") + for item in knows: + lines.append(f"- {item}") + lines.append("") + + if wants: + lines.append("### Wants") + lines.append("") + for item in wants: + lines.append(f"- {item}") + lines.append("") + + if will: + lines.append("### Will") + lines.append("") + for item in will: + lines.append(f"- {item}") + lines.append("") + + lines.append("## Was") + lines.append("") + for item in was: + lines.append(f"- {item}") + lines.append("") + + return "\n".join(lines) + + +def mark( + entity_type: str, + name: str, + event: str, + resolves: list[str] | None = None, + world_id: str | None = None, + base_path: Path | None = None, + campaign_log: CampaignLog | None = None, +) -> str: + """Record an event in an entity's history (## Was section). + + Use when something significant happens to or involving an entity. This builds + their history and helps maintain continuity across sessions. + + If the event resolves a Will trigger (e.g., "Vera introduced the player to + Harrik" resolves "If trusted → intro to Harrik"), provide the trigger text + in `resolves` to remove it from the Will section. + + Args: + entity_type: Type of entity: npcs, locations, items, factions, threads + name: Entity name (exact filename match) + event: What happened - brief description for the Was section + resolves: Optional list of Will items to remove if this event fired triggers + world_id: World containing the entity (required) + base_path: Base path for worlds directory + campaign_log: Campaign log for current game time (optional) + + Returns: + Confirmation message + """ + if not world_id: + return "Error: No world_id specified." + + if base_path is None: + base_path = Path.cwd() + file_path = base_path / "worlds" / world_id / entity_type / f"{name}.md" + if not file_path.exists(): + return f"Error: Entity '{name}' not found in {entity_type}" + + # Load existing entity + existing = _load_entity(file_path) + + # Get current game time for timestamp + if campaign_log: + timestamp = campaign_log.get_current_time().to_anchor() + else: + log = load_log(world_id, base_path) + timestamp = log.get_current_time().to_anchor() + + # Append to Was section + was = existing.get("was", []) + was.append(f"{timestamp} | {event}") + + # Remove resolved Will items + will = existing.get("will", []) + resolved = [] + for trigger in resolves or []: + if trigger in will: + will.remove(trigger) + resolved.append(trigger) + + # Rebuild and save the file + file_content = _format_entity( + name, + existing.get("description", ""), + existing.get("location", ""), + existing.get("knows", []), + existing.get("wants", []), + will, + was, + ) file_path.write_text(file_content) - return f"Established {content_type.rstrip('s')} '{name}' in {file_path.relative_to(base_path)}" + result = f"Marked: {event}" + if resolved: + if len(resolved) == 1: + result += f" (resolved: {resolved[0]})" + else: + result += f" (resolved {len(resolved)} triggers)" + return result def note_discovery( @@ -587,26 +786,68 @@ TOOL_DEFINITIONS = [ "input_schema": { "type": "object", "properties": { - "content_type": { + "entity_type": { "type": "string", - "description": "Type of content", - "enum": ["npcs", "locations", "factions", "events", "lore", "items"], + "description": "Type of entity", + "enum": ["npcs", "locations", "items", "factions", "threads", "lore"], }, "name": { "type": "string", - "description": "Display name (e.g., 'Vera Blackwater')", + "description": "Display name (exact filename, e.g., 'Vera Blackwater')", }, - "content": { + "description": { "type": "string", - "description": "Markdown body describing the entity", + "description": "Prose description with [[wikilinks]] for relationships", }, - "tags": { + "location": { + "type": "string", + "description": "Current location (e.g., '[[The Rusty Anchor]]' or 'In the basement of [[The Rusty Anchor]]')", + }, + "knows": { "type": "array", "items": {"type": "string"}, - "description": "Tags for categorization", + "description": "Secrets, hidden truths - what isn't obvious", + }, + "wants": { + "type": "array", + "items": {"type": "string"}, + "description": "Nature, tendencies, inclinations - even non-sentient things", + }, + "will": { + "type": "array", + "items": {"type": "string"}, + "description": "Conditional behaviors in 'If X → Y' format", + }, + }, + "required": ["entity_type", "name"], + }, + }, + { + "name": "mark", + "description": mark.__doc__, + "input_schema": { + "type": "object", + "properties": { + "entity_type": { + "type": "string", + "description": "Type of entity", + "enum": ["npcs", "locations", "items", "factions", "threads"], + }, + "name": { + "type": "string", + "description": "Entity name (exact filename match)", + }, + "event": { + "type": "string", + "description": "What happened - brief description", + }, + "resolves": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: Will items to remove if this event fired triggers", }, }, - "required": ["content_type", "name", "content"], + "required": ["entity_type", "name", "event"], }, }, { @@ -767,12 +1008,25 @@ def execute_tool( elif tool_name == "establish": return establish( - content_type=tool_input["content_type"], + entity_type=tool_input["entity_type"], name=tool_input["name"], - content=tool_input["content"], - tags=tool_input.get("tags"), + description=tool_input.get("description"), + knows=tool_input.get("knows"), + wants=tool_input.get("wants"), + will=tool_input.get("will"), + world_id=world_id, + base_path=base_path, + ) + + elif tool_name == "mark": + return mark( + entity_type=tool_input["entity_type"], + name=tool_input["name"], + event=tool_input["event"], + resolves=tool_input.get("resolves"), world_id=world_id, base_path=base_path, + campaign_log=campaign_log, ) elif tool_name == "note_discovery": diff --git a/tests/test_character.py b/tests/test_character.py new file mode 100644 index 0000000..4d755f6 --- /dev/null +++ b/tests/test_character.py @@ -0,0 +1,251 @@ +"""Tests for character loading, saving, and updates.""" + +from pathlib import Path + +import pytest + +from storied.character import ( + create_character, + load_character, + parse_character, + save_character, + update_character, +) + + +@pytest.fixture +def player_base(tmp_path: Path) -> Path: + """Create a base directory with player structure.""" + player = tmp_path / "players" / "test-player" + player.mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def basic_character(player_base: Path) -> dict: + """Create a basic character for testing.""" + create_character( + player_id="test-player", + name="Test Hero", + race="Human", + char_class="Fighter", + level=1, + abilities={ + "strength": 16, + "dexterity": 14, + "constitution": 15, + "intelligence": 10, + "wisdom": 12, + "charisma": 8, + }, + hp_max=12, + ac=16, + gold=50, + base_path=player_base, + ) + return load_character("test-player", player_base) + + +class TestParseCharacter: + """Tests for parsing character markdown.""" + + def test_parse_with_frontmatter(self): + content = """--- +name: Test +hp: + current: 10 + max: 10 +--- + +## Equipment +- Sword +""" + result = parse_character(content) + assert result["name"] == "Test" + assert result["hp"]["current"] == 10 + assert "Equipment" in result["body"] + + def test_parse_without_frontmatter(self): + content = "Just a body" + result = parse_character(content) + assert result["body"] == "Just a body" + + +class TestCreateCharacter: + """Tests for character creation.""" + + def test_create_basic_character(self, player_base: Path): + result = create_character( + player_id="test-player", + name="Conan", + race="Human", + char_class="Barbarian", + level=1, + abilities={ + "strength": 18, + "dexterity": 14, + "constitution": 16, + "intelligence": 8, + "wisdom": 10, + "charisma": 12, + }, + hp_max=15, + ac=14, + base_path=player_base, + ) + + assert "Created character 'Conan'" in result + char_file = player_base / "players/test-player/character.md" + assert char_file.exists() + + def test_created_character_has_full_hp(self, player_base: Path): + create_character( + player_id="test-player", + name="Test", + race="Human", + char_class="Fighter", + level=1, + abilities={"strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10}, + hp_max=10, + ac=10, + base_path=player_base, + ) + + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 10 + assert char["hp"]["max"] == 10 + + +class TestUpdateCharacter: + """Tests for character updates.""" + + def test_update_gold(self, player_base: Path, basic_character: dict): + result = update_character( + player_id="test-player", + updates={"gold": 100}, + base_path=player_base, + ) + + assert "gold = 100" in result + char = load_character("test-player", player_base) + assert char["gold"] == 100 + + def test_update_hp_current(self, player_base: Path, basic_character: dict): + result = update_character( + player_id="test-player", + updates={"hp.current": 5}, + base_path=player_base, + ) + + assert "hp.current = 5" in result + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 5 + + def test_update_section(self, player_base: Path, basic_character: dict): + update_character( + player_id="test-player", + updates={"section.Equipment": "- Longsword\n- Shield"}, + base_path=player_base, + ) + + char = load_character("test-player", player_base) + assert "Longsword" in char["body"] + assert "Shield" in char["body"] + + +class TestHPClamping: + """Tests for HP clamping to valid 5e range.""" + + def test_hp_cannot_go_negative(self, player_base: Path, basic_character: dict): + """In 5e, HP minimum is 0 (no negative HP).""" + update_character( + player_id="test-player", + updates={"hp.current": -5}, + base_path=player_base, + ) + + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 0 + + def test_hp_clamped_message(self, player_base: Path, basic_character: dict): + """Update result should indicate HP was clamped.""" + result = update_character( + player_id="test-player", + updates={"hp.current": -10}, + base_path=player_base, + ) + + assert "clamped to 0" in result + + def test_hp_cannot_exceed_max(self, player_base: Path, basic_character: dict): + """HP cannot exceed maximum.""" + update_character( + player_id="test-player", + updates={"hp.current": 100}, + base_path=player_base, + ) + + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 12 # max HP is 12 + + def test_hp_exceeds_max_clamped_message(self, player_base: Path, basic_character: dict): + """Update result should indicate HP was clamped to max.""" + result = update_character( + player_id="test-player", + updates={"hp.current": 999}, + base_path=player_base, + ) + + assert "clamped to 12" in result + + def test_valid_hp_not_clamped(self, player_base: Path, basic_character: dict): + """Valid HP values should not be modified.""" + result = update_character( + player_id="test-player", + updates={"hp.current": 6}, + base_path=player_base, + ) + + assert "clamped" not in result + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 6 + + def test_hp_zero_is_valid(self, player_base: Path, basic_character: dict): + """Setting HP to exactly 0 is valid (unconscious).""" + result = update_character( + player_id="test-player", + updates={"hp.current": 0}, + base_path=player_base, + ) + + assert "clamped" not in result + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 0 + + def test_hp_max_is_valid(self, player_base: Path, basic_character: dict): + """Setting HP to exactly max is valid.""" + result = update_character( + player_id="test-player", + updates={"hp.current": 12}, + base_path=player_base, + ) + + assert "clamped" not in result + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 12 + + def test_damage_calculation_example(self, player_base: Path, basic_character: dict): + """Simulate taking 20 damage when at 12 HP - should clamp to 0.""" + # Character starts at 12/12 HP + # Takes 20 damage, DM sets hp.current = -8 + # Should be clamped to 0 + + update_character( + player_id="test-player", + updates={"hp.current": -8}, + base_path=player_base, + ) + + char = load_character("test-player", player_base) + assert char["hp"]["current"] == 0 diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..6b031e6 --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,454 @@ +"""Tests for the entity model - establish, mark, and wikilink resolution.""" + +from pathlib import Path + +import pytest + +from storied.log import CampaignLog +from storied.session import ( + extract_wiki_links, + load_entity_content, + resolve_wiki_link, +) +from storied.tools import establish, mark + + +@pytest.fixture +def world_base(tmp_path: Path) -> Path: + """Create a base directory with world structure.""" + world = tmp_path / "worlds" / "test-world" + world.mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def campaign_log(world_base: Path) -> CampaignLog: + """Create a campaign log for the test world.""" + return CampaignLog("test-world", world_base) + + +class TestEstablish: + """Tests for the establish tool.""" + + def test_establish_creates_npc(self, world_base: Path): + result = establish( + entity_type="npcs", + name="Vera Blackwater", + description="Tavern owner, former smuggler.", + knows=["The guild smuggles weapons"], + wants=["Keep her tavern safe"], + will=["If trusted → introduce to Harrik"], + world_id="test-world", + base_path=world_base, + ) + + assert "Established" in result + npc_file = world_base / "worlds/test-world/npcs/Vera Blackwater.md" + assert npc_file.exists() + + def test_establish_file_format(self, world_base: Path): + establish( + entity_type="npcs", + name="Test NPC", + description="A test character.", + knows=["Secret one", "Secret two"], + wants=["Goal one"], + will=["If X → do Y"], + world_id="test-world", + base_path=world_base, + ) + + content = (world_base / "worlds/test-world/npcs/Test NPC.md").read_text() + + assert "# Test NPC" in content + assert "## Is" in content + assert "A test character." in content + assert "### Knows" in content + assert "- Secret one" in content + assert "- Secret two" in content + assert "### Wants" in content + assert "- Goal one" in content + assert "### Will" in content + assert "- If X → do Y" in content + + def test_establish_location(self, world_base: Path): + establish( + entity_type="locations", + name="The Rusty Anchor", + description="A dockside tavern.", + knows=["Hidden tunnel in cellar"], + wants=["To shelter those who need it"], + will=["If searched → reveal tunnel"], + world_id="test-world", + base_path=world_base, + ) + + loc_file = world_base / "worlds/test-world/locations/The Rusty Anchor.md" + assert loc_file.exists() + content = loc_file.read_text() + assert "Hidden tunnel in cellar" in content + + def test_establish_item(self, world_base: Path): + establish( + entity_type="items", + name="The Skeleton Key", + description="Ancient brass key, cold to the touch.", + knows=["Forged by Archmage Velius"], + wants=["To free what is locked away"], + will=["Unlock any non-magical lock"], + world_id="test-world", + base_path=world_base, + ) + + item_file = world_base / "worlds/test-world/items/The Skeleton Key.md" + assert item_file.exists() + + def test_establish_partial_update(self, world_base: Path): + # Create initial entity + establish( + entity_type="npcs", + name="Guard Mara", + description="A stern city guard.", + knows=["Patrol routes"], + wants=["Uphold the law"], + will=[], + world_id="test-world", + base_path=world_base, + ) + + # Update just the description + establish( + entity_type="npcs", + name="Guard Mara", + description="A stern city guard, recently promoted to sergeant.", + world_id="test-world", + base_path=world_base, + ) + + content = (world_base / "worlds/test-world/npcs/Guard Mara.md").read_text() + assert "recently promoted" in content + assert "Patrol routes" in content # Preserved from original + + def test_establish_with_wikilinks(self, world_base: Path): + establish( + entity_type="npcs", + name="Captain Harrik", + description="Veteran sailor who frequents [[The Rusty Anchor]].", + knows=["[[Vera Blackwater]] was once a smuggler"], + wants=["Find the [[Ghost Ship]]"], + will=[], + world_id="test-world", + base_path=world_base, + ) + + content = (world_base / "worlds/test-world/npcs/Captain Harrik.md").read_text() + assert "[[The Rusty Anchor]]" in content + assert "[[Vera Blackwater]]" in content + + def test_establish_empty_sections_omitted(self, world_base: Path): + establish( + entity_type="npcs", + name="Simple NPC", + description="Just a description.", + knows=[], + wants=[], + will=[], + world_id="test-world", + base_path=world_base, + ) + + content = (world_base / "worlds/test-world/npcs/Simple NPC.md").read_text() + assert "### Knows" not in content + assert "### Wants" not in content + assert "### Will" not in content + + def test_establish_with_location(self, world_base: Path): + establish( + entity_type="npcs", + name="Garrick the Jailer", + description="Heavyset man in his fifties.", + location="In the basement of [[Greyhaven City Jail]]", + knows=["Where the keys are kept"], + world_id="test-world", + base_path=world_base, + ) + + content = (world_base / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() + assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content + assert "Heavyset man" in content + + def test_establish_location_preserved_on_update(self, world_base: Path): + # Create with location + establish( + entity_type="npcs", + name="Wanderer", + description="A traveler.", + location="[[The Rusty Anchor]]", + world_id="test-world", + base_path=world_base, + ) + + # Update without specifying location + establish( + entity_type="npcs", + name="Wanderer", + description="A weary traveler.", + world_id="test-world", + base_path=world_base, + ) + + content = (world_base / "worlds/test-world/npcs/Wanderer.md").read_text() + assert "**Location:** [[The Rusty Anchor]]" in content + assert "weary traveler" in content + + +class TestMark: + """Tests for the mark tool.""" + + def test_mark_appends_to_was(self, world_base: Path, campaign_log: CampaignLog): + # Create entity first + establish( + entity_type="npcs", + name="Vera Blackwater", + description="Tavern owner.", + knows=[], + wants=[], + will=[], + world_id="test-world", + base_path=world_base, + ) + + result = mark( + entity_type="npcs", + name="Vera Blackwater", + event="Met the player, gave them a room", + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + assert "Marked" in result + content = (world_base / "worlds/test-world/npcs/Vera Blackwater.md").read_text() + assert "## Was" in content + assert "Met the player" in content + + def test_mark_includes_timestamp(self, world_base: Path, campaign_log: CampaignLog): + establish( + entity_type="npcs", + name="Test NPC", + description="Test.", + world_id="test-world", + base_path=world_base, + ) + + mark( + entity_type="npcs", + name="Test NPC", + event="Something happened", + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + content = (world_base / "worlds/test-world/npcs/Test NPC.md").read_text() + # Should have timestamp anchor format + assert "#d" in content + assert "|" in content + + def test_mark_resolves_will_trigger(self, world_base: Path, campaign_log: CampaignLog): + establish( + entity_type="npcs", + name="Vera Blackwater", + description="Tavern owner.", + knows=[], + wants=[], + will=["If trusted → introduce to Harrik", "If threatened → tip off guild"], + world_id="test-world", + base_path=world_base, + ) + + mark( + entity_type="npcs", + name="Vera Blackwater", + event="Introduced player to Captain Harrik", + resolves=["If trusted → introduce to Harrik"], + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + content = (world_base / "worlds/test-world/npcs/Vera Blackwater.md").read_text() + # Resolved trigger should be removed + assert "If trusted → introduce to Harrik" not in content + # Other trigger should remain + assert "If threatened → tip off guild" in content + + def test_mark_resolves_multiple_triggers(self, world_base: Path, campaign_log: CampaignLog): + establish( + entity_type="npcs", + name="Complex NPC", + description="Has many triggers.", + knows=[], + wants=[], + will=[ + "If friendly → share rumors", + "If trusted → reveal secret", + "If threatened → flee", + ], + world_id="test-world", + base_path=world_base, + ) + + result = mark( + entity_type="npcs", + name="Complex NPC", + event="Player became a trusted ally through multiple good deeds", + resolves=["If friendly → share rumors", "If trusted → reveal secret"], + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + content = (world_base / "worlds/test-world/npcs/Complex NPC.md").read_text() + # Both resolved triggers should be removed + assert "If friendly → share rumors" not in content + assert "If trusted → reveal secret" not in content + # Unresolve trigger should remain + assert "If threatened → flee" in content + # Result should mention resolving multiple + assert "resolved 2 triggers" in result + + def test_mark_multiple_events(self, world_base: Path, campaign_log: CampaignLog): + establish( + entity_type="locations", + name="The Docks", + description="Busy harbor area.", + world_id="test-world", + base_path=world_base, + ) + + mark( + entity_type="locations", + name="The Docks", + event="Player arrived by ferry", + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + # Advance time + campaign_log.append_entry("Time passed", "1 hour") + + mark( + entity_type="locations", + name="The Docks", + event="Player witnessed smugglers loading cargo", + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + content = (world_base / "worlds/test-world/locations/The Docks.md").read_text() + assert "Player arrived" in content + assert "witnessed smugglers" in content + + def test_mark_nonexistent_entity_fails(self, world_base: Path, campaign_log: CampaignLog): + result = mark( + entity_type="npcs", + name="Nobody", + event="Did something", + world_id="test-world", + base_path=world_base, + campaign_log=campaign_log, + ) + + assert "not found" in result.lower() + + +class TestWikilinkResolution: + """Tests for wikilink extraction and resolution.""" + + def test_extract_wiki_links(self): + text = "Met [[Vera Blackwater]] at [[The Rusty Anchor]]." + links = extract_wiki_links(text) + assert links == ["Vera Blackwater", "The Rusty Anchor"] + + def test_extract_wiki_links_empty(self): + text = "No links here." + links = extract_wiki_links(text) + assert links == [] + + def test_extract_wiki_links_multiple_same(self): + text = "[[Vera]] talked to [[Vera]] about [[Bob]]." + links = extract_wiki_links(text) + assert links == ["Vera", "Vera", "Bob"] + + def test_resolve_wiki_link_npc(self, world_base: Path): + establish( + entity_type="npcs", + name="Vera Blackwater", + description="Test.", + world_id="test-world", + base_path=world_base, + ) + + path = resolve_wiki_link("Vera Blackwater", "test-world", world_base) + assert path is not None + assert path.name == "Vera Blackwater.md" + assert "npcs" in str(path) + + def test_resolve_wiki_link_location(self, world_base: Path): + establish( + entity_type="locations", + name="The Rusty Anchor", + description="Test.", + world_id="test-world", + base_path=world_base, + ) + + path = resolve_wiki_link("The Rusty Anchor", "test-world", world_base) + assert path is not None + assert "locations" in str(path) + + def test_resolve_wiki_link_not_found(self, world_base: Path): + path = resolve_wiki_link("Nonexistent", "test-world", world_base) + assert path is None + + def test_resolve_wiki_link_priority_order(self, world_base: Path): + # Create same name in multiple directories - npcs should win + (world_base / "worlds/test-world/npcs").mkdir(parents=True) + (world_base / "worlds/test-world/locations").mkdir(parents=True) + + (world_base / "worlds/test-world/npcs/Ambiguous.md").write_text("# NPC version") + (world_base / "worlds/test-world/locations/Ambiguous.md").write_text( + "# Location version" + ) + + path = resolve_wiki_link("Ambiguous", "test-world", world_base) + assert path is not None + assert "npcs" in str(path) # NPCs have priority over locations + + +class TestLoadEntityContent: + """Tests for loading entity content by name.""" + + def test_load_entity_content(self, world_base: Path): + establish( + entity_type="npcs", + name="Test Character", + description="A test.", + knows=["A secret"], + world_id="test-world", + base_path=world_base, + ) + + entity = load_entity_content("Test Character", "test-world", world_base) + assert entity is not None + assert entity["name"] == "Test Character" + assert entity["entity_type"] == "npcs" + assert "A test." in entity["content"] + assert "A secret" in entity["content"] + + def test_load_entity_content_not_found(self, world_base: Path): + entity = load_entity_content("Nobody", "test-world", world_base) + assert entity is None