From 970cbef3a1aa31ee9ded5006a6af3d98c0f5773c Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 11 Apr 2026 09:58:25 -0400 Subject: [PATCH] Move campaign data to ~/.storied and add a user homebrew layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paths used to be threaded through every function as base_path kwargs, defaulting to cwd — a holdover from when storied ran out of the repo root. That pattern stopped making sense once worlds/ and players/ moved to ~/.storied/, and it meant a forgotten kwarg in a test would silently write to the user's real home dir. Centralize all filesystem roots in storied.paths as module globals, configured once at CLI startup via configure(). Everything downstream reads through getters (world_path, player_path, etc.). Drop base_path from every library function, class, and tool wrapper; delete the StorageRoot() dep and the base_path field on ToolContext. While the paths layer was already in pieces, add a third content layer for personal homebrew at ~/.storied/rules/. Priority is world > user > shipped — a campaign override beats your homebrew which beats the stock SRD. The vector index tags rows by source (srd/user/ world) and recall(scope="rules") now covers all three, so dropping a spell into ~/.storied/rules/spells/ shows up across every campaign after a `storied index rebuild`. Test isolation is now an autouse fixture that points paths at tmp_path for every function, with STORIED_HOME belt-and-suspenders for any subprocess that re-enters storied. The sandbox mode keeps user homebrew accessible — only worlds/players move to the tempdir. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 90 ++++--- design/architecture.md | 77 +++++- players/.gitkeep | 0 src/storied/advancement.py | 25 +- src/storied/character/data.py | 43 ++-- src/storied/character/operations.py | 99 ++++---- src/storied/cli.py | 141 ++++++++--- src/storied/content.py | 86 ++++--- src/storied/engine.py | 24 +- src/storied/log.py | 8 +- src/storied/mcp_server.py | 50 +++- src/storied/notifications.py | 19 +- src/storied/paths.py | 192 ++++++++++++++ src/storied/planner.py | 63 ++--- src/storied/search.py | 22 +- src/storied/session.py | 35 ++- src/storied/tools/__init__.py | 2 - src/storied/tools/_context.py | 24 +- src/storied/tools/character.py | 59 ++--- src/storied/tools/entities.py | 28 +-- src/storied/tools/mechanics.py | 24 +- src/storied/tools/scene.py | 19 +- tests/conftest.py | 50 +++- tests/test_advancement.py | 42 ++-- tests/test_character.py | 372 +++++++++++++--------------- tests/test_content.py | 305 +++++++++++++---------- tests/test_engine.py | 50 ++-- tests/test_entities.py | 144 +++++------ tests/test_execute_tool.py | 54 ++-- tests/test_mcp_server.py | 58 ++++- tests/test_notifications.py | 61 +++-- tests/test_planner.py | 87 +++---- tests/test_sandbox.py | 2 +- tests/test_seeder.py | 6 - tests/test_session.py | 44 ++-- tests/test_tune.py | 18 +- worlds/.gitkeep | 0 37 files changed, 1407 insertions(+), 1016 deletions(-) delete mode 100644 players/.gitkeep create mode 100644 src/storied/paths.py delete mode 100644 worlds/.gitkeep 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 -- 2.51.2