From fbacbb518c70b848620a3193fd5d2e1eb1a227cf Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Mon, 29 Dec 2025 18:08:45 -0500 Subject: [PATCH] Refactor world state architecture and tool semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big rework of how the DM tracks and persists game state. The campaign log now lives in worlds/{world}/log/ (world-scoped for future multiplayer) with separate day files (day+001.md, day-001.md for backstory support). Renamed tools to be more action-oriented: - roll_dice → roll - log_event → mark_time - update_session → set_scene - save_to_world → establish - Combined lookup_rule + query_world → recall Added note_discovery tool for tracking player knowledge separately from DM truth - players/{player}/worlds/{world}/ now holds what the character has learned, while worlds/{world}/ holds the full DM knowledge. Also fixed the dice parser to support compound notation like 2d8+1d6 (for spells like Chaos Bolt), and added --debug mode tool call logging that shows truncated params and results inline. Strengthened the DM prompt with an explicit "after every response" checklist to remind it to call mark_time, establish, note_discovery, and set_scene consistently. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- prompts/character-creation.md | 6 +- prompts/dm-system.md | 119 +++++++++-- src/storied/cli.py | 1 + src/storied/dice.py | 153 +++++++++----- src/storied/engine.py | 181 +++++++++++++---- src/storied/log.py | 157 +++++++------- src/storied/tools.py | 371 ++++++++++++++++++++-------------- tests/test_dice.py | 41 ++++ tests/test_log.py | 26 +-- 9 files changed, 699 insertions(+), 356 deletions(-) diff --git a/prompts/character-creation.md b/prompts/character-creation.md index 0d96ca1..7a50ead 100644 --- a/prompts/character-creation.md +++ b/prompts/character-creation.md @@ -30,7 +30,7 @@ Roll ability scores using the standard method (4d6, drop lowest, six times). Let ## Looking Things Up -Use `lookup_rule` freely to check: +Use `recall` freely to check: - Racial traits (darkvision, resistances, etc.) - Class features (hit dice, proficiencies, starting abilities) - Background features (skills, tools, equipment) @@ -40,6 +40,8 @@ Get the details right - this character will be with them for a while. ## Finalizing -Once you have everything, call `create_character` with the full character data. This saves their character sheet and you can transition to the adventure. +Once you have everything, call `create_character` with the full character data. This saves their character sheet. + +**IMPORTANT**: After calling `create_character`, give a brief summary of the character (a "quick reference" with key abilities, spells if any, and notable features) but do NOT start the adventure. The session will end and the player will start fresh with `storied play` using the full DM system. Don't rush. Character creation is part of the fun. Let them explore, change their mind, and discover who they want to play. diff --git a/prompts/dm-system.md b/prompts/dm-system.md index efc0058..dedb366 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -1,5 +1,32 @@ You are an expert D&D 5e Dungeon Master running a solo adventure. +## Available Tools + +| Tool | Purpose | +|------|---------| +| `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 | +| `mark_time` | Log events and advance the clock | +| `set_scene` | Update current situation, location, NPCs present | +| `update_character` | Modify character stats (HP, gold, equipment) | +| `create_character` | Create a new character | +| `end_session` | Gracefully end the session | + +## After Every Response: Tool Checklist + +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) +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. + +The world only persists if you save it. Narrative without tool calls is lost context. + ## Starting a Session When you see `[Session starting]`, begin with a brief "previously on..." recap: @@ -49,19 +76,20 @@ This is a real D&D game with real dice rolls and real rules. Never narrate outco ## Core Principle: Track Time -**Log events as they happen**, not at the end of the session. After each significant action or scene, call `log_event` immediately: +**Call `mark_time` after every response where time passes.** This is most responses! -- After a conversation: `log_event("Negotiated with the guard captain", "20 min")` -- After combat ends: `log_event("Defeated dock thugs", "3 rounds")` -- After travel: `log_event("Walked to the harbor district", "15 min")` -- After investigation: `log_event("Searched the warehouse", "30 min")` -- After a rest: `log_event("Short rest in alley", "1 hour", tags=["rest:short"])` +- Conversation at the gate: `mark_time("Spoke with guards about bandits", "10 min")` +- Explaining to the constable: `mark_time("Briefed Constable Harrik on bandit camp", "5 min")` +- Combat: `mark_time("Fought off thugs", "3 rounds")` +- Travel: `mark_time("Walked to the harbor district", "15 min")` +- Investigation: `mark_time("Searched the warehouse", "30 min")` +- Rest: `mark_time("Short rest in alley", "1 hour", tags=["rest:short"])` -The campaign log is the canonical clock. If you don't log it, time doesn't advance. Log frequently - every scene transition, every meaningful interaction, every passage of time. +The campaign log is the canonical clock. **If you don't log it, time doesn't advance.** A conversation that takes 10 minutes in-world should be logged, or the clock stays frozen. ## Core Principle: Save State -**Update session state frequently** so the game can resume if interrupted. Call `update_session` whenever: +**Update session state frequently** so the game can resume if interrupted. Call `set_scene` whenever: - The player moves to a new location - A scene ends or the situation changes significantly @@ -85,15 +113,23 @@ Example flow: 2. You: Roll 1d20+5 (attack) → if it beats AC, roll 1d8+3 (damage) 3. Narrate the result based on the actual numbers -## When to Look Up Rules +## When to Look Things Up -Use lookup_rule liberally: +Use `recall` liberally to look up rules or world content: + +**Rules** (scope: "rules"): - Before resolving spells - check the actual spell text - When a player tries something unusual - check if there's a rule - For monster stats - look up AC, HP, attacks, abilities - For conditions - what exactly does "grappled" or "prone" do? -Don't guess at rules. Look them up. The SRD has: spells, monsters, classes, magic-items, feats, equipment, conditions. +**World** (scope: "world"): +- NPC details when they appear in a scene +- Location descriptions when the player arrives somewhere +- Faction information when it becomes relevant +- Previous events or lore that affects the current situation + +Don't guess at rules or forget established world details. Look them up. The SRD has: spells, monsters, classes, magic-items, feats, equipment, conditions. ## Setting DCs @@ -161,7 +197,7 @@ Call update_character immediately when these changes happen, not at the end of t ## Session State -Use update_session to track the current situation so the game can resume naturally: +Use `set_scene` to track the current situation so the game can resume naturally: - **When the player moves**: `location: "rusty-anchor"` - **When the scene changes**: Update `situation` with a brief summary @@ -172,23 +208,62 @@ Write situation summaries as if briefing another DM taking over mid-session. Foc Example: ``` -situation: "Satchmo is at The Rusty Anchor, having just accepted Vera's offer to investigate the warehouse. She's given him a key and warned him about the night watch." -present: ["[[Vera Blackwater]] - waiting for results", "[[Henrik]] - pretending not to listen"] -threads: ["Warehouse investigation - Vera's job", "Merchant attacks - 50gp from Captain"] +set_scene( + situation="Satchmo is at The Rusty Anchor, having just accepted Vera's offer to investigate the warehouse. She's given him a key and warned him about the night watch.", + present=["[[Vera Blackwater]] - waiting for results", "[[Henrik]] - pretending not to listen"], + threads=["Warehouse investigation - Vera's job", "Merchant attacks - 50gp from Captain"] +) ``` ## World Persistence -Use save_to_world when you introduce entities worth remembering: +You manage two layers of knowledge: **world truth** and **player knowledge**. + +### Establishing World Truth + +Use `establish` when you introduce or update world facts: + +- **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 + +**Give names to everyone.** Don't introduce "a guard" - introduce "Mara, a guard". Then establish her. Named NPCs create a living world. -- Named NPCs the player has interacted with meaningfully -- Locations the player has visited (not just mentioned) -- Significant events that affect the world state -- Factions the player has learned about +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. -**Don't save** throwaway characters (random guard, unnamed merchant) or locations mentioned in passing. +Example: +``` +establish( + type="npc", + 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"] +) +``` + +### Recording Player Knowledge + +Use `note_discovery` when the player learns something significant: + +- Information an NPC reveals to them +- A location's secrets they uncover +- Lore or history they piece together +- Facts they learn through investigation + +This captures the player's perspective - which may be incomplete or even wrong. The player only knows what they've discovered. + +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" +) +``` -When saving, include useful details: appearance, personality, what they know, their goals, connections to other entities. +**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. ## Wiki References diff --git a/src/storied/cli.py b/src/storied/cli.py index 596f943..bace0e9 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -223,6 +223,7 @@ def cmd_play(args: argparse.Namespace) -> int: console.print() engine = DMEngine(world_id=world_id, player_id=player_id, prompt_name=prompt_name) + engine.debug = args.debug # If in creation mode, start the conversation if creation_mode: diff --git a/src/storied/dice.py b/src/storied/dice.py index 67aa8ce..80ae6f7 100644 --- a/src/storied/dice.py +++ b/src/storied/dice.py @@ -2,18 +2,43 @@ import random import re -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass -class DiceRoll: - """Parsed dice notation.""" +class DiceGroup: + """A single dice group (e.g., 2d6, 4d6kh3).""" count: int sides: int - modifier: int = 0 keep_highest: int | None = None keep_lowest: int | None = None + sign: int = 1 # +1 or -1 + + +@dataclass +class DiceRoll: + """Parsed dice notation with multiple groups.""" + + groups: list[DiceGroup] = field(default_factory=list) + modifier: int = 0 + + # Legacy single-group properties for backward compatibility + @property + def count(self) -> int: + return self.groups[0].count if self.groups else 0 + + @property + def sides(self) -> int: + return self.groups[0].sides if self.groups else 0 + + @property + def keep_highest(self) -> int | None: + return self.groups[0].keep_highest if self.groups else None + + @property + def keep_lowest(self) -> int | None: + return self.groups[0].keep_lowest if self.groups else None @dataclass @@ -37,21 +62,18 @@ class RollResult: } -# Pattern: XdY, optional kh/kl N, optional +/- modifier -DICE_PATTERN = re.compile( +# Pattern for a single dice group: XdY with optional kh/kl +DICE_GROUP_PATTERN = re.compile( r""" - ^\s* - (\d+)\s*[dD]\s*(\d+) # XdY + (\d+)\s*[dD]\s*(\d+) # XdY (?:\s*[kK]([hHlL])(\d+))? # optional keep highest/lowest - (?:\s*([+-])\s*(\d+))? # optional modifier - \s*$ """, re.VERBOSE, ) def parse_notation(notation: str) -> DiceRoll: - """Parse dice notation like '2d6+3' or '4d6kh3'. + """Parse dice notation like '2d6+3', '4d6kh3', or '2d8+1d6'. Supports: - Basic: XdY (e.g., 1d20, 3d6) @@ -59,43 +81,69 @@ def parse_notation(notation: str) -> DiceRoll: - Keep highest: XdYkhN (e.g., 4d6kh3 for ability scores) - Keep lowest: XdYklN (e.g., 2d20kl1 for disadvantage) - Combined: XdYkhN+Z (e.g., 4d6kh3+2) + - Compound: XdY+AdB (e.g., 2d8+1d6, 1d6+1d4+2) """ - match = DICE_PATTERN.match(notation) - if not match: + notation = notation.strip() + if not notation: raise ValueError(f"Invalid dice notation: {notation!r}") - count = int(match.group(1)) - sides = int(match.group(2)) + groups: list[DiceGroup] = [] + modifier = 0 - keep_highest = None - keep_lowest = None - if match.group(3): - keep_type = match.group(3).lower() - keep_count = int(match.group(4)) - if keep_type == "h": - keep_highest = keep_count + # Split by + and - while preserving the operator + # First, normalize the notation by adding a + at the start if it doesn't have a sign + if not notation.startswith(("+", "-")): + notation = "+" + notation + + # Find all terms (sign + value) + terms = re.findall(r"([+-])\s*([^+-]+)", notation) + + for sign_str, term in terms: + term = term.strip() + sign = 1 if sign_str == "+" else -1 + + # Try to parse as a dice group + dice_match = DICE_GROUP_PATTERN.match(term) + if dice_match: + count = int(dice_match.group(1)) + sides = int(dice_match.group(2)) + + keep_highest = None + keep_lowest = None + if dice_match.group(3): + keep_type = dice_match.group(3).lower() + keep_count = int(dice_match.group(4)) + if keep_type == "h": + keep_highest = keep_count + else: + keep_lowest = keep_count + + groups.append( + DiceGroup( + count=count, + sides=sides, + keep_highest=keep_highest, + keep_lowest=keep_lowest, + sign=sign, + ) + ) + elif term.isdigit(): + # It's a flat modifier + modifier += sign * int(term) else: - keep_lowest = keep_count + raise ValueError(f"Invalid dice notation: {notation!r}") - modifier = 0 - if match.group(5): - sign = 1 if match.group(5) == "+" else -1 - modifier = sign * int(match.group(6)) - - return DiceRoll( - count=count, - sides=sides, - modifier=modifier, - keep_highest=keep_highest, - keep_lowest=keep_lowest, - ) + if not groups and modifier == 0: + raise ValueError(f"Invalid dice notation: {notation!r}") + + return DiceRoll(groups=groups, modifier=modifier) def roll(notation: str, seed: int | None = None) -> RollResult: """Roll dice using standard notation. Args: - notation: Dice notation like '2d6+3' or '4d6kh3' + notation: Dice notation like '2d6+3', '4d6kh3', or '2d8+1d6' seed: Optional random seed for deterministic results Returns: @@ -104,22 +152,29 @@ def roll(notation: str, seed: int | None = None) -> RollResult: parsed = parse_notation(notation) rng = random.Random(seed) - rolls = [rng.randint(1, parsed.sides) for _ in range(parsed.count)] - - # Determine which dice to keep - if parsed.keep_highest: - kept = sorted(rolls, reverse=True)[: parsed.keep_highest] - elif parsed.keep_lowest: - kept = sorted(rolls)[: parsed.keep_lowest] - else: - kept = rolls.copy() + all_rolls: list[int] = [] + all_kept: list[int] = [] + total = parsed.modifier + + for group in parsed.groups: + rolls = [rng.randint(1, group.sides) for _ in range(group.count)] + all_rolls.extend(rolls) + + # Determine which dice to keep + if group.keep_highest: + kept = sorted(rolls, reverse=True)[: group.keep_highest] + elif group.keep_lowest: + kept = sorted(rolls)[: group.keep_lowest] + else: + kept = rolls.copy() - total = sum(kept) + parsed.modifier + all_kept.extend(kept) + total += group.sign * sum(kept) return RollResult( - notation=notation, - rolls=rolls, - kept=kept, + notation=notation.strip(), + rolls=all_rolls, + kept=all_kept, modifier=parsed.modifier, total=total, ) diff --git a/src/storied/engine.py b/src/storied/engine.py index 8451bbe..9c26ca7 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -7,6 +7,32 @@ from pathlib import Path import anthropic + +def _truncate(value: object, max_len: int = 60, max_items: int = 3) -> str: + """Truncate a value for debug display.""" + if isinstance(value, str): + if len(value) <= max_len: + return repr(value) + return repr(value[:max_len]) + f"...+{len(value) - max_len} chars" + + if isinstance(value, list): + if len(value) <= max_items: + return "[" + ", ".join(_truncate(v, 30, 2) for v in value) + "]" + items = [_truncate(v, 30, 2) for v in value[:max_items]] + return "[" + ", ".join(items) + f", ...+{len(value) - max_items} more]" + + if isinstance(value, dict): + if len(value) <= max_items: + pairs = [f"{k!r}: {_truncate(v, 30, 2)}" for k, v in value.items()] + return "{" + ", ".join(pairs) + "}" + pairs = [f"{k!r}: {_truncate(v, 30, 2)}" for k, v in list(value.items())[:max_items]] + return "{" + ", ".join(pairs) + f", ...+{len(value) - max_items} more" + "}" + + s = repr(value) + if len(s) <= max_len: + return s + return s[:max_len] + f"...+{len(s) - max_len} chars" + from storied.character import format_character_context, load_character from storied.content import ContentResolver from storied.log import CampaignLog @@ -71,8 +97,11 @@ class DMEngine: # Session end flag (set when end_session tool is called) self.session_ended: bool = False - # Campaign log for time tracking - self._campaign_log = CampaignLog(self.player_id, self.base_path) + # Debug mode for verbose tool output + self.debug: bool = False + + # Campaign log for time tracking (world-scoped) + self._campaign_log = CampaignLog(self.world_id, self.base_path) # Build system prompt with full context self._prompt_name = prompt_name @@ -107,8 +136,8 @@ class DMEngine: 1. Character sheet (always) 2. Campaign log (current time + recent events) 3. Session state if exists (situation, present, threads) - 4. Current location if set - 5. Present entities from [[wiki links]] + 4. Player knowledge (what the player has learned) + 5. DM knowledge: current location + present entities (smart loading) """ parts = [] self._context_parts = {} @@ -133,7 +162,15 @@ class DMEngine: self._context_parts["Session"] = session_context parts.append(session_context) - # 4. Current location + # 4. Player knowledge (what the player has learned about this world) + player_knowledge = self._load_player_knowledge() + if player_knowledge: + self._context_parts["PlayerKnowledge"] = player_knowledge + parts.append(player_knowledge) + + # 5. DM knowledge (smart loading: location + present entities) + if session: + # Current location location_slug = session.get("location") if location_slug and self.world_id: location_content = self._load_world_content("locations", location_slug) @@ -142,7 +179,7 @@ class DMEngine: self._context_parts["Location"] = loc_context parts.append(loc_context) - # 5. Present entities from wiki links + # Present entities from wiki links present_text = session.get("body", "") for name in extract_wiki_links(present_text): entity = self._find_entity(name) @@ -153,6 +190,59 @@ class DMEngine: return "\n\n---\n\n".join(parts) + def _load_player_knowledge(self) -> str | None: + """Load player's known entities from players/{player}/worlds/{world}/.""" + if not self.world_id: + return None + + knowledge_dir = ( + self.base_path / "players" / self.player_id / "worlds" / self.world_id + ) + if not knowledge_dir.exists(): + return None + + parts = ["## What You Know\n"] + + for content_type in ["npcs", "locations", "factions", "lore"]: + type_dir = knowledge_dir / content_type + if not type_dir.exists(): + continue + + for file in sorted(type_dir.glob("*.md")): + content = self._parse_knowledge_file(file) + if content: + name = content.get("name", file.stem) + body = content.get("body", "") + parts.append(f"### {name}\n{body}\n") + + return "\n".join(parts) if len(parts) > 1 else None + + def _parse_knowledge_file(self, file_path: Path) -> dict | None: + """Parse a player knowledge file with YAML frontmatter.""" + import re + + import yaml + + content = file_path.read_text() + if not content.startswith("---"): + return {"body": content} + + # Parse frontmatter + match = re.search(r"\n---\s*\n", content[3:]) + if not match: + return {"body": content} + + frontmatter_end = match.start() + 3 + frontmatter_str = content[3:frontmatter_end] + body = content[frontmatter_end + match.end() - match.start() :].strip() + + try: + frontmatter = yaml.safe_load(frontmatter_str) or {} + except yaml.YAMLError: + frontmatter = {} + + return {"body": body, **frontmatter} + def _load_world_content(self, content_type: str, name: str) -> dict | None: """Load world content by type and name.""" if not self.world_id: @@ -382,51 +472,66 @@ class DMEngine: if tool_uses: tool_results = [] for tool_use in tool_uses: - # Show the user what's happening - if tool_use["name"] == "roll_dice": - reason = tool_use["input"].get("reason", "") - notation = tool_use["input"].get("notation", "?") - if reason: - yield f"\n[{reason}: {notation}...]\n" - else: - yield f"\n[Rolling {notation}...]\n" - elif tool_use["name"] == "lookup_rule": - yield f"\n[Looking up: {tool_use['input'].get('query', '?')}...]\n" - elif tool_use["name"] == "query_world": - yield f"\n[Checking world: {tool_use['input'].get('query', '?')}...]\n" - elif tool_use["name"] == "update_character": - yield "\n[Updating character sheet...]\n" - elif tool_use["name"] == "create_character": - name = tool_use["input"].get("name", "character") - yield f"\n[Creating {name}...]\n" - elif tool_use["name"] == "update_session": - yield "\n[Updating session state...]\n" - elif tool_use["name"] == "save_to_world": - name = tool_use["input"].get("name", "?") - yield f"\n[Saving to world: {name}...]\n" - elif tool_use["name"] == "log_event": - event = tool_use["input"].get("event", "?") - duration = tool_use["input"].get("duration", "?") - yield f"\n[Logging: {event} ({duration})]...\n" - elif tool_use["name"] == "end_session": - yield "\n[Saving session...]...\n" + tool_name = tool_use["name"] + tool_input = tool_use["input"] + + # Debug mode: show full tool call with truncated params + if self.debug: + params = ", ".join( + f"{k}={_truncate(v)}" for k, v in tool_input.items() + ) + yield f"\n[→ {tool_name}({params})]\n" + else: + # Normal mode: show friendly messages + if tool_name == "roll": + reason = tool_input.get("reason", "") + notation = tool_input.get("notation", "?") + if reason: + yield f"\n[{reason}: {notation}...]\n" + else: + yield f"\n[Rolling {notation}...]\n" + elif tool_name == "recall": + yield f"\n[Recalling: {tool_input.get('query', '?')}...]\n" + elif tool_name == "update_character": + yield "\n[Updating character sheet...]\n" + elif tool_name == "create_character": + name = tool_input.get("name", "character") + yield f"\n[Creating {name}...]\n" + elif tool_name == "set_scene": + yield "\n[Setting scene...]\n" + elif tool_name == "establish": + name = tool_input.get("name", "?") + yield f"\n[Establishing: {name}...]\n" + elif tool_name == "note_discovery": + entity = tool_input.get("entity", "?") + yield f"\n[Noting: player learned about {entity}...]\n" + elif tool_name == "mark_time": + event = tool_input.get("event", "?") + duration = tool_input.get("duration", "?") + yield f"\n[{event} ({duration})]...\n" + elif tool_name == "end_session": + yield "\n[Saving session...]...\n" result = execute_tool( - tool_use["name"], - tool_use["input"], + tool_name, + tool_input, world_id=self.world_id, player_id=self.player_id, base_path=self.base_path, campaign_log=self._campaign_log, ) + # Debug mode: show result + if self.debug: + yield f"[⇐ {_truncate(result, max_len=100)}]\n\n" + # Check if session ended if result == "SESSION_ENDED": self.session_ended = True result = "Session saved. Farewell!" - # Show dice roll results immediately - if tool_use["name"] == "roll_dice": + # Show dice roll results immediately (non-debug mode) + if tool_name == "roll" and not self.debug: yield f"{result}\n" tool_results.append( diff --git a/src/storied/log.py b/src/storied/log.py index 427364a..fb32b26 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -139,18 +139,28 @@ class LogEntry: class CampaignLog: - """Manages the campaign log files.""" + """Manages the campaign log files. - def __init__(self, player_id: str = "default", base_path: Path | None = None): - self.player_id = player_id + The campaign log is world-scoped (lives in worlds/{world_id}/log/) since it + represents the canonical timeline of events in the world. + + File structure: + - index.md: metadata (current day, time) + summaries of previous days + - day-001.md: entries for day 1 + - day-002.md: entries for day 2 + - etc. + """ + + def __init__(self, world_id: str = "default", base_path: Path | None = None): + self.world_id = world_id self.base_path = base_path or Path.cwd() - self.log_dir = self.base_path / "players" / player_id / "log" + self.log_dir = self.base_path / "worlds" / world_id / "log" # Load or initialize state - self._load_index() + self._load_state() - def _load_index(self) -> None: - """Load the log index or initialize defaults.""" + def _load_state(self) -> None: + """Load the log state from index.md and current day file.""" index_path = self.log_dir / "index.md" if not index_path.exists(): @@ -160,17 +170,20 @@ class CampaignLog: self.current_entries: list[LogEntry] = [] return + # Parse index for metadata and summaries content = index_path.read_text() self._parse_index(content) + # Load current day's entries from day file + self.current_entries = self._load_day_entries(self.current_day) + def _parse_index(self, content: str) -> None: - """Parse index.md content.""" - # Defaults + """Parse index.md for metadata and previous day summaries.""" self.current_day = 1 self.current_time = GameTime(day=1, hour=6, minute=0) - body = content + self.previous_summaries = [] - # Parse frontmatter if present + # Parse frontmatter if content.startswith("---"): end_match = re.search(r"\n---\s*\n", content[3:]) if end_match: @@ -182,32 +195,47 @@ class CampaignLog: self.current_day = fm.get("current_day", 1) time_str = fm.get("current_time", "d1-0600") self.current_time = GameTime.from_anchor(time_str) + else: + body = content + else: + body = content # Parse previous day summaries - self.previous_summaries = [] in_previous = False for line in body.split("\n"): if line.strip() == "## Previous Days": in_previous = True continue if line.startswith("## ") and in_previous: - in_previous = False + break if in_previous and line.startswith("- "): self.previous_summaries.append(line[2:].strip()) - # Parse current day entries - self.current_entries = [] - for line in body.split("\n"): + def _day_filename(self, day: int) -> str: + """Generate filename for a day: day+001.md or day-001.md.""" + if day >= 0: + return f"day+{day:03d}.md" + else: + return f"day{day:04d}.md" # e.g., day-001.md (sign included in format) + + def _load_day_entries(self, day: int) -> list[LogEntry]: + """Load entries from a day file.""" + day_path = self.log_dir / self._day_filename(day) + if not day_path.exists(): + return [] + + entries = [] + for line in day_path.read_text().split("\n"): entry = LogEntry.parse(line) if entry: - self.current_entries.append(entry) + entries.append(entry) + return entries def _save_index(self) -> None: - """Save the current state to index.md.""" + """Save metadata and summaries to index.md.""" self.log_dir.mkdir(parents=True, exist_ok=True) index_path = self.log_dir / "index.md" - # Build content lines = [ "---", f"current_day: {self.current_day}", @@ -217,20 +245,41 @@ class CampaignLog: "# Campaign Log", ] - # Previous days section if self.previous_summaries: lines.append("") lines.append("## Previous Days") for summary in self.previous_summaries: lines.append(f"- {summary}") - # Current day section lines.append("") - lines.append(f"## Day {self.current_day} (Current)") + index_path.write_text("\n".join(lines)) + + def _save_day_file(self, day: int, entries: list[LogEntry]) -> None: + """Save entries to a day file.""" + self.log_dir.mkdir(parents=True, exist_ok=True) + day_path = self.log_dir / self._day_filename(day) + + # Build summary from first few events + if entries: + events = [e.event for e in entries[:3]] + summary = ", ".join(events) + if len(entries) > 3: + summary += ", ..." + else: + summary = "" + + lines = [ + "---", + f"day: {day}", + f"summary: {summary}", + "---", + "", + f"# Day {day}", + ] - # Group entries by period + # Group by period periods: dict[str, list[LogEntry]] = {} - for entry in self.current_entries: + for entry in entries: try: time = GameTime.from_anchor(entry.anchor) period = time.period_of_day() @@ -248,7 +297,7 @@ class CampaignLog: lines.append(entry.to_line()) lines.append("") - index_path.write_text("\n".join(lines)) + day_path.write_text("\n".join(lines)) def append_entry( self, @@ -277,6 +326,8 @@ class CampaignLog: if self.current_time.day > self.current_day: self._roll_day() + # Save current day entries + self._save_day_file(self.current_day, self.current_entries) self._save_index() return anchor @@ -290,60 +341,16 @@ class CampaignLog: summary += ", ..." self.previous_summaries.append(summary) - # Save full day log - self._save_day_file(self.current_day) - # Start new day self.current_day = self.current_time.day self.current_entries = [] - def _save_day_file(self, day: int) -> None: - """Save a full day's log to day-NNN.md.""" - day_path = self.log_dir / f"day-{day:03d}.md" - - # Build summary - events = [e.event for e in self.current_entries[:3]] - summary = ", ".join(events) - if len(self.current_entries) > 3: - summary += ", ..." - - lines = [ - "---", - f"day: {day}", - f"summary: {summary}", - "---", - "", - f"# Day {day}", - ] - - # Group by period - periods: dict[str, list[LogEntry]] = {} - for entry in self.current_entries: - try: - time = GameTime.from_anchor(entry.anchor) - period = time.period_of_day() - except ValueError: - period = "Morning" - if period not in periods: - periods[period] = [] - periods[period].append(entry) - - for period in ["Morning", "Afternoon", "Evening"]: - if period in periods: - lines.append("") - lines.append(f"### {period}") - for entry in periods[period]: - lines.append(entry.to_line()) - - lines.append("") - day_path.write_text("\n".join(lines)) - def get_current_time(self) -> GameTime: """Return the current game time.""" return self.current_time def format_for_context(self) -> str: - """Format the log index for inclusion in system prompt.""" + """Format the log for inclusion in system prompt.""" lines = [f"## Campaign Time: {self.current_time}"] if self.previous_summaries: @@ -375,9 +382,9 @@ class CampaignLog: return Duration(minutes=total_minutes + 8 * 60) # Add 8 hours as estimate -def load_log(player_id: str = "default", base_path: Path | None = None) -> CampaignLog: - """Load or create a campaign log for a player.""" - return CampaignLog(player_id=player_id, base_path=base_path) +def load_log(world_id: str = "default", base_path: Path | None = None) -> CampaignLog: + """Load or create a campaign log for a world.""" + return CampaignLog(world_id=world_id, base_path=base_path) def log_event( @@ -385,12 +392,12 @@ def log_event( duration: str, advance_time: bool = True, tags: list[str] | None = None, - player_id: str = "default", + world_id: str = "default", base_path: Path | None = None, ) -> str: """Convenience function to append an event to the log. Returns the timestamp anchor for the event. """ - log = load_log(player_id, base_path) + log = load_log(world_id, base_path) return log.append_entry(event, duration, advance_time, tags) diff --git a/src/storied/tools.py b/src/storied/tools.py index 6b580f5..45dc8c7 100644 --- a/src/storied/tools.py +++ b/src/storied/tools.py @@ -17,7 +17,7 @@ from storied.session import name_to_slug from storied.session import update_session as session_update -def roll_dice(notation: str, reason: str | None = None) -> dict: +def roll(notation: str, reason: str | None = None) -> dict: """Roll dice using standard notation like '1d20', '2d6+3', '4d6kh3'. Use for attack rolls, skill checks, saving throws, and damage rolls. @@ -35,100 +35,88 @@ def roll_dice(notation: str, reason: str | None = None) -> dict: return result.to_dict() -def lookup_rule( - query: str, - category: str | None = None, - base_path: Path | None = None, -) -> str: - """Search the D&D 5e SRD for rules, spells, monsters, items, or conditions. - - Use when you need to verify how an ability or spell works, look up monster - stats or item properties, or check condition effects. - - Args: - query: Search term (e.g., "fireball", "grappled", "ancient red dragon") - category: Optional category to limit search. One of: spells, monsters, - classes, magic-items, feats, or None to search all. - base_path: Base path for content resolution (for testing) - - Returns: - Content of the found rule, or a message if not found - """ - resolver = ContentResolver(base_path=base_path) - - # Try exact match first - content = resolver.load(query, content_type=category) - if content: - return content["body"] - - # Fall back to search - results = resolver.search(query, content_type=category) - if not results: - return f"No rules found matching '{query}'" - - if len(results) == 1: - # Single result - return full content - content = resolver.load(results[0].name, content_type=results[0].content_type) - if content: - return content["body"] - - # Multiple results - return list - lines = [f"Found {len(results)} matches for '{query}':"] - for r in results[:10]: # Limit to 10 results - lines.append(f"- {r.name} ({r.content_type})") - if len(results) > 10: - lines.append(f"... and {len(results) - 10} more") - return "\n".join(lines) - - -def query_world( +def recall( query: str, + scope: str = "all", content_type: str | None = None, world_id: str | None = None, base_path: Path | None = None, ) -> str: - """Query the current world state for locations, NPCs, or established facts. + """Look up rules, world content, or both. - Use when describing locations, recalling NPC details, checking what the - player has learned, or maintaining consistency with previous events. + Use to recall information about: + - Rules: spells, monsters, classes, items, conditions from the SRD + - World: NPCs, locations, factions, lore you've established + - Both: search everything (default) Args: - query: What to look up (e.g., "tavern", "captain vex", "merchant guild") - content_type: Optional type to limit search. One of: locations, npcs, - factions, monsters, magic-items, lore, events, or None. - world_id: The world to query (required for world-specific content) + query: What to look up (e.g., "fireball", "captain vex", "merchant guild") + scope: Where to search - "rules", "world", or "all" (default) + content_type: Optional type to limit search (e.g., "spells", "npcs") + world_id: World to query (required if scope includes world) base_path: Base path for content resolution (for testing) Returns: - Content of the found world element, or a message if not found + Content of the found item, or a message if not found """ - if not world_id: - return "No world specified. Use lookup_rule for base game content." - - resolver = ContentResolver(base_path=base_path, world_id=world_id) - - # Try exact match first - content = resolver.load(query, content_type=content_type) - if content: - return content["body"] + results_parts = [] - # Fall back to search - results = resolver.search(query, content_type=content_type) - if not results: - return f"Nothing found in the world matching '{query}'" + # Search rules if scope includes it + if scope in ("rules", "all"): + resolver = ContentResolver(base_path=base_path) + content = resolver.load(query, content_type=content_type) + if content: + return content["body"] - if len(results) == 1: - content = resolver.load(results[0].name, content_type=results[0].content_type) + results = resolver.search(query, content_type=content_type) + if results: + if len(results) == 1: + content = resolver.load( + results[0].name, content_type=results[0].content_type + ) + if content: + return content["body"] + else: + results_parts.append(("rules", results)) + + # Search world if scope includes it and world_id is provided + if scope in ("world", "all") and world_id: + resolver = ContentResolver(base_path=base_path, world_id=world_id) + content = resolver.load(query, content_type=content_type) if content: return content["body"] - # Multiple results - lines = [f"Found {len(results)} matches for '{query}':"] - for r in results[:10]: - lines.append(f"- {r.name} ({r.content_type})") - if len(results) > 10: - lines.append(f"... and {len(results) - 10} more") - return "\n".join(lines) + results = resolver.search(query, content_type=content_type) + if results: + if len(results) == 1: + content = resolver.load( + results[0].name, content_type=results[0].content_type + ) + if content: + return content["body"] + else: + results_parts.append(("world", results)) + + # No exact match - return list of results if any + if results_parts: + lines = [] + for source, results in results_parts: + lines.append(f"Found {len(results)} matches in {source}:") + for r in results[:5]: + lines.append(f" - {r.name} ({r.content_type})") + if len(results) > 5: + lines.append(f" ... and {len(results) - 5} more") + return "\n".join(lines) + + # Nothing found + if scope == "rules": + return f"No rules found matching '{query}'" + elif scope == "world": + if not world_id: + return "No world specified. Use scope='rules' for base game content." + return f"Nothing found in the world matching '{query}'" + else: + return f"Nothing found matching '{query}'" def update_character( @@ -222,7 +210,7 @@ def create_character( ) -def update_session( +def set_scene( situation: str | None = None, location: str | None = None, present: list[str] | None = None, @@ -230,7 +218,7 @@ def update_session( player_id: str = "default", base_path: Path | None = None, ) -> str: - """Update the current session state when the scene changes significantly. + """Update the current scene when the situation changes significantly. Call this when: - The player moves to a new location @@ -268,7 +256,7 @@ def update_session( return session_update(player_id, updates, base_path) -def save_to_world( +def establish( content_type: str, name: str, content: str, @@ -277,15 +265,16 @@ def save_to_world( base_path: Path | None = None, **extra_frontmatter: str, ) -> str: - """Save new content to the world for future reference. + """Establish something as true in the world. - Use when creating persistent world content that should be remembered: + 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 - Don't save: + Don't establish: - Unnamed background characters (random guard, merchant #3) - Locations mentioned but not visited - Minor events with no ongoing consequences @@ -293,8 +282,8 @@ def save_to_world( Args: content_type: Type of content. One of: npcs, locations, factions, events, lore, items name: Display name (e.g., "Vera Blackwater", "The Rusty Anchor") - content: Markdown body describing the entity. Include relevant details, - appearance, personality, what they know, connections, etc. + 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"]) world_id: World to save to (required) base_path: Base path for worlds directory @@ -336,21 +325,86 @@ def save_to_world( file_path.write_text(file_content) - return f"Saved {content_type.rstrip('s')} '{name}' to {file_path.relative_to(base_path)}" + return f"Established {content_type.rstrip('s')} '{name}' in {file_path.relative_to(base_path)}" + + +def note_discovery( + entity: str, + content: str, + content_type: str = "lore", + tags: list[str] | None = None, + world_id: str | None = None, + player_id: str = "default", + base_path: Path | None = None, +) -> str: + """Record what the player has learned about something. + + Use when the player discovers or learns information about an NPC, + location, faction, or other world element. This captures their + perspective, which may be incomplete or even wrong. + + The player's knowledge is separate from DM truth (use `establish` + for the full facts). This helps track what the player knows vs. + what they haven't discovered yet. + + Args: + entity: Name of what they learned about (e.g., "Vera Blackwater") + content: What the player learned or observed + content_type: Type of content - npcs, locations, factions, lore (default: lore) + tags: Optional tags for categorization + world_id: World this knowledge is about (required) + player_id: Player who learned this + base_path: Base path for players directory + + Returns: + Confirmation message + """ + if not world_id: + return "Error: No world_id specified. Cannot record player knowledge." + + if base_path is None: + base_path = Path.cwd() + + # Generate slug from entity name + slug = name_to_slug(entity) + + # Player knowledge path: players/{player}/worlds/{world}/{type}/ + knowledge_dir = base_path / "players" / player_id / "worlds" / world_id / content_type + knowledge_dir.mkdir(parents=True, exist_ok=True) + file_path = knowledge_dir / f"{slug}.md" + + # Build frontmatter + frontmatter = { + "type": content_type.rstrip("s"), + "name": entity, + } + if tags: + frontmatter["tags"] = tags + + # 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_path.write_text(file_content) + + return f"Noted: player learned about '{entity}'" -def log_event( +def mark_time( event: str, duration: str, advance_time: bool = True, tags: list[str] | None = None, campaign_log: CampaignLog | None = None, - player_id: str = "default", + world_id: str = "default", base_path: Path | None = None, ) -> str: - """Record an event in the campaign log with its duration. + """Record an event and advance game time. - Use this to track significant happenings and advance game time: + Use to track significant happenings and the passage of time: - Scene transitions: "Entered the tavern", "5 min" - Conversations: "Spoke with Vera about the job", "30 min" - Travel: "Traveled to Millford", "1 day" @@ -365,13 +419,13 @@ def log_event( advance_time: Whether to advance current time by duration (default: True) tags: Optional tags like ["combat"], ["rest:short"], ["rest:long"], ["travel"] campaign_log: Existing CampaignLog instance to use (avoids stale cache) - player_id: Player identifier - base_path: Base path for players directory + world_id: World identifier (log is world-scoped) + base_path: Base path for worlds directory Returns: Confirmation with the timestamp anchor (e.g., "#d1-0700") """ - log = campaign_log or load_log(player_id, base_path) + log = campaign_log or load_log(world_id, base_path) anchor = log.append_entry(event, duration, advance_time, tags) return f"Logged: {anchor} | {event} | {duration}" @@ -410,8 +464,8 @@ def end_session( # Tool definitions for the Anthropic API TOOL_DEFINITIONS = [ { - "name": "roll_dice", - "description": roll_dice.__doc__, + "name": "roll", + "description": roll.__doc__, "input_schema": { "type": "object", "properties": { @@ -428,53 +482,23 @@ TOOL_DEFINITIONS = [ }, }, { - "name": "lookup_rule", - "description": lookup_rule.__doc__, + "name": "recall", + "description": recall.__doc__, "input_schema": { "type": "object", "properties": { "query": { "type": "string", - "description": "Search term for the rule", + "description": "What to look up (e.g., 'fireball', 'captain vex')", }, - "category": { + "scope": { "type": "string", - "description": "Category to search: spells, monsters, classes, magic-items, feats", - "enum": [ - "spells", - "monsters", - "classes", - "magic-items", - "feats", - "animals", - ], - }, - }, - "required": ["query"], - }, - }, - { - "name": "query_world", - "description": query_world.__doc__, - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "What to look up in the world", + "description": "Where to search: 'rules' (SRD), 'world' (established content), or 'all' (both)", + "enum": ["rules", "world", "all"], }, "content_type": { "type": "string", - "description": "Type of content: locations, npcs, factions, monsters, magic-items, lore, events", - "enum": [ - "locations", - "npcs", - "factions", - "monsters", - "magic-items", - "lore", - "events", - ], + "description": "Type to limit search (e.g., 'spells', 'npcs', 'monsters')", }, }, "required": ["query"], @@ -530,8 +554,8 @@ TOOL_DEFINITIONS = [ }, }, { - "name": "update_session", - "description": update_session.__doc__, + "name": "set_scene", + "description": set_scene.__doc__, "input_schema": { "type": "object", "properties": { @@ -558,8 +582,8 @@ TOOL_DEFINITIONS = [ }, }, { - "name": "save_to_world", - "description": save_to_world.__doc__, + "name": "establish", + "description": establish.__doc__, "input_schema": { "type": "object", "properties": { @@ -586,8 +610,36 @@ TOOL_DEFINITIONS = [ }, }, { - "name": "log_event", - "description": log_event.__doc__, + "name": "note_discovery", + "description": note_discovery.__doc__, + "input_schema": { + "type": "object", + "properties": { + "entity": { + "type": "string", + "description": "Name of what they learned about (e.g., 'Vera Blackwater')", + }, + "content": { + "type": "string", + "description": "What the player learned or observed", + }, + "content_type": { + "type": "string", + "description": "Type of content", + "enum": ["npcs", "locations", "factions", "lore"], + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags for categorization", + }, + }, + "required": ["entity", "content"], + }, + }, + { + "name": "mark_time", + "description": mark_time.__doc__, "input_schema": { "type": "object", "properties": { @@ -655,8 +707,8 @@ def execute_tool( Returns: Tool result as a string """ - if tool_name == "roll_dice": - result = roll_dice(tool_input["notation"]) + if tool_name == "roll": + result = roll(tool_input["notation"]) # Format nicely for the DM rolls_str = ", ".join(str(r) for r in result["rolls"]) if result["kept"] != result["rolls"]: @@ -667,16 +719,10 @@ def execute_tool( else: return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" - elif tool_name == "lookup_rule": - return lookup_rule( - tool_input["query"], - category=tool_input.get("category"), - base_path=base_path, - ) - - elif tool_name == "query_world": - return query_world( + elif tool_name == "recall": + return recall( tool_input["query"], + scope=tool_input.get("scope", "all"), content_type=tool_input.get("content_type"), world_id=world_id, base_path=base_path, @@ -709,8 +755,8 @@ def execute_tool( base_path=base_path, ) - elif tool_name == "update_session": - return update_session( + elif tool_name == "set_scene": + return set_scene( situation=tool_input.get("situation"), location=tool_input.get("location"), present=tool_input.get("present"), @@ -719,8 +765,8 @@ def execute_tool( base_path=base_path, ) - elif tool_name == "save_to_world": - return save_to_world( + elif tool_name == "establish": + return establish( content_type=tool_input["content_type"], name=tool_input["name"], content=tool_input["content"], @@ -729,14 +775,25 @@ def execute_tool( base_path=base_path, ) - elif tool_name == "log_event": - return log_event( + elif tool_name == "note_discovery": + return note_discovery( + entity=tool_input["entity"], + content=tool_input["content"], + content_type=tool_input.get("content_type", "lore"), + tags=tool_input.get("tags"), + world_id=world_id, + player_id=player_id, + base_path=base_path, + ) + + elif tool_name == "mark_time": + return mark_time( event=tool_input["event"], duration=tool_input["duration"], advance_time=tool_input.get("advance_time", True), tags=tool_input.get("tags"), campaign_log=campaign_log, - player_id=player_id, + world_id=world_id, base_path=base_path, ) diff --git a/tests/test_dice.py b/tests/test_dice.py index 767c069..28020dd 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -151,3 +151,44 @@ class TestRoll: result2 = roll("3d6", seed=42) assert result1.rolls == result2.rolls assert result1.total == result2.total + + +class TestCompoundNotation: + """Tests for compound dice notation (e.g., 2d8+1d6).""" + + def test_two_dice_groups(self): + result = roll("2d8+1d6", seed=42) + assert len(result.rolls) == 3 # 2 d8s + 1 d6 + assert result.total == sum(result.rolls) + + def test_three_dice_groups(self): + result = roll("1d8+1d6+1d4", seed=42) + assert len(result.rolls) == 3 + assert result.total == sum(result.rolls) + + def test_compound_with_modifier(self): + result = roll("2d6+1d4+2", seed=42) + assert len(result.rolls) == 3 # 2 d6s + 1 d4 + assert result.modifier == 2 + assert result.total == sum(result.rolls) + 2 + + def test_subtraction_dice_group(self): + result = roll("2d6-1d4", seed=42) + # First two are d6s, last is d4 + d6_total = sum(result.rolls[:2]) + d4_total = result.rolls[2] + assert result.total == d6_total - d4_total + + def test_chaos_bolt_notation(self): + # Chaos Bolt does 2d8 + 1d6 damage + result = roll("2d8+1d6", seed=123) + assert len(result.rolls) == 3 + assert all(1 <= r <= 8 for r in result.rolls[:2]) + assert 1 <= result.rolls[2] <= 6 + + def test_complex_compound(self): + # e.g., 1d6+1d4+1d8+3 + result = roll("1d6+1d4+1d8+3", seed=42) + assert len(result.rolls) == 3 + assert result.modifier == 3 + assert result.total == sum(result.rolls) + 3 diff --git a/tests/test_log.py b/tests/test_log.py index 39797f1..2c0cb72 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -145,13 +145,13 @@ class TestLogEntry: class TestCampaignLog: def test_new_log(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) assert log.current_day == 1 assert log.current_time.hour == 6 assert log.current_entries == [] def test_append_entry(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) anchor = log.append_entry("Arrived at Port Haven", "30 min") assert anchor == "#d1-0600" @@ -159,7 +159,7 @@ class TestCampaignLog: assert log.current_time.minute == 30 def test_append_multiple_entries(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Event 1", "30 min") log.append_entry("Event 2", "1 hour") @@ -168,16 +168,16 @@ class TestCampaignLog: assert log.current_time.minute == 30 def test_persistence(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Test event", "30 min") # Load fresh - log2 = CampaignLog(player_id="test", base_path=tmp_path) + log2 = CampaignLog(world_id="test", base_path=tmp_path) assert len(log2.current_entries) == 1 assert log2.current_entries[0].event == "Test event" def test_roll_day(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00 log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day @@ -186,11 +186,11 @@ class TestCampaignLog: assert "Morning event" in log.previous_summaries[0] # Check day file was created - day_file = tmp_path / "players" / "test" / "log" / "day-001.md" + day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md" assert day_file.exists() def test_format_for_context(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Arrived at tavern", "30 min") log.append_entry("Met the barkeep", "1 hour") @@ -200,14 +200,14 @@ class TestCampaignLog: assert "Met the barkeep" in context def test_time_since_rest_no_rest(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Activity", "2 hours") since = log.time_since_rest("short") assert since.minutes >= 2 * 60 def test_time_since_rest_with_rest(self, tmp_path): - log = CampaignLog(player_id="test", base_path=tmp_path) + log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Activity", "2 hours") log.append_entry("Rest", "1 hour", tags=["rest:short"]) log.append_entry("More activity", "30 min") @@ -218,18 +218,18 @@ class TestCampaignLog: class TestConvenienceFunctions: def test_load_log(self, tmp_path): - log = load_log(player_id="test", base_path=tmp_path) + log = load_log(world_id="test", base_path=tmp_path) assert isinstance(log, CampaignLog) def test_log_event(self, tmp_path): anchor = log_event( "Test event", "30 min", - player_id="test", + world_id="test", base_path=tmp_path, ) assert anchor == "#d1-0600" # Verify it was saved - log = load_log(player_id="test", base_path=tmp_path) + log = load_log(world_id="test", base_path=tmp_path) assert len(log.current_entries) == 1 -- 2.51.2