diff --git a/CLAUDE.md b/CLAUDE.md index 9300f28..0175e2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,44 +26,74 @@ TUI Interface → DM Engine → World State (Markdown) + Rules Engine (5e SRD) ## File Structure +The repo holds code, prompts, and the shipped 5e SRD. Campaign data +(worlds, players, vector indices) lives outside the repo at +`~/.storied/` so backups and editing are simple and `git status` stays +clean. + ``` -storied/ -├── design/ # Architecture and design docs -├── plans/ # Implementation plans (in-progress work) -├── prompts/ # All LLM prompts (system prompts, formatting instructions) -├── src/storied/ # Python package -│ ├── engine.py # DM engine core -│ ├── claude.py # Claude subprocess driver (stream_with_tools, run_with_tools, run_prompt) -│ ├── tools.py # MCP tool implementations -│ ├── planner.py # World seeding, planning, ticking -│ ├── cli.py # CLI entry point -│ └── log.py # Campaign log and timekeeping -├── rules/ # Base game system (5e SRD) -├── worlds/ # World-specific content (gitignored) -├── players/ # Player/character state (gitignored) -├── tests/ # Test suite +storied/ # repo +├── design/ # Architecture and design docs +├── plans/ # Implementation plans (in-progress work) +├── prompts/ # All LLM prompts (system prompts, formatting) +├── src/storied/ # Python package +│ ├── engine.py # DM engine core +│ ├── paths.py # Filesystem path getters and configure() +│ ├── claude.py # Claude subprocess driver +│ ├── planner.py # World seeding, planning, ticking +│ ├── cli.py # CLI entry point +│ ├── log.py # Campaign log and timekeeping +│ └── tools/ # MCP tool implementations (per-module) +├── rules/ # Shipped 5e SRD (`make srd`) +├── tests/ # Test suite └── pyproject.toml + +~/.storied/ # data home (configurable; see below) +├── worlds/{world_id}/ # Campaign-specific content + vector index +├── players/{player_id}/ # Character sheets, notes, sessions +└── rules/ # Optional user-level homebrew overlay + └── {content_type}/ # e.g. monsters/goblin.md ``` +The data home defaults to `~/.storied/`. Override with the +`--base-path` flag on `storied play` / `storied reset`, or set the +`STORIED_HOME` environment variable. The CLI calls +`storied.paths.configure(...)` once at startup; everything downstream +reads from module getters (`worlds_path()`, `player_path(id)`, etc.). + ## Prompts -All LLM prompts live in `prompts/` as markdown files, loaded at runtime via `load_prompt(name)`. Don't put prompt text inline in Python code — if it's instructions for a model, it goes in `prompts/`. Tool docstrings in `tools.py` are the exception since they're tightly coupled to the function signatures and JSON schemas. +All LLM prompts live in `prompts/` as markdown files, loaded at runtime via `load_prompt(name)`. Don't put prompt text inline in Python code — if it's instructions for a model, it goes in `prompts/`. Tool docstrings in the `tools/*.py` modules are the exception since they're tightly coupled to the function signatures and JSON schemas. ## Content Layers -Game content is organized in layers that overlay each other (see `design/content-layers.md`): +Game content is organized in three layers that overlay each other, +with priority **world > user > shipped** (first match wins). See +`design/content-layers.md` for the spec. ``` -players/{player}/ → Character state (not shared content) -worlds/{world}/ → Campaign-specific (can override rules) -rules/{system}/ → Base game system (5e SRD) +~/.storied/worlds/{world_id}/ → Campaign-specific (overrides everything) +~/.storied/rules/ → Personal homebrew (overrides shipped) +/rules/srd-5.2.1/sections/ → Stock 5e SRD (built via `make srd`) ``` -When resolving content (monsters, spells, items), world layer is checked first, then rules. This allows worlds to have custom monsters or override base content. +- **World layer** (flat): per-campaign overrides AND narrative content + (NPCs, locations, factions, threads, lore, maps). A campaign-specific + goblin lives at `~/.storied/worlds/my-game/monsters/goblin.md`. +- **User layer** (flat): your personal homebrew that applies across + every campaign. Drop a homebrew spell at + `~/.storied/rules/spells/my-spell.md` and it shows up in `recall` + for every world. +- **Shipped layer** (nested): the stock 5e SRD bundled with the repo. + +The vector index (per-world `search.db`) tags rows by source — +`srd`, `user`, or `world`. `recall(scope="rules")` covers all three +sources; `recall(scope="world")` covers only world-tagged hits. -- `rules/` - Processed SRD, built via `make srd` -- `worlds/` - Gitignored, campaign-specific content -- `players/` - Gitignored, character data +**After adding files to `~/.storied/rules/` for the first time**, run +`storied index rebuild -w {world}` to pick them up. The index is +populated lazily on first use, so existing worlds need an explicit +rebuild to see new homebrew content. ## World File Format @@ -86,15 +116,15 @@ See `design/content-layers.md` for full specification. ## Player State -Player data lives in `players/{player_id}/`: +Player data lives in `~/.storied/players/{player_id}/`: ``` -players/{player_id}/ +~/.storied/players/{player_id}/ ├── character.yaml # Stats, class, abilities -├── inventory.yaml # Items carried -├── journal.md # Personal notes, discoveries -├── relationships.yaml # NPC relationship tracking -└── session_log.md # Running narrative history +├── character.md # Free-text backstory +├── notes.md # Appending journal of player observations +├── session.md # Current situation, location, present, threads +└── worlds/{world_id}/ # Player's discovered knowledge per world ``` Separate from world state to support multiple characters and future multiplayer. diff --git a/design/architecture.md b/design/architecture.md index 62c0030..c8eedbd 100644 --- a/design/architecture.md +++ b/design/architecture.md @@ -179,22 +179,81 @@ turns. ### Vector Index (`search.py`) -sqlite-vec + BGE-small embeddings over three corpora with source tags: -`srd`, `world`, `player`. Age-decay scoring favors recent world beats; -`recall` accepts a scope filter (`rules` / `world` / `all`) so the DM can -target the right layer. The index reseeds from a pre-built SRD sqlite -snapshot when present, otherwise re-indexes from markdown sections. +sqlite-vec + BGE-small embeddings over content tagged by source — +`srd` (shipped), `user` (homebrew), `world` (campaign-specific), and +`player` (player knowledge / discoveries). Age-decay scoring favors +recent world beats. `recall` accepts a scope filter: + +- `scope="rules"` → searches `srd + user + world` (rule content can + live at any of the three layers) +- `scope="world"` → narrative-only world content +- `scope="all"` → no filter + +`VectorIndex.search` accepts `source_filter: str | list[str] | None` +for OR semantics across multiple sources. The index reseeds from a +pre-built SRD sqlite snapshot when present, otherwise re-indexes from +markdown sections, then appends user homebrew and world content on +top. + +### Path Configuration (`paths.py`) + +Storied is "one process, one game." Filesystem paths live in module +globals on `storied.paths`, set once at CLI startup via `configure(...)`. +Library code reads via getters — `data_home()`, `worlds_path()`, +`players_path()`, `world_path(id)`, `player_path(id)`, `user_rules_path()`, +`shipped_rules_path()`. There is no `base_path` parameter threaded +through anything; each function asks the module for the specific path +it needs. + +```python +from storied.paths import configure, data_home, world_path + +# CLI startup (once) +configure(data_home=Path.home() / ".storied") + +# Library code (anywhere) +def load_session(player_id: str) -> dict | None: + path = player_path(player_id) / "session.md" + ... +``` + +Two independent globals back the configuration: + +- **`_data_home`** (default `~/.storied`) holds worlds, players, + sessions, transcripts, vector indices. +- **`_user_rules_home`** (default `/rules`) holds personal + homebrew. Sandbox mode points the data home at a tempdir but leaves + the user-rules home at the real `~/.storied/rules/` so your + homebrew is still available in throwaway sessions. + +`STORIED_HOME` env var override; `--base-path` CLI flag overrides +that. The `storied.paths.using_data_home(path)` context manager is +the test-friendly form (used by the autouse `_isolate_storied_paths` +fixture in `tests/conftest.py` to point each test at its own +`tmp_path`). + +Threads inherit module globals automatically — no `contextvars` +gymnastics. Subprocesses inherit via `STORIED_HOME` in the env dict +(exported in `cmd_play` for any future child process that re-enters +storied). ## Content Layers -See `content-layers.md` for the full spec. Summary: +See `content-layers.md` for the full spec. Three layers, priority +**world > user > shipped** (first match wins): ``` -players/{player}/ → character state, notes, knowledge -worlds/{world}/ → campaign-specific entities, can override rules -rules/srd-5.2.1/ → base 5e SRD +~/.storied/worlds/{world}/ → campaign-specific (top priority) +~/.storied/rules/ → personal homebrew (overrides shipped) +/rules/srd-5.2.1/sections/ → stock 5e SRD ``` +`ContentResolver.find()` walks all three layers for any content type +that might appear at multiple layers (monsters, spells, etc.). +Narrative content (npcs, locations, factions, threads, lore, maps) +only exists at the world layer; the upper-layer lookups miss +harmlessly. + Entity model (Is/Was/Knows/Wants/Will) is narrative, not mechanical. A door can "want" to stay closed. A coin can "know" where it was forged. The model gives the DM rich material to act on without encoding literal diff --git a/players/.gitkeep b/players/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/storied/advancement.py b/src/storied/advancement.py index cc47a2e..1240c73 100644 --- a/src/storied/advancement.py +++ b/src/storied/advancement.py @@ -12,6 +12,7 @@ from storied.claude import run_with_tools from storied.engine import load_prompt from storied.log import CampaignLog from storied.mcp_server import start_server as start_mcp_server +from storied.paths import data_home from storied.session import load_session @@ -29,13 +30,12 @@ class AdvancementResult: def build_advancement_context( world_id: str, player_id: str, - base_path: Path, ) -> str | None: """Build context for the advancement evaluator. Returns None if there's no character to evaluate. """ - character = load_character(player_id, base_path) + character = load_character(player_id) if character is None: return None @@ -46,7 +46,7 @@ def build_advancement_context( parts.append(char_context) # Campaign log — entries since last level-up - log = CampaignLog(world_id, base_path) + log = CampaignLog(world_id) parts.append(f"## Campaign Time: {log.get_current_time()}") entries_since_level = log.get_entries_since_tag("level") @@ -68,7 +68,7 @@ def build_advancement_context( parts.append("\n".join(lines)) # Session state for thread context - session = load_session(player_id, base_path) + session = load_session(player_id) if session: body = session.get("body", "") if body: @@ -80,13 +80,10 @@ def build_advancement_context( def evaluate_advancement( world_id: str = "default", player_id: str = "default", - base_path: Path | None = None, model: str = "claude-opus-4-6", on_progress: Callable[[str], None] | None = None, ) -> AdvancementResult: """Evaluate whether the character has earned a level-up.""" - if base_path is None: - base_path = Path.cwd() def progress(msg: str) -> None: if on_progress: @@ -94,7 +91,7 @@ def evaluate_advancement( start_time = time.monotonic() - character = load_character(player_id, base_path) + character = load_character(player_id) if character is None: progress("Skipped (no character)") return AdvancementResult(elapsed=time.monotonic() - start_time) @@ -104,7 +101,6 @@ def evaluate_advancement( char_name = character.get("identity", {}).get("name", "The character") notifications.append( world_id, - base_path, f"Reminder: {char_name} is still pending advancement to " f"level {pending_level}. The next narratively appropriate " f"moment — a rest, a quiet pause, after a triumph — should " @@ -113,16 +109,16 @@ def evaluate_advancement( progress(f"Posted reminder: pending level {pending_level}") return AdvancementResult(elapsed=time.monotonic() - start_time) - context = build_advancement_context(world_id, player_id, base_path) + context = build_advancement_context(world_id, player_id) if context is None: progress("Skipped (no context)") return AdvancementResult(elapsed=time.monotonic() - start_time) system_prompt = load_prompt("xp-evaluator") - campaign_log = CampaignLog(world_id, base_path) + campaign_log = CampaignLog(world_id) mcp = start_mcp_server( - world_id, player_id, base_path, "advancement", campaign_log, + world_id, player_id, "advancement", campaign_log, ) progress(f"Evaluating advancement with {model}...") @@ -137,7 +133,7 @@ def evaluate_advancement( mcp_url=mcp.url, model=model, on_tool_start=on_tool, - cwd=base_path, + cwd=data_home(), ) result = AdvancementResult( @@ -163,13 +159,11 @@ class BackgroundAdvancement: self, world_id: str, player_id: str, - base_path: Path, model: str = "claude-opus-4-6", interval: int = 5, ): self._world_id = world_id self._player_id = player_id - self._base_path = base_path self._model = model self._interval = interval self._turn_count: int = 0 @@ -203,7 +197,6 @@ class BackgroundAdvancement: self._result = evaluate_advancement( world_id=self._world_id, player_id=self._player_id, - base_path=self._base_path, model=self._model, ) diff --git a/src/storied/character/data.py b/src/storied/character/data.py index db9b724..90521a3 100644 --- a/src/storied/character/data.py +++ b/src/storied/character/data.py @@ -6,6 +6,7 @@ from pathlib import Path import yaml from storied.character.schema import coerce_character, validate_for_write +from storied.paths import player_path # Default character schema — used when creating a new character @@ -59,19 +60,15 @@ DEFAULT_SCHEMA: dict = { } -def _character_yaml_path(player_id: str, base_path: Path | None = None) -> Path: - if base_path is None: - base_path = Path.cwd() - return base_path / "players" / player_id / "character.yaml" +def _character_yaml_path(player_id: str) -> Path: + return player_path(player_id) / "character.yaml" -def _character_md_path(player_id: str, base_path: Path | None = None) -> Path: - if base_path is None: - base_path = Path.cwd() - return base_path / "players" / player_id / "character.md" +def _character_md_path(player_id: str) -> Path: + return player_path(player_id) / "character.md" -def load_character(player_id: str, base_path: Path | None = None) -> dict | None: +def load_character(player_id: str) -> dict | None: """Load a character's structured data from character.yaml. Returns None if no character exists. Returns the parsed YAML dict, with @@ -80,7 +77,7 @@ def load_character(player_id: str, base_path: Path | None = None) -> dict | None (e.g. resources stored as a list) are coerced into the canonical shape so the existing character keeps working. """ - yaml_path = _character_yaml_path(player_id, base_path) + yaml_path = _character_yaml_path(player_id) if not yaml_path.exists(): return None @@ -89,22 +86,22 @@ def load_character(player_id: str, base_path: Path | None = None) -> dict | None return _merge_defaults(coerced) -def load_character_prose(player_id: str, base_path: Path | None = None) -> str: +def load_character_prose(player_id: str) -> str: """Load the character's free-text prose from character.md. Returns empty string if no prose file exists. """ - md_path = _character_md_path(player_id, base_path) + md_path = _character_md_path(player_id) if not md_path.exists(): return "" return md_path.read_text() def save_character( - player_id: str, data: dict, base_path: Path | None = None + player_id: str, data: dict ) -> None: """Save character data back to character.yaml.""" - yaml_path = _character_yaml_path(player_id, base_path) + yaml_path = _character_yaml_path(player_id) yaml_path.parent.mkdir(parents=True, exist_ok=True) yaml_path.write_text( yaml.dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False) @@ -112,10 +109,10 @@ def save_character( def save_character_prose( - player_id: str, prose: str, base_path: Path | None = None + player_id: str, prose: str ) -> None: """Save the character's free-text prose to character.md.""" - md_path = _character_md_path(player_id, base_path) + md_path = _character_md_path(player_id) md_path.parent.mkdir(parents=True, exist_ok=True) md_path.write_text(prose) @@ -142,8 +139,7 @@ def _deep_update(target: dict, source: dict) -> None: def update_character( player_id: str, - updates: dict, - base_path: Path | None = None, + updates: dict ) -> str: """Update fields in character.yaml using dot notation. @@ -154,7 +150,7 @@ def update_character( is unchanged) and the returned message tells the DM what's wrong so they can retry with the correct shape. """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -187,7 +183,7 @@ def update_character( f"Character on disk is unchanged." ) - save_character(player_id, data, base_path) + save_character(player_id, data) return "Character updated: " + ", ".join(changes) @@ -229,8 +225,7 @@ def create_character( proficiencies: dict | None = None, features: list[dict] | None = None, equipment: dict | None = None, - backstory: str | None = None, - base_path: Path | None = None, + backstory: str | None = None ) -> str: """Create a new character with the new schema. @@ -257,9 +252,9 @@ def create_character( if equipment: data["equipment"] = equipment - save_character(player_id, data, base_path) + save_character(player_id, data) if backstory: - save_character_prose(player_id, f"# {name}\n\n{backstory}\n", base_path) + save_character_prose(player_id, f"# {name}\n\n{backstory}\n") return f"Created character '{name}' - a level {level} {race} {char_class}!" diff --git a/src/storied/character/operations.py b/src/storied/character/operations.py index a03888d..85c8004 100644 --- a/src/storied/character/operations.py +++ b/src/storied/character/operations.py @@ -11,6 +11,7 @@ from storied.character.data import ( load_character, save_character, ) +from storied.paths import player_path # --- HP operations --- @@ -19,8 +20,7 @@ from storied.character.data import ( def damage( player_id: str, amount: int, - damage_type: str | None = None, - base_path: Path | None = None, + damage_type: str | None = None ) -> str: """Apply raw damage to the character. @@ -29,7 +29,7 @@ def damage( resistances, vulnerabilities, or immunities. The DM pre-applies the math per whatever ruleset they're running. """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" if amount < 0: @@ -46,7 +46,7 @@ def damage( hp["current"] = max(0, hp["current"] - remaining) - save_character(player_id, data, base_path) + save_character(player_id, data) type_str = f" {damage_type}" if damage_type else "" parts = [f"Took {amount}{type_str} damage"] @@ -63,11 +63,10 @@ def damage( def heal( player_id: str, - amount: int, - base_path: Path | None = None, + amount: int ) -> str: """Heal the character, clamped to max HP.""" - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" if amount < 0: @@ -78,7 +77,7 @@ def heal( hp["current"] = min(hp["max"], hp["current"] + amount) actual = hp["current"] - before - save_character(player_id, data, base_path) + save_character(player_id, data) return f"Healed {actual} HP. HP: {hp['current']}/{hp['max']}" @@ -90,8 +89,7 @@ def add_effect( source: str, description: str, expires: str | None = None, - concentration: bool = False, - base_path: Path | None = None, + concentration: bool = False ) -> str: """Add a temporary effect to the character. @@ -100,7 +98,7 @@ def add_effect( The tool does not enforce uniqueness; the DM decides when to drop an effect (via `remove_effect`). """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -113,7 +111,7 @@ def add_effect( effect["concentration"] = True effects.append(effect) - save_character(player_id, data, base_path) + save_character(player_id, data) parts = [f"Effect added: {source} — {description}"] if expires: @@ -125,11 +123,10 @@ def add_effect( def remove_effect( player_id: str, - source: str, - base_path: Path | None = None, + source: str ) -> str: """Remove an effect by source name (case-insensitive substring match).""" - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -139,7 +136,7 @@ def remove_effect( for i, e in enumerate(effects): if needle in e.get("source", "").lower(): removed = effects.pop(i) - save_character(player_id, data, base_path) + save_character(player_id, data) return f"Effect removed: {removed.get('source', '?')}" return f"No effect matching '{source}' found" @@ -150,11 +147,10 @@ def remove_effect( def add_condition( player_id: str, - name: str, - base_path: Path | None = None, + name: str ) -> str: """Add a condition to the character (no duplicates).""" - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -164,17 +160,16 @@ def add_condition( return f"Already has condition: {name}" conditions.append(name) - save_character(player_id, data, base_path) + save_character(player_id, data) return f"Condition added: {name}" def remove_condition( player_id: str, - name: str, - base_path: Path | None = None, + name: str ) -> str: """Remove a condition (case-insensitive).""" - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -183,7 +178,7 @@ def remove_condition( for i, c in enumerate(conditions): if c.lower() == needle: removed = conditions.pop(i) - save_character(player_id, data, base_path) + save_character(player_id, data) return f"Condition removed: {removed}" return f"No condition matching '{name}' found" @@ -195,8 +190,7 @@ def remove_condition( def add_item( player_id: str, item: str, - location: str | None = None, - base_path: Path | None = None, + location: str | None = None ) -> str: """Add an item to a location in the equipment dict. @@ -204,7 +198,7 @@ def add_item( doesn't exist (using the given name as-is). If location is omitted, uses the first existing location, or 'on_person' as a default. """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -230,17 +224,16 @@ def add_item( equipment[target_key] = [] equipment[target_key].append(item) - save_character(player_id, data, base_path) + save_character(player_id, data) return f"Added '{item}' to {target_key}" def remove_item( player_id: str, - item: str, - base_path: Path | None = None, + item: str ) -> str: """Remove an item by case-insensitive substring match across all locations.""" - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -251,7 +244,7 @@ def remove_item( for i, existing in enumerate(items): if needle in existing.lower(): removed = items.pop(i) - save_character(player_id, data, base_path) + save_character(player_id, data) return f"Removed '{removed}' from {location}" return f"No item matching '{item}' found" @@ -260,8 +253,7 @@ def remove_item( def set_item_status( player_id: str, item: str, - status: str, - base_path: Path | None = None, + status: str ) -> str: """Set a magic item's status (attuned, equipped, carried). @@ -272,7 +264,7 @@ def set_item_status( if status not in valid_statuses: return f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}" - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -295,7 +287,7 @@ def set_item_status( # Add to the new status magic_items[status].append(wikilink) - save_character(player_id, data, base_path) + save_character(player_id, data) return f"{item} is now {status}" @@ -305,15 +297,14 @@ def set_item_status( def adjust_resource( player_id: str, name: str, - delta: int, - base_path: Path | None = None, + delta: int ) -> str: """Adjust a resource pool by a delta. Negative = use (clamped to 0), positive = restore (clamped to max). Substring match on the resource name or its notes field. """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -336,7 +327,7 @@ def adjust_resource( pool["current"] = new_current actual = new_current - before - save_character(player_id, data, base_path) + save_character(player_id, data) notes = pool.get("notes", target) if delta < 0: @@ -353,8 +344,7 @@ def adjust_resource( def rest( player_id: str, - rest_type: str, - base_path: Path | None = None, + rest_type: str ) -> str: """Take a short or long rest. Refreshes resources by refresh type. @@ -365,7 +355,7 @@ def rest( if rest_type not in ("short", "long"): return f"Invalid rest type '{rest_type}'. Must be 'short' or 'long'." - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -404,7 +394,7 @@ def rest( hp["current"] = hp["max"] msg_parts.append(f"HP restored to {hp['max']}.") - save_character(player_id, data, base_path) + save_character(player_id, data) return " ".join(msg_parts) @@ -413,14 +403,13 @@ def rest( def adjust_coins( player_id: str, - deltas: dict[str, int], - base_path: Path | None = None, + deltas: dict[str, int] ) -> str: """Apply relative coin changes (positive=gain, negative=spend). Each denomination is clamped to 0 minimum. """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -441,7 +430,7 @@ def adjust_coins( changes.append(f"{denom} {old} → {new}") purse[denom] = new - save_character(player_id, data, base_path) + save_character(player_id, data) coins = [] for denom in ("pp", "gp", "ep", "sp", "cp"): @@ -459,8 +448,7 @@ def level_up( new_level: int, hp_gain: int, features: list[dict] | None = None, - time_anchor: str | None = None, - base_path: Path | None = None, + time_anchor: str | None = None ) -> str: """Atomically level up the character. @@ -472,7 +460,7 @@ def level_up( The DM should look up the new-level class features before calling this and pass the full replacement features list. """ - data = load_character(player_id, base_path) + data = load_character(player_id) if data is None: return f"No character found for player '{player_id}'" @@ -518,7 +506,7 @@ def level_up( if "advancement_ready" in data: data["advancement_ready"] = None - save_character(player_id, data, base_path) + save_character(player_id, data) parts = [ f"Level up! {class_name} {old_level} → {new_level}.", @@ -534,13 +522,10 @@ def level_up( def add_note( player_id: str, text: str, - time_anchor: str | None = None, - base_path: Path | None = None, + time_anchor: str | None = None ) -> str: """Append a note to the player's notes.md file.""" - if base_path is None: - base_path = Path.cwd() - notes_path = base_path / "players" / player_id / "notes.md" + notes_path = player_path(player_id) / "notes.md" notes_path.parent.mkdir(parents=True, exist_ok=True) prefix = f"{time_anchor} | " if time_anchor else "" diff --git a/src/storied/cli.py b/src/storied/cli.py index 92a9e1e..83cf823 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -22,16 +22,17 @@ SLASH_COMMANDS = { def _format_character_display( player_id: str, full: bool, - base_path: Path | None = None, ) -> str | None: """Format character data for /status or /me display. - `base_path` must be threaded through from cmd_play so sandbox sessions - read the sandbox character.yaml instead of the cwd default. + Path resolution comes from ``storied.paths`` module globals (set + once at startup via ``configure``); sandbox sessions get the + sandbox character.yaml because the data home was overridden in + cmd_play before this function is called. """ from storied.character import format_sheet, format_status, load_character - data = load_character(player_id, base_path=base_path) + data = load_character(player_id) if data is None: return None return format_sheet(data) if full else format_status(data) @@ -156,12 +157,22 @@ def cmd_reset(args: argparse.Namespace) -> int: """Reset player and world state to start fresh.""" import shutil - base_path = Path.cwd() + from storied import paths + from storied.paths import ( + configure, + data_home, + player_path, + resolve_data_home, + world_path, + ) + + configure(data_home=resolve_data_home(getattr(args, "base_path", None))) + base_path = data_home() player_id = args.player or "default" world_id = args.world or "default" - player_dir = base_path / "players" / player_id - world_dir = base_path / "worlds" / world_id + player_dir = player_path(player_id) + world_dir = world_path(world_id) # Check what exists has_player = player_dir.exists() @@ -239,19 +250,32 @@ def cmd_play(args: argparse.Namespace) -> int: player_id = "default" sandbox = getattr(args, "sandbox", False) - # Sandbox mode: throwaway session in a temp directory - sandbox_dir: Path | None = None + from storied.paths import configure, data_home, resolve_data_home + + # Sandbox mode: throwaway worlds + players in a temp directory. + # User homebrew rules stay pointed at the real ~/.storied/rules/ + # so the sandbox isn't a pristine playground — it's still "your + # rules", just with a fresh world to mess around in. if sandbox: sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) (sandbox_dir / "worlds" / world_id).mkdir(parents=True) (sandbox_dir / "players" / player_id).mkdir(parents=True) - # Symlink rules/ from cwd so recall(scope="rules") works in the - # sandbox without rebuilding the SRD index from scratch. - rules_src = Path.cwd() / "rules" - if rules_src.exists(): - (sandbox_dir / "rules").symlink_to(rules_src.resolve()) + configure( + data_home=sandbox_dir, + user_rules_home=Path.home() / ".storied" / "rules", + ) + else: + configure( + data_home=resolve_data_home(getattr(args, "base_path", None)) + ) + data_home().mkdir(parents=True, exist_ok=True) - base_path = sandbox_dir if sandbox else None + # Export STORIED_HOME so any subprocess that re-enters storied + # (e.g. via run_code) sees the same data directory. + import os + os.environ["STORIED_HOME"] = str(data_home()) + + base_path = data_home() creation_mode = False if sandbox: @@ -319,10 +343,19 @@ def cmd_play(args: argparse.Namespace) -> int: player_id=player_id, prompt_name=prompt_name, transcript_path=transcript_path, - base_path=base_path, ) engine.debug = args.debug + # Debug mode: dump the base system prompt once at startup. The + # per-turn context is dumped separately before each stream_action + # call below. Between them, the user sees exactly what the DM + # subprocess is seeing each turn. + if args.debug: + console.print(Rule("System Prompt", style="dim")) + console.print(engine._base_prompt, style="dim", highlight=False) + console.print(Rule(style="dim")) + console.print() + # Background ticker for mid-session world advancement ticker = None advancement = None @@ -333,7 +366,6 @@ def cmd_play(args: argparse.Namespace) -> int: ticker = BackgroundTicker( world_id=world_id, player_id=player_id, - base_path=base_path or Path.cwd(), ) # Kick off initial tick in background ticker.maybe_tick(engine._campaign_log) @@ -341,7 +373,6 @@ def cmd_play(args: argparse.Namespace) -> int: advancement = BackgroundAdvancement( world_id=world_id, player_id=player_id, - base_path=base_path or Path.cwd(), ) # If in creation mode, start the conversation @@ -496,7 +527,7 @@ def cmd_play(args: argparse.Namespace) -> int: if action.strip().lower() in ("/status", "/me"): is_full = action.strip().lower() == "/me" formatted = _format_character_display( - player_id, full=is_full, base_path=base_path, + player_id, full=is_full, ) if formatted: console.print() @@ -522,7 +553,7 @@ def cmd_play(args: argparse.Namespace) -> int: from storied.session import load_session console.print() game_time = engine.get_current_time() - session = load_session(player_id, base_path=base_path) + session = load_session(player_id) location = (session or {}).get("location", "unknown") console.print( f"[green]Saved.[/green] [dim]{game_time} · {location}[/dim]" @@ -560,7 +591,6 @@ def cmd_play(args: argparse.Namespace) -> int: player_id, note_msg, time_anchor=time_anchor, - base_path=base_path, ) console.print() console.print( @@ -570,6 +600,21 @@ def cmd_play(args: argparse.Namespace) -> int: try: console.print(Rule(style="dim blue")) + + # Debug mode: dump the per-turn context before the + # response streams. `stream_action` rebuilds it + # internally, but the double-build is cheap and keeps + # the debug path a pure observer. + if args.debug: + console.print(Rule("Turn Context", style="dim")) + console.print( + engine._build_context(), + style="dim", + highlight=False, + ) + console.print(Rule(style="dim")) + console.print() + renderer = StreamRenderer(console) prev_type: str | None = None got_text = False @@ -614,6 +659,7 @@ def cmd_play(args: argparse.Namespace) -> int: if ticker: tick_result = ticker.pop_result() if tick_result and tick_result.tool_calls > 0: + console.print() console.print( f"[dim]The world shifted while you considered your next move. " f"({tick_result.tool_calls} changes)[/dim]" @@ -713,17 +759,19 @@ def cmd_index_srd(args: argparse.Namespace) -> int: def cmd_index_world(args: argparse.Namespace) -> int: - """Build search index for a world (includes SRD via seed copy).""" + """Build search index for a world: SRD seed + user homebrew + world content.""" + from storied import paths from storied.search import VectorIndex world_id = args.world or "default" - world_dir = Path(f"worlds/{world_id}") + world_dir = paths.world_path(world_id) if not world_dir.exists(): print(f"World not found at {world_dir}") return 1 db_path = world_dir / "search.db" - srd_seed = Path("rules/srd-5.2.1/search.db") + srd_root = paths.shipped_rules_path() / "srd-5.2.1" + srd_seed = srd_root / "search.db" # Seed from pre-built SRD index if available if srd_seed.exists() and not db_path.exists(): @@ -734,7 +782,18 @@ def cmd_index_world(args: argparse.Namespace) -> int: else: index = VectorIndex(db_path) if not srd_seed.exists(): - print("SRD index not found. Run 'storied index srd' first for faster setup.") + srd_dir = srd_root / "sections" + if srd_dir.exists(): + print(f"Indexing SRD from {srd_dir}...") + srd_count = index.reindex_directory(srd_dir, source="srd") + print(f" {srd_count} SRD chunks") + + # User homebrew layer (flat: /{content_type}/*.md) + user_rules = paths.user_rules_path() + if user_rules.exists(): + print(f"Indexing user homebrew from {user_rules}...") + user_count = index.reindex_directory(user_rules, source="user") + print(f" {user_count} user homebrew chunks") # Index world content on top print(f"Indexing world from {world_dir}...") @@ -748,10 +807,11 @@ def cmd_index_world(args: argparse.Namespace) -> int: def cmd_index_status(args: argparse.Namespace) -> int: """Show search index status.""" + from storied import paths from storied.search import VectorIndex world_id = args.world or "default" - db_path = Path(f"worlds/{world_id}/search.db") + db_path = paths.world_path(world_id) / "search.db" if not db_path.exists(): print(f"No search index at {db_path}") print(f"Run 'storied index world -w {world_id}' to create one.") @@ -769,9 +829,18 @@ def cmd_index_status(args: argparse.Namespace) -> int: def cmd_index_rebuild(args: argparse.Namespace) -> int: - """Force full rebuild of a world's search index.""" + """Force full rebuild of a world's search index. + + Drops the existing ``search.db`` and rebuilds from all three layers + (shipped SRD, user homebrew, world content). Run this after adding + homebrew files to ``~/.storied/rules/`` for the first time, or any + time you've edited content under ``~/.storied/rules/`` and want + those changes reflected in ``recall`` results. + """ + from storied import paths + world_id = args.world or "default" - db_path = Path(f"worlds/{world_id}/search.db") + db_path = paths.world_path(world_id) / "search.db" if db_path.exists(): db_path.unlink() print(f"Removed {db_path}") @@ -898,6 +967,14 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Throwaway session — no character, no world state", ) + play_parser.add_argument( + "--base-path", + type=Path, + help=( + "Where to store worlds and players (defaults to $STORIED_HOME " + "or ~/.storied/)" + ), + ) play_parser.set_defaults(func=cmd_play) # reset command @@ -915,6 +992,14 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Skip confirmation prompt", ) + reset_parser.add_argument( + "--base-path", + type=Path, + help=( + "Where worlds and players live (defaults to $STORIED_HOME or " + "~/.storied/)" + ), + ) reset_parser.set_defaults(func=cmd_reset) # seed command diff --git a/src/storied/content.py b/src/storied/content.py index 504a5ab..b7ed1c0 100644 --- a/src/storied/content.py +++ b/src/storied/content.py @@ -1,61 +1,84 @@ -"""Content layer resolution — finds and loads content across world/rules layers.""" +"""Content layer resolution — finds and loads content across world / user / rules layers.""" import re from pathlib import Path import yaml +from storied import paths -class ContentResolver: - """Resolves content across world and rules layers. - Content is searched in order: - 1. World layer: worlds/{world_id}/{content_type}/ - 2. Rules layer: rules/srd-5.2.1/sections/{content_type}/ +class ContentResolver: + """Resolves content across three layers with priority + **world > user > shipped**. + + 1. **World layer** (flat): ``/worlds/{world_id}/{content_type}/*.md`` + — campaign-specific overrides and narrative content. + 2. **User layer** (flat): ``/{content_type}/*.md`` — your + personal homebrew, applies across every campaign. + 3. **Shipped layer** (nested): ``/{rules_system}/sections/{content_type}/*.md`` + — the stock 5e SRD bundled with the repo. + + Narrative content (npcs, locations, factions, threads, lore, maps) + only exists at the world layer; layers 2 and 3 miss and the lookup + returns None. Rules content (monsters, spells, classes, etc.) can + live at any layer; the first match wins. """ def __init__( self, - base_path: Path | None = None, world_id: str | None = None, rules_system: str = "srd-5.2.1", ): - self.base_path = base_path or Path.cwd() self.world_id = world_id self.rules_system = rules_system - # Set up layer paths - if world_id: - self.world_path = self.base_path / "worlds" / world_id - else: - self.world_path = None - self.rules_path = self.base_path / "rules" / rules_system / "sections" + # ---- layer search roots ------------------------------------------------ - def _search_dirs(self, content_type: str | None) -> list[tuple[Path, str]]: - """Get directories to search in order, with their content types.""" + def _layer_roots(self) -> list[Path]: + """Return the layer search roots in priority order. + + Calls go through the ``paths`` module (not imported names) so + test fixtures can monkeypatch ``paths.shipped_rules_path`` to + redirect the shipped layer to a temp dir. + """ + roots: list[Path] = [] + if self.world_id: + roots.append(paths.world_path(self.world_id)) + roots.append(paths.user_rules_path()) + roots.append( + paths.shipped_rules_path() / self.rules_system / "sections" + ) + return roots + + def _search_dirs( + self, content_type: str | None + ) -> list[tuple[Path, str]]: + """Directories to search for content files, paired with their + content type label. Walks the three layers in priority order. + """ dirs: list[tuple[Path, str]] = [] if content_type: - # Search specific content type - if self.world_path: - dirs.append((self.world_path / content_type, content_type)) - dirs.append((self.rules_path / content_type, content_type)) + for root in self._layer_roots(): + dirs.append((root / content_type, content_type)) else: - # Search all content types - if self.world_path and self.world_path.exists(): - for subdir in self.world_path.iterdir(): - if subdir.is_dir(): - dirs.append((subdir, subdir.name)) - - if self.rules_path.exists(): - for subdir in self.rules_path.iterdir(): + # No content type specified — walk every type subdir in + # every layer that actually exists on disk. + for root in self._layer_roots(): + if not root.exists(): + continue + for subdir in root.iterdir(): if subdir.is_dir(): dirs.append((subdir, subdir.name)) return dirs + # ---- lookup ------------------------------------------------------------ + def find(self, name: str, content_type: str | None = None) -> Path | None: - """Find a content file by name. + """Find a content file by name. Returns the first hit across all + three layers in priority order (world > user > shipped). Args: name: The content name (e.g., 'goblin', 'fireball') @@ -64,7 +87,6 @@ class ContentResolver: Returns: Path to the content file, or None if not found """ - # Normalize name to filename format filename = f"{name.lower().replace(' ', '-')}.md" for search_dir, _ in self._search_dirs(content_type): @@ -97,9 +119,7 @@ class ContentResolver: """Parse a markdown file with optional YAML frontmatter.""" content = path.read_text() - # Check for YAML frontmatter if content.startswith("---"): - # Find the closing --- end_match = re.search(r"\n---\s*\n", content[3:]) if end_match: frontmatter_end = end_match.start() + 3 @@ -110,6 +130,4 @@ class ContentResolver: result["body"] = body.strip() return result - # No frontmatter, just body return {"body": content.strip()} - diff --git a/src/storied/engine.py b/src/storied/engine.py index c302444..36456a7 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -27,6 +27,7 @@ from storied.claude import ( from storied.mcp_server import start_server as start_mcp_server from storied.content import ContentResolver from storied.log import CampaignLog, TranscriptLog +from storied.paths import data_home, player_path, world_path from storied.session import ( extract_wiki_links, format_session_context, @@ -71,7 +72,6 @@ class DMEngine: self, world_id: str = "default", player_id: str = "default", - base_path: Path | None = None, model: str = "claude-opus-4-6", prompt_name: str = "dm-system", transcript_path: Path | None = None, @@ -79,7 +79,6 @@ class DMEngine: self.model = model self.world_id = world_id self.player_id = player_id - self.base_path = base_path or Path.cwd() # Session management self._session_id: str | None = None @@ -103,16 +102,15 @@ class DMEngine: transcript_path.parent.mkdir(parents=True, exist_ok=True) # Campaign log for time tracking (world-scoped, shared with MCP server) - self._campaign_log = CampaignLog(self.world_id, self.base_path) + self._campaign_log = CampaignLog(self.world_id) # Transcript log for conversation history - self._transcript = TranscriptLog(self.world_id, self.base_path) + self._transcript = TranscriptLog(self.world_id) # Start in-process MCP server (shares CampaignLog with engine) self._mcp = start_mcp_server( world_id=self.world_id, player_id=self.player_id, - base_path=self.base_path, tool_set="dm", campaign_log=self._campaign_log, ) @@ -179,14 +177,14 @@ class DMEngine: # 1. DM style tuning (player preferences for pacing, tone, focus) if self.world_id: - style_path = self.base_path / "worlds" / self.world_id / "style.md" + style_path = world_path(self.world_id) / "style.md" if style_path.exists(): style_content = style_path.read_text().strip() self._context_parts["Style"] = style_content parts.append(style_content) # 1. Character sheet - character = load_character(self.player_id, self.base_path) + character = load_character(self.player_id) if character: char_context = format_character_context(character) self._context_parts["Character"] = char_context @@ -214,7 +212,7 @@ class DMEngine: parts.append(transcript_context) # 4. Session state - session = load_session(self.player_id, self.base_path) + session = load_session(self.player_id) if session: session_context = format_session_context(session) self._context_parts["Session"] = session_context @@ -276,7 +274,7 @@ class DMEngine: # Notifications from background agents (planner, ticker, advancement) if self.world_id: - pending = notifications.drain(self.world_id, self.base_path) + pending = notifications.drain(self.world_id) if pending: notif_lines = ["## Recent World Changes\n"] notif_lines.extend(f"- {msg}" for msg in pending) @@ -306,7 +304,7 @@ class DMEngine: return None knowledge_dir = ( - self.base_path / "players" / self.player_id / "worlds" / self.world_id + player_path(self.player_id) / "worlds" / self.world_id ) if not knowledge_dir.exists(): return None @@ -352,7 +350,7 @@ class DMEngine: """Load world content by type and name.""" if not self.world_id: return None - resolver = ContentResolver(base_path=self.base_path, world_id=self.world_id) + resolver = ContentResolver(world_id=self.world_id) return resolver.load(name, content_type=content_type) def _find_entity(self, name: str) -> dict | None: @@ -369,7 +367,7 @@ class DMEngine: } # Fallback: filesystem scan - entity = load_entity_content(name, self.world_id, self.base_path) + entity = load_entity_content(name, self.world_id) if entity: return { "name": entity["name"], @@ -498,7 +496,7 @@ class DMEngine: mcp_url=self._mcp.url, model=self.model, resume_session_id=self._session_id, - cwd=self.base_path, + cwd=data_home(), ): match event: case TextDelta(text=text): diff --git a/src/storied/log.py b/src/storied/log.py index 8f981c9..2d2edcb 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -177,8 +177,10 @@ class CampaignLog: """ def __init__(self, world_id: str = "default", base_path: Path | None = None): + from storied.paths import data_home + self.world_id = world_id - self.base_path = base_path or Path.cwd() + self.base_path = base_path or data_home() self.log_dir = self.base_path / "worlds" / world_id / "log" # Load or initialize state @@ -495,7 +497,9 @@ class TranscriptLog: """ def __init__(self, world_id: str, base_path: Path | None = None): - self.base_path = base_path or Path.cwd() + from storied.paths import data_home + + self.base_path = base_path or data_home() self.transcript_dir = self.base_path / "worlds" / world_id / "transcripts" def _day_path(self, day: int) -> Path: diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py index 2100691..241b0e0 100644 --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -16,6 +16,7 @@ from pathlib import Path import uvicorn from fastmcp import FastMCP +from storied import paths from storied.log import CampaignLog from storied.search import VectorIndex from storied.tools import character, combat, entities, mechanics, run_code, scene @@ -63,15 +64,44 @@ class MCPServerHandle: self._thread.join(timeout=2) -def _populate_index(base_path: Path, world_dir: Path, vi: VectorIndex) -> None: - """Auto-populate an empty index from SRD seed + world content.""" - srd_seed = base_path / "rules" / "srd-5.2.1" / "search.db" +def _populate_index( + world_dir: Path, + vi: VectorIndex, + srd_root: Path | None = None, +) -> None: + """Auto-populate an empty index from all three content layers. + + Populates in priority-inverse order so higher-priority layers are + indexed last (letting the SRD seed fast-path do its job first): + + 1. Shipped SRD — ``/srd-5.2.1/`` via the prebuilt + ``search.db`` seed if present, else reindex the sections dir. + Tagged ``source="srd"``. + 2. User homebrew — ``/`` if the directory exists. + Tagged ``source="user"``. + 3. World content — ``/``. Tagged ``source="world"``. + + ``srd_root`` is an optional override for tests that want to point + the shipped layer at a tmp path. In production it resolves to + ``paths.shipped_rules_path() / "srd-5.2.1"``. + """ + # 1. Shipped SRD + if srd_root is None: + srd_root = paths.shipped_rules_path() / "srd-5.2.1" + srd_seed = srd_root / "search.db" if srd_seed.exists(): vi.reseed(srd_seed) else: - srd_dir = base_path / "rules" / "srd-5.2.1" / "sections" + srd_dir = srd_root / "sections" if srd_dir.exists(): vi.reindex_directory(srd_dir, source="srd") + + # 2. User homebrew — flat layout, source="user" + user_rules = paths.user_rules_path() + if user_rules.exists(): + vi.reindex_directory(user_rules, source="user") + + # 3. World content — flat layout, source="world" if world_dir.exists(): vi.reindex_directory(world_dir, source="world") @@ -133,7 +163,6 @@ async def _compose_server(role: str) -> FastMCP: def start_server( # pragma: no cover world_id: str, player_id: str, - base_path: Path, tool_set: str = "dm", campaign_log: CampaignLog | None = None, ) -> MCPServerHandle: @@ -143,6 +172,10 @@ def start_server( # pragma: no cover The server runs in a daemon thread and shares the process-global ToolContext (set via init_ctx) with the caller. + Paths are resolved via :mod:`storied.paths` (the data home is set + once at CLI startup via ``configure()``), so this function takes + no path parameters. + Not unit-tested: this is the live launcher that spins up uvicorn in a daemon thread and waits for the port to open. The pure compose path (`_compose_server`) and tool registry it builds are tested separately @@ -150,19 +183,18 @@ def start_server( # pragma: no cover mock, not the launcher. """ if campaign_log is None: - campaign_log = CampaignLog(world_id, base_path) + campaign_log = CampaignLog(world_id) - world_dir = base_path / "worlds" / world_id + world_dir = paths.world_path(world_id) vector_index = VectorIndex( world_dir / "search.db", - on_empty=lambda vi: _populate_index(base_path, world_dir, vi), + on_empty=lambda vi: _populate_index(world_dir, vi), ) ctx = init_ctx( world_id=world_id, player_id=player_id, - base_path=base_path, campaign_log=campaign_log, entity_index=EntityIndex(world_dir), vector_index=vector_index, diff --git a/src/storied/notifications.py b/src/storied/notifications.py index fe1dba4..1fa4375 100644 --- a/src/storied/notifications.py +++ b/src/storied/notifications.py @@ -2,34 +2,35 @@ Background agents (planner, ticker, advancement evaluator) append messages. The DM engine reads and clears them each turn via _build_context. + +Path resolution uses :func:`storied.paths.world_path` — all notifications +for a given world live at ``/dm_notifications.md``, under whatever +the current ``data_home`` is. """ import threading -from pathlib import Path - -_lock = threading.Lock() +from storied.paths import world_path -def _notifications_path(world_id: str, base_path: Path) -> Path: - return base_path / "worlds" / world_id / "dm_notifications.md" +_lock = threading.Lock() -def append(world_id: str, base_path: Path, message: str) -> None: +def append(world_id: str, message: str) -> None: """Append a notification for the DM to see next turn.""" - path = _notifications_path(world_id, base_path) + path = world_path(world_id) / "dm_notifications.md" with _lock: path.parent.mkdir(parents=True, exist_ok=True) with path.open("a") as f: f.write(f"- {message}\n") -def drain(world_id: str, base_path: Path) -> list[str]: +def drain(world_id: str) -> list[str]: """Read all pending notifications and clear the file. Returns a list of notification messages (without the leading "- "). """ - path = _notifications_path(world_id, base_path) + path = world_path(world_id) / "dm_notifications.md" with _lock: if not path.exists(): return [] diff --git a/src/storied/paths.py b/src/storied/paths.py new file mode 100644 index 0000000..186031f --- /dev/null +++ b/src/storied/paths.py @@ -0,0 +1,192 @@ +"""Filesystem path resolution for storied. + +Campaign data (worlds, players, campaign logs, vector search indices, +transcripts) lives under a single "data home" directory. By default +that's ``~/.storied/`` — tightly coupled, easy to back up, out of the +repo working tree. + +User homebrew rules (spells, monsters, etc. that apply across every +campaign you run) live at ``~/.storied/rules/`` by default — see +:func:`user_rules_path`. Sandbox mode overrides the data home to a +tempdir but keeps user rules pointed at the real directory so you +still have your homebrew available in a throwaway session. + +The shipped SRD is static reference content bundled with the repo +and resolved separately via :func:`shipped_rules_path`. + +## Configuration model + +Storied is "one process, one game." Path configuration is held in +module-level globals, set once at CLI startup via :func:`configure`. +Subsequent calls to the getters (:func:`data_home`, +:func:`worlds_path`, :func:`player_path`, etc.) return the configured +values. + +Threads (the uvicorn MCP server, background agents) inherit the +module globals automatically — no ``contextvars.copy_context()`` +gymnastics needed. Subprocesses that re-enter storied inherit the +``STORIED_HOME`` environment variable, which the CLI exports at +startup so child processes resolve the same path. +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +_DEFAULT_DATA_HOME = Path.home() / ".storied" + + +# Module globals — the single source of truth for path configuration. +# Defaults are set here and overridden by :func:`configure` at CLI +# startup. Library code reads via the getter functions below; do NOT +# import these names directly. +_data_home: Path = _DEFAULT_DATA_HOME +_user_rules_home: Path = _DEFAULT_DATA_HOME / "rules" + + +# Backwards compatibility: older code imports DEFAULT_DATA_HOME +# directly. Retire this re-export once all callers are migrated. +DEFAULT_DATA_HOME = _DEFAULT_DATA_HOME + + +def configure( + *, + data_home: Path | None = None, + user_rules_home: Path | None = None, +) -> None: + """Set the process-wide path configuration. + + Called once at CLI startup. In normal mode, ``data_home`` is + passed and ``user_rules_home`` is left alone — it's computed as + ``data_home / "rules"`` to keep the two in sync. + + In sandbox mode the caller passes both: ``data_home`` points at + a tempdir (worlds/players are throwaway) while + ``user_rules_home`` points at the real ``~/.storied/rules/`` so + the user's homebrew stays available in the sandbox. + + If ``data_home`` is passed without ``user_rules_home``, the user + rules home is updated to ``data_home / "rules"`` so a + ``--base-path /elsewhere`` flag moves both in lockstep. + """ + global _data_home, _user_rules_home + if data_home is not None: + _data_home = Path(data_home).expanduser().resolve() + if user_rules_home is None: + _user_rules_home = _data_home / "rules" + if user_rules_home is not None: + _user_rules_home = Path(user_rules_home).expanduser().resolve() + + +def resolve_data_home(explicit: Path | None = None) -> Path: + """Resolve the storied data directory for CLI commands. + + Priority: ``explicit`` arg > ``$STORIED_HOME`` env > ``~/.storied/``. + The returned path is expanded and absolutized so downstream code + can use it without worrying about ``~`` or relative bits. + """ + if explicit is not None: + return Path(explicit).expanduser().resolve() + env = os.environ.get("STORIED_HOME") + if env: + return Path(env).expanduser().resolve() + return _DEFAULT_DATA_HOME + + +@contextmanager +def using_data_home(path: Path) -> Iterator[Path]: + """Temporarily override ``data_home`` (and ``user_rules_home``) + for the duration of a block. + + For tests and ad-hoc scripts. Restores the previous configuration + on exit. Not thread-safe — don't use it to run multiple + configurations in parallel within the same process. + + Example:: + + with using_data_home(tmp_path): + load_character("default") + """ + global _data_home, _user_rules_home + prev_data = _data_home + prev_user = _user_rules_home + _data_home = Path(path).expanduser().resolve() + _user_rules_home = _data_home / "rules" + try: + yield _data_home + finally: + _data_home = prev_data + _user_rules_home = prev_user + + +# --------------------------------------------------------------------------- +# Data-home getters: campaign state, character sheets, etc. +# --------------------------------------------------------------------------- + + +def data_home() -> Path: + """The root directory for campaign data.""" + return _data_home + + +def worlds_path() -> Path: + """Directory containing every world — ``/worlds/``.""" + return _data_home / "worlds" + + +def players_path() -> Path: + """Directory containing every player — ``/players/``.""" + return _data_home / "players" + + +def world_path(world_id: str) -> Path: + """Directory for a specific world.""" + return _data_home / "worlds" / world_id + + +def player_path(player_id: str) -> Path: + """Directory for a specific player.""" + return _data_home / "players" / player_id + + +# --------------------------------------------------------------------------- +# Rules-layer getters: user homebrew and shipped SRD. +# --------------------------------------------------------------------------- + + +def user_rules_path() -> Path: + """User-level homebrew rules overlay. + + Defaults to ``/rules/`` but is configured independently + so sandbox mode can keep this pointed at the real user home while + the data home is a tempdir. + + Layout is flat: files live at ``/{content_type}/*.md`` + — matching the world-layer shape, NOT the shipped-layer's + ``srd-5.2.1/sections/`` nesting. If you homebrew the goblin, it + goes at ``~/.storied/rules/monsters/goblin.md``. + """ + return _user_rules_home + + +def shipped_rules_path() -> Path: + """The SRD rules directory bundled with the repo. + + Lives at ``/rules``, computed relative to the installed + package source. This works for editable installs (``pip install + -e .``); for a real wheel install we'd need ``importlib.resources`` + plus a ``[tool.hatch.build]`` package-data entry. Noted as a + followup; not blocking while storied is dev-install only. + """ + return (Path(__file__).parent.parent.parent / "rules").resolve() + + +# Backwards compatibility alias. Older code imports ``rules_home`` +# directly. Retire once all callers use :func:`shipped_rules_path`. +def rules_home() -> Path: + """Alias for :func:`shipped_rules_path`.""" + return shipped_rules_path() diff --git a/src/storied/planner.py b/src/storied/planner.py index 5480eb8..c0bf884 100644 --- a/src/storied/planner.py +++ b/src/storied/planner.py @@ -11,6 +11,7 @@ from storied.claude import Result, run_with_tools from storied.engine import load_prompt from storied.log import CampaignLog from storied.mcp_server import start_server as start_mcp_server +from storied.paths import data_home from storied.session import ( extract_wiki_links, load_session, @@ -62,7 +63,6 @@ def entity_richness(path: Path) -> float: def find_nearby_entities( session: dict, world_id: str, - base_path: Path, ) -> list[tuple[str, Path]]: """Find entities near the player by walking wikilinks. @@ -78,7 +78,7 @@ def find_nearby_entities( if name in seen: return seen.add(name) - path = resolve_wiki_link(name, world_id, base_path) + path = resolve_wiki_link(name, world_id) if path: results.append((name, path)) to_follow.append(path) @@ -107,7 +107,6 @@ def find_nearby_entities( def build_planning_context( world_id: str, player_id: str, - base_path: Path, candidates: list[tuple[str, Path]], ) -> str: """Build the context string that the planner LLM sees. @@ -118,7 +117,7 @@ def build_planning_context( parts: list[str] = [] # Session state - session = load_session(player_id, base_path) + session = load_session(player_id) if session: location = session.get("location", "unknown") parts.append(f"## Current Location: {location}") @@ -128,7 +127,7 @@ def build_planning_context( parts.append(body) # Campaign log — full recent entries so the planner can spot casual mentions - log = CampaignLog(world_id, base_path) + log = CampaignLog(world_id) parts.append(f"## Campaign Time: {log.get_current_time()}") recent = log.get_recent_entries(days=2) @@ -170,7 +169,6 @@ class PlanResult: def plan_world( world_id: str = "default", player_id: str = "default", - base_path: Path | None = None, model: str = "claude-opus-4-6", threshold: float = 0.7, max_entities: int = 8, @@ -178,8 +176,6 @@ def plan_world( on_progress: Callable[[str], None] | None = None, ) -> PlanResult: """Enrich thin entities near the player's current position.""" - if base_path is None: - base_path = Path.cwd() def progress(msg: str) -> None: if on_progress: @@ -188,7 +184,7 @@ def plan_world( start_time = time.monotonic() # Load session - session = load_session(player_id, base_path) + session = load_session(player_id) if not session: return PlanResult(dry_run=dry_run) @@ -196,7 +192,7 @@ def plan_world( progress(f"Current location: {location}") # Find and score nearby entities - nearby = find_nearby_entities(session, world_id, base_path) + nearby = find_nearby_entities(session, world_id) progress(f"Scanning {len(nearby)} nearby entities...") scored: list[tuple[str, Path, float]] = [] @@ -223,12 +219,12 @@ def plan_world( # Build context and run claude -p candidate_pairs = [(name, path) for name, path, _ in candidates] - context = build_planning_context(world_id, player_id, base_path, candidate_pairs) + context = build_planning_context(world_id, player_id, candidate_pairs) system_prompt = load_prompt("planner-system") - campaign_log = CampaignLog(world_id, base_path) + campaign_log = CampaignLog(world_id) mcp = start_mcp_server( - world_id, player_id, base_path, "planner", campaign_log, + world_id, player_id, "planner", campaign_log, ) progress(f"Planning with {model}...") @@ -243,7 +239,7 @@ def plan_world( mcp_url=mcp.url, model=model, on_tool_start=on_tool, - cwd=base_path, + cwd=data_home(), ) if claude_result: @@ -268,13 +264,10 @@ class SeedResult: def seed_world( world_id: str = "default", player_id: str = "default", - base_path: Path | None = None, model: str = "claude-opus-4-6", on_progress: Callable[[str], None] | None = None, ) -> SeedResult: """Build the initial world from a character sheet.""" - if base_path is None: - base_path = Path.cwd() def progress(msg: str) -> None: if on_progress: @@ -283,7 +276,7 @@ def seed_world( start_time = time.monotonic() # Load the character — nothing to seed without one - character = load_character(player_id, base_path) + character = load_character(player_id) if character is None: return SeedResult() @@ -291,9 +284,9 @@ def seed_world( context = format_character_context(character) system_prompt = load_prompt("world-seed") - campaign_log = CampaignLog(world_id, base_path) + campaign_log = CampaignLog(world_id) mcp = start_mcp_server( - world_id, player_id, base_path, "seeder", campaign_log, + world_id, player_id, "seeder", campaign_log, ) progress(f"Seeding with {model}...") @@ -308,7 +301,7 @@ def seed_world( mcp_url=mcp.url, model=model, on_tool_start=on_tool, - cwd=base_path, + cwd=data_home(), ) seed_result = SeedResult(elapsed=time.monotonic() - start_time) @@ -334,10 +327,11 @@ class TickResult: def _find_entities_with_will( world_id: str, - base_path: Path, ) -> list[tuple[str, Path]]: """Find all entities that have Will triggers defined.""" - world_dir = base_path / "worlds" / world_id + from storied.paths import world_path + + world_dir = world_path(world_id) if not world_dir.exists(): return [] @@ -357,19 +351,18 @@ def _find_entities_with_will( def build_tick_context( world_id: str, player_id: str, - base_path: Path, entities: list[tuple[str, Path]], ) -> str: """Build context for the world tick agent.""" parts: list[str] = [] # Campaign log and time - log = CampaignLog(world_id, base_path) + log = CampaignLog(world_id) current_time = log.get_current_time() parts.append(f"## Current Game Time: {current_time}") # Session state for last-played context - session = load_session(player_id, base_path) + session = load_session(player_id) if session: location = session.get("location", "unknown") parts.append(f"## Player's Last Location: {location}") @@ -404,7 +397,6 @@ def build_tick_context( def tick_world( # pragma: no cover world_id: str = "default", player_id: str = "default", - base_path: Path | None = None, model: str = "claude-opus-4-6", on_progress: Callable[[str], None] | None = None, ) -> TickResult: @@ -415,8 +407,6 @@ def tick_world( # pragma: no cover composes (`_find_entities_with_will`, `build_tick_context`) are covered separately. """ - if base_path is None: - base_path = Path.cwd() def progress(msg: str) -> None: if on_progress: @@ -425,19 +415,19 @@ def tick_world( # pragma: no cover start_time = time.monotonic() # Find entities with Will triggers - entities = _find_entities_with_will(world_id, base_path) + entities = _find_entities_with_will(world_id) progress(f"Found {len(entities)} entities with active triggers") if not entities: return TickResult(elapsed=time.monotonic() - start_time) # Build context and run - context = build_tick_context(world_id, player_id, base_path, entities) + context = build_tick_context(world_id, player_id, entities) system_prompt = load_prompt("world-tick") - campaign_log = CampaignLog(world_id, base_path) + campaign_log = CampaignLog(world_id) mcp = start_mcp_server( - world_id, player_id, base_path, "planner", campaign_log, + world_id, player_id, "planner", campaign_log, ) progress(f"Ticking with {model}...") @@ -452,7 +442,7 @@ def tick_world( # pragma: no cover mcp_url=mcp.url, model=model, on_tool_start=on_tool, - cwd=base_path, + cwd=data_home(), ) result = TickResult( @@ -479,12 +469,10 @@ class BackgroundTicker: self, world_id: str, player_id: str, - base_path: Path, model: str = "claude-opus-4-6", ): self._world_id = world_id self._player_id = player_id - self._base_path = base_path self._model = model self._last_tick_day: int = 0 self._thread: Thread | None = None @@ -498,7 +486,7 @@ class BackgroundTicker: if self._thread and self._thread.is_alive(): return - triggers = _find_entities_with_will(self._world_id, self._base_path) + triggers = _find_entities_with_will(self._world_id) if not triggers: self._last_tick_day = current_day return @@ -514,7 +502,6 @@ class BackgroundTicker: self._result = tick_world( world_id=self._world_id, player_id=self._player_id, - base_path=self._base_path, model=self._model, ) diff --git a/src/storied/search.py b/src/storied/search.py index 2508a01..147e91d 100644 --- a/src/storied/search.py +++ b/src/storied/search.py @@ -270,7 +270,7 @@ class VectorIndex: self, query: str, limit: int = 5, - source_filter: str | None = None, + source_filter: str | list[str] | None = None, exclude_source: str | None = None, decay_ref: int | None = None, ) -> list[SearchHit]: @@ -279,8 +279,13 @@ class VectorIndex: Args: query: Natural language search query limit: Max results to return - source_filter: Restrict to a single source ("srd", "world", etc.) - exclude_source: Exclude a single source from results + source_filter: Restrict to one or more sources. A single string + matches exactly one source ("srd", "world", etc.); a list + of strings is an OR ("srd", "user", and "world" all match). + ``None`` (default) returns hits from every source. + exclude_source: Exclude a single source from results. Retained + for backwards compatibility; prefer ``source_filter`` for + new code. decay_ref: Current game day for age-decay on transcripts. If None, no decay is applied. """ @@ -293,6 +298,15 @@ class VectorIndex: self._on_empty(self) self._on_empty = None + # Normalize source_filter to a set for O(1) membership checks. + allowed_sources: set[str] | None + if source_filter is None: + allowed_sources = None + elif isinstance(source_filter, str): + allowed_sources = {source_filter} + else: + allowed_sources = set(source_filter) + vec = self.embed([query])[0] blob = _serialize_f32(vec) @@ -311,7 +325,7 @@ class VectorIndex: hits: list[SearchHit] = [] for doc_id, distance, path, source, ctype, preview, game_day in rows: - if source_filter and source != source_filter: + if allowed_sources is not None and source not in allowed_sources: continue if exclude_source and source == exclude_source: continue diff --git a/src/storied/session.py b/src/storied/session.py index 9ea3665..39c6d0c 100644 --- a/src/storied/session.py +++ b/src/storied/session.py @@ -6,21 +6,21 @@ from pathlib import Path import yaml +from storied.paths import player_path, world_path -def load_session(player_id: str, base_path: Path | None = None) -> dict | None: + +def load_session(player_id: str) -> dict | None: """Load session state for a player. Args: player_id: Player identifier (directory name under players/) - base_path: Base path for players directory (defaults to cwd) + base_path: Base path for players directory (defaults to ~/.storied/) Returns: Dict with frontmatter fields plus 'body' key, or None if not found """ - if base_path is None: - base_path = Path.cwd() - session_path = base_path / "players" / player_id / "session.md" + session_path = player_path(player_id) / "session.md" if not session_path.exists(): return None @@ -44,7 +44,7 @@ def parse_session(content: str) -> dict: return {"body": content.strip()} -def save_session(player_id: str, data: dict, base_path: Path | None = None) -> None: +def save_session(player_id: str, data: dict) -> None: """Save session state to file. Args: @@ -52,10 +52,8 @@ def save_session(player_id: str, data: dict, base_path: Path | None = None) -> N data: Session data with frontmatter fields and 'body' base_path: Base path for players directory """ - if base_path is None: - base_path = Path.cwd() - session_path = base_path / "players" / player_id / "session.md" + session_path = player_path(player_id) / "session.md" session_path.parent.mkdir(parents=True, exist_ok=True) # Update timestamp @@ -79,8 +77,7 @@ def save_session(player_id: str, data: dict, base_path: Path | None = None) -> N def update_session( player_id: str, - updates: dict, - base_path: Path | None = None, + updates: dict ) -> str: """Update specific fields in the session state. @@ -97,7 +94,7 @@ def update_session( Returns: Confirmation message """ - data = load_session(player_id, base_path) + data = load_session(player_id) if data is None: # Create new session data = {"body": ""} @@ -127,7 +124,7 @@ def update_session( changes.append(f"{key} = {value}") data["body"] = body - save_session(player_id, data, base_path) + save_session(player_id, data) if not changes: return "No changes made to session" @@ -172,8 +169,7 @@ ENTITY_TYPES = [ def resolve_wiki_link( name: str, - world_id: str, - base_path: Path | None = None, + world_id: str ) -> Path | None: """Resolve a wikilink name to a file path. @@ -187,10 +183,8 @@ def resolve_wiki_link( 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 + world_dir = world_path(world_id) for entity_type in ENTITY_TYPES: file_path = world_dir / entity_type / f"{name}.md" @@ -202,8 +196,7 @@ def resolve_wiki_link( def load_entity_content( name: str, - world_id: str, - base_path: Path | None = None, + world_id: str ) -> dict | None: """Load an entity's content by resolving its wikilink. @@ -215,7 +208,7 @@ def load_entity_content( Returns: Dict with entity_type, name, and content, or None if not found """ - file_path = resolve_wiki_link(name, world_id, base_path) + file_path = resolve_wiki_link(name, world_id) if file_path is None: return None diff --git a/src/storied/tools/__init__.py b/src/storied/tools/__init__.py index 10b947c..35b5cb5 100644 --- a/src/storied/tools/__init__.py +++ b/src/storied/tools/__init__.py @@ -13,7 +13,6 @@ from storied.tools._context import ( EntityIndex, Lore, Player, - StorageRoot, Timekeeper, ToolContext, World, @@ -30,7 +29,6 @@ __all__ = [ "EntityIndex", "Lore", "Player", - "StorageRoot", "Timekeeper", "ToolContext", "World", diff --git a/src/storied/tools/_context.py b/src/storied/tools/_context.py index 2c97501..91c0205 100644 --- a/src/storied/tools/_context.py +++ b/src/storied/tools/_context.py @@ -65,11 +65,14 @@ class ToolContext: Created once per process via init_ctx() and exposed to tools through the Dependency subclasses below. Tools should never reach for the full ToolContext — they ask for the specific slices they need. + + Filesystem paths live in :mod:`storied.paths` (module globals, + configured at CLI startup), not on the ToolContext. The context + holds only stateful runtime objects. """ world_id: str player_id: str - base_path: Path campaign_log: CampaignLog entity_index: EntityIndex vector_index: VectorIndex @@ -84,7 +87,6 @@ _ctx: ToolContext | None = None def init_ctx( world_id: str, player_id: str, - base_path: Path, campaign_log: CampaignLog, entity_index: EntityIndex, vector_index: VectorIndex, @@ -97,7 +99,6 @@ def init_ctx( _ctx = ToolContext( world_id=world_id, player_id=player_id, - base_path=base_path, campaign_log=campaign_log, entity_index=entity_index, vector_index=vector_index, @@ -144,14 +145,6 @@ class Player(Dependency[str]): return _require().player_id -class StorageRoot(Dependency[Path]): - """The filesystem root for worlds, players, and rules.""" - single = True - - async def __aenter__(self) -> Path: - return _require().base_path - - class Timekeeper(Dependency[CampaignLog]): """The campaign log: game time, world events, recent history.""" single = True @@ -191,12 +184,15 @@ def _sync_player_hp( target: str, initiative: InitiativeTracker, player_id: str, - base_path: Path, result: str, ) -> str: - """Auto-sync player character sheet when damage/heal targets a player.""" + """Auto-sync player character sheet when damage/heal targets a player. + + Path resolution happens inside ``char_update`` via the module + globals in :mod:`storied.paths`. + """ combatant = initiative._find(target) if combatant and combatant.is_player: - char_update(player_id, {"state.hp.current": combatant.hp}, base_path) + char_update(player_id, {"state.hp.current": combatant.hp}) result += f" (character sheet synced to {combatant.hp} HP)" return result diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index 38940c0..3ad1e5b 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -65,7 +65,6 @@ from storied.log import CampaignLog from storied.tools._context import ( Combat, Player, - StorageRoot, Timekeeper, _sync_player_hp, ) @@ -126,7 +125,6 @@ def refresh_advancement_visibility(character: dict | None) -> None: def update_character( updates: dict[str, JsonValue], player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Update arbitrary fields on the character sheet via dot notation. @@ -153,7 +151,7 @@ def update_character( Returns: Confirmation of what was updated, or a rejection error. """ - return char_update(player, updates, root) + return char_update(player, updates) @mcp.tool(tags={"dm", "character"}) @@ -171,7 +169,6 @@ def create_character( subclass: str | None = None, backstory: str | None = None, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Create a new player character with the new structured schema. @@ -209,8 +206,7 @@ def create_character( purse=purse.model_dump() if purse else None, subclass=subclass, backstory=backstory, - base_path=root, - ) + ) # --- HP operations (unified — work in or out of combat) --- @@ -223,7 +219,6 @@ def damage( type: str | None = None, combat: InitiativeTracker = Combat(), player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Apply raw damage to a named target. @@ -248,11 +243,11 @@ def damage( """ if combat.active and combat._find(target) is not None: result = combat.apply_damage(target, amount) - return _sync_player_hp(target, combat, player, root, result) + return _sync_player_hp(target, combat, player, result) - char = load_character(player, root) + char = load_character(player) if char and char["identity"]["name"] == target: - return char_damage(player, amount, damage_type=type, base_path=root) + return char_damage(player, amount, damage_type=type) return f"No such target: {target}" @@ -263,7 +258,6 @@ def heal( amount: int, combat: InitiativeTracker = Combat(), player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Heal a named target. @@ -281,11 +275,11 @@ def heal( """ if combat.active and combat._find(target) is not None: result = combat.apply_heal(target, amount) - return _sync_player_hp(target, combat, player, root, result) + return _sync_player_hp(target, combat, player, result) - char = load_character(player, root) + char = load_character(player) if char and char["identity"]["name"] == target: - return char_heal(player, amount, base_path=root) + return char_heal(player, amount) return f"No such target: {target}" @@ -297,7 +291,6 @@ def heal( def adjust_coins( deltas: CoinDelta, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Adjust the player's coins by relative amounts. @@ -314,7 +307,7 @@ def adjust_coins( # Drop zero-deltas before passing to the underlying op so the result # message only mentions denominations the DM actually touched. nonzero = {k: v for k, v in deltas.model_dump().items() if v != 0} - return char_adjust_coins(player, nonzero, root) + return char_adjust_coins(player, nonzero) # --- Effect operations --- @@ -327,7 +320,6 @@ def add_effect( expires: str | None = None, concentration: bool = False, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Track a temporary effect on the character. @@ -351,7 +343,7 @@ def add_effect( """ return char_add_effect( player, source, description, - expires=expires, concentration=concentration, base_path=root, + expires=expires, concentration=concentration, ) @@ -359,7 +351,6 @@ def add_effect( def remove_effect( source: str, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Remove an effect by source name (case-insensitive substring match). @@ -369,7 +360,7 @@ def remove_effect( Returns: Confirmation of what was removed """ - return char_remove_effect(player, source, base_path=root) + return char_remove_effect(player, source) # --- Condition operations --- @@ -379,7 +370,6 @@ def remove_effect( def add_condition( name: str, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Mark a condition on the character (5e conditions or any custom name). @@ -389,14 +379,13 @@ def add_condition( Returns: Confirmation """ - return char_add_condition(player, name, base_path=root) + return char_add_condition(player, name) @mcp.tool(tags={"dm", "character"}) def remove_condition( name: str, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Remove a condition from the character. @@ -406,7 +395,7 @@ def remove_condition( Returns: Confirmation """ - return char_remove_condition(player, name, base_path=root) + return char_remove_condition(player, name) # --- Inventory operations --- @@ -417,7 +406,6 @@ def add_item( item: str, location: str | None = None, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Add a mundane item to the character's equipment. @@ -435,14 +423,13 @@ def add_item( Returns: Confirmation with the location it was added to """ - return char_add_item(player, item, location=location, base_path=root) + return char_add_item(player, item, location=location) @mcp.tool(tags={"dm", "character"}) def remove_item( item: str, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Remove an item from the character's equipment by name. @@ -455,7 +442,7 @@ def remove_item( Returns: Confirmation with the item that was removed """ - return char_remove_item(player, item, base_path=root) + return char_remove_item(player, item) @mcp.tool(tags={"dm", "character"}) @@ -463,7 +450,6 @@ def set_item_status( item: str, status: Literal["attuned", "equipped", "carried"], player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Set a magic item's status. @@ -478,7 +464,7 @@ def set_item_status( Returns: Confirmation """ - return char_set_item_status(player, item, status, base_path=root) + return char_set_item_status(player, item, status) # --- Resource operations --- @@ -489,7 +475,6 @@ def adjust_resource( name: str, delta: int, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Adjust a resource pool by a delta (negative = use, positive = restore). @@ -504,14 +489,13 @@ def adjust_resource( Returns: Confirmation with new count """ - return char_adjust_resource(player, name, delta, base_path=root) + return char_adjust_resource(player, name, delta) @mcp.tool(tags={"dm", "character"}) def rest( type: Literal["short", "long"], player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Take a short or long rest. @@ -525,7 +509,7 @@ def rest( Returns: Summary of what was refreshed """ - return char_rest(player, type, base_path=root) + return char_rest(player, type) # --- Level advancement --- @@ -539,7 +523,6 @@ def level_up( features: list[dict] | None = None, timekeeper: CampaignLog = Timekeeper(), player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Atomically level up the character. @@ -575,8 +558,7 @@ def level_up( hp_gain=hp_gain, features=features, time_anchor=time_anchor, - base_path=root, - ) + ) # --- Notes --- @@ -587,7 +569,6 @@ def add_note( text: str, timekeeper: CampaignLog = Timekeeper(), player: str = Player(), - root: Path = StorageRoot(), ) -> str: """Append a note to the player's notes.md journal. @@ -601,4 +582,4 @@ def add_note( Confirmation """ time_anchor = timekeeper.get_current_time().to_anchor() - return char_add_note(player, text, time_anchor=time_anchor, base_path=root) + return char_add_note(player, text, time_anchor=time_anchor) diff --git a/src/storied/tools/entities.py b/src/storied/tools/entities.py index f62726c..209491b 100644 --- a/src/storied/tools/entities.py +++ b/src/storied/tools/entities.py @@ -9,6 +9,7 @@ from fastmcp import FastMCP from storied.character import load_character from storied.log import CampaignLog +from storied.paths import player_path, world_path from storied.search import VectorIndex from storied.session import name_to_slug from storied.tools._context import ( @@ -16,7 +17,6 @@ from storied.tools._context import ( EntityIndex, Lore, Player, - StorageRoot, Timekeeper, World, _get_file_lock, @@ -176,14 +176,13 @@ def _do_establish( knows: list[str] | None, wants: list[str] | None, will: list[str] | None, - base_path: Path, world_id: str, entity_index: EntityIndex, lore: VectorIndex, ) -> str: """Plain bookkeeping form of establish; called by both the FastMCP wrapper and any internal caller (e.g. world seeding).""" - world_dir = base_path / "worlds" / world_id / entity_type + world_dir = world_path(world_id) / entity_type world_dir.mkdir(parents=True, exist_ok=True) file_path = world_dir / f"{name}.md" @@ -233,7 +232,6 @@ def _do_mark( name: str, event: str, resolves: list[str] | None, - base_path: Path, world_id: str, entity_index: EntityIndex, lore: VectorIndex, @@ -243,7 +241,7 @@ def _do_mark( """Plain bookkeeping form of mark.""" file_path = entity_index.resolve(name) if file_path is None: - file_path = base_path / "worlds" / world_id / entity_type / f"{name}.md" + file_path = world_path(world_id) / entity_type / f"{name}.md" if not file_path.exists(): return f"Error: Entity '{name}' not found in {entity_type}" @@ -344,7 +342,6 @@ def _minutes_since( def _auto_mark_present( present: list[str], event: str, - base_path: Path, world_id: str, entity_index: EntityIndex, lore: VectorIndex, @@ -365,6 +362,7 @@ def _auto_mark_present( now = timekeeper.get_current_time() now_tuple = (now.day, now.hour, now.minute) + world_dir = world_path(world_id) marked: list[str] = [] for ref in present: @@ -376,7 +374,7 @@ def _auto_mark_present( file_path = entity_index.resolve(name) if file_path is None: for etype in ("npcs", "locations", "items", "factions"): - candidate = base_path / "worlds" / world_id / etype / f"{name}.md" + candidate = world_dir / etype / f"{name}.md" if candidate.exists(): file_path = candidate break @@ -397,7 +395,7 @@ def _auto_mark_present( entity_type = file_path.parent.name _do_mark( entity_type, name, event, None, - base_path, world_id, entity_index, lore, timekeeper, + world_id, entity_index, lore, timekeeper, ) marked.append(name) @@ -416,7 +414,6 @@ def establish( knows: list[str] | None = None, wants: list[str] | None = None, will: list[str] | None = None, - root: Path = StorageRoot(), world: str = World(), player: str = Player(), entities: EntityIndex = Entities(), @@ -453,7 +450,7 @@ def establish( Confirmation with the file path """ if entity_type == "npcs": - character = load_character(player, root) + character = load_character(player) if character: pc_name = character.get("identity", {}).get("name", "") if pc_name and pc_name == name: @@ -464,7 +461,7 @@ def establish( return _do_establish( entity_type, name, description, location, knows, wants, will, - root, world, entities, lore, + world, entities, lore, ) @@ -475,7 +472,6 @@ def mark( event: str, resolves: list[str] | None = None, when: str | None = None, - root: Path = StorageRoot(), world: str = World(), entities: EntityIndex = Entities(), lore: VectorIndex = Lore(), @@ -507,7 +503,7 @@ def mark( """ return _do_mark( entity_type, name, event, resolves, - root, world, entities, lore, timekeeper, when=when, + world, entities, lore, timekeeper, when=when, ) @@ -516,7 +512,6 @@ def amend_mark( entity_type: MarkType, name: str, event: str, - root: Path = StorageRoot(), world: str = World(), entities: EntityIndex = Entities(), lore: VectorIndex = Lore(), @@ -542,7 +537,7 @@ def amend_mark( """ file_path = entities.resolve(name) if file_path is None: - file_path = root / "worlds" / world / entity_type / f"{name}.md" + file_path = world_path(world) / entity_type / f"{name}.md" if not file_path.exists(): return f"Error: Entity '{name}' not found in {entity_type}" @@ -582,7 +577,6 @@ def note_discovery( content: str, content_type: DiscoveryType = "lore", tags: list[str] | None = None, - root: Path = StorageRoot(), world: str = World(), player: str = Player(), lore: VectorIndex = Lore(), @@ -608,7 +602,7 @@ def note_discovery( """ slug = name_to_slug(entity) - knowledge_dir = root / "players" / player / "worlds" / world / content_type + knowledge_dir = player_path(player) / "worlds" / world / content_type knowledge_dir.mkdir(parents=True, exist_ok=True) file_path = knowledge_dir / f"{slug}.md" diff --git a/src/storied/tools/mechanics.py b/src/storied/tools/mechanics.py index 9be5041..9c3dd5e 100644 --- a/src/storied/tools/mechanics.py +++ b/src/storied/tools/mechanics.py @@ -56,27 +56,35 @@ def recall( """Look up rules, world content, or both. 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) + - Rules: spells, monsters, classes, items, conditions — shipped + SRD, your personal homebrew, or world-specific overrides. The + "rules" scope covers all three layers. + - World: NPCs, locations, factions, lore you've established. + - All: search everything (default). Args: query: What to look up (e.g., "fireball", "captain vex", "merchant guild") - scope: Which corpus to search — "rules" (SRD), "world" (established - content), or "all" (both, default) + scope: Which corpus to search — "rules" covers every content + layer that might contain rule-ish content (shipped SRD, + user homebrew, and world overrides); "world" returns + only world-specific content; "all" searches everything + (default) content_type: Optional type to limit search (e.g., "spells", "npcs") Returns: Content of the found item, or a message if not found """ - source_filter: str | None = None + source_filter: str | list[str] | None if scope == "rules": - source_filter = "srd" + source_filter = ["srd", "user", "world"] + elif scope == "world": + source_filter = "world" + else: + source_filter = None current_day = timekeeper.get_current_time().day hits = lore.search( query, limit=5, source_filter=source_filter, - exclude_source="srd" if scope == "world" else None, decay_ref=current_day, ) if hits: diff --git a/src/storied/tools/scene.py b/src/storied/tools/scene.py index 5548938..c16f7d7 100644 --- a/src/storied/tools/scene.py +++ b/src/storied/tools/scene.py @@ -1,11 +1,10 @@ """Scene management, session, style tuning, and DM notification tools.""" -from pathlib import Path - from fastmcp import FastMCP from storied import notifications from storied.log import CampaignLog +from storied.paths import world_path from storied.search import VectorIndex from storied.session import update_session as session_update from storied.tools._context import ( @@ -13,7 +12,6 @@ from storied.tools._context import ( EntityIndex, Lore, Player, - StorageRoot, Timekeeper, World, ) @@ -34,7 +32,6 @@ def set_scene( timekeeper: CampaignLog = Timekeeper(), player: str = Player(), world: str = World(), - root: Path = StorageRoot(), entities: EntityIndex = Entities(), lore: VectorIndex = Lore(), ) -> str: @@ -80,12 +77,12 @@ def set_scene( updates["threads"] = threads if updates: - result = session_update(player, updates, root) + result = session_update(player, updates) parts.append(result) if event and present: marked = _auto_mark_present( - present, event, root, world, entities, lore, timekeeper, + present, event, world, entities, lore, timekeeper, ) if marked: parts.append(f"Auto-marked: {', '.join(marked)}") @@ -97,7 +94,6 @@ def set_scene( def tune( tuning: str, world: str = World(), - root: Path = StorageRoot(), ) -> str: """Update your storytelling style based on player feedback. @@ -105,7 +101,8 @@ def tune( entire current style. Incorporate existing preferences where they still apply — don't discard preferences the player hasn't contradicted. """ - path = root / "worlds" / world / "style.md" + path = world_path(world) / "style.md" + path.parent.mkdir(parents=True, exist_ok=True) path.write_text(f"# Style\n\n{tuning}\n") return "Style updated." @@ -115,7 +112,6 @@ def end_session( situation: str, threads: list[str] | None = None, player: str = Player(), - root: Path = StorageRoot(), ) -> str: """End the current session, saving the game state for next time. @@ -136,7 +132,7 @@ def end_session( if threads is not None: updates["threads"] = threads - session_update(player, updates, root) + session_update(player, updates) return "SESSION_ENDED" @@ -144,7 +140,6 @@ def end_session( def notify_dm( message: str, world: str = World(), - root: Path = StorageRoot(), ) -> str: """Send a notification that the DM will see at the start of the next turn. @@ -157,5 +152,5 @@ def notify_dm( Returns: Confirmation that the notification was queued """ - notifications.append(world, root, message) + notifications.append(world, message) return f"Notification queued: {message}" diff --git a/tests/conftest.py b/tests/conftest.py index c1c0b17..e5125a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,24 @@ """Shared test fixtures. The synchronous tool-invocation helper ``call_tool`` lives in -``storied._testing`` so it's importable by test modules without needing +``storied.testing`` so it's importable by test modules without needing ``tests/`` to be a Python package — pytest's conftest loading isn't a regular import. + +## Path isolation + +Storied's path configuration lives in module globals on +``storied.paths``. The test suite MUST NEVER touch the user's real +``~/.storied/`` directory. We enforce this with a single autouse +function-level fixture that uses ``monkeypatch`` to rebind the +globals to a fresh ``tmp_path`` for every test. + +``tmp_path`` is pytest's per-function temp dir, so every test gets +its own clean directory and pytest cleans them up automatically. +``monkeypatch`` restores the previous value when the fixture tears +down, but since every subsequent test re-runs the fixture before any +storied code touches paths, there's no window where a stale default +matters. """ import hashlib @@ -12,10 +27,34 @@ from pathlib import Path import pytest +from storied import paths from storied.log import CampaignLog from storied.search import VectorIndex from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx + +@pytest.fixture(autouse=True) +def _isolate_storied_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> Iterator[Path]: + """Point storied's data home and user rules at ``tmp_path``. + + Runs for every test (autouse, function scope). Uses the public + ``paths.using_data_home`` context manager — same code path as the + CLI's startup configuration, with automatic restoration on + teardown. Library code that reads ``data_home()`` / ``worlds_path()`` + / ``player_path()`` / etc. during the test resolves under + ``tmp_path``, so writes land in pytest's per-function temp dir + instead of the user's real ``~/.storied/``. + + ``STORIED_HOME`` is also exported so subprocesses inherit the + isolation if any test ever spawns one. + """ + monkeypatch.setenv("STORIED_HOME", str(tmp_path)) + with paths.using_data_home(tmp_path): + yield tmp_path + + EMBED_DIM = 384 @@ -41,9 +80,9 @@ def _fake_embed(texts: list[str]) -> list[list[float]]: def ctx(tmp_path: Path) -> Iterator[ToolContext]: """Process-global ToolContext with a fake embedder. - Tools resolve their Dependency parameters from this context. Each test - gets a fresh tempdir-rooted ctx; teardown clears the global to prevent - cross-test leakage. + Depends on the autouse ``_isolate_storied_paths`` fixture having + already rebound ``storied.paths._data_home`` to ``tmp_path``, so + library reads of ``data_home()`` resolve here. """ world_dir = tmp_path / "worlds" / "test-world" world_dir.mkdir(parents=True) @@ -54,8 +93,7 @@ def ctx(tmp_path: Path) -> Iterator[ToolContext]: context = init_ctx( world_id="test-world", player_id="default", - base_path=tmp_path, - campaign_log=CampaignLog("test-world", tmp_path), + campaign_log=CampaignLog("test-world"), entity_index=EntityIndex(world_dir), vector_index=vi, ) diff --git a/tests/test_advancement.py b/tests/test_advancement.py index 6a72059..5c639ab 100644 --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -57,7 +57,7 @@ def character(ctx: ToolContext) -> dict: {"source": "Rogue Lv2", "name": "Cunning Action", "text": ""}, ], } - save_character("default", data, ctx.base_path) + save_character("default", data) return data @@ -79,19 +79,19 @@ def campaign_with_events(ctx: ToolContext) -> CampaignLog: class TestNotifyDM: - def test_appends_notification(self, ctx: ToolContext): + def test_appends_notification(self, ctx: ToolContext, tmp_path: Path): result = notify_dm("Test message", ctx) assert "queued" in result.lower() - path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" assert path.exists() assert "Test message" in path.read_text() - def test_multiple_notifications(self, ctx: ToolContext): + def test_multiple_notifications(self, ctx: ToolContext, tmp_path: Path): notify_dm("First", ctx) notify_dm("Second", ctx) - path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" content = path.read_text() assert "First" in content assert "Second" in content @@ -101,9 +101,9 @@ class TestNotifyDM: class TestBuildAdvancementContext: - def test_returns_none_without_character(self, ctx: ToolContext): + def test_returns_none_without_character(self, ctx: ToolContext, tmp_path: Path): result = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path + ctx.world_id, ctx.player_id ) assert result is None @@ -111,7 +111,7 @@ class TestBuildAdvancementContext: self, ctx: ToolContext, character: dict ): context = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path + ctx.world_id, ctx.player_id ) assert context is not None assert "Kira" in context @@ -125,7 +125,7 @@ class TestBuildAdvancementContext: campaign_with_events: CampaignLog, ): context = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path + ctx.world_id, ctx.player_id ) assert context is not None assert "warehouse" in context @@ -141,7 +141,7 @@ class TestBuildAdvancementContext: log.append_entry("Fought a dragon", "5 rounds", tags=["combat"]) context = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path + ctx.world_id, ctx.player_id ) assert context is not None assert "Old event before level-up" not in context @@ -158,7 +158,7 @@ class TestBuildAdvancementContext: log.append_entry("Recent events", "1 hour") context = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path + ctx.world_id, ctx.player_id ) assert context is not None assert "Advancement History" in context @@ -174,11 +174,10 @@ class TestBuildAdvancementContext: "location": "Town Square", "body": "## Open Threads\n- Find the missing merchant", }, - ctx.base_path, ) context = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path + ctx.world_id, ctx.player_id ) assert context is not None assert "missing merchant" in context @@ -262,25 +261,23 @@ class TestEvaluateAdvancement: result = evaluate_advancement( world_id=ctx.world_id, player_id=ctx.player_id, - base_path=ctx.base_path, ) assert result.evaluated is False def test_posts_reminder_when_advancement_pending( - self, ctx: ToolContext, character: dict + self, ctx: ToolContext, character: dict, tmp_path: Path, ): character["advancement_ready"] = 4 - save_character("default", character, ctx.base_path) + save_character("default", character) result = evaluate_advancement( world_id=ctx.world_id, player_id=ctx.player_id, - base_path=ctx.base_path, ) assert result.evaluated is False path = ( - ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" + tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" ) assert path.exists() contents = path.read_text() @@ -312,7 +309,6 @@ class TestEvaluateAdvancement: result = evaluate_advancement( world_id=ctx.world_id, player_id=ctx.player_id, - base_path=ctx.base_path, ) assert result.evaluated is True @@ -328,7 +324,6 @@ class TestBackgroundAdvancement: adv = BackgroundAdvancement( world_id="test", player_id="default", - base_path=Path("/tmp/fake"), interval=5, ) # 4 turns should not trigger @@ -342,7 +337,6 @@ class TestBackgroundAdvancement: adv = BackgroundAdvancement( world_id=ctx.world_id, player_id=ctx.player_id, - base_path=ctx.base_path, interval=100, ) @@ -358,7 +352,6 @@ class TestBackgroundAdvancement: adv = BackgroundAdvancement( world_id="test", player_id="default", - base_path=Path("/tmp/fake"), interval=5, ) with patch("storied.advancement.evaluate_advancement") as mock_eval: @@ -371,7 +364,7 @@ class TestBackgroundAdvancement: def test_pop_result_returns_none_when_no_thread(self): adv = BackgroundAdvancement( - world_id="test", player_id="default", base_path=Path("/tmp/fake"), + world_id="test", player_id="default", ) assert adv.pop_result() is None @@ -379,7 +372,6 @@ class TestBackgroundAdvancement: adv = BackgroundAdvancement( world_id="test", player_id="default", - base_path=Path("/tmp/fake"), interval=1, ) with patch("storied.advancement.evaluate_advancement") as mock_eval: @@ -396,7 +388,7 @@ class TestBackgroundAdvancement: def test_maybe_evaluate_skips_when_already_running(self): adv = BackgroundAdvancement( - world_id="test", player_id="default", base_path=Path("/tmp/fake"), + world_id="test", player_id="default", ) # Stub a fake "still running" thread on the instance from unittest.mock import MagicMock diff --git a/tests/test_character.py b/tests/test_character.py index ea779f8..fe79d15 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -72,7 +72,6 @@ def mira(player_dir: Path) -> dict: ac=16, background="Criminal", purse={"gp": 43, "cp": 66}, - base_path=player_dir, ) # Add proficiencies and resources via update_character update_character( @@ -86,7 +85,6 @@ def mira(player_dir: Path) -> dict: "proficiencies.skills.deception": "proficient", "proficiencies.skills.insight": "proficient", }, - base_path=player_dir, ) update_character( "test-player", @@ -96,9 +94,8 @@ def mira(player_dir: Path) -> dict: "notes": "Hit Dice (d8)", }, }, - base_path=player_dir, ) - return load_character("test-player", player_dir) + return load_character("test-player") # --- Data layer tests --- @@ -106,7 +103,7 @@ def mira(player_dir: Path) -> dict: class TestDataLayer: def test_load_returns_none_for_missing(self, player_dir: Path): - assert load_character("test-player", player_dir) is None + assert load_character("test-player") is None def test_create_and_load(self, player_dir: Path): create_character( @@ -119,9 +116,8 @@ class TestDataLayer: "intelligence": 8, "wisdom": 10, "charisma": 12}, hp_max=15, ac=14, - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["identity"]["name"] == "Conan" assert data["identity"]["classes"][0]["class"] == "Barbarian" assert data["identity"]["classes"][0]["level"] == 1 @@ -130,8 +126,8 @@ class TestDataLayer: def test_load_fills_defaults(self, player_dir: Path): # Save a sparse character - save_character("test-player", {"identity": {"name": "Sparse"}}, player_dir) - data = load_character("test-player", player_dir) + save_character("test-player", {"identity": {"name": "Sparse"}}) + data = load_character("test-player") # Default schema should be merged in assert "abilities" in data assert "state" in data @@ -149,48 +145,47 @@ class TestDataLayer: hp_max=9, ac=12, backstory="A wandering minstrel with secrets.", - base_path=player_dir, ) - prose = load_character_prose("test-player", player_dir) + prose = load_character_prose("test-player") assert "wandering minstrel" in prose class TestUpdateCharacter: def test_update_simple_field(self, mira: dict, player_dir: Path): - update_character("test-player", {"state.ac": 17}, base_path=player_dir) - data = load_character("test-player", player_dir) + update_character("test-player", {"state.ac": 17}) + data = load_character("test-player") assert data["state"]["ac"] == 17 def test_update_nested_via_dot(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.hp.max": 30}, base_path=player_dir + "test-player", {"state.hp.max": 30} ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["state"]["hp"]["max"] == 30 def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.hp.current": -5}, base_path=player_dir + "test-player", {"state.hp.current": -5} ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["state"]["hp"]["current"] == 0 def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.hp.current": 100}, base_path=player_dir + "test-player", {"state.hp.current": 100} ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_negative_coins_clamped(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.purse.sp": -20}, base_path=player_dir + "test-player", {"state.purse.sp": -20} ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["state"]["purse"]["sp"] == 0 def test_no_character_returns_error(self, player_dir: Path): - result = update_character("missing", {"foo": "bar"}, base_path=player_dir) + result = update_character("missing", {"foo": "bar"}) assert "no character" in result.lower() @@ -199,24 +194,22 @@ class TestSchemaValidation: error and leave the on-disk character unchanged.""" def test_resources_as_list_is_rejected(self, mira: dict, player_dir: Path): - before = load_character("test-player", player_dir) + before = load_character("test-player") result = update_character( "test-player", {"resources": [{"name": "Channel Divinity", "current": 1, "max": 1}]}, - base_path=player_dir, ) assert "rejected" in result.lower() assert "resources" in result assert "dict" in result.lower() # On-disk character is unchanged - after = load_character("test-player", player_dir) + after = load_character("test-player") assert after["resources"] == before["resources"] def test_equipment_as_list_is_rejected(self, mira: dict, player_dir: Path): result = update_character( "test-player", {"equipment": ["Longsword", "Shield"]}, - base_path=player_dir, ) assert "rejected" in result.lower() assert "equipment" in result @@ -225,11 +218,10 @@ class TestSchemaValidation: result = update_character( "test-player", {"state.hp": 24}, # missing required fields - base_path=player_dir, - ) + ) assert "rejected" in result.lower() # Original HP block is preserved - data = load_character("test-player", player_dir) + data = load_character("test-player") assert isinstance(data["state"]["hp"], dict) assert data["state"]["hp"]["max"] == 24 @@ -240,10 +232,9 @@ class TestSchemaValidation: "current": 1, "max": 1, "refresh": "short_rest", "notes": "Channel Divinity", }}, - base_path=player_dir, ) assert "rejected" not in result.lower() - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["resources"]["channel_divinity"]["current"] == 1 def test_error_message_contains_an_example(self, mira: dict, player_dir: Path): @@ -251,7 +242,6 @@ class TestSchemaValidation: result = update_character( "test-player", {"resources": [{"name": "x"}]}, - base_path=player_dir, ) # Concrete example helps the LLM correct itself assert "channel_divinity" in result or "{" in result @@ -277,7 +267,7 @@ class TestSchemaCoercion: "refresh": "long_rest"}, ], })) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert isinstance(data["resources"], dict) assert "channel_divinity" in data["resources"] assert data["resources"]["channel_divinity"]["current"] == 1 @@ -299,7 +289,7 @@ class TestSchemaCoercion: ], })) result = adjust_resource( - "test-player", "channel", -1, base_path=player_dir + "test-player", "channel", -1 ) assert "Used 1" in result @@ -313,7 +303,7 @@ class TestSchemaCoercion: "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, "equipment": ["Longsword", "Shield"], })) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert isinstance(data["equipment"], dict) assert data["equipment"]["on_person"] == ["Longsword", "Shield"] @@ -339,10 +329,8 @@ class TestComputation: {"identity": {"classes": [ {"class": "Fighter", "level": 3}, {"class": "Wizard", "level": 2}, - ]}}, - player_dir, - ) - data = load_character("test-player", player_dir) + ]}}) + data = load_character("test-player") assert total_level(data) == 5 def test_proficiency_bonus_scaling(self, player_dir: Path): @@ -350,10 +338,8 @@ class TestComputation: (12, 4), (13, 5), (16, 5), (17, 6), (20, 6)]: save_character( "test-player", - {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, - player_dir, - ) - data = load_character("test-player", player_dir) + {"identity": {"classes": [{"class": "Fighter", "level": level}]}}) + data = load_character("test-player") assert proficiency_bonus(data) == expected, f"level {level}" def test_skill_modifier_with_expertise(self, mira: dict): @@ -413,10 +399,8 @@ class TestComputation: def test_effective_hp_with_temp(self, player_dir: Path): save_character( "test-player", - {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}, - player_dir, - ) - data = load_character("test-player", player_dir) + {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}) + data = load_character("test-player") hp = effective_hp(data) assert hp["effective"] == 25 assert hp["current"] == 20 @@ -480,9 +464,9 @@ class TestDisplay: self, mira: dict, player_dir: Path ): update_character( - "test-player", {"advancement_ready": 4}, base_path=player_dir + "test-player", {"advancement_ready": 4} ) - data = load_character("test-player", player_dir) + data = load_character("test-player") result = format_character_context(data) assert "Advancement Ready" in result assert "Level 4" in result @@ -614,9 +598,8 @@ class TestDisplay: "intelligence": 10, "wisdom": 10, "charisma": 10}, hp_max=10, ac=10, - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") result = format_status(data) assert "Purse" not in result @@ -628,38 +611,39 @@ class TestDisplay: update_character( "test-player", {f"equipment.on_person": [f"item_{i}" for i in range(12)]}, - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") result = format_status(data) assert "and 4 more" in result # 12 items - 8 shown = 4 more - def test_format_character_display_respects_base_path( + def test_format_character_display_respects_data_home( self, mira: dict, player_dir: Path, tmp_path: Path, ): - """The /me slash command must read from the passed base_path so - sandbox sessions don't load the cwd's real character.""" + """The /me slash command resolves the character via the + ``storied.paths`` module globals — sandbox sessions get the + sandbox character because the data home was overridden in + ``cmd_play`` before this function is called.""" from storied.cli import _format_character_display + from storied.paths import using_data_home - # mira lives at player_dir/players/test-player; an unrelated other_path - # has no character at all. Asking for the other_path must return None, - # not silently fall back to mira via cwd. + # mira lives at player_dir/players/test-player; an unrelated + # other_path has no character. Pointing data_home at other_path + # must return None instead of silently loading mira from elsewhere. other_path = tmp_path / "other" (other_path / "players" / "test-player").mkdir(parents=True) - result = _format_character_display( - "test-player", full=True, base_path=other_path, - ) + with using_data_home(other_path): + result = _format_character_display("test-player", full=True) assert result is None, ( - "_format_character_display must use the passed base_path; " - "loading from cwd by default is what caused /me to show the wrong " - "character in sandbox sessions." + "_format_character_display must use the configured data_home; " + "falling back to a stale path is what caused /me to show the " + "wrong character in sandbox sessions." ) - # And it should return real content when given the right base_path - result = _format_character_display( - "test-player", full=True, base_path=player_dir, - ) + # And it should return real content when data_home points at + # the dir mira actually lives in. + with using_data_home(player_dir): + result = _format_character_display("test-player", full=True) assert result is not None assert "Mira" in result @@ -669,52 +653,52 @@ class TestDisplay: class TestDamageHeal: def test_damage_subtracts_hp(self, mira: dict, player_dir: Path): - damage("test-player", 5, base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 5) + data = load_character("test-player") assert data["state"]["hp"]["current"] == 19 def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.hp.temp": 5}, base_path=player_dir + "test-player", {"state.hp.temp": 5} ) - damage("test-player", 3, base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 3) + data = load_character("test-player") assert data["state"]["hp"]["temp"] == 2 assert data["state"]["hp"]["current"] == 24 def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.hp.temp": 5}, base_path=player_dir + "test-player", {"state.hp.temp": 5} ) - damage("test-player", 8, base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 8) + data = load_character("test-player") assert data["state"]["hp"]["temp"] == 0 assert data["state"]["hp"]["current"] == 21 def test_damage_clamps_to_zero(self, mira: dict, player_dir: Path): - damage("test-player", 100, base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 100) + data = load_character("test-player") assert data["state"]["hp"]["current"] == 0 def test_damage_at_zero_mentions_death_saves( self, mira: dict, player_dir: Path ): - result = damage("test-player", 100, base_path=player_dir) + result = damage("test-player", 100) assert "death save" in result.lower() def test_heal_restores_hp(self, mira: dict, player_dir: Path): - damage("test-player", 10, base_path=player_dir) - heal("test-player", 5, base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 10) + heal("test-player", 5) + data = load_character("test-player") assert data["state"]["hp"]["current"] == 19 def test_heal_clamped_to_max(self, mira: dict, player_dir: Path): - heal("test-player", 100, base_path=player_dir) - data = load_character("test-player", player_dir) + heal("test-player", 100) + data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): - result = damage("test-player", 3, damage_type="fire", base_path=player_dir) + result = damage("test-player", 3, damage_type="fire") assert "fire" in result def test_damage_ignores_resistances(self, mira: dict, player_dir: Path): @@ -723,10 +707,9 @@ class TestDamageHeal: update_character( "test-player", {"defenses.resistances": [{"damage": "fire"}]}, - base_path=player_dir, ) - damage("test-player", 10, damage_type="fire", base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 10, damage_type="fire") + data = load_character("test-player") # Raw 10, not halved assert data["state"]["hp"]["current"] == 14 @@ -734,12 +717,11 @@ class TestDamageHeal: update_character( "test-player", {"defenses.vulnerabilities": [{"damage": "radiant"}]}, - base_path=player_dir, ) damage( - "test-player", 5, damage_type="radiant", base_path=player_dir, + "test-player", 5, damage_type="radiant", ) - data = load_character("test-player", player_dir) + data = load_character("test-player") # Raw 5, not doubled assert data["state"]["hp"]["current"] == 19 @@ -747,12 +729,11 @@ class TestDamageHeal: update_character( "test-player", {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, - base_path=player_dir, ) damage( - "test-player", 12, damage_type="poison", base_path=player_dir, + "test-player", 12, damage_type="poison", ) - data = load_character("test-player", player_dir) + data = load_character("test-player") # Raw 12, not zeroed assert data["state"]["hp"]["current"] == 12 @@ -766,9 +747,8 @@ class TestLevelUp: class_name="Rogue", new_level=4, hp_gain=6, - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 4 assert "3 → 4" in result @@ -778,9 +758,9 @@ class TestLevelUp: # mira starts with 24/24 level_up( "test-player", "Rogue", - new_level=4, hp_gain=6, base_path=player_dir, + new_level=4, hp_gain=6, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["state"]["hp"]["max"] == 30 assert data["state"]["hp"]["current"] == 30 @@ -788,13 +768,13 @@ class TestLevelUp: self, mira: dict, player_dir: Path, ): # Wound the character first - damage("test-player", 10, base_path=player_dir) + damage("test-player", 10) # HP is now 14/24 level_up( "test-player", "Rogue", - new_level=4, hp_gain=6, base_path=player_dir, + new_level=4, hp_gain=6, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) assert data["state"]["hp"]["max"] == 30 assert data["state"]["hp"]["current"] == 20 @@ -806,9 +786,8 @@ class TestLevelUp: "test-player", "Rogue", new_level=4, hp_gain=6, time_anchor="#d12-1500", - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["level_since"] == "#d12-1500" def test_level_up_clears_advancement_ready( @@ -817,13 +796,12 @@ class TestLevelUp: update_character( "test-player", {"advancement_ready": 4}, - base_path=player_dir, ) level_up( "test-player", "Rogue", - new_level=4, hp_gain=6, base_path=player_dir, + new_level=4, hp_gain=6, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data.get("advancement_ready") is None def test_level_up_replaces_features_when_provided( @@ -837,9 +815,8 @@ class TestLevelUp: "test-player", "Rogue", new_level=4, hp_gain=6, features=new_features, - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert len(data["features"]) == 2 assert data["features"][1]["name"] == "Uncanny Dodge" @@ -849,13 +826,12 @@ class TestLevelUp: update_character( "test-player", {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, - base_path=player_dir, ) level_up( "test-player", "Rogue", - new_level=4, hp_gain=6, base_path=player_dir, + new_level=4, hp_gain=6, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] def test_level_up_rejects_downgrade( @@ -863,10 +839,10 @@ class TestLevelUp: ): result = level_up( "test-player", "Rogue", - new_level=2, hp_gain=0, base_path=player_dir, + new_level=2, hp_gain=0, ) assert "Refusing" in result - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 # unchanged def test_level_up_rejects_unknown_class( @@ -874,10 +850,10 @@ class TestLevelUp: ): result = level_up( "test-player", "Wizard", - new_level=4, hp_gain=4, base_path=player_dir, + new_level=4, hp_gain=4, ) assert "No class matching" in result - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 def test_level_up_multiclass_finds_correct_class( @@ -890,13 +866,12 @@ class TestLevelUp: {"class": "Rogue", "subclass": "Thief", "level": 3}, {"class": "Fighter", "subclass": None, "level": 1}, ]}, - base_path=player_dir, ) level_up( "test-player", "Fighter", - new_level=2, hp_gain=7, base_path=player_dir, + new_level=2, hp_gain=7, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged assert data["identity"]["classes"][1]["level"] == 2 # Fighter bumped @@ -912,10 +887,10 @@ class TestConcentration: ): result = add_effect( "test-player", "Bless", "+1d4 to attacks", - concentration=True, base_path=player_dir, + concentration=True, ) assert "[Concentration]" in result - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["effects"][0]["concentration"] is True def test_multiple_concentration_effects_allowed( @@ -924,13 +899,13 @@ class TestConcentration: """No enforcement — the DM can flag two effects concentration.""" add_effect( "test-player", "Bless", "+1d4", - concentration=True, base_path=player_dir, + concentration=True, ) add_effect( "test-player", "Hold Person", "paralyzed", - concentration=True, base_path=player_dir, + concentration=True, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") sources = [e["source"] for e in data["effects"]] assert "Bless" in sources assert "Hold Person" in sources @@ -941,69 +916,69 @@ class TestConcentration: """The DM issues concentration saves manually per the rules.""" add_effect( "test-player", "Bless", "+1d4", - concentration=True, base_path=player_dir, + concentration=True, ) - result = damage("test-player", 6, base_path=player_dir) + result = damage("test-player", 6) assert "Concentration save" not in result class TestEffects: def test_add_effect_appends(self, mira: dict, player_dir: Path): - add_effect("test-player", "Bless", "+1d4 to attacks", base_path=player_dir) - data = load_character("test-player", player_dir) + add_effect("test-player", "Bless", "+1d4 to attacks") + data = load_character("test-player") assert len(data["effects"]) == 1 assert data["effects"][0]["source"] == "Bless" def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): add_effect( "test-player", "Potion", "+10 temp HP", - expires="d1-1430", base_path=player_dir, + expires="d1-1430", ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["effects"][0]["expires"] == "d1-1430" def test_remove_effect_by_source(self, mira: dict, player_dir: Path): - add_effect("test-player", "Bless", "+1d4", base_path=player_dir) - result = remove_effect("test-player", "bless", base_path=player_dir) - data = load_character("test-player", player_dir) + add_effect("test-player", "Bless", "+1d4") + result = remove_effect("test-player", "bless") + data = load_character("test-player") assert len(data["effects"]) == 0 assert "Bless" in result def test_remove_effect_substring_match(self, mira: dict, player_dir: Path): - add_effect("test-player", "Potion of Heroism", "+10 temp HP", base_path=player_dir) - remove_effect("test-player", "Heroism", base_path=player_dir) - data = load_character("test-player", player_dir) + add_effect("test-player", "Potion of Heroism", "+10 temp HP") + remove_effect("test-player", "Heroism") + data = load_character("test-player") assert len(data["effects"]) == 0 def test_remove_effect_not_found(self, mira: dict, player_dir: Path): - result = remove_effect("test-player", "Nonexistent", base_path=player_dir) + result = remove_effect("test-player", "Nonexistent") assert "no effect matching" in result.lower() class TestConditions: def test_add_condition(self, mira: dict, player_dir: Path): - add_condition("test-player", "Poisoned", base_path=player_dir) - data = load_character("test-player", player_dir) + add_condition("test-player", "Poisoned") + data = load_character("test-player") assert "Poisoned" in data["conditions"] def test_add_condition_no_duplicate(self, mira: dict, player_dir: Path): - add_condition("test-player", "Prone", base_path=player_dir) - result = add_condition("test-player", "prone", base_path=player_dir) - data = load_character("test-player", player_dir) + add_condition("test-player", "Prone") + result = add_condition("test-player", "prone") + data = load_character("test-player") assert len(data["conditions"]) == 1 assert "already" in result.lower() def test_remove_condition(self, mira: dict, player_dir: Path): - add_condition("test-player", "Frightened", base_path=player_dir) - remove_condition("test-player", "Frightened", base_path=player_dir) - data = load_character("test-player", player_dir) + add_condition("test-player", "Frightened") + remove_condition("test-player", "Frightened") + data = load_character("test-player") assert "Frightened" not in data["conditions"] class TestInventory: def test_add_item_to_default_location(self, mira: dict, player_dir: Path): - add_item("test-player", "Lockpicks", base_path=player_dir) - data = load_character("test-player", player_dir) + add_item("test-player", "Lockpicks") + data = load_character("test-player") # Should create on_person if no equipment exists all_items = [] for items in data["equipment"].values(): @@ -1013,26 +988,25 @@ class TestInventory: def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): add_item( "test-player", "Rope (50ft)", location="backpack", - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert "Rope (50ft)" in data["equipment"]["backpack"] def test_add_item_substring_location_match(self, mira: dict, player_dir: Path): - add_item("test-player", "First", location="on_person", base_path=player_dir) - add_item("test-player", "Second", location="On Person", base_path=player_dir) - data = load_character("test-player", player_dir) + add_item("test-player", "First", location="on_person") + add_item("test-player", "Second", location="On Person") + data = load_character("test-player") # Both should land in the same location assert "First" in data["equipment"]["on_person"] assert "Second" in data["equipment"]["on_person"] def test_remove_item_substring(self, mira: dict, player_dir: Path): - add_item("test-player", "Boots of Elvenkind (worn)", base_path=player_dir) - result = remove_item("test-player", "Boots", base_path=player_dir) + add_item("test-player", "Boots of Elvenkind (worn)") + result = remove_item("test-player", "Boots") assert "Boots of Elvenkind" in result def test_remove_item_not_found(self, mira: dict, player_dir: Path): - result = remove_item("test-player", "Nonexistent", base_path=player_dir) + result = remove_item("test-player", "Nonexistent") assert "no item matching" in result.lower() @@ -1040,156 +1014,154 @@ class TestMagicItems: def test_set_item_status_attuned(self, mira: dict, player_dir: Path): set_item_status( "test-player", "Bracer of the Unseen Step", "attuned", - base_path=player_dir, ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): set_item_status( - "test-player", "Cloak", "carried", base_path=player_dir + "test-player", "Cloak", "carried" ) set_item_status( - "test-player", "Cloak", "equipped", base_path=player_dir + "test-player", "Cloak", "equipped" ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert "[[Cloak]]" not in data["magic_items"]["carried"] assert "[[Cloak]]" in data["magic_items"]["equipped"] def test_set_item_status_invalid(self, mira: dict, player_dir: Path): result = set_item_status( - "test-player", "Cloak", "invalid", base_path=player_dir + "test-player", "Cloak", "invalid" ) assert "invalid status" in result.lower() class TestResources: def test_adjust_resource_spend_one(self, mira: dict, player_dir: Path): - adjust_resource("test-player", "hit_dice", -1, base_path=player_dir) - data = load_character("test-player", player_dir) + adjust_resource("test-player", "hit_dice", -1) + data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 2 def test_adjust_resource_spend_multiple( self, mira: dict, player_dir: Path, ): - adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) - data = load_character("test-player", player_dir) + adjust_resource("test-player", "hit_dice", -2) + data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 1 def test_adjust_resource_clamped_to_zero( self, mira: dict, player_dir: Path, ): result = adjust_resource( - "test-player", "hit_dice", -10, base_path=player_dir + "test-player", "hit_dice", -10 ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 0 assert "short" in result.lower() def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): result = adjust_resource( - "test-player", "nonexistent", -1, base_path=player_dir + "test-player", "nonexistent", -1 ) assert "no resource matching" in result.lower() def test_adjust_resource_restore_clamped_to_max( self, mira: dict, player_dir: Path, ): - adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) + adjust_resource("test-player", "hit_dice", -2) adjust_resource( - "test-player", "hit_dice", 100, base_path=player_dir + "test-player", "hit_dice", 100 ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 def test_adjust_resource_zero_delta_is_noop( self, mira: dict, player_dir: Path, ): result = adjust_resource( - "test-player", "hit_dice", 0, base_path=player_dir + "test-player", "hit_dice", 0 ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 assert "no change" in result.lower() class TestRest: def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): - adjust_resource("test-player", "hit_dice", -3, base_path=player_dir) - rest("test-player", "long", base_path=player_dir) - data = load_character("test-player", player_dir) + adjust_resource("test-player", "hit_dice", -3) + rest("test-player", "long") + data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 def test_long_rest_restores_hp(self, mira: dict, player_dir: Path): - damage("test-player", 10, base_path=player_dir) - rest("test-player", "long", base_path=player_dir) - data = load_character("test-player", player_dir) + damage("test-player", 10) + rest("test-player", "long") + data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_long_rest_clears_death_saves(self, mira: dict, player_dir: Path): update_character( "test-player", {"state.death_saves.successes": 2, "state.death_saves.failures": 1}, - base_path=player_dir, ) - rest("test-player", "long", base_path=player_dir) - data = load_character("test-player", player_dir) + rest("test-player", "long") + data = load_character("test-player") assert data["state"]["death_saves"]["successes"] == 0 assert data["state"]["death_saves"]["failures"] == 0 def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): update_character( - "test-player", {"state.exhaustion": 3}, base_path=player_dir + "test-player", {"state.exhaustion": 3} ) - rest("test-player", "long", base_path=player_dir) - data = load_character("test-player", player_dir) + rest("test-player", "long") + data = load_character("test-player") assert data["state"]["exhaustion"] == 2 def test_short_rest_doesnt_refresh_long_rest_resources( self, mira: dict, player_dir: Path ): - adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) - rest("test-player", "short", base_path=player_dir) - data = load_character("test-player", player_dir) + adjust_resource("test-player", "hit_dice", -2) + rest("test-player", "short") + data = load_character("test-player") # hit_dice has refresh: long_rest, so short rest shouldn't refresh it assert data["resources"]["hit_dice_d8"]["current"] == 1 def test_invalid_rest_type(self, mira: dict, player_dir: Path): - result = rest("test-player", "epic", base_path=player_dir) + result = rest("test-player", "epic") assert "invalid" in result.lower() class TestCoins: def test_adjust_coins_spending(self, mira: dict, player_dir: Path): - adjust_coins("test-player", {"gp": -5}, base_path=player_dir) - data = load_character("test-player", player_dir) + adjust_coins("test-player", {"gp": -5}) + data = load_character("test-player") assert data["state"]["purse"]["gp"] == 38 def test_adjust_coins_gaining(self, mira: dict, player_dir: Path): - adjust_coins("test-player", {"gp": 10, "sp": 5}, base_path=player_dir) - data = load_character("test-player", player_dir) + adjust_coins("test-player", {"gp": 10, "sp": 5}) + data = load_character("test-player") assert data["state"]["purse"]["gp"] == 53 assert data["state"]["purse"]["sp"] == 5 def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path): result = adjust_coins( - "test-player", {"gp": -100}, base_path=player_dir + "test-player", {"gp": -100} ) - data = load_character("test-player", player_dir) + data = load_character("test-player") assert data["state"]["purse"]["gp"] == 0 assert "short" in result.lower() class TestNotes: def test_add_note_creates_file(self, mira: dict, player_dir: Path): - add_note("test-player", "Found a secret door", base_path=player_dir) + add_note("test-player", "Found a secret door") notes_path = player_dir / "players" / "test-player" / "notes.md" assert notes_path.exists() assert "secret door" in notes_path.read_text() def test_add_note_appends(self, mira: dict, player_dir: Path): - add_note("test-player", "First note", base_path=player_dir) - add_note("test-player", "Second note", base_path=player_dir) + add_note("test-player", "First note") + add_note("test-player", "Second note") notes_path = player_dir / "players" / "test-player" / "notes.md" content = notes_path.read_text() assert "First note" in content @@ -1198,7 +1170,7 @@ class TestNotes: def test_add_note_with_anchor(self, mira: dict, player_dir: Path): add_note( "test-player", "Witnessed the heist", - time_anchor="d28-1330", base_path=player_dir, + time_anchor="d28-1330", ) notes_path = player_dir / "players" / "test-player" / "notes.md" assert "d28-1330" in notes_path.read_text() @@ -1224,5 +1196,5 @@ class TestEdgeCases: (rest, ("short",)), (adjust_coins, ({"gp": 5},)), ]: - result = fn("missing-player", *args, base_path=player_dir) + result = fn("missing-player", *args) assert "no character" in result.lower(), f"{fn.__name__} failed" diff --git a/tests/test_content.py b/tests/test_content.py index 452040f..5341cd4 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1,169 +1,202 @@ -"""Tests for content layer resolution and search.""" +"""Tests for three-layer content resolution (world > user > shipped). + +The autouse ``_isolate_storied_paths`` fixture in ``conftest.py`` already +rebinds ``_data_home`` and ``_user_rules_home`` to ``tmp_path``; these +tests additionally monkeypatch ``shipped_rules_path`` so the "shipped" +layer can be faked per-test under the same tmp dir. +""" from pathlib import Path import pytest +from storied import paths from storied.content import ContentResolver -@pytest.fixture -def rules_dir(tmp_path: Path) -> Path: - """Create a mock rules directory.""" - rules = tmp_path / "rules" / "srd-5.2.1" / "sections" - rules.mkdir(parents=True) - - # Create some monster files - monsters = rules / "monsters" - monsters.mkdir() - (monsters / "goblin.md").write_text( - "# Goblin\n\n_Small Humanoid, Neutral Evil_\n\n**AC** 15 **HP** 7\n" - ) - (monsters / "ancient-red-dragon.md").write_text( - "# Ancient Red Dragon\n\n_Gargantuan Dragon_\n\n**AC** 22 **HP** 507\n" - ) - - # Create some spell files - spells = rules / "spells" - spells.mkdir() - (spells / "fireball.md").write_text( - "# Fireball\n\n_Level 3 Evocation_\n\n8d6 Fire damage\n" - ) - (spells / "magic-missile.md").write_text( - "# Magic Missile\n\n_Level 1 Evocation_\n\nAuto-hit force damage\n" - ) - - return tmp_path +# --------------------------------------------------------------------------- +# Fixtures — build a fake three-layer setup under tmp_path. +# --------------------------------------------------------------------------- @pytest.fixture -def world_dir(tmp_path: Path) -> Path: - """Create a mock world directory.""" - world = tmp_path / "worlds" / "test-world" - world.mkdir(parents=True) - - # Override the goblin - monsters = world / "monsters" - monsters.mkdir() - (monsters / "goblin.md").write_text( - "# Island Goblin\n\n_Tougher variant_\n\n**AC** 16 **HP** 12\n" - ) - - # World-specific NPC - npcs = world / "npcs" - npcs.mkdir() - (npcs / "captain-vex.md").write_text( - "# Captain Vex\n\nA notorious pirate captain.\n" - ) - - return tmp_path +def shipped_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect the shipped rules layer to a tmp subdir so tests can write + fake SRD content without touching the real package rules.""" + root = tmp_path / "shipped" + root.mkdir() + monkeypatch.setattr(paths, "shipped_rules_path", lambda: root) + return root @pytest.fixture -def resolver(rules_dir: Path) -> ContentResolver: - """Create a resolver with rules only.""" - return ContentResolver(base_path=rules_dir) +def shipped_goblin(shipped_root: Path) -> Path: + """Standard SRD goblin — the bottom layer.""" + monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" + monsters.mkdir(parents=True) + path = monsters / "goblin.md" + path.write_text("# Goblin\n\n_Small Humanoid, Neutral Evil_\n") + return path @pytest.fixture -def world_resolver(world_dir: Path, rules_dir: Path) -> ContentResolver: - """Create a resolver with world and rules.""" - # Copy rules into world_dir since they share tmp_path - return ContentResolver(base_path=world_dir, world_id="test-world") - - -class TestFindContent: - """Tests for finding content files.""" - - def test_find_monster_in_rules(self, resolver: ContentResolver): - path = resolver.find("goblin", content_type="monsters") - assert path is not None - assert path.name == "goblin.md" - - def test_find_spell_in_rules(self, resolver: ContentResolver): - path = resolver.find("fireball", content_type="spells") - assert path is not None - assert path.name == "fireball.md" - - def test_find_not_found(self, resolver: ContentResolver): - path = resolver.find("nonexistent", content_type="monsters") - assert path is None - - def test_find_without_content_type(self, resolver: ContentResolver): - # Should search all categories - path = resolver.find("goblin") - assert path is not None - assert "goblin" in path.name - - def test_find_with_hyphenated_name(self, resolver: ContentResolver): - path = resolver.find("ancient-red-dragon", content_type="monsters") - assert path is not None - assert path.name == "ancient-red-dragon.md" - +def shipped_fireball(shipped_root: Path) -> Path: + spells = shipped_root / "srd-5.2.1" / "sections" / "spells" + spells.mkdir(parents=True) + path = spells / "fireball.md" + path.write_text("# Fireball\n\n_Level 3 Evocation_\n") + return path -class TestLayerResolution: - """Tests for world layer overriding rules layer.""" - def test_world_overrides_rules(self, world_dir: Path): - # Create rules in same base - rules = world_dir / "rules" / "srd-5.2.1" / "sections" / "monsters" - rules.mkdir(parents=True) - (rules / "goblin.md").write_text("# Standard Goblin\n") - - resolver = ContentResolver(base_path=world_dir, world_id="test-world") - path = resolver.find("goblin", content_type="monsters") - - assert path is not None - content = path.read_text() - assert "Island Goblin" in content # World version, not Standard - - def test_falls_back_to_rules(self, world_dir: Path): - # Create rules with dragon that world doesn't have - rules = world_dir / "rules" / "srd-5.2.1" / "sections" / "monsters" - rules.mkdir(parents=True) - (rules / "dragon.md").write_text("# Dragon\n") +@pytest.fixture +def user_goblin(tmp_path: Path) -> Path: + """User homebrew goblin — middle layer.""" + monsters = tmp_path / "rules" / "monsters" + monsters.mkdir(parents=True) + path = monsters / "goblin.md" + path.write_text("# Homebrew Goblin\n\n_Slightly meaner_\n") + return path - resolver = ContentResolver(base_path=world_dir, world_id="test-world") - path = resolver.find("dragon", content_type="monsters") - assert path is not None - assert "Dragon" in path.read_text() +@pytest.fixture +def world_goblin(tmp_path: Path) -> Path: + """World-specific goblin override — top layer.""" + monsters = tmp_path / "worlds" / "test-world" / "monsters" + monsters.mkdir(parents=True) + path = monsters / "goblin.md" + path.write_text("# Island Goblin\n\n_Tougher variant_\n") + return path - def test_world_only_content(self, world_dir: Path): - resolver = ContentResolver(base_path=world_dir, world_id="test-world") - path = resolver.find("captain-vex", content_type="npcs") - assert path is not None - assert "Captain Vex" in path.read_text() +@pytest.fixture +def world_vera(tmp_path: Path) -> Path: + """Narrative content only exists in the world layer.""" + npcs = tmp_path / "worlds" / "test-world" / "npcs" + npcs.mkdir(parents=True) + path = npcs / "vera.md" + path.write_text("# Vera\n\nInnkeeper at the Rusty Anchor.\n") + return path + + +# --------------------------------------------------------------------------- +# Layer priority tests. +# --------------------------------------------------------------------------- + + +class TestLayerPriority: + def test_shipped_only(self, shipped_goblin: Path): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin", content_type="monsters") + assert hit == shipped_goblin + + def test_user_overrides_shipped( + self, shipped_goblin: Path, user_goblin: Path, + ): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin", content_type="monsters") + assert hit == user_goblin + assert "Homebrew" in hit.read_text() + + def test_world_overrides_user( + self, shipped_goblin: Path, user_goblin: Path, world_goblin: Path, + ): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin", content_type="monsters") + assert hit == world_goblin + assert "Island Goblin" in hit.read_text() + + def test_world_overrides_shipped_without_user( + self, shipped_goblin: Path, world_goblin: Path, + ): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin", content_type="monsters") + assert hit == world_goblin + + def test_user_overrides_shipped_without_world( + self, shipped_goblin: Path, user_goblin: Path, + ): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin", content_type="monsters") + assert hit == user_goblin + + +# --------------------------------------------------------------------------- +# Narrative content (world-only) tests. +# --------------------------------------------------------------------------- + + +class TestNarrativeContent: + def test_find_world_npc(self, world_vera: Path): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("vera", content_type="npcs") + assert hit == world_vera + + def test_narrative_misses_fall_through_cleanly(self, shipped_root: Path): + """Looking up a narrative entity that doesn't exist returns None + without error, even though the user/shipped layers have no + ``npcs/`` subdir.""" + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("nonexistent", content_type="npcs") + assert hit is None + + def test_no_world_id_still_searches_rule_layers( + self, shipped_fireball: Path, + ): + """A resolver with no world still finds rule content at the + user and shipped layers.""" + resolver = ContentResolver() + hit = resolver.find("fireball", content_type="spells") + assert hit == shipped_fireball + + +# --------------------------------------------------------------------------- +# Loading content (parsed dict). +# --------------------------------------------------------------------------- class TestLoadContent: - """Tests for loading and parsing content files.""" - - def test_load_returns_content(self, resolver: ContentResolver): - content = resolver.load("goblin", content_type="monsters") - assert content is not None - assert "body" in content - assert "Goblin" in content["body"] - - def test_load_not_found(self, resolver: ContentResolver): - content = resolver.load("nonexistent", content_type="monsters") - assert content is None - - def test_load_with_frontmatter(self, rules_dir: Path): - # Create file with YAML frontmatter - monsters = rules_dir / "rules" / "srd-5.2.1" / "sections" / "monsters" + def test_load_plain_markdown(self, shipped_goblin: Path): + resolver = ContentResolver(world_id="test-world") + data = resolver.load("goblin", content_type="monsters") + assert data is not None + assert "Goblin" in data["body"] + + def test_load_with_frontmatter(self, shipped_root: Path): + monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" + monsters.mkdir(parents=True) (monsters / "orc.md").write_text( "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n# Orc\n\nBig and mean.\n" ) - resolver = ContentResolver(base_path=rules_dir) - content = resolver.load("orc", content_type="monsters") + resolver = ContentResolver(world_id="test-world") + data = resolver.load("orc", content_type="monsters") + + assert data is not None + assert data.get("type") == "monster" + assert data.get("cr") == 0.5 + assert data.get("tags") == ["humanoid"] + assert "Orc" in data["body"] + + def test_load_not_found(self, shipped_root: Path): + resolver = ContentResolver(world_id="test-world") + data = resolver.load("nonexistent", content_type="monsters") + assert data is None + + +# --------------------------------------------------------------------------- +# Untyped search (walks every content_type subdir in every layer). +# --------------------------------------------------------------------------- - assert content is not None - assert content.get("type") == "monster" - assert content.get("cr") == 0.5 - assert content.get("tags") == ["humanoid"] - assert "Orc" in content["body"] +class TestUntypedSearch: + def test_finds_goblin_without_content_type(self, shipped_goblin: Path): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin") + assert hit == shipped_goblin + def test_world_wins_in_untyped_search( + self, shipped_goblin: Path, world_goblin: Path, + ): + resolver = ContentResolver(world_id="test-world") + hit = resolver.find("goblin") + assert hit == world_goblin diff --git a/tests/test_engine.py b/tests/test_engine.py index 1db1963..fb8d7e5 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -89,7 +89,6 @@ class TestDMEngineContext: return DMEngine( world_id="test", player_id="default", - base_path=tmp_path, prompt_name="dm-system", ) @@ -97,8 +96,8 @@ class TestDMEngineContext: context = engine._build_context() assert "Style" not in engine._context_parts - def test_build_context_with_style(self, engine): - style_path = engine.base_path / "worlds" / "test" / "style.md" + def test_build_context_with_style(self, engine, tmp_path: Path): + style_path = tmp_path / "worlds" / "test" / "style.md" style_path.write_text("# Style\n\nMore intrigue, less combat.\n") context = engine._build_context() @@ -106,10 +105,10 @@ class TestDMEngineContext: assert "Style" in engine._context_parts assert "intrigue" in engine._context_parts["Style"] - def test_time_is_first_context_part(self, engine): + def test_time_is_first_context_part(self, engine, tmp_path: Path): """The ambient clock header goes at the top of every turn's context so the DM can't miss it. Style comes immediately after.""" - style_path = engine.base_path / "worlds" / "test" / "style.md" + style_path = tmp_path / "worlds" / "test" / "style.md" style_path.write_text("# Style\n\nDark tone.\n") engine._build_context() @@ -193,7 +192,6 @@ class TestDMEngineContext: engine = DMEngine( world_id="test", player_id="default", - base_path=tmp_path, prompt_name="dm-system", transcript_path=transcript_path, ) @@ -228,10 +226,10 @@ class TestDMEngineContext: engine.reset() assert engine._session_id is None - def test_build_context_with_character(self, engine): + def test_build_context_with_character(self, engine, tmp_path: Path): # Drop a character file in place; _build_context should pick it up from storied.character import create_character - (engine.base_path / "players" / "default").mkdir(parents=True) + (tmp_path / "players" / "default").mkdir(parents=True) create_character( player_id="default", name="Mira", @@ -242,41 +240,40 @@ class TestDMEngineContext: "intelligence": 14, "wisdom": 12, "charisma": 16}, hp_max=24, ac=16, - base_path=engine.base_path, ) engine._build_context() assert "Character" in engine._context_parts assert "Mira" in engine._context_parts["Character"] - def test_build_context_with_session(self, engine): + def test_build_context_with_session(self, engine, tmp_path: Path): from storied.session import save_session - (engine.base_path / "players" / "default").mkdir(parents=True) + (tmp_path / "players" / "default").mkdir(parents=True) save_session("default", { "location": "The Tavern", "body": "## Present\n- [[Vera]]", "situation": "Resting", - }, engine.base_path) + }) engine._build_context() assert "Session" in engine._context_parts - def test_build_context_loads_present_entities(self, engine): + def test_build_context_loads_present_entities(self, engine, tmp_path: Path): from storied.session import save_session from storied.tools.entities import _do_establish - (engine.base_path / "players" / "default").mkdir(parents=True) + (tmp_path / "players" / "default").mkdir(parents=True) # Establish an NPC and put them in the session's present list _do_establish( "npcs", "Vera", "Tavern owner.", "[[The Tavern]]", None, None, None, - engine.base_path, "test", + "test", engine._mcp.ctx.entity_index, type("FakeIdx", (), {"upsert": lambda *a, **k: None})(), ) save_session("default", { "location": "The Tavern", "body": "## Present\n- [[Vera]]", - }, engine.base_path) + }) engine._build_context() # The Vera entity should have been loaded into the DM context @@ -287,9 +284,9 @@ class TestDMEngineContext: result = engine._load_player_knowledge() assert result is None - def test_load_player_knowledge_aggregates_files(self, engine): + def test_load_player_knowledge_aggregates_files(self, engine, tmp_path: Path): knowledge = ( - engine.base_path / "players" / "default" / "worlds" / "test" / "npcs" + tmp_path / "players" / "default" / "worlds" / "test" / "npcs" ) knowledge.mkdir(parents=True) (knowledge / "vera.md").write_text( @@ -300,9 +297,9 @@ class TestDMEngineContext: assert "Vera Blackwater" in result assert "tavern owner" in result - def test_find_entity_via_index(self, engine): + def test_find_entity_via_index(self, engine, tmp_path: Path): # Drop an entity file directly and rebuild the index so the lookup hits - npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" + npc_path = tmp_path / "worlds" / "test" / "npcs" / "Vera.md" npc_path.parent.mkdir(parents=True, exist_ok=True) npc_path.write_text("# Vera\n\nA tavern owner.") engine._mcp.ctx.entity_index.register("Vera", npc_path) @@ -316,15 +313,15 @@ class TestDMEngineContext: def test_find_entity_returns_none_when_missing(self, engine): assert engine._find_entity("Nobody") is None - def test_build_context_loads_location_and_one_hop_linked(self, engine): + def test_build_context_loads_location_and_one_hop_linked(self, engine, tmp_path: Path): """When the session points at a location, _build_context should load the location, then one-hop into entities the location wikilinks.""" from storied.session import save_session - (engine.base_path / "players" / "default").mkdir(parents=True) + (tmp_path / "players" / "default").mkdir(parents=True) # Location wikilinks to a related NPC - loc_path = engine.base_path / "worlds" / "test" / "locations" / "Tavern.md" + loc_path = tmp_path / "worlds" / "test" / "locations" / "Tavern.md" loc_path.parent.mkdir(parents=True, exist_ok=True) loc_path.write_text( "# Tavern\n\nA cozy spot where [[Vera]] holds court." @@ -332,7 +329,7 @@ class TestDMEngineContext: engine._mcp.ctx.entity_index.register("Tavern", loc_path) # The linked NPC - npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" + npc_path = tmp_path / "worlds" / "test" / "npcs" / "Vera.md" npc_path.parent.mkdir(parents=True, exist_ok=True) npc_path.write_text("# Vera\n\nTavern owner.") engine._mcp.ctx.entity_index.register("Vera", npc_path) @@ -340,7 +337,7 @@ class TestDMEngineContext: save_session("default", { "location": "Tavern", "body": "Player just walked in.", - }, engine.base_path) + }) engine._build_context() # Location should be loaded @@ -355,8 +352,7 @@ class TestDMEngineContext: from storied import notifications notifications.append( - engine.world_id, engine.base_path, - "World tick: Vera left the tavern", + engine.world_id, "World tick: Vera left the tavern", ) engine._build_context() assert "Notifications" in engine._context_parts diff --git a/tests/test_entities.py b/tests/test_entities.py index 620ded8..d3537bf 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -38,7 +38,7 @@ def amend_mark(**kwargs): class TestEstablish: """Tests for the establish tool.""" - def test_establish_creates_npc(self, ctx: ToolContext): + def test_establish_creates_npc(self, ctx: ToolContext, tmp_path: Path): result = establish( entity_type="npcs", name="Vera Blackwater", @@ -50,10 +50,10 @@ class TestEstablish: ) assert "Established" in result - npc_file = ctx.base_path / "worlds/test-world/npcs/Vera Blackwater.md" + npc_file = tmp_path / "worlds/test-world/npcs/Vera Blackwater.md" assert npc_file.exists() - def test_establish_file_format(self, ctx: ToolContext): + def test_establish_file_format(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Test NPC", @@ -64,7 +64,7 @@ class TestEstablish: will=["If X → do Y"], ) - content = (ctx.base_path / "worlds/test-world/npcs/Test NPC.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Test NPC.md").read_text() assert "# Test NPC" in content assert "## Is" in content @@ -77,7 +77,7 @@ class TestEstablish: assert "### Will" in content assert "- If X → do Y" in content - def test_establish_location(self, ctx: ToolContext): + def test_establish_location(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="locations", name="The Rusty Anchor", @@ -88,22 +88,22 @@ class TestEstablish: will=["If searched → reveal tunnel"], ) - loc_file = ctx.base_path / "worlds/test-world/locations/The Rusty Anchor.md" + loc_file = tmp_path / "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_map(self, ctx: ToolContext): + def test_establish_map(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="maps", name="Ashenmere District Map", ctx=ctx, description="```map\n┌────┐\n│ A │\n└────┘\n```", ) - map_file = ctx.base_path / "worlds/test-world/maps/Ashenmere District Map.md" + map_file = tmp_path / "worlds/test-world/maps/Ashenmere District Map.md" assert map_file.exists() - def test_establish_item(self, ctx: ToolContext): + def test_establish_item(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="items", name="The Skeleton Key", @@ -114,10 +114,10 @@ class TestEstablish: will=["Unlock any non-magical lock"], ) - item_file = ctx.base_path / "worlds/test-world/items/The Skeleton Key.md" + item_file = tmp_path / "worlds/test-world/items/The Skeleton Key.md" assert item_file.exists() - def test_establish_partial_update(self, ctx: ToolContext): + def test_establish_partial_update(self, ctx: ToolContext, tmp_path: Path): # Create initial entity establish( entity_type="npcs", @@ -137,11 +137,11 @@ class TestEstablish: description="A stern city guard, recently promoted to sergeant.", ) - content = (ctx.base_path / "worlds/test-world/npcs/Guard Mara.md").read_text() + content = (tmp_path / "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, ctx: ToolContext): + def test_establish_with_wikilinks(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Captain Harrik", @@ -152,11 +152,11 @@ class TestEstablish: will=[], ) - content = (ctx.base_path / "worlds/test-world/npcs/Captain Harrik.md").read_text() + content = (tmp_path / "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, ctx: ToolContext): + def test_establish_empty_sections_omitted(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Simple NPC", @@ -167,12 +167,12 @@ class TestEstablish: will=[], ) - content = (ctx.base_path / "worlds/test-world/npcs/Simple NPC.md").read_text() + content = (tmp_path / "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, ctx: ToolContext): + def test_establish_with_location(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Garrick the Jailer", @@ -182,11 +182,11 @@ class TestEstablish: knows=["Where the keys are kept"], ) - content = (ctx.base_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() + content = (tmp_path / "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 his fifties." in content - def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext): + def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext, tmp_path: Path): from storied.character import create_character create_character( @@ -198,7 +198,6 @@ class TestEstablish: abilities={"strength": 10, "dexterity": 16, "constitution": 12, "intelligence": 12, "wisdom": 12, "charisma": 14}, hp_max=24, ac=16, - base_path=ctx.base_path, ) result = establish( @@ -210,9 +209,9 @@ class TestEstablish: assert "Refused" in result assert "player character" in result - assert not (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() + assert not (tmp_path / "worlds/test-world/npcs/Mira.md").exists() - def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext): + def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext, tmp_path: Path): """The guard is NPC-scoped — an NPC can't share the PC's name, but a location or thread happening to be named 'Mira' is fine.""" from storied.character import create_character @@ -226,7 +225,6 @@ class TestEstablish: abilities={"strength": 10, "dexterity": 16, "constitution": 12, "intelligence": 12, "wisdom": 12, "charisma": 14}, hp_max=8, ac=14, - base_path=ctx.base_path, ) result = establish( @@ -237,10 +235,10 @@ class TestEstablish: ) assert "Established" in result - assert (ctx.base_path / "worlds/test-world/locations/Mira.md").exists() + assert (tmp_path / "worlds/test-world/locations/Mira.md").exists() def test_establish_allows_npc_matching_pc_name_with_no_character( - self, ctx: ToolContext, + self, ctx: ToolContext, tmp_path: Path, ): """Without a character sheet on disk, the guard should not fire.""" result = establish( @@ -251,9 +249,9 @@ class TestEstablish: ) assert "Established" in result - assert (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() + assert (tmp_path / "worlds/test-world/npcs/Mira.md").exists() - def test_establish_location_preserved_on_update(self, ctx: ToolContext): + def test_establish_location_preserved_on_update(self, ctx: ToolContext, tmp_path: Path): # Create with location establish( entity_type="npcs", @@ -271,7 +269,7 @@ class TestEstablish: description="A weary traveler.", ) - content = (ctx.base_path / "worlds/test-world/npcs/Wanderer.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Wanderer.md").read_text() assert "**Location:** [[The Rusty Anchor]]" in content assert "weary traveler" in content @@ -279,7 +277,7 @@ class TestEstablish: class TestMark: """Tests for the mark tool.""" - def test_mark_appends_to_was(self, ctx: ToolContext): + def test_mark_appends_to_was(self, ctx: ToolContext, tmp_path: Path): # Create entity first establish( entity_type="npcs", @@ -299,11 +297,11 @@ class TestMark: ) assert "Marked" in result - content = (ctx.base_path / "worlds/test-world/npcs/Vera Blackwater.md").read_text() + content = (tmp_path / "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, ctx: ToolContext): + def test_mark_includes_timestamp(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Test NPC", @@ -318,12 +316,12 @@ class TestMark: ctx=ctx, ) - content = (ctx.base_path / "worlds/test-world/npcs/Test NPC.md").read_text() + content = (tmp_path / "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, ctx: ToolContext): + def test_mark_resolves_will_trigger(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Vera Blackwater", @@ -342,13 +340,13 @@ class TestMark: resolves=["If trusted → introduce to Harrik"], ) - content = (ctx.base_path / "worlds/test-world/npcs/Vera Blackwater.md").read_text() + content = (tmp_path / "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, ctx: ToolContext): + def test_mark_resolves_multiple_triggers(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Complex NPC", @@ -371,7 +369,7 @@ class TestMark: resolves=["If friendly → share rumors", "If trusted → reveal secret"], ) - content = (ctx.base_path / "worlds/test-world/npcs/Complex NPC.md").read_text() + content = (tmp_path / "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 @@ -380,7 +378,7 @@ class TestMark: # Result should mention resolving multiple assert "resolved 2 triggers" in result - def test_mark_multiple_events(self, ctx: ToolContext): + def test_mark_multiple_events(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="locations", name="The Docks", @@ -405,7 +403,7 @@ class TestMark: ctx=ctx, ) - content = (ctx.base_path / "worlds/test-world/locations/The Docks.md").read_text() + content = (tmp_path / "worlds/test-world/locations/The Docks.md").read_text() assert "Player arrived" in content assert "witnessed smugglers" in content @@ -419,7 +417,7 @@ class TestMark: assert "not found" in result.lower() - def test_mark_with_when_backdates_the_entry(self, ctx: ToolContext): + def test_mark_with_when_backdates_the_entry(self, ctx: ToolContext, tmp_path: Path): # Advance the clock to d5-1400 so the current time is clearly # distinct from the backdated time we're about to pass. ctx.campaign_log.append_entry("Clock advance", "5 days") @@ -439,14 +437,14 @@ class TestMark: ctx=ctx, ) - content = (ctx.base_path / "worlds/test-world/npcs/Dortha Cray.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Dortha Cray.md").read_text() # The timestamp on the Was entry should be the backdated one, # not the current clock time. assert "#d1-0900" in content # And crucially there is NO double-timestamp prefix. assert "#d1-0900 | #" not in content - def test_mark_with_when_accepts_hash_prefix(self, ctx: ToolContext): + def test_mark_with_when_accepts_hash_prefix(self, ctx: ToolContext, tmp_path: Path): establish(entity_type="npcs", name="Somebody", ctx=ctx, description="x") mark( entity_type="npcs", @@ -455,7 +453,7 @@ class TestMark: when="#d2-1430", ctx=ctx, ) - content = (ctx.base_path / "worlds/test-world/npcs/Somebody.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Somebody.md").read_text() assert "#d2-1430" in content def test_mark_with_invalid_when_falls_back_gracefully( @@ -478,7 +476,7 @@ class TestMark: class TestAmendMark: """Tests for the amend_mark tool — replaces the most recent Was entry.""" - def test_amend_replaces_most_recent_entry(self, ctx: ToolContext): + def test_amend_replaces_most_recent_entry(self, ctx: ToolContext, tmp_path: Path): establish(entity_type="npcs", name="Vera", ctx=ctx, description="x") mark( entity_type="npcs", name="Vera", @@ -492,11 +490,11 @@ class TestAmendMark: ) assert "Amended" in result - content = (ctx.base_path / "worlds/test-world/npcs/Vera.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Vera.md").read_text() assert "full truth" in content assert "half-truth" not in content - def test_amend_preserves_anchor(self, ctx: ToolContext): + def test_amend_preserves_anchor(self, ctx: ToolContext, tmp_path: Path): establish(entity_type="npcs", name="Tam", ctx=ctx, description="x") mark( entity_type="npcs", name="Tam", @@ -508,10 +506,10 @@ class TestAmendMark: event="Corrected beat", ctx=ctx, ) - content = (ctx.base_path / "worlds/test-world/npcs/Tam.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Tam.md").read_text() assert "#d5-1200 | Corrected beat" in content - def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext): + def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext, tmp_path: Path): establish(entity_type="npcs", name="Oben", ctx=ctx, description="x") mark(entity_type="npcs", name="Oben", event="First beat", ctx=ctx) ctx.campaign_log.append_entry("advance", "1 hour") @@ -522,7 +520,7 @@ class TestAmendMark: event="Second beat, corrected", ctx=ctx, ) - content = (ctx.base_path / "worlds/test-world/npcs/Oben.md").read_text() + content = (tmp_path / "worlds/test-world/npcs/Oben.md").read_text() assert "First beat" in content assert "Second beat, corrected" in content assert "- Second beat\n" not in content # old unamended line gone @@ -563,7 +561,7 @@ class TestWikilinkResolution: links = extract_wiki_links(text) assert links == ["Vera", "Vera", "Bob"] - def test_resolve_wiki_link_npc(self, ctx: ToolContext): + def test_resolve_wiki_link_npc(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Vera Blackwater", @@ -571,12 +569,12 @@ class TestWikilinkResolution: description="Test.", ) - path = resolve_wiki_link("Vera Blackwater", "test-world", ctx.base_path) + path = resolve_wiki_link("Vera Blackwater", "test-world") assert path is not None assert path.name == "Vera Blackwater.md" assert "npcs" in str(path) - def test_resolve_wiki_link_location(self, ctx: ToolContext): + def test_resolve_wiki_link_location(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="locations", name="The Rusty Anchor", @@ -584,25 +582,25 @@ class TestWikilinkResolution: description="Test.", ) - path = resolve_wiki_link("The Rusty Anchor", "test-world", ctx.base_path) + path = resolve_wiki_link("The Rusty Anchor", "test-world") assert path is not None assert "locations" in str(path) - def test_resolve_wiki_link_not_found(self, ctx: ToolContext): - path = resolve_wiki_link("Nonexistent", "test-world", ctx.base_path) + def test_resolve_wiki_link_not_found(self, ctx: ToolContext, tmp_path: Path): + path = resolve_wiki_link("Nonexistent", "test-world") assert path is None - def test_resolve_wiki_link_priority_order(self, ctx: ToolContext): + def test_resolve_wiki_link_priority_order(self, ctx: ToolContext, tmp_path: Path): # Create same name in multiple directories - npcs should win - (ctx.base_path / "worlds/test-world/npcs").mkdir(parents=True, exist_ok=True) - (ctx.base_path / "worlds/test-world/locations").mkdir(parents=True, exist_ok=True) + (tmp_path / "worlds/test-world/npcs").mkdir(parents=True, exist_ok=True) + (tmp_path / "worlds/test-world/locations").mkdir(parents=True, exist_ok=True) - (ctx.base_path / "worlds/test-world/npcs/Ambiguous.md").write_text("# NPC version") - (ctx.base_path / "worlds/test-world/locations/Ambiguous.md").write_text( + (tmp_path / "worlds/test-world/npcs/Ambiguous.md").write_text("# NPC version") + (tmp_path / "worlds/test-world/locations/Ambiguous.md").write_text( "# Location version" ) - path = resolve_wiki_link("Ambiguous", "test-world", ctx.base_path) + path = resolve_wiki_link("Ambiguous", "test-world") assert path is not None assert "npcs" in str(path) # NPCs have priority over locations @@ -610,7 +608,7 @@ class TestWikilinkResolution: class TestLoadEntityContent: """Tests for loading entity content by name.""" - def test_load_entity_content(self, ctx: ToolContext): + def test_load_entity_content(self, ctx: ToolContext, tmp_path: Path): establish( entity_type="npcs", name="Test Character", @@ -619,15 +617,15 @@ class TestLoadEntityContent: knows=["A secret"], ) - entity = load_entity_content("Test Character", "test-world", ctx.base_path) + entity = load_entity_content("Test Character", "test-world") 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, ctx: ToolContext): - entity = load_entity_content("Nobody", "test-world", ctx.base_path) + def test_load_entity_content_not_found(self, ctx: ToolContext, tmp_path: Path): + entity = load_entity_content("Nobody", "test-world") assert entity is None @@ -635,9 +633,11 @@ class TestLoadEntityContent: @pytest.fixture -def indexed_world(ctx: ToolContext) -> tuple[Path, EntityIndex]: +def indexed_world( + ctx: ToolContext, tmp_path: Path, +) -> tuple[Path, EntityIndex]: """Create a world with entities and build an index.""" - world_dir = ctx.base_path / "worlds" / "test-world" + world_dir = tmp_path / "worlds" / "test-world" for etype, name in [("npcs", "Vera"), ("locations", "Tavern"), ("items", "Sword")]: d = world_dir / etype d.mkdir(parents=True, exist_ok=True) @@ -698,8 +698,8 @@ class TestEntityIndex: class TestEstablishWithIndex: """Tests that establish writes through to the index and cache.""" - def test_establish_registers_in_index(self, ctx: ToolContext): - ctx.entity_index = EntityIndex(ctx.base_path / "worlds" / ctx.world_id) + def test_establish_registers_in_index(self, ctx: ToolContext, tmp_path: Path): + ctx.entity_index = EntityIndex(tmp_path / "worlds" / ctx.world_id) establish( entity_type="npcs", name="New NPC", @@ -708,8 +708,8 @@ class TestEstablishWithIndex: ) assert ctx.entity_index.resolve("New NPC") is not None - def test_establish_caches_entity(self, ctx: ToolContext): - ctx.entity_index = EntityIndex(ctx.base_path / "worlds" / ctx.world_id) + def test_establish_caches_entity(self, ctx: ToolContext, tmp_path: Path): + ctx.entity_index = EntityIndex(tmp_path / "worlds" / ctx.world_id) establish( entity_type="npcs", name="Cached NPC", @@ -723,8 +723,8 @@ class TestEstablishWithIndex: assert cached["description"] == "Cached." assert cached["knows"] == ["a secret"] - def test_mark_updates_cache(self, ctx: ToolContext): - ctx.entity_index = EntityIndex(ctx.base_path / "worlds" / ctx.world_id) + def test_mark_updates_cache(self, ctx: ToolContext, tmp_path: Path): + ctx.entity_index = EntityIndex(tmp_path / "worlds" / ctx.world_id) establish( entity_type="npcs", name="Markable", diff --git a/tests/test_execute_tool.py b/tests/test_execute_tool.py index 45ac363..2ec8803 100644 --- a/tests/test_execute_tool.py +++ b/tests/test_execute_tool.py @@ -4,6 +4,8 @@ These tests exercise the same call path the production server uses (claude → MCP → tool function), but in-process via fastmcp.Client. """ +from pathlib import Path + import asyncio from typing import Any @@ -111,7 +113,7 @@ class TestToolDispatch: class TestUpdateCharacter: - def test_landed_in_state_hp_current(self, ctx: ToolContext): + def test_landed_in_state_hp_current(self, ctx: ToolContext, tmp_path: Path): call("create_character", { "name": "Test", "race": "Human", "char_class": "Fighter", "level": 1, "abilities": { @@ -123,7 +125,7 @@ class TestUpdateCharacter: result = call("update_character", {"updates": {"state.hp.current": 8}}) assert "updated" in result.lower() - data = load_character(ctx.player_id, ctx.base_path) + data = load_character(ctx.player_id) assert data["state"]["hp"]["current"] == 8, ( "update_character should write to state.hp.current with the new schema, " f"but state.hp.current is {data['state']['hp']['current']}" @@ -136,29 +138,29 @@ class TestUpdateCharacter: class TestNoteDiscoveryDirect: """Direct (non-MCP) calls to note_discovery exercise the wrapper itself.""" - def test_creates_knowledge_file(self, ctx: ToolContext): + def test_creates_knowledge_file(self, ctx: ToolContext, tmp_path: Path): call_tool(_note_discovery, entity="The Rusty Anchor", content="A seedy tavern on the docks") knowledge_dir = ( - ctx.base_path / "players" / ctx.player_id / "worlds" + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" ) assert any(knowledge_dir.iterdir()) - def test_with_content_type(self, ctx: ToolContext): + def test_with_content_type(self, ctx: ToolContext, tmp_path: Path): call_tool(_note_discovery, entity="Vera", content="Tavern owner", content_type="npcs") knowledge_dir = ( - ctx.base_path / "players" / ctx.player_id / "worlds" + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "npcs" ) assert any(knowledge_dir.iterdir()) - def test_with_tags(self, ctx: ToolContext): + def test_with_tags(self, ctx: ToolContext, tmp_path: Path): call_tool(_note_discovery, entity="Old Map", content="Shows a hidden passage", tags=["quest"]) knowledge_dir = ( - ctx.base_path / "players" / ctx.player_id / "worlds" + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" ) content = next(knowledge_dir.iterdir()).read_text() @@ -183,8 +185,8 @@ class TestEndSessionDirect: class TestRecall: - def test_recall_finds_indexed_entity(self, ctx: ToolContext): - entity_dir = ctx.base_path / "worlds" / ctx.world_id / "npcs" + def test_recall_finds_indexed_entity(self, ctx: ToolContext, tmp_path: Path): + entity_dir = tmp_path / "worlds" / ctx.world_id / "npcs" entity_dir.mkdir(parents=True, exist_ok=True) entity_file = entity_dir / "Vera Blackwater.md" entity_file.write_text("# Vera Blackwater\n\nTavern owner.\n") @@ -233,7 +235,7 @@ class TestDamageHealCombat: assert "3" in result assert ctx.initiative._find("Goblin").hp == 4 - def test_damage_syncs_player_hp(self, ctx: ToolContext): + def test_damage_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): call("create_character", { "name": "Kira", "race": "Human", "char_class": "Fighter", "level": 1, "abilities": { @@ -251,12 +253,12 @@ class TestDamageHealCombat: ) assert "synced" in result - char = load_character(ctx.player_id, ctx.base_path) + char = load_character(ctx.player_id) assert char["state"]["hp"]["current"] == 18, ( "_sync_player_hp must write to state.hp.current with the new schema" ) - def test_heal_syncs_player_hp(self, ctx: ToolContext): + def test_heal_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): call("create_character", { "name": "Kira", "race": "Human", "char_class": "Fighter", "level": 1, "abilities": { @@ -275,7 +277,7 @@ class TestDamageHealCombat: ) assert "synced" in result - char = load_character(ctx.player_id, ctx.base_path) + char = load_character(ctx.player_id) assert char["state"]["hp"]["current"] == 23, ( "_sync_player_hp must write to state.hp.current with the new schema" ) @@ -473,7 +475,7 @@ class TestCharacterToolWrappers: def test_damage_player_by_name(self, kira: ToolContext): result = call("damage", {"target": "Kira", "amount": 3}) assert "3" in result - char = load_character("default", kira.base_path) + char = load_character("default") assert char["state"]["hp"]["current"] == 9 def test_damage_with_type(self, kira: ToolContext): @@ -484,13 +486,13 @@ class TestCharacterToolWrappers: call("damage", {"target": "Kira", "amount": 5}) result = call("heal", {"target": "Kira", "amount": 3}) assert result - char = load_character("default", kira.base_path) + char = load_character("default") assert char["state"]["hp"]["current"] == 10 def test_adjust_coins(self, kira: ToolContext): result = call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) assert "10" in result - char = load_character("default", kira.base_path) + char = load_character("default") assert char["state"]["purse"]["gp"] == 10 assert char["state"]["purse"]["sp"] == 5 @@ -498,7 +500,7 @@ class TestCharacterToolWrappers: # Spending only gp; the zero-delta filter exercises the comprehension branch call("adjust_coins", {"deltas": {"gp": 5}}) result = call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) - char = load_character("default", kira.base_path) + char = load_character("default") assert char["state"]["purse"]["gp"] == 2 assert "silver" not in result.lower() @@ -618,7 +620,7 @@ class TestSceneToolWrappers: assert "Auto-marked: Vera" in result def test_auto_mark_cooldown_suppresses_near_repeats( - self, ctx: ToolContext, + self, ctx: ToolContext, tmp_path: Path, ): """A second set_scene with the same present entity within the cooldown window should NOT append another Was entry.""" @@ -640,7 +642,7 @@ class TestSceneToolWrappers: assert "Auto-marked" not in result2 content = ( - ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Margit.md" + tmp_path / "worlds" / ctx.world_id / "npcs" / "Margit.md" ).read_text() assert content.count("Met Mira at the candle shop") == 1 assert "Walked together to dinner" not in content @@ -670,7 +672,7 @@ class TestSceneToolWrappers: assert "Auto-marked: Aldric" in result3 - def test_auto_mark_skips_operational_events(self, ctx: ToolContext): + def test_auto_mark_skips_operational_events(self, ctx: ToolContext, tmp_path: Path): """Session-lifecycle events (Session resumed, etc) must never land in an entity's history.""" call("establish", { @@ -685,7 +687,7 @@ class TestSceneToolWrappers: assert "Auto-marked" not in result content = ( - ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md" + tmp_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md" ).read_text() assert "Session resumed" not in content @@ -702,10 +704,10 @@ class TestSceneToolWrappers: result = call("set_scene", {}) assert result == "No updates" - def test_tune_writes_style_file(self, ctx: ToolContext): + def test_tune_writes_style_file(self, ctx: ToolContext, tmp_path: Path): result = call("tune", {"tuning": "Lean into intrigue and slow pacing."}) assert "updated" in result.lower() - style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" + style_path = tmp_path / "worlds" / ctx.world_id / "style.md" assert style_path.exists() assert "intrigue" in style_path.read_text() @@ -720,10 +722,10 @@ class TestSceneToolWrappers: }) assert result == "SESSION_ENDED" - def test_notify_dm_appends_to_queue(self, ctx: ToolContext): + def test_notify_dm_appends_to_queue(self, ctx: ToolContext, tmp_path: Path): result = call("notify_dm", {"message": "Background world has shifted"}) assert "queued" in result.lower() - path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" assert path.exists() assert "Background world has shifted" in path.read_text() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index bf655e3..3589259 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -284,7 +284,11 @@ class TestAdvancementVisibility: class TestPopulateIndex: - """Cover the SRD-seeding helper without launching a real server.""" + """Cover the SRD-seeding helper without launching a real server. + + Tests pass an explicit ``srd_root`` pointed at a tmp path so the + real package rules directory isn't touched. + """ def test_no_srd_no_world_dir(self, tmp_path): from unittest.mock import MagicMock @@ -292,7 +296,11 @@ class TestPopulateIndex: from storied.mcp_server import _populate_index vi = MagicMock() - _populate_index(tmp_path, tmp_path / "worlds" / "missing", vi) + _populate_index( + tmp_path / "worlds" / "missing", + vi, + srd_root=tmp_path / "srd-missing", + ) # No SRD seed, no SRD sections, no world dir → nothing should be called vi.reseed.assert_not_called() vi.reindex_directory.assert_not_called() @@ -305,7 +313,9 @@ class TestPopulateIndex: world_dir = tmp_path / "worlds" / "test" world_dir.mkdir(parents=True) vi = MagicMock() - _populate_index(tmp_path, world_dir, vi) + _populate_index( + world_dir, vi, srd_root=tmp_path / "srd-missing", + ) vi.reindex_directory.assert_called_once_with(world_dir, source="world") def test_srd_sections_dir(self, tmp_path): @@ -313,15 +323,42 @@ class TestPopulateIndex: from storied.mcp_server import _populate_index - srd_dir = tmp_path / "rules" / "srd-5.2.1" / "sections" + srd_root = tmp_path / "srd-5.2.1" + srd_dir = srd_root / "sections" srd_dir.mkdir(parents=True) world_dir = tmp_path / "worlds" / "test" vi = MagicMock() - _populate_index(tmp_path, world_dir, vi) - # SRD sections present → reindex SRD; no world dir → no second call + _populate_index(world_dir, vi, srd_root=srd_root) + # SRD sections present → reindex SRD; no user layer, no world dir assert vi.reindex_directory.call_count == 1 vi.reindex_directory.assert_called_with(srd_dir, source="srd") + def test_user_layer_indexed(self, tmp_path): + """When the user homebrew directory exists, _populate_index + reindexes it with source='user' after the shipped SRD.""" + from unittest.mock import MagicMock + + from storied.mcp_server import _populate_index + + # The autouse fixture sets _user_rules_home = tmp_path / "rules". + user_dir = tmp_path / "rules" + user_dir.mkdir(parents=True) + (user_dir / "monsters").mkdir() + (user_dir / "monsters" / "homebrew.md").write_text("# Homebrew") + + world_dir = tmp_path / "worlds" / "test" + world_dir.mkdir(parents=True) + + vi = MagicMock() + _populate_index( + world_dir, vi, srd_root=tmp_path / "srd-missing", + ) + # Should have indexed user and world, in that order + calls = vi.reindex_directory.call_args_list + assert len(calls) == 2 + assert calls[0] == ((user_dir,), {"source": "user"}) + assert calls[1] == ((world_dir,), {"source": "world"}) + def test_flip_helpers_no_op_when_root_unset(self): """The combat-tag flip helpers must not crash when no top-level server has been registered (e.g. when the combat module is imported @@ -345,14 +382,15 @@ class TestPopulateIndex: from storied.mcp_server import _populate_index - srd_seed = tmp_path / "rules" / "srd-5.2.1" / "search.db" - srd_seed.parent.mkdir(parents=True) + srd_root = tmp_path / "srd-5.2.1" + srd_root.mkdir(parents=True) + srd_seed = srd_root / "search.db" srd_seed.write_bytes(b"sqlite stub") # Also create the sections dir to verify the seed wins - (tmp_path / "rules" / "srd-5.2.1" / "sections").mkdir() + (srd_root / "sections").mkdir() world_dir = tmp_path / "worlds" / "test" vi = MagicMock() - _populate_index(tmp_path, world_dir, vi) + _populate_index(world_dir, vi, srd_root=srd_root) vi.reseed.assert_called_once_with(srd_seed) # When the seed exists, we don't also reindex SRD sections vi.reindex_directory.assert_not_called() diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 06b074a..ffc43a0 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -8,63 +8,58 @@ from storied import notifications @pytest.fixture -def world(tmp_path: Path) -> tuple[str, Path]: - """Return (world_id, base_path) with world directory created.""" +def world(tmp_path: Path) -> str: + """Return the world_id with world directory created under the + autouse-isolated data home.""" world_dir = tmp_path / "worlds" / "test-world" world_dir.mkdir(parents=True) - return "test-world", tmp_path + return "test-world" class TestAppendAndDrain: - def test_drain_empty(self, world: tuple[str, Path]): - world_id, base_path = world - assert notifications.drain(world_id, base_path) == [] + def test_drain_empty(self, world: str): + assert notifications.drain(world) == [] - def test_append_then_drain(self, world: tuple[str, Path]): - world_id, base_path = world - notifications.append(world_id, base_path, "Something happened") + def test_append_then_drain(self, world: str): + notifications.append(world, "Something happened") - messages = notifications.drain(world_id, base_path) + messages = notifications.drain(world) assert messages == ["Something happened"] - def test_drain_clears(self, world: tuple[str, Path]): - world_id, base_path = world - notifications.append(world_id, base_path, "First") + def test_drain_clears(self, world: str): + notifications.append(world, "First") - notifications.drain(world_id, base_path) - assert notifications.drain(world_id, base_path) == [] + notifications.drain(world) + assert notifications.drain(world) == [] - def test_multiple_messages(self, world: tuple[str, Path]): - world_id, base_path = world - notifications.append(world_id, base_path, "First thing") - notifications.append(world_id, base_path, "Second thing") - notifications.append(world_id, base_path, "Third thing") + def test_multiple_messages(self, world: str): + notifications.append(world, "First thing") + notifications.append(world, "Second thing") + notifications.append(world, "Third thing") - messages = notifications.drain(world_id, base_path) + messages = notifications.drain(world) assert messages == ["First thing", "Second thing", "Third thing"] - def test_file_removed_after_drain(self, world: tuple[str, Path]): - world_id, base_path = world - notifications.append(world_id, base_path, "Temporary") + def test_file_removed_after_drain(self, world: str, tmp_path: Path): + notifications.append(world, "Temporary") - notifications.drain(world_id, base_path) - path = base_path / "worlds" / "test-world" / "dm_notifications.md" + notifications.drain(world) + path = tmp_path / "worlds" / "test-world" / "dm_notifications.md" assert not path.exists() - def test_creates_world_dir_if_missing(self, tmp_path: Path): - notifications.append("new-world", tmp_path, "Hello") + def test_creates_world_dir_if_missing(self): + notifications.append("new-world", "Hello") - messages = notifications.drain("new-world", tmp_path) + messages = notifications.drain("new-world") assert messages == ["Hello"] def test_drain_empty_existing_file_returns_empty( - self, world: tuple[str, Path], + self, world: str, tmp_path: Path, ): """An existing notifications file with only whitespace drains as empty and is removed.""" - world_id, base_path = world - path = base_path / "worlds" / "test-world" / "dm_notifications.md" + path = tmp_path / "worlds" / "test-world" / "dm_notifications.md" path.write_text(" \n \n") - assert notifications.drain(world_id, base_path) == [] + assert notifications.drain(world) == [] assert not path.exists() diff --git a/tests/test_planner.py b/tests/test_planner.py index e4113fd..1d68d94 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -61,22 +61,22 @@ def populated_world(ctx: ToolContext) -> ToolContext: class TestEntityRichness: """Tests for richness scoring.""" - def test_empty_entity_scores_zero(self, ctx: ToolContext): + def test_empty_entity_scores_zero(self, ctx: ToolContext, tmp_path: Path): call_tool(establish, entity_type="npcs", name="Empty") - path = ctx.base_path / "worlds/test-world/npcs/Empty.md" + path = tmp_path / "worlds/test-world/npcs/Empty.md" assert entity_richness(path) == 0.0 - def test_description_only(self, ctx: ToolContext): + def test_description_only(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", name="Described", description="A tall warrior.", ) - path = ctx.base_path / "worlds/test-world/npcs/Described.md" + path = tmp_path / "worlds/test-world/npcs/Described.md" assert entity_richness(path) == pytest.approx(0.2) - def test_fully_rich_entity(self, ctx: ToolContext): + def test_fully_rich_entity(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", @@ -92,11 +92,11 @@ class TestEntityRichness: name="Rich NPC", event="Something happened", ) - path = ctx.base_path / "worlds/test-world/npcs/Rich NPC.md" + path = tmp_path / "worlds/test-world/npcs/Rich NPC.md" score = entity_richness(path) assert score == pytest.approx(1.0) - def test_partial_richness(self, ctx: ToolContext): + def test_partial_richness(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", @@ -104,19 +104,19 @@ class TestEntityRichness: description="Has description and knows.", knows=["A secret"], ) - path = ctx.base_path / "worlds/test-world/npcs/Partial.md" + path = tmp_path / "worlds/test-world/npcs/Partial.md" score = entity_richness(path) # description (0.2) + knows (0.2) = 0.4 assert score == pytest.approx(0.4) - def test_wikilinks_contribute(self, ctx: ToolContext): + def test_wikilinks_contribute(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", name="Linked", description="Hangs out at [[The Tavern]] with [[Bob]].", ) - path = ctx.base_path / "worlds/test-world/npcs/Linked.md" + path = tmp_path / "worlds/test-world/npcs/Linked.md" score = entity_richness(path) # description (0.2) + wikilinks (0.1) = 0.3 assert score == pytest.approx(0.3) @@ -130,7 +130,7 @@ class TestFindNearbyEntities: "location": "Town Square", "body": "", } - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) + nearby = find_nearby_entities(session, "test-world") names = {name for name, _ in nearby} assert "Old Gregor" in names assert "Millford" in names @@ -140,7 +140,7 @@ class TestFindNearbyEntities: "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", } - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) + nearby = find_nearby_entities(session, "test-world") names = {name for name, _ in nearby} assert "Thin NPC" in names @@ -149,7 +149,7 @@ class TestFindNearbyEntities: "location": "Town Square", "body": "", } - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) + nearby = find_nearby_entities(session, "test-world") names = {name for name, _ in nearby} assert "Town Square" in names @@ -158,13 +158,13 @@ class TestFindNearbyEntities: "location": "Town Square", "body": "## Present\n- [[Old Gregor]]", } - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) + nearby = find_nearby_entities(session, "test-world") names = [name for name, _ in nearby] assert names.count("Old Gregor") == 1 def test_empty_session(self, ctx: ToolContext): session = {"body": ""} - nearby = find_nearby_entities(session, "test-world", ctx.base_path) + nearby = find_nearby_entities(session, "test-world") assert nearby == [] @@ -215,28 +215,26 @@ class TestBuildPlanningContext: "location": "Town Square", "body": "## Situation\nThe player just arrived.\n\n## Open Threads\n- Find the missing cat", }, - populated_world.base_path, ) context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, - candidates=[("Thin NPC", populated_world.base_path / "worlds/test-world/npcs/Thin NPC.md")], + candidates=[], ) assert "Town Square" in context assert "missing cat" in context - def test_includes_candidate_content(self, populated_world: ToolContext): + def test_includes_candidate_content( + self, populated_world: ToolContext, tmp_path: Path, + ): save_session( "default", {"location": "Town Square", "body": ""}, - populated_world.base_path, ) - path = populated_world.base_path / "worlds/test-world/npcs/Thin NPC.md" + path = tmp_path / "worlds/test-world/npcs/Thin NPC.md" context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, candidates=[("Thin NPC", path)], ) assert "Thin NPC" in context @@ -246,12 +244,10 @@ class TestBuildPlanningContext: save_session( "default", {"location": "Town Square", "body": ""}, - populated_world.base_path, ) context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, candidates=[], ) assert "No entities" in context @@ -260,7 +256,6 @@ class TestBuildPlanningContext: save_session( "default", {"location": "Town Square", "body": ""}, - populated_world.base_path, ) populated_world.campaign_log.append_entry("Fought three goblins in the clearing", "5 rounds") populated_world.campaign_log.append_entry("Spotted two lookouts near the old mill", "30 min") @@ -268,7 +263,6 @@ class TestBuildPlanningContext: context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, candidates=[], ) assert "Recent Events" in context @@ -286,12 +280,10 @@ class TestPlanWorld: "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", }, - populated_world.base_path, ) result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, dry_run=True, ) assert isinstance(result, PlanResult) @@ -304,12 +296,10 @@ class TestPlanWorld: save_session( "default", {"location": "Town Square", "body": ""}, - populated_world.base_path, ) result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, dry_run=True, threshold=0.0, ) @@ -319,12 +309,10 @@ class TestPlanWorld: save_session( "default", {"location": "Town Square", "body": ""}, - populated_world.base_path, ) result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, dry_run=True, max_entities=1, ) @@ -334,7 +322,6 @@ class TestPlanWorld: result = plan_world( world_id=ctx.world_id, player_id=ctx.player_id, - base_path=ctx.base_path, dry_run=True, ) assert len(result.candidates) == 0 @@ -347,7 +334,6 @@ class TestPlanWorld: "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", }, - populated_world.base_path, ) # Mock subprocess returning a result event @@ -368,7 +354,6 @@ class TestPlanWorld: result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, model="claude-opus-4-6", ) @@ -385,7 +370,6 @@ class TestPlanWorld: "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", }, - populated_world.base_path, ) # Stream with tool_use events followed by result @@ -417,7 +401,6 @@ class TestPlanWorld: result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, model="claude-opus-4-6", ) @@ -428,8 +411,8 @@ class TestPlanWorld: class TestFindEntitiesWithWill: - def test_returns_empty_when_world_missing(self, ctx: ToolContext, tmp_path: Path): - results = _find_entities_with_will("nonexistent", tmp_path) + def test_returns_empty_when_world_missing(self, ctx: ToolContext): + results = _find_entities_with_will("nonexistent") assert results == [] def test_finds_entities_with_will_triggers(self, populated_world: ToolContext): @@ -448,7 +431,7 @@ class TestFindEntitiesWithWill: description="No triggers.", ) results = _find_entities_with_will( - populated_world.world_id, populated_world.base_path, + populated_world.world_id, ) names = {name for name, _ in results} assert "Triggered" in names @@ -460,7 +443,7 @@ class TestFindEntitiesWithWill: # The fixture creates npcs and locations but not items/factions/threads. # The function should iterate without crashing. results = _find_entities_with_will( - populated_world.world_id, populated_world.base_path, + populated_world.world_id, ) assert isinstance(results, list) @@ -468,8 +451,7 @@ class TestFindEntitiesWithWill: class TestBuildTickContext: def test_includes_current_time(self, populated_world: ToolContext): ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, - populated_world.base_path, entities=[], + populated_world.world_id, populated_world.player_id, entities=[], ) assert "Current Game Time" in ctx_str @@ -477,11 +459,10 @@ class TestBuildTickContext: save_session("default", { "location": "Town Square", "body": "## Present\n- [[Old Gregor]]", - }, populated_world.base_path) + }) ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, - populated_world.base_path, entities=[], + populated_world.world_id, populated_world.player_id, entities=[], ) assert "Town Square" in ctx_str assert "Old Gregor" in ctx_str @@ -494,8 +475,7 @@ class TestBuildTickContext: "Found the secret door", "5 min", ) ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, - populated_world.base_path, entities=[], + populated_world.world_id, populated_world.player_id, entities=[], ) assert "Recent Events" in ctx_str assert "Met the merchant" in ctx_str @@ -509,11 +489,10 @@ class TestBuildTickContext: will=["If alone → emerge"], ) triggers = _find_entities_with_will( - populated_world.world_id, populated_world.base_path, + populated_world.world_id, ) ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, - populated_world.base_path, entities=triggers, + populated_world.world_id, populated_world.player_id, entities=triggers, ) assert "Active Triggers" in ctx_str assert "Lurker" in ctx_str @@ -530,7 +509,7 @@ class TestBackgroundTicker: def test_init_stores_state(self, tmp_path: Path): ticker = BackgroundTicker( - world_id="test", player_id="default", base_path=tmp_path, + world_id="test", player_id="default", ) assert ticker._world_id == "test" assert ticker._last_tick_day == 0 @@ -542,7 +521,6 @@ class TestBackgroundTicker: ticker = BackgroundTicker( world_id=populated_world.world_id, player_id=populated_world.player_id, - base_path=populated_world.base_path, ) ticker._last_tick_day = populated_world.campaign_log.current_day ticker.maybe_tick(populated_world.campaign_log) @@ -555,7 +533,6 @@ class TestBackgroundTicker: ticker = BackgroundTicker( world_id=ctx.world_id, player_id=ctx.player_id, - base_path=ctx.base_path, ) # Advance the day so the day-changed guard passes ctx.campaign_log.append_entry("Travel", "18 hours") @@ -565,6 +542,6 @@ class TestBackgroundTicker: def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): ticker = BackgroundTicker( - world_id="test", player_id="default", base_path=tmp_path, + world_id="test", player_id="default", ) assert ticker.pop_result() is None diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 9e44b99..f79045a 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -157,6 +157,6 @@ class TestToolSignatures: sigs = build_tool_signatures() # None of the Dependency-class instances should appear as defaults - for marker in ("Combat()", "Lore()", "StorageRoot()", "Player()", + for marker in ("Combat()", "Lore()", "Player()", "Timekeeper()", "Entities()", "World()"): assert marker not in sigs diff --git a/tests/test_seeder.py b/tests/test_seeder.py index 3698f2c..e4a093d 100644 --- a/tests/test_seeder.py +++ b/tests/test_seeder.py @@ -54,7 +54,6 @@ def character_world(tmp_path: Path) -> Path: purse={"gp": 50}, equipment={"on_person": ["Longsword", "Chain mail", "Shield"]}, backstory="A former soldier haunted by a battle gone wrong.", - base_path=tmp_path, ) return tmp_path @@ -83,7 +82,6 @@ class TestSeedWorld: seed_world( world_id="default", player_id="default", - base_path=character_world, ) # Verify the subprocess was called with claude args @@ -133,7 +131,6 @@ class TestSeedWorld: result = seed_world( world_id="default", player_id="default", - base_path=character_world, ) assert result.tool_calls == 2 @@ -159,7 +156,6 @@ class TestSeedWorld: result = seed_world( world_id="default", player_id="default", - base_path=character_world, ) assert isinstance(result, SeedResult) @@ -200,7 +196,6 @@ class TestSeedWorld: seed_world( world_id="default", player_id="default", - base_path=character_world, on_progress=progress_messages.append, ) @@ -210,7 +205,6 @@ class TestSeedWorld: result = seed_world( world_id="default", player_id="default", - base_path=tmp_path, ) assert isinstance(result, SeedResult) assert result.tool_calls == 0 diff --git a/tests/test_session.py b/tests/test_session.py index 716d74e..9e58d2b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -39,8 +39,8 @@ def saved_session(session_base: Path) -> dict: "- Find the missing merchant" ), } - save_session("test-player", data, session_base) - return load_session("test-player", session_base) + save_session("test-player", data) + return load_session("test-player") # ── Parsing ────────────────────────────────────────────────────────────── @@ -67,17 +67,17 @@ class TestParseSession: class TestLoadSaveSession: def test_save_and_load(self, session_base: Path): - save_session("test-player", {"location": "docks", "body": "At the docks."}, session_base) - loaded = load_session("test-player", session_base) + save_session("test-player", {"location": "docks", "body": "At the docks."}) + loaded = load_session("test-player") assert loaded["location"] == "docks" assert "At the docks" in loaded["body"] def test_load_nonexistent(self, session_base: Path): - assert load_session("nobody", session_base) is None + assert load_session("nobody") is None def test_save_adds_timestamp(self, session_base: Path): - save_session("test-player", {"body": ""}, session_base) - loaded = load_session("test-player", session_base) + save_session("test-player", {"body": ""}) + loaded = load_session("test-player") assert "updated" in loaded @@ -89,19 +89,17 @@ class TestUpdateSession: result = update_session( "test-player", {"situation": "Escaped to the harbor."}, - session_base, ) assert "Updated situation" in result - loaded = load_session("test-player", session_base) + loaded = load_session("test-player") assert "Escaped to the harbor" in loaded["body"] def test_update_threads_from_list(self, session_base: Path, saved_session: dict): update_session( "test-player", {"threads": ["New thread one", "New thread two"]}, - session_base, ) - loaded = load_session("test-player", session_base) + loaded = load_session("test-player") assert "- New thread one" in loaded["body"] assert "- New thread two" in loaded["body"] @@ -109,42 +107,38 @@ class TestUpdateSession: update_session( "test-player", {"present": ["[[Captain Harrik]]"]}, - session_base, ) - loaded = load_session("test-player", session_base) + loaded = load_session("test-player") assert "Captain Harrik" in loaded["body"] def test_update_location(self, session_base: Path, saved_session: dict): result = update_session( "test-player", {"location": "harbor"}, - session_base, ) assert "location = harbor" in result - loaded = load_session("test-player", session_base) + loaded = load_session("test-player") assert loaded["location"] == "harbor" def test_creates_session_if_missing(self, session_base: Path): update_session( "test-player", {"situation": "Starting fresh."}, - session_base, ) - loaded = load_session("test-player", session_base) + loaded = load_session("test-player") assert "Starting fresh" in loaded["body"] def test_no_changes(self, session_base: Path, saved_session: dict): - result = update_session("test-player", {}, session_base) + result = update_session("test-player", {}) assert "No changes" in result def test_appends_new_section(self, session_base: Path): - save_session("test-player", {"body": ""}, session_base) + save_session("test-player", {"body": ""}) update_session( "test-player", {"situation": "Brand new situation."}, - session_base, ) - loaded = load_session("test-player", session_base) + loaded = load_session("test-player") assert "## Situation" in loaded["body"] assert "Brand new situation" in loaded["body"] @@ -172,7 +166,7 @@ class TestResolveWikiLink: npc_dir = tmp_path / "worlds" / "default" / "npcs" npc_dir.mkdir(parents=True) (npc_dir / "Vera Blackwater.md").write_text("---\nname: Vera\n---\n") - result = resolve_wiki_link("Vera Blackwater", "default", tmp_path) + result = resolve_wiki_link("Vera Blackwater", "default") assert result is not None assert result.name == "Vera Blackwater.md" @@ -180,12 +174,12 @@ class TestResolveWikiLink: loc_dir = tmp_path / "worlds" / "default" / "locations" loc_dir.mkdir(parents=True) (loc_dir / "The Rusty Anchor.md").write_text("---\nname: The Rusty Anchor\n---\n") - result = resolve_wiki_link("The Rusty Anchor", "default", tmp_path) + result = resolve_wiki_link("The Rusty Anchor", "default") assert result is not None def test_not_found(self, tmp_path: Path): (tmp_path / "worlds" / "default").mkdir(parents=True) - assert resolve_wiki_link("Nobody", "default", tmp_path) is None + assert resolve_wiki_link("Nobody", "default") is None def test_priority_order(self, tmp_path: Path): """NPCs are checked before locations.""" @@ -193,7 +187,7 @@ class TestResolveWikiLink: d = tmp_path / "worlds" / "default" / entity_type d.mkdir(parents=True) (d / "Ambiguous.md").write_text(f"---\ntype: {entity_type}\n---\n") - result = resolve_wiki_link("Ambiguous", "default", tmp_path) + result = resolve_wiki_link("Ambiguous", "default") assert "npcs" in str(result) diff --git a/tests/test_tune.py b/tests/test_tune.py index 3616484..6a63d06 100644 --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -1,5 +1,7 @@ """Tests for the DM style tuning system.""" +from pathlib import Path + from storied.tools import ToolContext from storied.tools.scene import tune as _tune @@ -13,29 +15,29 @@ def tune(tuning: str) -> str: class TestTune: """Tests for the tune tool.""" - def test_tune_creates_style_file(self, ctx: ToolContext): + def test_tune_creates_style_file(self, ctx: ToolContext, tmp_path: Path): tune("Lean into intrigue and social encounters.") - style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" + style_path = tmp_path / "worlds" / ctx.world_id / "style.md" assert style_path.exists() - def test_tune_writes_content(self, ctx: ToolContext): + def test_tune_writes_content(self, ctx: ToolContext, tmp_path: Path): tune("More exploration, less combat.") - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() + content = (tmp_path / "worlds" / ctx.world_id / "style.md").read_text() assert "More exploration, less combat." in content - def test_tune_has_heading(self, ctx: ToolContext): + def test_tune_has_heading(self, ctx: ToolContext, tmp_path: Path): tune("Keep pacing slow.") - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() + content = (tmp_path / "worlds" / ctx.world_id / "style.md").read_text() assert content.startswith("# Style\n") - def test_tune_replaces_existing(self, ctx: ToolContext): + def test_tune_replaces_existing(self, ctx: ToolContext, tmp_path: Path): tune("Lots of combat.") tune("Actually, less combat.") - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() + content = (tmp_path / "worlds" / ctx.world_id / "style.md").read_text() assert "Actually, less combat." in content assert "Lots of combat." not in content diff --git a/worlds/.gitkeep b/worlds/.gitkeep deleted file mode 100644 index e69de29..0000000