A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Move campaign data to ~/.storied and add a user homebrew layer

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) <noreply@anthropic.com>

+1390 -999
+60 -30
CLAUDE.md
··· 26 26 27 27 ## File Structure 28 28 29 + The repo holds code, prompts, and the shipped 5e SRD. Campaign data 30 + (worlds, players, vector indices) lives outside the repo at 31 + `~/.storied/` so backups and editing are simple and `git status` stays 32 + clean. 33 + 29 34 ``` 30 - storied/ 31 - ├── design/ # Architecture and design docs 32 - ├── plans/ # Implementation plans (in-progress work) 33 - ├── prompts/ # All LLM prompts (system prompts, formatting instructions) 34 - ├── src/storied/ # Python package 35 - │ ├── engine.py # DM engine core 36 - │ ├── claude.py # Claude subprocess driver (stream_with_tools, run_with_tools, run_prompt) 37 - │ ├── tools.py # MCP tool implementations 38 - │ ├── planner.py # World seeding, planning, ticking 39 - │ ├── cli.py # CLI entry point 40 - │ └── log.py # Campaign log and timekeeping 41 - ├── rules/ # Base game system (5e SRD) 42 - ├── worlds/ # World-specific content (gitignored) 43 - ├── players/ # Player/character state (gitignored) 44 - ├── tests/ # Test suite 35 + storied/ # repo 36 + ├── design/ # Architecture and design docs 37 + ├── plans/ # Implementation plans (in-progress work) 38 + ├── prompts/ # All LLM prompts (system prompts, formatting) 39 + ├── src/storied/ # Python package 40 + │ ├── engine.py # DM engine core 41 + │ ├── paths.py # Filesystem path getters and configure() 42 + │ ├── claude.py # Claude subprocess driver 43 + │ ├── planner.py # World seeding, planning, ticking 44 + │ ├── cli.py # CLI entry point 45 + │ ├── log.py # Campaign log and timekeeping 46 + │ └── tools/ # MCP tool implementations (per-module) 47 + ├── rules/ # Shipped 5e SRD (`make srd`) 48 + ├── tests/ # Test suite 45 49 └── pyproject.toml 50 + 51 + ~/.storied/ # data home (configurable; see below) 52 + ├── worlds/{world_id}/ # Campaign-specific content + vector index 53 + ├── players/{player_id}/ # Character sheets, notes, sessions 54 + └── rules/ # Optional user-level homebrew overlay 55 + └── {content_type}/ # e.g. monsters/goblin.md 46 56 ``` 47 57 58 + The data home defaults to `~/.storied/`. Override with the 59 + `--base-path` flag on `storied play` / `storied reset`, or set the 60 + `STORIED_HOME` environment variable. The CLI calls 61 + `storied.paths.configure(...)` once at startup; everything downstream 62 + reads from module getters (`worlds_path()`, `player_path(id)`, etc.). 63 + 48 64 ## Prompts 49 65 50 - 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. 66 + 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. 51 67 52 68 ## Content Layers 53 69 54 - Game content is organized in layers that overlay each other (see `design/content-layers.md`): 70 + Game content is organized in three layers that overlay each other, 71 + with priority **world > user > shipped** (first match wins). See 72 + `design/content-layers.md` for the spec. 55 73 56 74 ``` 57 - players/{player}/ → Character state (not shared content) 58 - worlds/{world}/ → Campaign-specific (can override rules) 59 - rules/{system}/ → Base game system (5e SRD) 75 + ~/.storied/worlds/{world_id}/ → Campaign-specific (overrides everything) 76 + ~/.storied/rules/ → Personal homebrew (overrides shipped) 77 + <repo>/rules/srd-5.2.1/sections/ → Stock 5e SRD (built via `make srd`) 60 78 ``` 61 79 62 - When resolving content (monsters, spells, items), world layer is checked first, then rules. This allows worlds to have custom monsters or override base content. 80 + - **World layer** (flat): per-campaign overrides AND narrative content 81 + (NPCs, locations, factions, threads, lore, maps). A campaign-specific 82 + goblin lives at `~/.storied/worlds/my-game/monsters/goblin.md`. 83 + - **User layer** (flat): your personal homebrew that applies across 84 + every campaign. Drop a homebrew spell at 85 + `~/.storied/rules/spells/my-spell.md` and it shows up in `recall` 86 + for every world. 87 + - **Shipped layer** (nested): the stock 5e SRD bundled with the repo. 88 + 89 + The vector index (per-world `search.db`) tags rows by source — 90 + `srd`, `user`, or `world`. `recall(scope="rules")` covers all three 91 + sources; `recall(scope="world")` covers only world-tagged hits. 63 92 64 - - `rules/` - Processed SRD, built via `make srd` 65 - - `worlds/` - Gitignored, campaign-specific content 66 - - `players/` - Gitignored, character data 93 + **After adding files to `~/.storied/rules/` for the first time**, run 94 + `storied index rebuild -w {world}` to pick them up. The index is 95 + populated lazily on first use, so existing worlds need an explicit 96 + rebuild to see new homebrew content. 67 97 68 98 ## World File Format 69 99 ··· 86 116 87 117 ## Player State 88 118 89 - Player data lives in `players/{player_id}/`: 119 + Player data lives in `~/.storied/players/{player_id}/`: 90 120 91 121 ``` 92 - players/{player_id}/ 122 + ~/.storied/players/{player_id}/ 93 123 ├── character.yaml # Stats, class, abilities 94 - ├── inventory.yaml # Items carried 95 - ├── journal.md # Personal notes, discoveries 96 - ├── relationships.yaml # NPC relationship tracking 97 - └── session_log.md # Running narrative history 124 + ├── character.md # Free-text backstory 125 + ├── notes.md # Appending journal of player observations 126 + ├── session.md # Current situation, location, present, threads 127 + └── worlds/{world_id}/ # Player's discovered knowledge per world 98 128 ``` 99 129 100 130 Separate from world state to support multiple characters and future multiplayer.
+68 -9
design/architecture.md
··· 179 179 180 180 ### Vector Index (`search.py`) 181 181 182 - sqlite-vec + BGE-small embeddings over three corpora with source tags: 183 - `srd`, `world`, `player`. Age-decay scoring favors recent world beats; 184 - `recall` accepts a scope filter (`rules` / `world` / `all`) so the DM can 185 - target the right layer. The index reseeds from a pre-built SRD sqlite 186 - snapshot when present, otherwise re-indexes from markdown sections. 182 + sqlite-vec + BGE-small embeddings over content tagged by source — 183 + `srd` (shipped), `user` (homebrew), `world` (campaign-specific), and 184 + `player` (player knowledge / discoveries). Age-decay scoring favors 185 + recent world beats. `recall` accepts a scope filter: 186 + 187 + - `scope="rules"` → searches `srd + user + world` (rule content can 188 + live at any of the three layers) 189 + - `scope="world"` → narrative-only world content 190 + - `scope="all"` → no filter 191 + 192 + `VectorIndex.search` accepts `source_filter: str | list[str] | None` 193 + for OR semantics across multiple sources. The index reseeds from a 194 + pre-built SRD sqlite snapshot when present, otherwise re-indexes from 195 + markdown sections, then appends user homebrew and world content on 196 + top. 197 + 198 + ### Path Configuration (`paths.py`) 199 + 200 + Storied is "one process, one game." Filesystem paths live in module 201 + globals on `storied.paths`, set once at CLI startup via `configure(...)`. 202 + Library code reads via getters — `data_home()`, `worlds_path()`, 203 + `players_path()`, `world_path(id)`, `player_path(id)`, `user_rules_path()`, 204 + `shipped_rules_path()`. There is no `base_path` parameter threaded 205 + through anything; each function asks the module for the specific path 206 + it needs. 207 + 208 + ```python 209 + from storied.paths import configure, data_home, world_path 210 + 211 + # CLI startup (once) 212 + configure(data_home=Path.home() / ".storied") 213 + 214 + # Library code (anywhere) 215 + def load_session(player_id: str) -> dict | None: 216 + path = player_path(player_id) / "session.md" 217 + ... 218 + ``` 219 + 220 + Two independent globals back the configuration: 221 + 222 + - **`_data_home`** (default `~/.storied`) holds worlds, players, 223 + sessions, transcripts, vector indices. 224 + - **`_user_rules_home`** (default `<data_home>/rules`) holds personal 225 + homebrew. Sandbox mode points the data home at a tempdir but leaves 226 + the user-rules home at the real `~/.storied/rules/` so your 227 + homebrew is still available in throwaway sessions. 228 + 229 + `STORIED_HOME` env var override; `--base-path` CLI flag overrides 230 + that. The `storied.paths.using_data_home(path)` context manager is 231 + the test-friendly form (used by the autouse `_isolate_storied_paths` 232 + fixture in `tests/conftest.py` to point each test at its own 233 + `tmp_path`). 234 + 235 + Threads inherit module globals automatically — no `contextvars` 236 + gymnastics. Subprocesses inherit via `STORIED_HOME` in the env dict 237 + (exported in `cmd_play` for any future child process that re-enters 238 + storied). 187 239 188 240 ## Content Layers 189 241 190 - See `content-layers.md` for the full spec. Summary: 242 + See `content-layers.md` for the full spec. Three layers, priority 243 + **world > user > shipped** (first match wins): 191 244 192 245 ``` 193 - players/{player}/ → character state, notes, knowledge 194 - worlds/{world}/ → campaign-specific entities, can override rules 195 - rules/srd-5.2.1/ → base 5e SRD 246 + ~/.storied/worlds/{world}/ → campaign-specific (top priority) 247 + ~/.storied/rules/ → personal homebrew (overrides shipped) 248 + <repo>/rules/srd-5.2.1/sections/ → stock 5e SRD 196 249 ``` 250 + 251 + `ContentResolver.find()` walks all three layers for any content type 252 + that might appear at multiple layers (monsters, spells, etc.). 253 + Narrative content (npcs, locations, factions, threads, lore, maps) 254 + only exists at the world layer; the upper-layer lookups miss 255 + harmlessly. 197 256 198 257 Entity model (Is/Was/Knows/Wants/Will) is narrative, not mechanical. A 199 258 door can "want" to stay closed. A coin can "know" where it was forged. The
players/.gitkeep

This is a binary file and will not be displayed.

+9 -16
src/storied/advancement.py
··· 12 12 from storied.engine import load_prompt 13 13 from storied.log import CampaignLog 14 14 from storied.mcp_server import start_server as start_mcp_server 15 + from storied.paths import data_home 15 16 from storied.session import load_session 16 17 17 18 ··· 29 30 def build_advancement_context( 30 31 world_id: str, 31 32 player_id: str, 32 - base_path: Path, 33 33 ) -> str | None: 34 34 """Build context for the advancement evaluator. 35 35 36 36 Returns None if there's no character to evaluate. 37 37 """ 38 - character = load_character(player_id, base_path) 38 + character = load_character(player_id) 39 39 if character is None: 40 40 return None 41 41 ··· 46 46 parts.append(char_context) 47 47 48 48 # Campaign log — entries since last level-up 49 - log = CampaignLog(world_id, base_path) 49 + log = CampaignLog(world_id) 50 50 parts.append(f"## Campaign Time: {log.get_current_time()}") 51 51 52 52 entries_since_level = log.get_entries_since_tag("level") ··· 68 68 parts.append("\n".join(lines)) 69 69 70 70 # Session state for thread context 71 - session = load_session(player_id, base_path) 71 + session = load_session(player_id) 72 72 if session: 73 73 body = session.get("body", "") 74 74 if body: ··· 80 80 def evaluate_advancement( 81 81 world_id: str = "default", 82 82 player_id: str = "default", 83 - base_path: Path | None = None, 84 83 model: str = "claude-opus-4-6", 85 84 on_progress: Callable[[str], None] | None = None, 86 85 ) -> AdvancementResult: 87 86 """Evaluate whether the character has earned a level-up.""" 88 - if base_path is None: 89 - base_path = Path.cwd() 90 87 91 88 def progress(msg: str) -> None: 92 89 if on_progress: ··· 94 91 95 92 start_time = time.monotonic() 96 93 97 - character = load_character(player_id, base_path) 94 + character = load_character(player_id) 98 95 if character is None: 99 96 progress("Skipped (no character)") 100 97 return AdvancementResult(elapsed=time.monotonic() - start_time) ··· 104 101 char_name = character.get("identity", {}).get("name", "The character") 105 102 notifications.append( 106 103 world_id, 107 - base_path, 108 104 f"Reminder: {char_name} is still pending advancement to " 109 105 f"level {pending_level}. The next narratively appropriate " 110 106 f"moment — a rest, a quiet pause, after a triumph — should " ··· 113 109 progress(f"Posted reminder: pending level {pending_level}") 114 110 return AdvancementResult(elapsed=time.monotonic() - start_time) 115 111 116 - context = build_advancement_context(world_id, player_id, base_path) 112 + context = build_advancement_context(world_id, player_id) 117 113 if context is None: 118 114 progress("Skipped (no context)") 119 115 return AdvancementResult(elapsed=time.monotonic() - start_time) 120 116 121 117 system_prompt = load_prompt("xp-evaluator") 122 118 123 - campaign_log = CampaignLog(world_id, base_path) 119 + campaign_log = CampaignLog(world_id) 124 120 mcp = start_mcp_server( 125 - world_id, player_id, base_path, "advancement", campaign_log, 121 + world_id, player_id, "advancement", campaign_log, 126 122 ) 127 123 128 124 progress(f"Evaluating advancement with {model}...") ··· 137 133 mcp_url=mcp.url, 138 134 model=model, 139 135 on_tool_start=on_tool, 140 - cwd=base_path, 136 + cwd=data_home(), 141 137 ) 142 138 143 139 result = AdvancementResult( ··· 163 159 self, 164 160 world_id: str, 165 161 player_id: str, 166 - base_path: Path, 167 162 model: str = "claude-opus-4-6", 168 163 interval: int = 5, 169 164 ): 170 165 self._world_id = world_id 171 166 self._player_id = player_id 172 - self._base_path = base_path 173 167 self._model = model 174 168 self._interval = interval 175 169 self._turn_count: int = 0 ··· 203 197 self._result = evaluate_advancement( 204 198 world_id=self._world_id, 205 199 player_id=self._player_id, 206 - base_path=self._base_path, 207 200 model=self._model, 208 201 ) 209 202
+19 -24
src/storied/character/data.py
··· 6 6 import yaml 7 7 8 8 from storied.character.schema import coerce_character, validate_for_write 9 + from storied.paths import player_path 9 10 10 11 11 12 # Default character schema — used when creating a new character ··· 59 60 } 60 61 61 62 62 - def _character_yaml_path(player_id: str, base_path: Path | None = None) -> Path: 63 - if base_path is None: 64 - base_path = Path.cwd() 65 - return base_path / "players" / player_id / "character.yaml" 63 + def _character_yaml_path(player_id: str) -> Path: 64 + return player_path(player_id) / "character.yaml" 66 65 67 66 68 - def _character_md_path(player_id: str, base_path: Path | None = None) -> Path: 69 - if base_path is None: 70 - base_path = Path.cwd() 71 - return base_path / "players" / player_id / "character.md" 67 + def _character_md_path(player_id: str) -> Path: 68 + return player_path(player_id) / "character.md" 72 69 73 70 74 - def load_character(player_id: str, base_path: Path | None = None) -> dict | None: 71 + def load_character(player_id: str) -> dict | None: 75 72 """Load a character's structured data from character.yaml. 76 73 77 74 Returns None if no character exists. Returns the parsed YAML dict, with ··· 80 77 (e.g. resources stored as a list) are coerced into the canonical shape 81 78 so the existing character keeps working. 82 79 """ 83 - yaml_path = _character_yaml_path(player_id, base_path) 80 + yaml_path = _character_yaml_path(player_id) 84 81 if not yaml_path.exists(): 85 82 return None 86 83 ··· 89 86 return _merge_defaults(coerced) 90 87 91 88 92 - def load_character_prose(player_id: str, base_path: Path | None = None) -> str: 89 + def load_character_prose(player_id: str) -> str: 93 90 """Load the character's free-text prose from character.md. 94 91 95 92 Returns empty string if no prose file exists. 96 93 """ 97 - md_path = _character_md_path(player_id, base_path) 94 + md_path = _character_md_path(player_id) 98 95 if not md_path.exists(): 99 96 return "" 100 97 return md_path.read_text() 101 98 102 99 103 100 def save_character( 104 - player_id: str, data: dict, base_path: Path | None = None 101 + player_id: str, data: dict 105 102 ) -> None: 106 103 """Save character data back to character.yaml.""" 107 - yaml_path = _character_yaml_path(player_id, base_path) 104 + yaml_path = _character_yaml_path(player_id) 108 105 yaml_path.parent.mkdir(parents=True, exist_ok=True) 109 106 yaml_path.write_text( 110 107 yaml.dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False) ··· 112 109 113 110 114 111 def save_character_prose( 115 - player_id: str, prose: str, base_path: Path | None = None 112 + player_id: str, prose: str 116 113 ) -> None: 117 114 """Save the character's free-text prose to character.md.""" 118 - md_path = _character_md_path(player_id, base_path) 115 + md_path = _character_md_path(player_id) 119 116 md_path.parent.mkdir(parents=True, exist_ok=True) 120 117 md_path.write_text(prose) 121 118 ··· 142 139 143 140 def update_character( 144 141 player_id: str, 145 - updates: dict, 146 - base_path: Path | None = None, 142 + updates: dict 147 143 ) -> str: 148 144 """Update fields in character.yaml using dot notation. 149 145 ··· 154 150 is unchanged) and the returned message tells the DM what's wrong so 155 151 they can retry with the correct shape. 156 152 """ 157 - data = load_character(player_id, base_path) 153 + data = load_character(player_id) 158 154 if data is None: 159 155 return f"No character found for player '{player_id}'" 160 156 ··· 187 183 f"Character on disk is unchanged." 188 184 ) 189 185 190 - save_character(player_id, data, base_path) 186 + save_character(player_id, data) 191 187 return "Character updated: " + ", ".join(changes) 192 188 193 189 ··· 229 225 proficiencies: dict | None = None, 230 226 features: list[dict] | None = None, 231 227 equipment: dict | None = None, 232 - backstory: str | None = None, 233 - base_path: Path | None = None, 228 + backstory: str | None = None 234 229 ) -> str: 235 230 """Create a new character with the new schema. 236 231 ··· 257 252 if equipment: 258 253 data["equipment"] = equipment 259 254 260 - save_character(player_id, data, base_path) 255 + save_character(player_id, data) 261 256 262 257 if backstory: 263 - save_character_prose(player_id, f"# {name}\n\n{backstory}\n", base_path) 258 + save_character_prose(player_id, f"# {name}\n\n{backstory}\n") 264 259 265 260 return f"Created character '{name}' - a level {level} {race} {char_class}!"
+42 -57
src/storied/character/operations.py
··· 11 11 load_character, 12 12 save_character, 13 13 ) 14 + from storied.paths import player_path 14 15 15 16 16 17 # --- HP operations --- ··· 19 20 def damage( 20 21 player_id: str, 21 22 amount: int, 22 - damage_type: str | None = None, 23 - base_path: Path | None = None, 23 + damage_type: str | None = None 24 24 ) -> str: 25 25 """Apply raw damage to the character. 26 26 ··· 29 29 resistances, vulnerabilities, or immunities. The DM pre-applies the 30 30 math per whatever ruleset they're running. 31 31 """ 32 - data = load_character(player_id, base_path) 32 + data = load_character(player_id) 33 33 if data is None: 34 34 return f"No character found for player '{player_id}'" 35 35 if amount < 0: ··· 46 46 47 47 hp["current"] = max(0, hp["current"] - remaining) 48 48 49 - save_character(player_id, data, base_path) 49 + save_character(player_id, data) 50 50 51 51 type_str = f" {damage_type}" if damage_type else "" 52 52 parts = [f"Took {amount}{type_str} damage"] ··· 63 63 64 64 def heal( 65 65 player_id: str, 66 - amount: int, 67 - base_path: Path | None = None, 66 + amount: int 68 67 ) -> str: 69 68 """Heal the character, clamped to max HP.""" 70 - data = load_character(player_id, base_path) 69 + data = load_character(player_id) 71 70 if data is None: 72 71 return f"No character found for player '{player_id}'" 73 72 if amount < 0: ··· 78 77 hp["current"] = min(hp["max"], hp["current"] + amount) 79 78 actual = hp["current"] - before 80 79 81 - save_character(player_id, data, base_path) 80 + save_character(player_id, data) 82 81 return f"Healed {actual} HP. HP: {hp['current']}/{hp['max']}" 83 82 84 83 ··· 90 89 source: str, 91 90 description: str, 92 91 expires: str | None = None, 93 - concentration: bool = False, 94 - base_path: Path | None = None, 92 + concentration: bool = False 95 93 ) -> str: 96 94 """Add a temporary effect to the character. 97 95 ··· 100 98 The tool does not enforce uniqueness; the DM decides when to drop an 101 99 effect (via `remove_effect`). 102 100 """ 103 - data = load_character(player_id, base_path) 101 + data = load_character(player_id) 104 102 if data is None: 105 103 return f"No character found for player '{player_id}'" 106 104 ··· 113 111 effect["concentration"] = True 114 112 effects.append(effect) 115 113 116 - save_character(player_id, data, base_path) 114 + save_character(player_id, data) 117 115 118 116 parts = [f"Effect added: {source} — {description}"] 119 117 if expires: ··· 125 123 126 124 def remove_effect( 127 125 player_id: str, 128 - source: str, 129 - base_path: Path | None = None, 126 + source: str 130 127 ) -> str: 131 128 """Remove an effect by source name (case-insensitive substring match).""" 132 - data = load_character(player_id, base_path) 129 + data = load_character(player_id) 133 130 if data is None: 134 131 return f"No character found for player '{player_id}'" 135 132 ··· 139 136 for i, e in enumerate(effects): 140 137 if needle in e.get("source", "").lower(): 141 138 removed = effects.pop(i) 142 - save_character(player_id, data, base_path) 139 + save_character(player_id, data) 143 140 return f"Effect removed: {removed.get('source', '?')}" 144 141 145 142 return f"No effect matching '{source}' found" ··· 150 147 151 148 def add_condition( 152 149 player_id: str, 153 - name: str, 154 - base_path: Path | None = None, 150 + name: str 155 151 ) -> str: 156 152 """Add a condition to the character (no duplicates).""" 157 - data = load_character(player_id, base_path) 153 + data = load_character(player_id) 158 154 if data is None: 159 155 return f"No character found for player '{player_id}'" 160 156 ··· 164 160 return f"Already has condition: {name}" 165 161 conditions.append(name) 166 162 167 - save_character(player_id, data, base_path) 163 + save_character(player_id, data) 168 164 return f"Condition added: {name}" 169 165 170 166 171 167 def remove_condition( 172 168 player_id: str, 173 - name: str, 174 - base_path: Path | None = None, 169 + name: str 175 170 ) -> str: 176 171 """Remove a condition (case-insensitive).""" 177 - data = load_character(player_id, base_path) 172 + data = load_character(player_id) 178 173 if data is None: 179 174 return f"No character found for player '{player_id}'" 180 175 ··· 183 178 for i, c in enumerate(conditions): 184 179 if c.lower() == needle: 185 180 removed = conditions.pop(i) 186 - save_character(player_id, data, base_path) 181 + save_character(player_id, data) 187 182 return f"Condition removed: {removed}" 188 183 189 184 return f"No condition matching '{name}' found" ··· 195 190 def add_item( 196 191 player_id: str, 197 192 item: str, 198 - location: str | None = None, 199 - base_path: Path | None = None, 193 + location: str | None = None 200 194 ) -> str: 201 195 """Add an item to a location in the equipment dict. 202 196 ··· 204 198 doesn't exist (using the given name as-is). If location is omitted, 205 199 uses the first existing location, or 'on_person' as a default. 206 200 """ 207 - data = load_character(player_id, base_path) 201 + data = load_character(player_id) 208 202 if data is None: 209 203 return f"No character found for player '{player_id}'" 210 204 ··· 230 224 equipment[target_key] = [] 231 225 232 226 equipment[target_key].append(item) 233 - save_character(player_id, data, base_path) 227 + save_character(player_id, data) 234 228 return f"Added '{item}' to {target_key}" 235 229 236 230 237 231 def remove_item( 238 232 player_id: str, 239 - item: str, 240 - base_path: Path | None = None, 233 + item: str 241 234 ) -> str: 242 235 """Remove an item by case-insensitive substring match across all locations.""" 243 - data = load_character(player_id, base_path) 236 + data = load_character(player_id) 244 237 if data is None: 245 238 return f"No character found for player '{player_id}'" 246 239 ··· 251 244 for i, existing in enumerate(items): 252 245 if needle in existing.lower(): 253 246 removed = items.pop(i) 254 - save_character(player_id, data, base_path) 247 + save_character(player_id, data) 255 248 return f"Removed '{removed}' from {location}" 256 249 257 250 return f"No item matching '{item}' found" ··· 260 253 def set_item_status( 261 254 player_id: str, 262 255 item: str, 263 - status: str, 264 - base_path: Path | None = None, 256 + status: str 265 257 ) -> str: 266 258 """Set a magic item's status (attuned, equipped, carried). 267 259 ··· 272 264 if status not in valid_statuses: 273 265 return f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}" 274 266 275 - data = load_character(player_id, base_path) 267 + data = load_character(player_id) 276 268 if data is None: 277 269 return f"No character found for player '{player_id}'" 278 270 ··· 295 287 # Add to the new status 296 288 magic_items[status].append(wikilink) 297 289 298 - save_character(player_id, data, base_path) 290 + save_character(player_id, data) 299 291 return f"{item} is now {status}" 300 292 301 293 ··· 305 297 def adjust_resource( 306 298 player_id: str, 307 299 name: str, 308 - delta: int, 309 - base_path: Path | None = None, 300 + delta: int 310 301 ) -> str: 311 302 """Adjust a resource pool by a delta. 312 303 313 304 Negative = use (clamped to 0), positive = restore (clamped to max). 314 305 Substring match on the resource name or its notes field. 315 306 """ 316 - data = load_character(player_id, base_path) 307 + data = load_character(player_id) 317 308 if data is None: 318 309 return f"No character found for player '{player_id}'" 319 310 ··· 336 327 pool["current"] = new_current 337 328 actual = new_current - before 338 329 339 - save_character(player_id, data, base_path) 330 + save_character(player_id, data) 340 331 341 332 notes = pool.get("notes", target) 342 333 if delta < 0: ··· 353 344 354 345 def rest( 355 346 player_id: str, 356 - rest_type: str, 357 - base_path: Path | None = None, 347 + rest_type: str 358 348 ) -> str: 359 349 """Take a short or long rest. Refreshes resources by refresh type. 360 350 ··· 365 355 if rest_type not in ("short", "long"): 366 356 return f"Invalid rest type '{rest_type}'. Must be 'short' or 'long'." 367 357 368 - data = load_character(player_id, base_path) 358 + data = load_character(player_id) 369 359 if data is None: 370 360 return f"No character found for player '{player_id}'" 371 361 ··· 404 394 hp["current"] = hp["max"] 405 395 msg_parts.append(f"HP restored to {hp['max']}.") 406 396 407 - save_character(player_id, data, base_path) 397 + save_character(player_id, data) 408 398 return " ".join(msg_parts) 409 399 410 400 ··· 413 403 414 404 def adjust_coins( 415 405 player_id: str, 416 - deltas: dict[str, int], 417 - base_path: Path | None = None, 406 + deltas: dict[str, int] 418 407 ) -> str: 419 408 """Apply relative coin changes (positive=gain, negative=spend). 420 409 421 410 Each denomination is clamped to 0 minimum. 422 411 """ 423 - data = load_character(player_id, base_path) 412 + data = load_character(player_id) 424 413 if data is None: 425 414 return f"No character found for player '{player_id}'" 426 415 ··· 441 430 changes.append(f"{denom} {old} → {new}") 442 431 purse[denom] = new 443 432 444 - save_character(player_id, data, base_path) 433 + save_character(player_id, data) 445 434 446 435 coins = [] 447 436 for denom in ("pp", "gp", "ep", "sp", "cp"): ··· 459 448 new_level: int, 460 449 hp_gain: int, 461 450 features: list[dict] | None = None, 462 - time_anchor: str | None = None, 463 - base_path: Path | None = None, 451 + time_anchor: str | None = None 464 452 ) -> str: 465 453 """Atomically level up the character. 466 454 ··· 472 460 The DM should look up the new-level class features before calling this 473 461 and pass the full replacement features list. 474 462 """ 475 - data = load_character(player_id, base_path) 463 + data = load_character(player_id) 476 464 if data is None: 477 465 return f"No character found for player '{player_id}'" 478 466 ··· 518 506 if "advancement_ready" in data: 519 507 data["advancement_ready"] = None 520 508 521 - save_character(player_id, data, base_path) 509 + save_character(player_id, data) 522 510 523 511 parts = [ 524 512 f"Level up! {class_name} {old_level} → {new_level}.", ··· 534 522 def add_note( 535 523 player_id: str, 536 524 text: str, 537 - time_anchor: str | None = None, 538 - base_path: Path | None = None, 525 + time_anchor: str | None = None 539 526 ) -> str: 540 527 """Append a note to the player's notes.md file.""" 541 - if base_path is None: 542 - base_path = Path.cwd() 543 - notes_path = base_path / "players" / player_id / "notes.md" 528 + notes_path = player_path(player_id) / "notes.md" 544 529 notes_path.parent.mkdir(parents=True, exist_ok=True) 545 530 546 531 prefix = f"{time_anchor} | " if time_anchor else ""
+113 -28
src/storied/cli.py
··· 22 22 def _format_character_display( 23 23 player_id: str, 24 24 full: bool, 25 - base_path: Path | None = None, 26 25 ) -> str | None: 27 26 """Format character data for /status or /me display. 28 27 29 - `base_path` must be threaded through from cmd_play so sandbox sessions 30 - read the sandbox character.yaml instead of the cwd default. 28 + Path resolution comes from ``storied.paths`` module globals (set 29 + once at startup via ``configure``); sandbox sessions get the 30 + sandbox character.yaml because the data home was overridden in 31 + cmd_play before this function is called. 31 32 """ 32 33 from storied.character import format_sheet, format_status, load_character 33 34 34 - data = load_character(player_id, base_path=base_path) 35 + data = load_character(player_id) 35 36 if data is None: 36 37 return None 37 38 return format_sheet(data) if full else format_status(data) ··· 156 157 """Reset player and world state to start fresh.""" 157 158 import shutil 158 159 159 - base_path = Path.cwd() 160 + from storied import paths 161 + from storied.paths import ( 162 + configure, 163 + data_home, 164 + player_path, 165 + resolve_data_home, 166 + world_path, 167 + ) 168 + 169 + configure(data_home=resolve_data_home(getattr(args, "base_path", None))) 170 + base_path = data_home() 160 171 player_id = args.player or "default" 161 172 world_id = args.world or "default" 162 173 163 - player_dir = base_path / "players" / player_id 164 - world_dir = base_path / "worlds" / world_id 174 + player_dir = player_path(player_id) 175 + world_dir = world_path(world_id) 165 176 166 177 # Check what exists 167 178 has_player = player_dir.exists() ··· 239 250 player_id = "default" 240 251 sandbox = getattr(args, "sandbox", False) 241 252 242 - # Sandbox mode: throwaway session in a temp directory 243 - sandbox_dir: Path | None = None 253 + from storied.paths import configure, data_home, resolve_data_home 254 + 255 + # Sandbox mode: throwaway worlds + players in a temp directory. 256 + # User homebrew rules stay pointed at the real ~/.storied/rules/ 257 + # so the sandbox isn't a pristine playground — it's still "your 258 + # rules", just with a fresh world to mess around in. 244 259 if sandbox: 245 260 sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) 246 261 (sandbox_dir / "worlds" / world_id).mkdir(parents=True) 247 262 (sandbox_dir / "players" / player_id).mkdir(parents=True) 248 - # Symlink rules/ from cwd so recall(scope="rules") works in the 249 - # sandbox without rebuilding the SRD index from scratch. 250 - rules_src = Path.cwd() / "rules" 251 - if rules_src.exists(): 252 - (sandbox_dir / "rules").symlink_to(rules_src.resolve()) 263 + configure( 264 + data_home=sandbox_dir, 265 + user_rules_home=Path.home() / ".storied" / "rules", 266 + ) 267 + else: 268 + configure( 269 + data_home=resolve_data_home(getattr(args, "base_path", None)) 270 + ) 271 + data_home().mkdir(parents=True, exist_ok=True) 253 272 254 - base_path = sandbox_dir if sandbox else None 273 + # Export STORIED_HOME so any subprocess that re-enters storied 274 + # (e.g. via run_code) sees the same data directory. 275 + import os 276 + os.environ["STORIED_HOME"] = str(data_home()) 277 + 278 + base_path = data_home() 255 279 creation_mode = False 256 280 257 281 if sandbox: ··· 319 343 player_id=player_id, 320 344 prompt_name=prompt_name, 321 345 transcript_path=transcript_path, 322 - base_path=base_path, 323 346 ) 324 347 engine.debug = args.debug 325 348 349 + # Debug mode: dump the base system prompt once at startup. The 350 + # per-turn context is dumped separately before each stream_action 351 + # call below. Between them, the user sees exactly what the DM 352 + # subprocess is seeing each turn. 353 + if args.debug: 354 + console.print(Rule("System Prompt", style="dim")) 355 + console.print(engine._base_prompt, style="dim", highlight=False) 356 + console.print(Rule(style="dim")) 357 + console.print() 358 + 326 359 # Background ticker for mid-session world advancement 327 360 ticker = None 328 361 advancement = None ··· 333 366 ticker = BackgroundTicker( 334 367 world_id=world_id, 335 368 player_id=player_id, 336 - base_path=base_path or Path.cwd(), 337 369 ) 338 370 # Kick off initial tick in background 339 371 ticker.maybe_tick(engine._campaign_log) ··· 341 373 advancement = BackgroundAdvancement( 342 374 world_id=world_id, 343 375 player_id=player_id, 344 - base_path=base_path or Path.cwd(), 345 376 ) 346 377 347 378 # If in creation mode, start the conversation ··· 496 527 if action.strip().lower() in ("/status", "/me"): 497 528 is_full = action.strip().lower() == "/me" 498 529 formatted = _format_character_display( 499 - player_id, full=is_full, base_path=base_path, 530 + player_id, full=is_full, 500 531 ) 501 532 if formatted: 502 533 console.print() ··· 522 553 from storied.session import load_session 523 554 console.print() 524 555 game_time = engine.get_current_time() 525 - session = load_session(player_id, base_path=base_path) 556 + session = load_session(player_id) 526 557 location = (session or {}).get("location", "unknown") 527 558 console.print( 528 559 f"[green]Saved.[/green] [dim]{game_time} · {location}[/dim]" ··· 560 591 player_id, 561 592 note_msg, 562 593 time_anchor=time_anchor, 563 - base_path=base_path, 564 594 ) 565 595 console.print() 566 596 console.print( ··· 570 600 571 601 try: 572 602 console.print(Rule(style="dim blue")) 603 + 604 + # Debug mode: dump the per-turn context before the 605 + # response streams. `stream_action` rebuilds it 606 + # internally, but the double-build is cheap and keeps 607 + # the debug path a pure observer. 608 + if args.debug: 609 + console.print(Rule("Turn Context", style="dim")) 610 + console.print( 611 + engine._build_context(), 612 + style="dim", 613 + highlight=False, 614 + ) 615 + console.print(Rule(style="dim")) 616 + console.print() 617 + 573 618 renderer = StreamRenderer(console) 574 619 prev_type: str | None = None 575 620 got_text = False ··· 614 659 if ticker: 615 660 tick_result = ticker.pop_result() 616 661 if tick_result and tick_result.tool_calls > 0: 662 + console.print() 617 663 console.print( 618 664 f"[dim]The world shifted while you considered your next move. " 619 665 f"({tick_result.tool_calls} changes)[/dim]" ··· 713 759 714 760 715 761 def cmd_index_world(args: argparse.Namespace) -> int: 716 - """Build search index for a world (includes SRD via seed copy).""" 762 + """Build search index for a world: SRD seed + user homebrew + world content.""" 763 + from storied import paths 717 764 from storied.search import VectorIndex 718 765 719 766 world_id = args.world or "default" 720 - world_dir = Path(f"worlds/{world_id}") 767 + world_dir = paths.world_path(world_id) 721 768 if not world_dir.exists(): 722 769 print(f"World not found at {world_dir}") 723 770 return 1 724 771 725 772 db_path = world_dir / "search.db" 726 - srd_seed = Path("rules/srd-5.2.1/search.db") 773 + srd_root = paths.shipped_rules_path() / "srd-5.2.1" 774 + srd_seed = srd_root / "search.db" 727 775 728 776 # Seed from pre-built SRD index if available 729 777 if srd_seed.exists() and not db_path.exists(): ··· 734 782 else: 735 783 index = VectorIndex(db_path) 736 784 if not srd_seed.exists(): 737 - print("SRD index not found. Run 'storied index srd' first for faster setup.") 785 + srd_dir = srd_root / "sections" 786 + if srd_dir.exists(): 787 + print(f"Indexing SRD from {srd_dir}...") 788 + srd_count = index.reindex_directory(srd_dir, source="srd") 789 + print(f" {srd_count} SRD chunks") 790 + 791 + # User homebrew layer (flat: <user_rules>/{content_type}/*.md) 792 + user_rules = paths.user_rules_path() 793 + if user_rules.exists(): 794 + print(f"Indexing user homebrew from {user_rules}...") 795 + user_count = index.reindex_directory(user_rules, source="user") 796 + print(f" {user_count} user homebrew chunks") 738 797 739 798 # Index world content on top 740 799 print(f"Indexing world from {world_dir}...") ··· 748 807 749 808 def cmd_index_status(args: argparse.Namespace) -> int: 750 809 """Show search index status.""" 810 + from storied import paths 751 811 from storied.search import VectorIndex 752 812 753 813 world_id = args.world or "default" 754 - db_path = Path(f"worlds/{world_id}/search.db") 814 + db_path = paths.world_path(world_id) / "search.db" 755 815 if not db_path.exists(): 756 816 print(f"No search index at {db_path}") 757 817 print(f"Run 'storied index world -w {world_id}' to create one.") ··· 769 829 770 830 771 831 def cmd_index_rebuild(args: argparse.Namespace) -> int: 772 - """Force full rebuild of a world's search index.""" 832 + """Force full rebuild of a world's search index. 833 + 834 + Drops the existing ``search.db`` and rebuilds from all three layers 835 + (shipped SRD, user homebrew, world content). Run this after adding 836 + homebrew files to ``~/.storied/rules/`` for the first time, or any 837 + time you've edited content under ``~/.storied/rules/`` and want 838 + those changes reflected in ``recall`` results. 839 + """ 840 + from storied import paths 841 + 773 842 world_id = args.world or "default" 774 - db_path = Path(f"worlds/{world_id}/search.db") 843 + db_path = paths.world_path(world_id) / "search.db" 775 844 if db_path.exists(): 776 845 db_path.unlink() 777 846 print(f"Removed {db_path}") ··· 898 967 action="store_true", 899 968 help="Throwaway session — no character, no world state", 900 969 ) 970 + play_parser.add_argument( 971 + "--base-path", 972 + type=Path, 973 + help=( 974 + "Where to store worlds and players (defaults to $STORIED_HOME " 975 + "or ~/.storied/)" 976 + ), 977 + ) 901 978 play_parser.set_defaults(func=cmd_play) 902 979 903 980 # reset command ··· 914 991 "--force", "-f", 915 992 action="store_true", 916 993 help="Skip confirmation prompt", 994 + ) 995 + reset_parser.add_argument( 996 + "--base-path", 997 + type=Path, 998 + help=( 999 + "Where worlds and players live (defaults to $STORIED_HOME or " 1000 + "~/.storied/)" 1001 + ), 917 1002 ) 918 1003 reset_parser.set_defaults(func=cmd_reset) 919 1004
+51 -33
src/storied/content.py
··· 1 - """Content layer resolution — finds and loads content across world/rules layers.""" 1 + """Content layer resolution — finds and loads content across world / user / rules layers.""" 2 2 3 3 import re 4 4 from pathlib import Path 5 5 6 6 import yaml 7 7 8 + from storied import paths 9 + 8 10 9 11 class ContentResolver: 10 - """Resolves content across world and rules layers. 12 + """Resolves content across three layers with priority 13 + **world > user > shipped**. 14 + 15 + 1. **World layer** (flat): ``<data_home>/worlds/{world_id}/{content_type}/*.md`` 16 + — campaign-specific overrides and narrative content. 17 + 2. **User layer** (flat): ``<user_rules>/{content_type}/*.md`` — your 18 + personal homebrew, applies across every campaign. 19 + 3. **Shipped layer** (nested): ``<shipped_rules>/{rules_system}/sections/{content_type}/*.md`` 20 + — the stock 5e SRD bundled with the repo. 11 21 12 - Content is searched in order: 13 - 1. World layer: worlds/{world_id}/{content_type}/ 14 - 2. Rules layer: rules/srd-5.2.1/sections/{content_type}/ 22 + Narrative content (npcs, locations, factions, threads, lore, maps) 23 + only exists at the world layer; layers 2 and 3 miss and the lookup 24 + returns None. Rules content (monsters, spells, classes, etc.) can 25 + live at any layer; the first match wins. 15 26 """ 16 27 17 28 def __init__( 18 29 self, 19 - base_path: Path | None = None, 20 30 world_id: str | None = None, 21 31 rules_system: str = "srd-5.2.1", 22 32 ): 23 - self.base_path = base_path or Path.cwd() 24 33 self.world_id = world_id 25 34 self.rules_system = rules_system 26 35 27 - # Set up layer paths 28 - if world_id: 29 - self.world_path = self.base_path / "worlds" / world_id 30 - else: 31 - self.world_path = None 32 - self.rules_path = self.base_path / "rules" / rules_system / "sections" 36 + # ---- layer search roots ------------------------------------------------ 37 + 38 + def _layer_roots(self) -> list[Path]: 39 + """Return the layer search roots in priority order. 33 40 34 - def _search_dirs(self, content_type: str | None) -> list[tuple[Path, str]]: 35 - """Get directories to search in order, with their content types.""" 41 + Calls go through the ``paths`` module (not imported names) so 42 + test fixtures can monkeypatch ``paths.shipped_rules_path`` to 43 + redirect the shipped layer to a temp dir. 44 + """ 45 + roots: list[Path] = [] 46 + if self.world_id: 47 + roots.append(paths.world_path(self.world_id)) 48 + roots.append(paths.user_rules_path()) 49 + roots.append( 50 + paths.shipped_rules_path() / self.rules_system / "sections" 51 + ) 52 + return roots 53 + 54 + def _search_dirs( 55 + self, content_type: str | None 56 + ) -> list[tuple[Path, str]]: 57 + """Directories to search for content files, paired with their 58 + content type label. Walks the three layers in priority order. 59 + """ 36 60 dirs: list[tuple[Path, str]] = [] 37 61 38 62 if content_type: 39 - # Search specific content type 40 - if self.world_path: 41 - dirs.append((self.world_path / content_type, content_type)) 42 - dirs.append((self.rules_path / content_type, content_type)) 63 + for root in self._layer_roots(): 64 + dirs.append((root / content_type, content_type)) 43 65 else: 44 - # Search all content types 45 - if self.world_path and self.world_path.exists(): 46 - for subdir in self.world_path.iterdir(): 66 + # No content type specified — walk every type subdir in 67 + # every layer that actually exists on disk. 68 + for root in self._layer_roots(): 69 + if not root.exists(): 70 + continue 71 + for subdir in root.iterdir(): 47 72 if subdir.is_dir(): 48 73 dirs.append((subdir, subdir.name)) 49 74 50 - if self.rules_path.exists(): 51 - for subdir in self.rules_path.iterdir(): 52 - if subdir.is_dir(): 53 - dirs.append((subdir, subdir.name)) 75 + return dirs 54 76 55 - return dirs 77 + # ---- lookup ------------------------------------------------------------ 56 78 57 79 def find(self, name: str, content_type: str | None = None) -> Path | None: 58 - """Find a content file by name. 80 + """Find a content file by name. Returns the first hit across all 81 + three layers in priority order (world > user > shipped). 59 82 60 83 Args: 61 84 name: The content name (e.g., 'goblin', 'fireball') ··· 64 87 Returns: 65 88 Path to the content file, or None if not found 66 89 """ 67 - # Normalize name to filename format 68 90 filename = f"{name.lower().replace(' ', '-')}.md" 69 91 70 92 for search_dir, _ in self._search_dirs(content_type): ··· 97 119 """Parse a markdown file with optional YAML frontmatter.""" 98 120 content = path.read_text() 99 121 100 - # Check for YAML frontmatter 101 122 if content.startswith("---"): 102 - # Find the closing --- 103 123 end_match = re.search(r"\n---\s*\n", content[3:]) 104 124 if end_match: 105 125 frontmatter_end = end_match.start() + 3 ··· 110 130 result["body"] = body.strip() 111 131 return result 112 132 113 - # No frontmatter, just body 114 133 return {"body": content.strip()} 115 -
+11 -13
src/storied/engine.py
··· 27 27 from storied.mcp_server import start_server as start_mcp_server 28 28 from storied.content import ContentResolver 29 29 from storied.log import CampaignLog, TranscriptLog 30 + from storied.paths import data_home, player_path, world_path 30 31 from storied.session import ( 31 32 extract_wiki_links, 32 33 format_session_context, ··· 71 72 self, 72 73 world_id: str = "default", 73 74 player_id: str = "default", 74 - base_path: Path | None = None, 75 75 model: str = "claude-opus-4-6", 76 76 prompt_name: str = "dm-system", 77 77 transcript_path: Path | None = None, ··· 79 79 self.model = model 80 80 self.world_id = world_id 81 81 self.player_id = player_id 82 - self.base_path = base_path or Path.cwd() 83 82 84 83 # Session management 85 84 self._session_id: str | None = None ··· 103 102 transcript_path.parent.mkdir(parents=True, exist_ok=True) 104 103 105 104 # Campaign log for time tracking (world-scoped, shared with MCP server) 106 - self._campaign_log = CampaignLog(self.world_id, self.base_path) 105 + self._campaign_log = CampaignLog(self.world_id) 107 106 108 107 # Transcript log for conversation history 109 - self._transcript = TranscriptLog(self.world_id, self.base_path) 108 + self._transcript = TranscriptLog(self.world_id) 110 109 111 110 # Start in-process MCP server (shares CampaignLog with engine) 112 111 self._mcp = start_mcp_server( 113 112 world_id=self.world_id, 114 113 player_id=self.player_id, 115 - base_path=self.base_path, 116 114 tool_set="dm", 117 115 campaign_log=self._campaign_log, 118 116 ) ··· 179 177 180 178 # 1. DM style tuning (player preferences for pacing, tone, focus) 181 179 if self.world_id: 182 - style_path = self.base_path / "worlds" / self.world_id / "style.md" 180 + style_path = world_path(self.world_id) / "style.md" 183 181 if style_path.exists(): 184 182 style_content = style_path.read_text().strip() 185 183 self._context_parts["Style"] = style_content 186 184 parts.append(style_content) 187 185 188 186 # 1. Character sheet 189 - character = load_character(self.player_id, self.base_path) 187 + character = load_character(self.player_id) 190 188 if character: 191 189 char_context = format_character_context(character) 192 190 self._context_parts["Character"] = char_context ··· 214 212 parts.append(transcript_context) 215 213 216 214 # 4. Session state 217 - session = load_session(self.player_id, self.base_path) 215 + session = load_session(self.player_id) 218 216 if session: 219 217 session_context = format_session_context(session) 220 218 self._context_parts["Session"] = session_context ··· 276 274 277 275 # Notifications from background agents (planner, ticker, advancement) 278 276 if self.world_id: 279 - pending = notifications.drain(self.world_id, self.base_path) 277 + pending = notifications.drain(self.world_id) 280 278 if pending: 281 279 notif_lines = ["## Recent World Changes\n"] 282 280 notif_lines.extend(f"- {msg}" for msg in pending) ··· 306 304 return None 307 305 308 306 knowledge_dir = ( 309 - self.base_path / "players" / self.player_id / "worlds" / self.world_id 307 + player_path(self.player_id) / "worlds" / self.world_id 310 308 ) 311 309 if not knowledge_dir.exists(): 312 310 return None ··· 352 350 """Load world content by type and name.""" 353 351 if not self.world_id: 354 352 return None 355 - resolver = ContentResolver(base_path=self.base_path, world_id=self.world_id) 353 + resolver = ContentResolver(world_id=self.world_id) 356 354 return resolver.load(name, content_type=content_type) 357 355 358 356 def _find_entity(self, name: str) -> dict | None: ··· 369 367 } 370 368 371 369 # Fallback: filesystem scan 372 - entity = load_entity_content(name, self.world_id, self.base_path) 370 + entity = load_entity_content(name, self.world_id) 373 371 if entity: 374 372 return { 375 373 "name": entity["name"], ··· 498 496 mcp_url=self._mcp.url, 499 497 model=self.model, 500 498 resume_session_id=self._session_id, 501 - cwd=self.base_path, 499 + cwd=data_home(), 502 500 ): 503 501 match event: 504 502 case TextDelta(text=text):
+6 -2
src/storied/log.py
··· 177 177 """ 178 178 179 179 def __init__(self, world_id: str = "default", base_path: Path | None = None): 180 + from storied.paths import data_home 181 + 180 182 self.world_id = world_id 181 - self.base_path = base_path or Path.cwd() 183 + self.base_path = base_path or data_home() 182 184 self.log_dir = self.base_path / "worlds" / world_id / "log" 183 185 184 186 # Load or initialize state ··· 495 497 """ 496 498 497 499 def __init__(self, world_id: str, base_path: Path | None = None): 498 - self.base_path = base_path or Path.cwd() 500 + from storied.paths import data_home 501 + 502 + self.base_path = base_path or data_home() 499 503 self.transcript_dir = self.base_path / "worlds" / world_id / "transcripts" 500 504 501 505 def _day_path(self, day: int) -> Path:
+41 -9
src/storied/mcp_server.py
··· 16 16 import uvicorn 17 17 from fastmcp import FastMCP 18 18 19 + from storied import paths 19 20 from storied.log import CampaignLog 20 21 from storied.search import VectorIndex 21 22 from storied.tools import character, combat, entities, mechanics, run_code, scene ··· 63 64 self._thread.join(timeout=2) 64 65 65 66 66 - def _populate_index(base_path: Path, world_dir: Path, vi: VectorIndex) -> None: 67 - """Auto-populate an empty index from SRD seed + world content.""" 68 - srd_seed = base_path / "rules" / "srd-5.2.1" / "search.db" 67 + def _populate_index( 68 + world_dir: Path, 69 + vi: VectorIndex, 70 + srd_root: Path | None = None, 71 + ) -> None: 72 + """Auto-populate an empty index from all three content layers. 73 + 74 + Populates in priority-inverse order so higher-priority layers are 75 + indexed last (letting the SRD seed fast-path do its job first): 76 + 77 + 1. Shipped SRD — ``<shipped_rules>/srd-5.2.1/`` via the prebuilt 78 + ``search.db`` seed if present, else reindex the sections dir. 79 + Tagged ``source="srd"``. 80 + 2. User homebrew — ``<user_rules>/`` if the directory exists. 81 + Tagged ``source="user"``. 82 + 3. World content — ``<world_dir>/``. Tagged ``source="world"``. 83 + 84 + ``srd_root`` is an optional override for tests that want to point 85 + the shipped layer at a tmp path. In production it resolves to 86 + ``paths.shipped_rules_path() / "srd-5.2.1"``. 87 + """ 88 + # 1. Shipped SRD 89 + if srd_root is None: 90 + srd_root = paths.shipped_rules_path() / "srd-5.2.1" 91 + srd_seed = srd_root / "search.db" 69 92 if srd_seed.exists(): 70 93 vi.reseed(srd_seed) 71 94 else: 72 - srd_dir = base_path / "rules" / "srd-5.2.1" / "sections" 95 + srd_dir = srd_root / "sections" 73 96 if srd_dir.exists(): 74 97 vi.reindex_directory(srd_dir, source="srd") 98 + 99 + # 2. User homebrew — flat layout, source="user" 100 + user_rules = paths.user_rules_path() 101 + if user_rules.exists(): 102 + vi.reindex_directory(user_rules, source="user") 103 + 104 + # 3. World content — flat layout, source="world" 75 105 if world_dir.exists(): 76 106 vi.reindex_directory(world_dir, source="world") 77 107 ··· 133 163 def start_server( # pragma: no cover 134 164 world_id: str, 135 165 player_id: str, 136 - base_path: Path, 137 166 tool_set: str = "dm", 138 167 campaign_log: CampaignLog | None = None, 139 168 ) -> MCPServerHandle: ··· 143 172 The server runs in a daemon thread and shares the process-global 144 173 ToolContext (set via init_ctx) with the caller. 145 174 175 + Paths are resolved via :mod:`storied.paths` (the data home is set 176 + once at CLI startup via ``configure()``), so this function takes 177 + no path parameters. 178 + 146 179 Not unit-tested: this is the live launcher that spins up uvicorn in 147 180 a daemon thread and waits for the port to open. The pure compose path 148 181 (`_compose_server`) and tool registry it builds are tested separately ··· 150 183 mock, not the launcher. 151 184 """ 152 185 if campaign_log is None: 153 - campaign_log = CampaignLog(world_id, base_path) 186 + campaign_log = CampaignLog(world_id) 154 187 155 - world_dir = base_path / "worlds" / world_id 188 + world_dir = paths.world_path(world_id) 156 189 157 190 vector_index = VectorIndex( 158 191 world_dir / "search.db", 159 - on_empty=lambda vi: _populate_index(base_path, world_dir, vi), 192 + on_empty=lambda vi: _populate_index(world_dir, vi), 160 193 ) 161 194 162 195 ctx = init_ctx( 163 196 world_id=world_id, 164 197 player_id=player_id, 165 - base_path=base_path, 166 198 campaign_log=campaign_log, 167 199 entity_index=EntityIndex(world_dir), 168 200 vector_index=vector_index,
+10 -9
src/storied/notifications.py
··· 2 2 3 3 Background agents (planner, ticker, advancement evaluator) append messages. 4 4 The DM engine reads and clears them each turn via _build_context. 5 + 6 + Path resolution uses :func:`storied.paths.world_path` — all notifications 7 + for a given world live at ``<world>/dm_notifications.md``, under whatever 8 + the current ``data_home`` is. 5 9 """ 6 10 7 11 import threading 8 - from pathlib import Path 12 + 13 + from storied.paths import world_path 9 14 10 15 11 16 _lock = threading.Lock() 12 17 13 18 14 - def _notifications_path(world_id: str, base_path: Path) -> Path: 15 - return base_path / "worlds" / world_id / "dm_notifications.md" 16 - 17 - 18 - def append(world_id: str, base_path: Path, message: str) -> None: 19 + def append(world_id: str, message: str) -> None: 19 20 """Append a notification for the DM to see next turn.""" 20 - path = _notifications_path(world_id, base_path) 21 + path = world_path(world_id) / "dm_notifications.md" 21 22 with _lock: 22 23 path.parent.mkdir(parents=True, exist_ok=True) 23 24 with path.open("a") as f: 24 25 f.write(f"- {message}\n") 25 26 26 27 27 - def drain(world_id: str, base_path: Path) -> list[str]: 28 + def drain(world_id: str) -> list[str]: 28 29 """Read all pending notifications and clear the file. 29 30 30 31 Returns a list of notification messages (without the leading "- "). 31 32 """ 32 - path = _notifications_path(world_id, base_path) 33 + path = world_path(world_id) / "dm_notifications.md" 33 34 with _lock: 34 35 if not path.exists(): 35 36 return []
+192
src/storied/paths.py
··· 1 + """Filesystem path resolution for storied. 2 + 3 + Campaign data (worlds, players, campaign logs, vector search indices, 4 + transcripts) lives under a single "data home" directory. By default 5 + that's ``~/.storied/`` — tightly coupled, easy to back up, out of the 6 + repo working tree. 7 + 8 + User homebrew rules (spells, monsters, etc. that apply across every 9 + campaign you run) live at ``~/.storied/rules/`` by default — see 10 + :func:`user_rules_path`. Sandbox mode overrides the data home to a 11 + tempdir but keeps user rules pointed at the real directory so you 12 + still have your homebrew available in a throwaway session. 13 + 14 + The shipped SRD is static reference content bundled with the repo 15 + and resolved separately via :func:`shipped_rules_path`. 16 + 17 + ## Configuration model 18 + 19 + Storied is "one process, one game." Path configuration is held in 20 + module-level globals, set once at CLI startup via :func:`configure`. 21 + Subsequent calls to the getters (:func:`data_home`, 22 + :func:`worlds_path`, :func:`player_path`, etc.) return the configured 23 + values. 24 + 25 + Threads (the uvicorn MCP server, background agents) inherit the 26 + module globals automatically — no ``contextvars.copy_context()`` 27 + gymnastics needed. Subprocesses that re-enter storied inherit the 28 + ``STORIED_HOME`` environment variable, which the CLI exports at 29 + startup so child processes resolve the same path. 30 + """ 31 + 32 + from __future__ import annotations 33 + 34 + import os 35 + from contextlib import contextmanager 36 + from pathlib import Path 37 + from typing import Iterator 38 + 39 + 40 + _DEFAULT_DATA_HOME = Path.home() / ".storied" 41 + 42 + 43 + # Module globals — the single source of truth for path configuration. 44 + # Defaults are set here and overridden by :func:`configure` at CLI 45 + # startup. Library code reads via the getter functions below; do NOT 46 + # import these names directly. 47 + _data_home: Path = _DEFAULT_DATA_HOME 48 + _user_rules_home: Path = _DEFAULT_DATA_HOME / "rules" 49 + 50 + 51 + # Backwards compatibility: older code imports DEFAULT_DATA_HOME 52 + # directly. Retire this re-export once all callers are migrated. 53 + DEFAULT_DATA_HOME = _DEFAULT_DATA_HOME 54 + 55 + 56 + def configure( 57 + *, 58 + data_home: Path | None = None, 59 + user_rules_home: Path | None = None, 60 + ) -> None: 61 + """Set the process-wide path configuration. 62 + 63 + Called once at CLI startup. In normal mode, ``data_home`` is 64 + passed and ``user_rules_home`` is left alone — it's computed as 65 + ``data_home / "rules"`` to keep the two in sync. 66 + 67 + In sandbox mode the caller passes both: ``data_home`` points at 68 + a tempdir (worlds/players are throwaway) while 69 + ``user_rules_home`` points at the real ``~/.storied/rules/`` so 70 + the user's homebrew stays available in the sandbox. 71 + 72 + If ``data_home`` is passed without ``user_rules_home``, the user 73 + rules home is updated to ``data_home / "rules"`` so a 74 + ``--base-path /elsewhere`` flag moves both in lockstep. 75 + """ 76 + global _data_home, _user_rules_home 77 + if data_home is not None: 78 + _data_home = Path(data_home).expanduser().resolve() 79 + if user_rules_home is None: 80 + _user_rules_home = _data_home / "rules" 81 + if user_rules_home is not None: 82 + _user_rules_home = Path(user_rules_home).expanduser().resolve() 83 + 84 + 85 + def resolve_data_home(explicit: Path | None = None) -> Path: 86 + """Resolve the storied data directory for CLI commands. 87 + 88 + Priority: ``explicit`` arg > ``$STORIED_HOME`` env > ``~/.storied/``. 89 + The returned path is expanded and absolutized so downstream code 90 + can use it without worrying about ``~`` or relative bits. 91 + """ 92 + if explicit is not None: 93 + return Path(explicit).expanduser().resolve() 94 + env = os.environ.get("STORIED_HOME") 95 + if env: 96 + return Path(env).expanduser().resolve() 97 + return _DEFAULT_DATA_HOME 98 + 99 + 100 + @contextmanager 101 + def using_data_home(path: Path) -> Iterator[Path]: 102 + """Temporarily override ``data_home`` (and ``user_rules_home``) 103 + for the duration of a block. 104 + 105 + For tests and ad-hoc scripts. Restores the previous configuration 106 + on exit. Not thread-safe — don't use it to run multiple 107 + configurations in parallel within the same process. 108 + 109 + Example:: 110 + 111 + with using_data_home(tmp_path): 112 + load_character("default") 113 + """ 114 + global _data_home, _user_rules_home 115 + prev_data = _data_home 116 + prev_user = _user_rules_home 117 + _data_home = Path(path).expanduser().resolve() 118 + _user_rules_home = _data_home / "rules" 119 + try: 120 + yield _data_home 121 + finally: 122 + _data_home = prev_data 123 + _user_rules_home = prev_user 124 + 125 + 126 + # --------------------------------------------------------------------------- 127 + # Data-home getters: campaign state, character sheets, etc. 128 + # --------------------------------------------------------------------------- 129 + 130 + 131 + def data_home() -> Path: 132 + """The root directory for campaign data.""" 133 + return _data_home 134 + 135 + 136 + def worlds_path() -> Path: 137 + """Directory containing every world — ``<data_home>/worlds/``.""" 138 + return _data_home / "worlds" 139 + 140 + 141 + def players_path() -> Path: 142 + """Directory containing every player — ``<data_home>/players/``.""" 143 + return _data_home / "players" 144 + 145 + 146 + def world_path(world_id: str) -> Path: 147 + """Directory for a specific world.""" 148 + return _data_home / "worlds" / world_id 149 + 150 + 151 + def player_path(player_id: str) -> Path: 152 + """Directory for a specific player.""" 153 + return _data_home / "players" / player_id 154 + 155 + 156 + # --------------------------------------------------------------------------- 157 + # Rules-layer getters: user homebrew and shipped SRD. 158 + # --------------------------------------------------------------------------- 159 + 160 + 161 + def user_rules_path() -> Path: 162 + """User-level homebrew rules overlay. 163 + 164 + Defaults to ``<data_home>/rules/`` but is configured independently 165 + so sandbox mode can keep this pointed at the real user home while 166 + the data home is a tempdir. 167 + 168 + Layout is flat: files live at ``<user_rules>/{content_type}/*.md`` 169 + — matching the world-layer shape, NOT the shipped-layer's 170 + ``srd-5.2.1/sections/`` nesting. If you homebrew the goblin, it 171 + goes at ``~/.storied/rules/monsters/goblin.md``. 172 + """ 173 + return _user_rules_home 174 + 175 + 176 + def shipped_rules_path() -> Path: 177 + """The SRD rules directory bundled with the repo. 178 + 179 + Lives at ``<repo-root>/rules``, computed relative to the installed 180 + package source. This works for editable installs (``pip install 181 + -e .``); for a real wheel install we'd need ``importlib.resources`` 182 + plus a ``[tool.hatch.build]`` package-data entry. Noted as a 183 + followup; not blocking while storied is dev-install only. 184 + """ 185 + return (Path(__file__).parent.parent.parent / "rules").resolve() 186 + 187 + 188 + # Backwards compatibility alias. Older code imports ``rules_home`` 189 + # directly. Retire once all callers use :func:`shipped_rules_path`. 190 + def rules_home() -> Path: 191 + """Alias for :func:`shipped_rules_path`.""" 192 + return shipped_rules_path()
+25 -38
src/storied/planner.py
··· 11 11 from storied.engine import load_prompt 12 12 from storied.log import CampaignLog 13 13 from storied.mcp_server import start_server as start_mcp_server 14 + from storied.paths import data_home 14 15 from storied.session import ( 15 16 extract_wiki_links, 16 17 load_session, ··· 62 63 def find_nearby_entities( 63 64 session: dict, 64 65 world_id: str, 65 - base_path: Path, 66 66 ) -> list[tuple[str, Path]]: 67 67 """Find entities near the player by walking wikilinks. 68 68 ··· 78 78 if name in seen: 79 79 return 80 80 seen.add(name) 81 - path = resolve_wiki_link(name, world_id, base_path) 81 + path = resolve_wiki_link(name, world_id) 82 82 if path: 83 83 results.append((name, path)) 84 84 to_follow.append(path) ··· 107 107 def build_planning_context( 108 108 world_id: str, 109 109 player_id: str, 110 - base_path: Path, 111 110 candidates: list[tuple[str, Path]], 112 111 ) -> str: 113 112 """Build the context string that the planner LLM sees. ··· 118 117 parts: list[str] = [] 119 118 120 119 # Session state 121 - session = load_session(player_id, base_path) 120 + session = load_session(player_id) 122 121 if session: 123 122 location = session.get("location", "unknown") 124 123 parts.append(f"## Current Location: {location}") ··· 128 127 parts.append(body) 129 128 130 129 # Campaign log — full recent entries so the planner can spot casual mentions 131 - log = CampaignLog(world_id, base_path) 130 + log = CampaignLog(world_id) 132 131 parts.append(f"## Campaign Time: {log.get_current_time()}") 133 132 134 133 recent = log.get_recent_entries(days=2) ··· 170 169 def plan_world( 171 170 world_id: str = "default", 172 171 player_id: str = "default", 173 - base_path: Path | None = None, 174 172 model: str = "claude-opus-4-6", 175 173 threshold: float = 0.7, 176 174 max_entities: int = 8, ··· 178 176 on_progress: Callable[[str], None] | None = None, 179 177 ) -> PlanResult: 180 178 """Enrich thin entities near the player's current position.""" 181 - if base_path is None: 182 - base_path = Path.cwd() 183 179 184 180 def progress(msg: str) -> None: 185 181 if on_progress: ··· 188 184 start_time = time.monotonic() 189 185 190 186 # Load session 191 - session = load_session(player_id, base_path) 187 + session = load_session(player_id) 192 188 if not session: 193 189 return PlanResult(dry_run=dry_run) 194 190 ··· 196 192 progress(f"Current location: {location}") 197 193 198 194 # Find and score nearby entities 199 - nearby = find_nearby_entities(session, world_id, base_path) 195 + nearby = find_nearby_entities(session, world_id) 200 196 progress(f"Scanning {len(nearby)} nearby entities...") 201 197 202 198 scored: list[tuple[str, Path, float]] = [] ··· 223 219 224 220 # Build context and run claude -p 225 221 candidate_pairs = [(name, path) for name, path, _ in candidates] 226 - context = build_planning_context(world_id, player_id, base_path, candidate_pairs) 222 + context = build_planning_context(world_id, player_id, candidate_pairs) 227 223 system_prompt = load_prompt("planner-system") 228 224 229 - campaign_log = CampaignLog(world_id, base_path) 225 + campaign_log = CampaignLog(world_id) 230 226 mcp = start_mcp_server( 231 - world_id, player_id, base_path, "planner", campaign_log, 227 + world_id, player_id, "planner", campaign_log, 232 228 ) 233 229 234 230 progress(f"Planning with {model}...") ··· 243 239 mcp_url=mcp.url, 244 240 model=model, 245 241 on_tool_start=on_tool, 246 - cwd=base_path, 242 + cwd=data_home(), 247 243 ) 248 244 249 245 if claude_result: ··· 268 264 def seed_world( 269 265 world_id: str = "default", 270 266 player_id: str = "default", 271 - base_path: Path | None = None, 272 267 model: str = "claude-opus-4-6", 273 268 on_progress: Callable[[str], None] | None = None, 274 269 ) -> SeedResult: 275 270 """Build the initial world from a character sheet.""" 276 - if base_path is None: 277 - base_path = Path.cwd() 278 271 279 272 def progress(msg: str) -> None: 280 273 if on_progress: ··· 283 276 start_time = time.monotonic() 284 277 285 278 # Load the character — nothing to seed without one 286 - character = load_character(player_id, base_path) 279 + character = load_character(player_id) 287 280 if character is None: 288 281 return SeedResult() 289 282 ··· 291 284 context = format_character_context(character) 292 285 system_prompt = load_prompt("world-seed") 293 286 294 - campaign_log = CampaignLog(world_id, base_path) 287 + campaign_log = CampaignLog(world_id) 295 288 mcp = start_mcp_server( 296 - world_id, player_id, base_path, "seeder", campaign_log, 289 + world_id, player_id, "seeder", campaign_log, 297 290 ) 298 291 299 292 progress(f"Seeding with {model}...") ··· 308 301 mcp_url=mcp.url, 309 302 model=model, 310 303 on_tool_start=on_tool, 311 - cwd=base_path, 304 + cwd=data_home(), 312 305 ) 313 306 314 307 seed_result = SeedResult(elapsed=time.monotonic() - start_time) ··· 334 327 335 328 def _find_entities_with_will( 336 329 world_id: str, 337 - base_path: Path, 338 330 ) -> list[tuple[str, Path]]: 339 331 """Find all entities that have Will triggers defined.""" 340 - world_dir = base_path / "worlds" / world_id 332 + from storied.paths import world_path 333 + 334 + world_dir = world_path(world_id) 341 335 if not world_dir.exists(): 342 336 return [] 343 337 ··· 357 351 def build_tick_context( 358 352 world_id: str, 359 353 player_id: str, 360 - base_path: Path, 361 354 entities: list[tuple[str, Path]], 362 355 ) -> str: 363 356 """Build context for the world tick agent.""" 364 357 parts: list[str] = [] 365 358 366 359 # Campaign log and time 367 - log = CampaignLog(world_id, base_path) 360 + log = CampaignLog(world_id) 368 361 current_time = log.get_current_time() 369 362 parts.append(f"## Current Game Time: {current_time}") 370 363 371 364 # Session state for last-played context 372 - session = load_session(player_id, base_path) 365 + session = load_session(player_id) 373 366 if session: 374 367 location = session.get("location", "unknown") 375 368 parts.append(f"## Player's Last Location: {location}") ··· 404 397 def tick_world( # pragma: no cover 405 398 world_id: str = "default", 406 399 player_id: str = "default", 407 - base_path: Path | None = None, 408 400 model: str = "claude-opus-4-6", 409 401 on_progress: Callable[[str], None] | None = None, 410 402 ) -> TickResult: ··· 415 407 composes (`_find_entities_with_will`, `build_tick_context`) are covered 416 408 separately. 417 409 """ 418 - if base_path is None: 419 - base_path = Path.cwd() 420 410 421 411 def progress(msg: str) -> None: 422 412 if on_progress: ··· 425 415 start_time = time.monotonic() 426 416 427 417 # Find entities with Will triggers 428 - entities = _find_entities_with_will(world_id, base_path) 418 + entities = _find_entities_with_will(world_id) 429 419 progress(f"Found {len(entities)} entities with active triggers") 430 420 431 421 if not entities: 432 422 return TickResult(elapsed=time.monotonic() - start_time) 433 423 434 424 # Build context and run 435 - context = build_tick_context(world_id, player_id, base_path, entities) 425 + context = build_tick_context(world_id, player_id, entities) 436 426 system_prompt = load_prompt("world-tick") 437 427 438 - campaign_log = CampaignLog(world_id, base_path) 428 + campaign_log = CampaignLog(world_id) 439 429 mcp = start_mcp_server( 440 - world_id, player_id, base_path, "planner", campaign_log, 430 + world_id, player_id, "planner", campaign_log, 441 431 ) 442 432 443 433 progress(f"Ticking with {model}...") ··· 452 442 mcp_url=mcp.url, 453 443 model=model, 454 444 on_tool_start=on_tool, 455 - cwd=base_path, 445 + cwd=data_home(), 456 446 ) 457 447 458 448 result = TickResult( ··· 479 469 self, 480 470 world_id: str, 481 471 player_id: str, 482 - base_path: Path, 483 472 model: str = "claude-opus-4-6", 484 473 ): 485 474 self._world_id = world_id 486 475 self._player_id = player_id 487 - self._base_path = base_path 488 476 self._model = model 489 477 self._last_tick_day: int = 0 490 478 self._thread: Thread | None = None ··· 498 486 if self._thread and self._thread.is_alive(): 499 487 return 500 488 501 - triggers = _find_entities_with_will(self._world_id, self._base_path) 489 + triggers = _find_entities_with_will(self._world_id) 502 490 if not triggers: 503 491 self._last_tick_day = current_day 504 492 return ··· 514 502 self._result = tick_world( 515 503 world_id=self._world_id, 516 504 player_id=self._player_id, 517 - base_path=self._base_path, 518 505 model=self._model, 519 506 ) 520 507
+18 -4
src/storied/search.py
··· 270 270 self, 271 271 query: str, 272 272 limit: int = 5, 273 - source_filter: str | None = None, 273 + source_filter: str | list[str] | None = None, 274 274 exclude_source: str | None = None, 275 275 decay_ref: int | None = None, 276 276 ) -> list[SearchHit]: ··· 279 279 Args: 280 280 query: Natural language search query 281 281 limit: Max results to return 282 - source_filter: Restrict to a single source ("srd", "world", etc.) 283 - exclude_source: Exclude a single source from results 282 + source_filter: Restrict to one or more sources. A single string 283 + matches exactly one source ("srd", "world", etc.); a list 284 + of strings is an OR ("srd", "user", and "world" all match). 285 + ``None`` (default) returns hits from every source. 286 + exclude_source: Exclude a single source from results. Retained 287 + for backwards compatibility; prefer ``source_filter`` for 288 + new code. 284 289 decay_ref: Current game day for age-decay on transcripts. 285 290 If None, no decay is applied. 286 291 """ ··· 293 298 self._on_empty(self) 294 299 self._on_empty = None 295 300 301 + # Normalize source_filter to a set for O(1) membership checks. 302 + allowed_sources: set[str] | None 303 + if source_filter is None: 304 + allowed_sources = None 305 + elif isinstance(source_filter, str): 306 + allowed_sources = {source_filter} 307 + else: 308 + allowed_sources = set(source_filter) 309 + 296 310 vec = self.embed([query])[0] 297 311 blob = _serialize_f32(vec) 298 312 ··· 311 325 312 326 hits: list[SearchHit] = [] 313 327 for doc_id, distance, path, source, ctype, preview, game_day in rows: 314 - if source_filter and source != source_filter: 328 + if allowed_sources is not None and source not in allowed_sources: 315 329 continue 316 330 if exclude_source and source == exclude_source: 317 331 continue
+14 -21
src/storied/session.py
··· 6 6 7 7 import yaml 8 8 9 + from storied.paths import player_path, world_path 9 10 10 - def load_session(player_id: str, base_path: Path | None = None) -> dict | None: 11 + 12 + def load_session(player_id: str) -> dict | None: 11 13 """Load session state for a player. 12 14 13 15 Args: 14 16 player_id: Player identifier (directory name under players/) 15 - base_path: Base path for players directory (defaults to cwd) 17 + base_path: Base path for players directory (defaults to ~/.storied/) 16 18 17 19 Returns: 18 20 Dict with frontmatter fields plus 'body' key, or None if not found 19 21 """ 20 - if base_path is None: 21 - base_path = Path.cwd() 22 22 23 - session_path = base_path / "players" / player_id / "session.md" 23 + session_path = player_path(player_id) / "session.md" 24 24 if not session_path.exists(): 25 25 return None 26 26 ··· 44 44 return {"body": content.strip()} 45 45 46 46 47 - def save_session(player_id: str, data: dict, base_path: Path | None = None) -> None: 47 + def save_session(player_id: str, data: dict) -> None: 48 48 """Save session state to file. 49 49 50 50 Args: ··· 52 52 data: Session data with frontmatter fields and 'body' 53 53 base_path: Base path for players directory 54 54 """ 55 - if base_path is None: 56 - base_path = Path.cwd() 57 55 58 - session_path = base_path / "players" / player_id / "session.md" 56 + session_path = player_path(player_id) / "session.md" 59 57 session_path.parent.mkdir(parents=True, exist_ok=True) 60 58 61 59 # Update timestamp ··· 79 77 80 78 def update_session( 81 79 player_id: str, 82 - updates: dict, 83 - base_path: Path | None = None, 80 + updates: dict 84 81 ) -> str: 85 82 """Update specific fields in the session state. 86 83 ··· 97 94 Returns: 98 95 Confirmation message 99 96 """ 100 - data = load_session(player_id, base_path) 97 + data = load_session(player_id) 101 98 if data is None: 102 99 # Create new session 103 100 data = {"body": ""} ··· 127 124 changes.append(f"{key} = {value}") 128 125 129 126 data["body"] = body 130 - save_session(player_id, data, base_path) 127 + save_session(player_id, data) 131 128 132 129 if not changes: 133 130 return "No changes made to session" ··· 172 169 173 170 def resolve_wiki_link( 174 171 name: str, 175 - world_id: str, 176 - base_path: Path | None = None, 172 + world_id: str 177 173 ) -> Path | None: 178 174 """Resolve a wikilink name to a file path. 179 175 ··· 187 183 Returns: 188 184 Path to the entity file, or None if not found 189 185 """ 190 - if base_path is None: 191 - base_path = Path.cwd() 192 186 193 - world_dir = base_path / "worlds" / world_id 187 + world_dir = world_path(world_id) 194 188 195 189 for entity_type in ENTITY_TYPES: 196 190 file_path = world_dir / entity_type / f"{name}.md" ··· 202 196 203 197 def load_entity_content( 204 198 name: str, 205 - world_id: str, 206 - base_path: Path | None = None, 199 + world_id: str 207 200 ) -> dict | None: 208 201 """Load an entity's content by resolving its wikilink. 209 202 ··· 215 208 Returns: 216 209 Dict with entity_type, name, and content, or None if not found 217 210 """ 218 - file_path = resolve_wiki_link(name, world_id, base_path) 211 + file_path = resolve_wiki_link(name, world_id) 219 212 if file_path is None: 220 213 return None 221 214
-2
src/storied/tools/__init__.py
··· 13 13 EntityIndex, 14 14 Lore, 15 15 Player, 16 - StorageRoot, 17 16 Timekeeper, 18 17 ToolContext, 19 18 World, ··· 30 29 "EntityIndex", 31 30 "Lore", 32 31 "Player", 33 - "StorageRoot", 34 32 "Timekeeper", 35 33 "ToolContext", 36 34 "World",
+10 -14
src/storied/tools/_context.py
··· 65 65 Created once per process via init_ctx() and exposed to tools through 66 66 the Dependency subclasses below. Tools should never reach for the 67 67 full ToolContext — they ask for the specific slices they need. 68 + 69 + Filesystem paths live in :mod:`storied.paths` (module globals, 70 + configured at CLI startup), not on the ToolContext. The context 71 + holds only stateful runtime objects. 68 72 """ 69 73 70 74 world_id: str 71 75 player_id: str 72 - base_path: Path 73 76 campaign_log: CampaignLog 74 77 entity_index: EntityIndex 75 78 vector_index: VectorIndex ··· 84 87 def init_ctx( 85 88 world_id: str, 86 89 player_id: str, 87 - base_path: Path, 88 90 campaign_log: CampaignLog, 89 91 entity_index: EntityIndex, 90 92 vector_index: VectorIndex, ··· 97 99 _ctx = ToolContext( 98 100 world_id=world_id, 99 101 player_id=player_id, 100 - base_path=base_path, 101 102 campaign_log=campaign_log, 102 103 entity_index=entity_index, 103 104 vector_index=vector_index, ··· 144 145 return _require().player_id 145 146 146 147 147 - class StorageRoot(Dependency[Path]): 148 - """The filesystem root for worlds, players, and rules.""" 149 - single = True 150 - 151 - async def __aenter__(self) -> Path: 152 - return _require().base_path 153 - 154 - 155 148 class Timekeeper(Dependency[CampaignLog]): 156 149 """The campaign log: game time, world events, recent history.""" 157 150 single = True ··· 191 184 target: str, 192 185 initiative: InitiativeTracker, 193 186 player_id: str, 194 - base_path: Path, 195 187 result: str, 196 188 ) -> str: 197 - """Auto-sync player character sheet when damage/heal targets a player.""" 189 + """Auto-sync player character sheet when damage/heal targets a player. 190 + 191 + Path resolution happens inside ``char_update`` via the module 192 + globals in :mod:`storied.paths`. 193 + """ 198 194 combatant = initiative._find(target) 199 195 if combatant and combatant.is_player: 200 - char_update(player_id, {"state.hp.current": combatant.hp}, base_path) 196 + char_update(player_id, {"state.hp.current": combatant.hp}) 201 197 result += f" (character sheet synced to {combatant.hp} HP)" 202 198 return result
+20 -39
src/storied/tools/character.py
··· 65 65 from storied.tools._context import ( 66 66 Combat, 67 67 Player, 68 - StorageRoot, 69 68 Timekeeper, 70 69 _sync_player_hp, 71 70 ) ··· 126 125 def update_character( 127 126 updates: dict[str, JsonValue], 128 127 player: str = Player(), 129 - root: Path = StorageRoot(), 130 128 ) -> str: 131 129 """Update arbitrary fields on the character sheet via dot notation. 132 130 ··· 153 151 Returns: 154 152 Confirmation of what was updated, or a rejection error. 155 153 """ 156 - return char_update(player, updates, root) 154 + return char_update(player, updates) 157 155 158 156 159 157 @mcp.tool(tags={"dm", "character"}) ··· 171 169 subclass: str | None = None, 172 170 backstory: str | None = None, 173 171 player: str = Player(), 174 - root: Path = StorageRoot(), 175 172 ) -> str: 176 173 """Create a new player character with the new structured schema. 177 174 ··· 209 206 purse=purse.model_dump() if purse else None, 210 207 subclass=subclass, 211 208 backstory=backstory, 212 - base_path=root, 213 - ) 209 + ) 214 210 215 211 216 212 # --- HP operations (unified — work in or out of combat) --- ··· 223 219 type: str | None = None, 224 220 combat: InitiativeTracker = Combat(), 225 221 player: str = Player(), 226 - root: Path = StorageRoot(), 227 222 ) -> str: 228 223 """Apply raw damage to a named target. 229 224 ··· 248 243 """ 249 244 if combat.active and combat._find(target) is not None: 250 245 result = combat.apply_damage(target, amount) 251 - return _sync_player_hp(target, combat, player, root, result) 246 + return _sync_player_hp(target, combat, player, result) 252 247 253 - char = load_character(player, root) 248 + char = load_character(player) 254 249 if char and char["identity"]["name"] == target: 255 - return char_damage(player, amount, damage_type=type, base_path=root) 250 + return char_damage(player, amount, damage_type=type) 256 251 257 252 return f"No such target: {target}" 258 253 ··· 263 258 amount: int, 264 259 combat: InitiativeTracker = Combat(), 265 260 player: str = Player(), 266 - root: Path = StorageRoot(), 267 261 ) -> str: 268 262 """Heal a named target. 269 263 ··· 281 275 """ 282 276 if combat.active and combat._find(target) is not None: 283 277 result = combat.apply_heal(target, amount) 284 - return _sync_player_hp(target, combat, player, root, result) 278 + return _sync_player_hp(target, combat, player, result) 285 279 286 - char = load_character(player, root) 280 + char = load_character(player) 287 281 if char and char["identity"]["name"] == target: 288 - return char_heal(player, amount, base_path=root) 282 + return char_heal(player, amount) 289 283 290 284 return f"No such target: {target}" 291 285 ··· 297 291 def adjust_coins( 298 292 deltas: CoinDelta, 299 293 player: str = Player(), 300 - root: Path = StorageRoot(), 301 294 ) -> str: 302 295 """Adjust the player's coins by relative amounts. 303 296 ··· 314 307 # Drop zero-deltas before passing to the underlying op so the result 315 308 # message only mentions denominations the DM actually touched. 316 309 nonzero = {k: v for k, v in deltas.model_dump().items() if v != 0} 317 - return char_adjust_coins(player, nonzero, root) 310 + return char_adjust_coins(player, nonzero) 318 311 319 312 320 313 # --- Effect operations --- ··· 327 320 expires: str | None = None, 328 321 concentration: bool = False, 329 322 player: str = Player(), 330 - root: Path = StorageRoot(), 331 323 ) -> str: 332 324 """Track a temporary effect on the character. 333 325 ··· 351 343 """ 352 344 return char_add_effect( 353 345 player, source, description, 354 - expires=expires, concentration=concentration, base_path=root, 346 + expires=expires, concentration=concentration, 355 347 ) 356 348 357 349 ··· 359 351 def remove_effect( 360 352 source: str, 361 353 player: str = Player(), 362 - root: Path = StorageRoot(), 363 354 ) -> str: 364 355 """Remove an effect by source name (case-insensitive substring match). 365 356 ··· 369 360 Returns: 370 361 Confirmation of what was removed 371 362 """ 372 - return char_remove_effect(player, source, base_path=root) 363 + return char_remove_effect(player, source) 373 364 374 365 375 366 # --- Condition operations --- ··· 379 370 def add_condition( 380 371 name: str, 381 372 player: str = Player(), 382 - root: Path = StorageRoot(), 383 373 ) -> str: 384 374 """Mark a condition on the character (5e conditions or any custom name). 385 375 ··· 389 379 Returns: 390 380 Confirmation 391 381 """ 392 - return char_add_condition(player, name, base_path=root) 382 + return char_add_condition(player, name) 393 383 394 384 395 385 @mcp.tool(tags={"dm", "character"}) 396 386 def remove_condition( 397 387 name: str, 398 388 player: str = Player(), 399 - root: Path = StorageRoot(), 400 389 ) -> str: 401 390 """Remove a condition from the character. 402 391 ··· 406 395 Returns: 407 396 Confirmation 408 397 """ 409 - return char_remove_condition(player, name, base_path=root) 398 + return char_remove_condition(player, name) 410 399 411 400 412 401 # --- Inventory operations --- ··· 417 406 item: str, 418 407 location: str | None = None, 419 408 player: str = Player(), 420 - root: Path = StorageRoot(), 421 409 ) -> str: 422 410 """Add a mundane item to the character's equipment. 423 411 ··· 435 423 Returns: 436 424 Confirmation with the location it was added to 437 425 """ 438 - return char_add_item(player, item, location=location, base_path=root) 426 + return char_add_item(player, item, location=location) 439 427 440 428 441 429 @mcp.tool(tags={"dm", "character"}) 442 430 def remove_item( 443 431 item: str, 444 432 player: str = Player(), 445 - root: Path = StorageRoot(), 446 433 ) -> str: 447 434 """Remove an item from the character's equipment by name. 448 435 ··· 455 442 Returns: 456 443 Confirmation with the item that was removed 457 444 """ 458 - return char_remove_item(player, item, base_path=root) 445 + return char_remove_item(player, item) 459 446 460 447 461 448 @mcp.tool(tags={"dm", "character"}) ··· 463 450 item: str, 464 451 status: Literal["attuned", "equipped", "carried"], 465 452 player: str = Player(), 466 - root: Path = StorageRoot(), 467 453 ) -> str: 468 454 """Set a magic item's status. 469 455 ··· 478 464 Returns: 479 465 Confirmation 480 466 """ 481 - return char_set_item_status(player, item, status, base_path=root) 467 + return char_set_item_status(player, item, status) 482 468 483 469 484 470 # --- Resource operations --- ··· 489 475 name: str, 490 476 delta: int, 491 477 player: str = Player(), 492 - root: Path = StorageRoot(), 493 478 ) -> str: 494 479 """Adjust a resource pool by a delta (negative = use, positive = restore). 495 480 ··· 504 489 Returns: 505 490 Confirmation with new count 506 491 """ 507 - return char_adjust_resource(player, name, delta, base_path=root) 492 + return char_adjust_resource(player, name, delta) 508 493 509 494 510 495 @mcp.tool(tags={"dm", "character"}) 511 496 def rest( 512 497 type: Literal["short", "long"], 513 498 player: str = Player(), 514 - root: Path = StorageRoot(), 515 499 ) -> str: 516 500 """Take a short or long rest. 517 501 ··· 525 509 Returns: 526 510 Summary of what was refreshed 527 511 """ 528 - return char_rest(player, type, base_path=root) 512 + return char_rest(player, type) 529 513 530 514 531 515 # --- Level advancement --- ··· 539 523 features: list[dict] | None = None, 540 524 timekeeper: CampaignLog = Timekeeper(), 541 525 player: str = Player(), 542 - root: Path = StorageRoot(), 543 526 ) -> str: 544 527 """Atomically level up the character. 545 528 ··· 575 558 hp_gain=hp_gain, 576 559 features=features, 577 560 time_anchor=time_anchor, 578 - base_path=root, 579 - ) 561 + ) 580 562 581 563 582 564 # --- Notes --- ··· 587 569 text: str, 588 570 timekeeper: CampaignLog = Timekeeper(), 589 571 player: str = Player(), 590 - root: Path = StorageRoot(), 591 572 ) -> str: 592 573 """Append a note to the player's notes.md journal. 593 574 ··· 601 582 Confirmation 602 583 """ 603 584 time_anchor = timekeeper.get_current_time().to_anchor() 604 - return char_add_note(player, text, time_anchor=time_anchor, base_path=root) 585 + return char_add_note(player, text, time_anchor=time_anchor)
+11 -17
src/storied/tools/entities.py
··· 9 9 10 10 from storied.character import load_character 11 11 from storied.log import CampaignLog 12 + from storied.paths import player_path, world_path 12 13 from storied.search import VectorIndex 13 14 from storied.session import name_to_slug 14 15 from storied.tools._context import ( ··· 16 17 EntityIndex, 17 18 Lore, 18 19 Player, 19 - StorageRoot, 20 20 Timekeeper, 21 21 World, 22 22 _get_file_lock, ··· 176 176 knows: list[str] | None, 177 177 wants: list[str] | None, 178 178 will: list[str] | None, 179 - base_path: Path, 180 179 world_id: str, 181 180 entity_index: EntityIndex, 182 181 lore: VectorIndex, 183 182 ) -> str: 184 183 """Plain bookkeeping form of establish; called by both the FastMCP wrapper 185 184 and any internal caller (e.g. world seeding).""" 186 - world_dir = base_path / "worlds" / world_id / entity_type 185 + world_dir = world_path(world_id) / entity_type 187 186 world_dir.mkdir(parents=True, exist_ok=True) 188 187 file_path = world_dir / f"{name}.md" 189 188 ··· 233 232 name: str, 234 233 event: str, 235 234 resolves: list[str] | None, 236 - base_path: Path, 237 235 world_id: str, 238 236 entity_index: EntityIndex, 239 237 lore: VectorIndex, ··· 243 241 """Plain bookkeeping form of mark.""" 244 242 file_path = entity_index.resolve(name) 245 243 if file_path is None: 246 - file_path = base_path / "worlds" / world_id / entity_type / f"{name}.md" 244 + file_path = world_path(world_id) / entity_type / f"{name}.md" 247 245 248 246 if not file_path.exists(): 249 247 return f"Error: Entity '{name}' not found in {entity_type}" ··· 344 342 def _auto_mark_present( 345 343 present: list[str], 346 344 event: str, 347 - base_path: Path, 348 345 world_id: str, 349 346 entity_index: EntityIndex, 350 347 lore: VectorIndex, ··· 365 362 366 363 now = timekeeper.get_current_time() 367 364 now_tuple = (now.day, now.hour, now.minute) 365 + world_dir = world_path(world_id) 368 366 369 367 marked: list[str] = [] 370 368 for ref in present: ··· 376 374 file_path = entity_index.resolve(name) 377 375 if file_path is None: 378 376 for etype in ("npcs", "locations", "items", "factions"): 379 - candidate = base_path / "worlds" / world_id / etype / f"{name}.md" 377 + candidate = world_dir / etype / f"{name}.md" 380 378 if candidate.exists(): 381 379 file_path = candidate 382 380 break ··· 397 395 entity_type = file_path.parent.name 398 396 _do_mark( 399 397 entity_type, name, event, None, 400 - base_path, world_id, entity_index, lore, timekeeper, 398 + world_id, entity_index, lore, timekeeper, 401 399 ) 402 400 marked.append(name) 403 401 ··· 416 414 knows: list[str] | None = None, 417 415 wants: list[str] | None = None, 418 416 will: list[str] | None = None, 419 - root: Path = StorageRoot(), 420 417 world: str = World(), 421 418 player: str = Player(), 422 419 entities: EntityIndex = Entities(), ··· 453 450 Confirmation with the file path 454 451 """ 455 452 if entity_type == "npcs": 456 - character = load_character(player, root) 453 + character = load_character(player) 457 454 if character: 458 455 pc_name = character.get("identity", {}).get("name", "") 459 456 if pc_name and pc_name == name: ··· 464 461 465 462 return _do_establish( 466 463 entity_type, name, description, location, knows, wants, will, 467 - root, world, entities, lore, 464 + world, entities, lore, 468 465 ) 469 466 470 467 ··· 475 472 event: str, 476 473 resolves: list[str] | None = None, 477 474 when: str | None = None, 478 - root: Path = StorageRoot(), 479 475 world: str = World(), 480 476 entities: EntityIndex = Entities(), 481 477 lore: VectorIndex = Lore(), ··· 507 503 """ 508 504 return _do_mark( 509 505 entity_type, name, event, resolves, 510 - root, world, entities, lore, timekeeper, when=when, 506 + world, entities, lore, timekeeper, when=when, 511 507 ) 512 508 513 509 ··· 516 512 entity_type: MarkType, 517 513 name: str, 518 514 event: str, 519 - root: Path = StorageRoot(), 520 515 world: str = World(), 521 516 entities: EntityIndex = Entities(), 522 517 lore: VectorIndex = Lore(), ··· 542 537 """ 543 538 file_path = entities.resolve(name) 544 539 if file_path is None: 545 - file_path = root / "worlds" / world / entity_type / f"{name}.md" 540 + file_path = world_path(world) / entity_type / f"{name}.md" 546 541 547 542 if not file_path.exists(): 548 543 return f"Error: Entity '{name}' not found in {entity_type}" ··· 582 577 content: str, 583 578 content_type: DiscoveryType = "lore", 584 579 tags: list[str] | None = None, 585 - root: Path = StorageRoot(), 586 580 world: str = World(), 587 581 player: str = Player(), 588 582 lore: VectorIndex = Lore(), ··· 608 602 """ 609 603 slug = name_to_slug(entity) 610 604 611 - knowledge_dir = root / "players" / player / "worlds" / world / content_type 605 + knowledge_dir = player_path(player) / "worlds" / world / content_type 612 606 knowledge_dir.mkdir(parents=True, exist_ok=True) 613 607 file_path = knowledge_dir / f"{slug}.md" 614 608
+16 -8
src/storied/tools/mechanics.py
··· 56 56 """Look up rules, world content, or both. 57 57 58 58 Use to recall information about: 59 - - Rules: spells, monsters, classes, items, conditions from the SRD 60 - - World: NPCs, locations, factions, lore you've established 61 - - Both: search everything (default) 59 + - Rules: spells, monsters, classes, items, conditions — shipped 60 + SRD, your personal homebrew, or world-specific overrides. The 61 + "rules" scope covers all three layers. 62 + - World: NPCs, locations, factions, lore you've established. 63 + - All: search everything (default). 62 64 63 65 Args: 64 66 query: What to look up (e.g., "fireball", "captain vex", "merchant guild") 65 - scope: Which corpus to search — "rules" (SRD), "world" (established 66 - content), or "all" (both, default) 67 + scope: Which corpus to search — "rules" covers every content 68 + layer that might contain rule-ish content (shipped SRD, 69 + user homebrew, and world overrides); "world" returns 70 + only world-specific content; "all" searches everything 71 + (default) 67 72 content_type: Optional type to limit search (e.g., "spells", "npcs") 68 73 69 74 Returns: 70 75 Content of the found item, or a message if not found 71 76 """ 72 - source_filter: str | None = None 77 + source_filter: str | list[str] | None 73 78 if scope == "rules": 74 - source_filter = "srd" 79 + source_filter = ["srd", "user", "world"] 80 + elif scope == "world": 81 + source_filter = "world" 82 + else: 83 + source_filter = None 75 84 76 85 current_day = timekeeper.get_current_time().day 77 86 hits = lore.search( 78 87 query, limit=5, source_filter=source_filter, 79 - exclude_source="srd" if scope == "world" else None, 80 88 decay_ref=current_day, 81 89 ) 82 90 if hits:
+7 -12
src/storied/tools/scene.py
··· 1 1 """Scene management, session, style tuning, and DM notification tools.""" 2 2 3 - from pathlib import Path 4 - 5 3 from fastmcp import FastMCP 6 4 7 5 from storied import notifications 8 6 from storied.log import CampaignLog 7 + from storied.paths import world_path 9 8 from storied.search import VectorIndex 10 9 from storied.session import update_session as session_update 11 10 from storied.tools._context import ( ··· 13 12 EntityIndex, 14 13 Lore, 15 14 Player, 16 - StorageRoot, 17 15 Timekeeper, 18 16 World, 19 17 ) ··· 34 32 timekeeper: CampaignLog = Timekeeper(), 35 33 player: str = Player(), 36 34 world: str = World(), 37 - root: Path = StorageRoot(), 38 35 entities: EntityIndex = Entities(), 39 36 lore: VectorIndex = Lore(), 40 37 ) -> str: ··· 80 77 updates["threads"] = threads 81 78 82 79 if updates: 83 - result = session_update(player, updates, root) 80 + result = session_update(player, updates) 84 81 parts.append(result) 85 82 86 83 if event and present: 87 84 marked = _auto_mark_present( 88 - present, event, root, world, entities, lore, timekeeper, 85 + present, event, world, entities, lore, timekeeper, 89 86 ) 90 87 if marked: 91 88 parts.append(f"Auto-marked: {', '.join(marked)}") ··· 97 94 def tune( 98 95 tuning: str, 99 96 world: str = World(), 100 - root: Path = StorageRoot(), 101 97 ) -> str: 102 98 """Update your storytelling style based on player feedback. 103 99 ··· 105 101 entire current style. Incorporate existing preferences where they still 106 102 apply — don't discard preferences the player hasn't contradicted. 107 103 """ 108 - path = root / "worlds" / world / "style.md" 104 + path = world_path(world) / "style.md" 105 + path.parent.mkdir(parents=True, exist_ok=True) 109 106 path.write_text(f"# Style\n\n{tuning}\n") 110 107 return "Style updated." 111 108 ··· 115 112 situation: str, 116 113 threads: list[str] | None = None, 117 114 player: str = Player(), 118 - root: Path = StorageRoot(), 119 115 ) -> str: 120 116 """End the current session, saving the game state for next time. 121 117 ··· 136 132 if threads is not None: 137 133 updates["threads"] = threads 138 134 139 - session_update(player, updates, root) 135 + session_update(player, updates) 140 136 return "SESSION_ENDED" 141 137 142 138 ··· 144 140 def notify_dm( 145 141 message: str, 146 142 world: str = World(), 147 - root: Path = StorageRoot(), 148 143 ) -> str: 149 144 """Send a notification that the DM will see at the start of the next turn. 150 145 ··· 157 152 Returns: 158 153 Confirmation that the notification was queued 159 154 """ 160 - notifications.append(world, root, message) 155 + notifications.append(world, message) 161 156 return f"Notification queued: {message}"
+44 -6
tests/conftest.py
··· 1 1 """Shared test fixtures. 2 2 3 3 The synchronous tool-invocation helper ``call_tool`` lives in 4 - ``storied._testing`` so it's importable by test modules without needing 4 + ``storied.testing`` so it's importable by test modules without needing 5 5 ``tests/`` to be a Python package — pytest's conftest loading isn't a 6 6 regular import. 7 + 8 + ## Path isolation 9 + 10 + Storied's path configuration lives in module globals on 11 + ``storied.paths``. The test suite MUST NEVER touch the user's real 12 + ``~/.storied/`` directory. We enforce this with a single autouse 13 + function-level fixture that uses ``monkeypatch`` to rebind the 14 + globals to a fresh ``tmp_path`` for every test. 15 + 16 + ``tmp_path`` is pytest's per-function temp dir, so every test gets 17 + its own clean directory and pytest cleans them up automatically. 18 + ``monkeypatch`` restores the previous value when the fixture tears 19 + down, but since every subsequent test re-runs the fixture before any 20 + storied code touches paths, there's no window where a stale default 21 + matters. 7 22 """ 8 23 9 24 import hashlib ··· 12 27 13 28 import pytest 14 29 30 + from storied import paths 15 31 from storied.log import CampaignLog 16 32 from storied.search import VectorIndex 17 33 from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx 18 34 35 + 36 + @pytest.fixture(autouse=True) 37 + def _isolate_storied_paths( 38 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 39 + ) -> Iterator[Path]: 40 + """Point storied's data home and user rules at ``tmp_path``. 41 + 42 + Runs for every test (autouse, function scope). Uses the public 43 + ``paths.using_data_home`` context manager — same code path as the 44 + CLI's startup configuration, with automatic restoration on 45 + teardown. Library code that reads ``data_home()`` / ``worlds_path()`` 46 + / ``player_path()`` / etc. during the test resolves under 47 + ``tmp_path``, so writes land in pytest's per-function temp dir 48 + instead of the user's real ``~/.storied/``. 49 + 50 + ``STORIED_HOME`` is also exported so subprocesses inherit the 51 + isolation if any test ever spawns one. 52 + """ 53 + monkeypatch.setenv("STORIED_HOME", str(tmp_path)) 54 + with paths.using_data_home(tmp_path): 55 + yield tmp_path 56 + 57 + 19 58 EMBED_DIM = 384 20 59 21 60 ··· 41 80 def ctx(tmp_path: Path) -> Iterator[ToolContext]: 42 81 """Process-global ToolContext with a fake embedder. 43 82 44 - Tools resolve their Dependency parameters from this context. Each test 45 - gets a fresh tempdir-rooted ctx; teardown clears the global to prevent 46 - cross-test leakage. 83 + Depends on the autouse ``_isolate_storied_paths`` fixture having 84 + already rebound ``storied.paths._data_home`` to ``tmp_path``, so 85 + library reads of ``data_home()`` resolve here. 47 86 """ 48 87 world_dir = tmp_path / "worlds" / "test-world" 49 88 world_dir.mkdir(parents=True) ··· 54 93 context = init_ctx( 55 94 world_id="test-world", 56 95 player_id="default", 57 - base_path=tmp_path, 58 - campaign_log=CampaignLog("test-world", tmp_path), 96 + campaign_log=CampaignLog("test-world"), 59 97 entity_index=EntityIndex(world_dir), 60 98 vector_index=vi, 61 99 )
+17 -25
tests/test_advancement.py
··· 57 57 {"source": "Rogue Lv2", "name": "Cunning Action", "text": ""}, 58 58 ], 59 59 } 60 - save_character("default", data, ctx.base_path) 60 + save_character("default", data) 61 61 return data 62 62 63 63 ··· 79 79 80 80 81 81 class TestNotifyDM: 82 - def test_appends_notification(self, ctx: ToolContext): 82 + def test_appends_notification(self, ctx: ToolContext, tmp_path: Path): 83 83 result = notify_dm("Test message", ctx) 84 84 85 85 assert "queued" in result.lower() 86 - path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 86 + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 87 87 assert path.exists() 88 88 assert "Test message" in path.read_text() 89 89 90 - def test_multiple_notifications(self, ctx: ToolContext): 90 + def test_multiple_notifications(self, ctx: ToolContext, tmp_path: Path): 91 91 notify_dm("First", ctx) 92 92 notify_dm("Second", ctx) 93 93 94 - path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 94 + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 95 95 content = path.read_text() 96 96 assert "First" in content 97 97 assert "Second" in content ··· 101 101 102 102 103 103 class TestBuildAdvancementContext: 104 - def test_returns_none_without_character(self, ctx: ToolContext): 104 + def test_returns_none_without_character(self, ctx: ToolContext, tmp_path: Path): 105 105 result = build_advancement_context( 106 - ctx.world_id, ctx.player_id, ctx.base_path 106 + ctx.world_id, ctx.player_id 107 107 ) 108 108 assert result is None 109 109 ··· 111 111 self, ctx: ToolContext, character: dict 112 112 ): 113 113 context = build_advancement_context( 114 - ctx.world_id, ctx.player_id, ctx.base_path 114 + ctx.world_id, ctx.player_id 115 115 ) 116 116 assert context is not None 117 117 assert "Kira" in context ··· 125 125 campaign_with_events: CampaignLog, 126 126 ): 127 127 context = build_advancement_context( 128 - ctx.world_id, ctx.player_id, ctx.base_path 128 + ctx.world_id, ctx.player_id 129 129 ) 130 130 assert context is not None 131 131 assert "warehouse" in context ··· 141 141 log.append_entry("Fought a dragon", "5 rounds", tags=["combat"]) 142 142 143 143 context = build_advancement_context( 144 - ctx.world_id, ctx.player_id, ctx.base_path 144 + ctx.world_id, ctx.player_id 145 145 ) 146 146 assert context is not None 147 147 assert "Old event before level-up" not in context ··· 158 158 log.append_entry("Recent events", "1 hour") 159 159 160 160 context = build_advancement_context( 161 - ctx.world_id, ctx.player_id, ctx.base_path 161 + ctx.world_id, ctx.player_id 162 162 ) 163 163 assert context is not None 164 164 assert "Advancement History" in context ··· 174 174 "location": "Town Square", 175 175 "body": "## Open Threads\n- Find the missing merchant", 176 176 }, 177 - ctx.base_path, 178 177 ) 179 178 180 179 context = build_advancement_context( 181 - ctx.world_id, ctx.player_id, ctx.base_path 180 + ctx.world_id, ctx.player_id 182 181 ) 183 182 assert context is not None 184 183 assert "missing merchant" in context ··· 262 261 result = evaluate_advancement( 263 262 world_id=ctx.world_id, 264 263 player_id=ctx.player_id, 265 - base_path=ctx.base_path, 266 264 ) 267 265 assert result.evaluated is False 268 266 269 267 def test_posts_reminder_when_advancement_pending( 270 - self, ctx: ToolContext, character: dict 268 + self, ctx: ToolContext, character: dict, tmp_path: Path, 271 269 ): 272 270 character["advancement_ready"] = 4 273 - save_character("default", character, ctx.base_path) 271 + save_character("default", character) 274 272 275 273 result = evaluate_advancement( 276 274 world_id=ctx.world_id, 277 275 player_id=ctx.player_id, 278 - base_path=ctx.base_path, 279 276 ) 280 277 281 278 assert result.evaluated is False 282 279 path = ( 283 - ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 280 + tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 284 281 ) 285 282 assert path.exists() 286 283 contents = path.read_text() ··· 312 309 result = evaluate_advancement( 313 310 world_id=ctx.world_id, 314 311 player_id=ctx.player_id, 315 - base_path=ctx.base_path, 316 312 ) 317 313 318 314 assert result.evaluated is True ··· 328 324 adv = BackgroundAdvancement( 329 325 world_id="test", 330 326 player_id="default", 331 - base_path=Path("/tmp/fake"), 332 327 interval=5, 333 328 ) 334 329 # 4 turns should not trigger ··· 342 337 adv = BackgroundAdvancement( 343 338 world_id=ctx.world_id, 344 339 player_id=ctx.player_id, 345 - base_path=ctx.base_path, 346 340 interval=100, 347 341 ) 348 342 ··· 358 352 adv = BackgroundAdvancement( 359 353 world_id="test", 360 354 player_id="default", 361 - base_path=Path("/tmp/fake"), 362 355 interval=5, 363 356 ) 364 357 with patch("storied.advancement.evaluate_advancement") as mock_eval: ··· 371 364 372 365 def test_pop_result_returns_none_when_no_thread(self): 373 366 adv = BackgroundAdvancement( 374 - world_id="test", player_id="default", base_path=Path("/tmp/fake"), 367 + world_id="test", player_id="default", 375 368 ) 376 369 assert adv.pop_result() is None 377 370 ··· 379 372 adv = BackgroundAdvancement( 380 373 world_id="test", 381 374 player_id="default", 382 - base_path=Path("/tmp/fake"), 383 375 interval=1, 384 376 ) 385 377 with patch("storied.advancement.evaluate_advancement") as mock_eval: ··· 396 388 397 389 def test_maybe_evaluate_skips_when_already_running(self): 398 390 adv = BackgroundAdvancement( 399 - world_id="test", player_id="default", base_path=Path("/tmp/fake"), 391 + world_id="test", player_id="default", 400 392 ) 401 393 # Stub a fake "still running" thread on the instance 402 394 from unittest.mock import MagicMock
+172 -200
tests/test_character.py
··· 72 72 ac=16, 73 73 background="Criminal", 74 74 purse={"gp": 43, "cp": 66}, 75 - base_path=player_dir, 76 75 ) 77 76 # Add proficiencies and resources via update_character 78 77 update_character( ··· 86 85 "proficiencies.skills.deception": "proficient", 87 86 "proficiencies.skills.insight": "proficient", 88 87 }, 89 - base_path=player_dir, 90 88 ) 91 89 update_character( 92 90 "test-player", ··· 96 94 "notes": "Hit Dice (d8)", 97 95 }, 98 96 }, 99 - base_path=player_dir, 100 97 ) 101 - return load_character("test-player", player_dir) 98 + return load_character("test-player") 102 99 103 100 104 101 # --- Data layer tests --- ··· 106 103 107 104 class TestDataLayer: 108 105 def test_load_returns_none_for_missing(self, player_dir: Path): 109 - assert load_character("test-player", player_dir) is None 106 + assert load_character("test-player") is None 110 107 111 108 def test_create_and_load(self, player_dir: Path): 112 109 create_character( ··· 119 116 "intelligence": 8, "wisdom": 10, "charisma": 12}, 120 117 hp_max=15, 121 118 ac=14, 122 - base_path=player_dir, 123 119 ) 124 - data = load_character("test-player", player_dir) 120 + data = load_character("test-player") 125 121 assert data["identity"]["name"] == "Conan" 126 122 assert data["identity"]["classes"][0]["class"] == "Barbarian" 127 123 assert data["identity"]["classes"][0]["level"] == 1 ··· 130 126 131 127 def test_load_fills_defaults(self, player_dir: Path): 132 128 # Save a sparse character 133 - save_character("test-player", {"identity": {"name": "Sparse"}}, player_dir) 134 - data = load_character("test-player", player_dir) 129 + save_character("test-player", {"identity": {"name": "Sparse"}}) 130 + data = load_character("test-player") 135 131 # Default schema should be merged in 136 132 assert "abilities" in data 137 133 assert "state" in data ··· 149 145 hp_max=9, 150 146 ac=12, 151 147 backstory="A wandering minstrel with secrets.", 152 - base_path=player_dir, 153 148 ) 154 - prose = load_character_prose("test-player", player_dir) 149 + prose = load_character_prose("test-player") 155 150 assert "wandering minstrel" in prose 156 151 157 152 158 153 class TestUpdateCharacter: 159 154 def test_update_simple_field(self, mira: dict, player_dir: Path): 160 - update_character("test-player", {"state.ac": 17}, base_path=player_dir) 161 - data = load_character("test-player", player_dir) 155 + update_character("test-player", {"state.ac": 17}) 156 + data = load_character("test-player") 162 157 assert data["state"]["ac"] == 17 163 158 164 159 def test_update_nested_via_dot(self, mira: dict, player_dir: Path): 165 160 update_character( 166 - "test-player", {"state.hp.max": 30}, base_path=player_dir 161 + "test-player", {"state.hp.max": 30} 167 162 ) 168 - data = load_character("test-player", player_dir) 163 + data = load_character("test-player") 169 164 assert data["state"]["hp"]["max"] == 30 170 165 171 166 def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): 172 167 update_character( 173 - "test-player", {"state.hp.current": -5}, base_path=player_dir 168 + "test-player", {"state.hp.current": -5} 174 169 ) 175 - data = load_character("test-player", player_dir) 170 + data = load_character("test-player") 176 171 assert data["state"]["hp"]["current"] == 0 177 172 178 173 def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): 179 174 update_character( 180 - "test-player", {"state.hp.current": 100}, base_path=player_dir 175 + "test-player", {"state.hp.current": 100} 181 176 ) 182 - data = load_character("test-player", player_dir) 177 + data = load_character("test-player") 183 178 assert data["state"]["hp"]["current"] == 24 184 179 185 180 def test_negative_coins_clamped(self, mira: dict, player_dir: Path): 186 181 update_character( 187 - "test-player", {"state.purse.sp": -20}, base_path=player_dir 182 + "test-player", {"state.purse.sp": -20} 188 183 ) 189 - data = load_character("test-player", player_dir) 184 + data = load_character("test-player") 190 185 assert data["state"]["purse"]["sp"] == 0 191 186 192 187 def test_no_character_returns_error(self, player_dir: Path): 193 - result = update_character("missing", {"foo": "bar"}, base_path=player_dir) 188 + result = update_character("missing", {"foo": "bar"}) 194 189 assert "no character" in result.lower() 195 190 196 191 ··· 199 194 error and leave the on-disk character unchanged.""" 200 195 201 196 def test_resources_as_list_is_rejected(self, mira: dict, player_dir: Path): 202 - before = load_character("test-player", player_dir) 197 + before = load_character("test-player") 203 198 result = update_character( 204 199 "test-player", 205 200 {"resources": [{"name": "Channel Divinity", "current": 1, "max": 1}]}, 206 - base_path=player_dir, 207 201 ) 208 202 assert "rejected" in result.lower() 209 203 assert "resources" in result 210 204 assert "dict" in result.lower() 211 205 # On-disk character is unchanged 212 - after = load_character("test-player", player_dir) 206 + after = load_character("test-player") 213 207 assert after["resources"] == before["resources"] 214 208 215 209 def test_equipment_as_list_is_rejected(self, mira: dict, player_dir: Path): 216 210 result = update_character( 217 211 "test-player", 218 212 {"equipment": ["Longsword", "Shield"]}, 219 - base_path=player_dir, 220 213 ) 221 214 assert "rejected" in result.lower() 222 215 assert "equipment" in result ··· 225 218 result = update_character( 226 219 "test-player", 227 220 {"state.hp": 24}, # missing required fields 228 - base_path=player_dir, 229 - ) 221 + ) 230 222 assert "rejected" in result.lower() 231 223 # Original HP block is preserved 232 - data = load_character("test-player", player_dir) 224 + data = load_character("test-player") 233 225 assert isinstance(data["state"]["hp"], dict) 234 226 assert data["state"]["hp"]["max"] == 24 235 227 ··· 240 232 "current": 1, "max": 1, "refresh": "short_rest", 241 233 "notes": "Channel Divinity", 242 234 }}, 243 - base_path=player_dir, 244 235 ) 245 236 assert "rejected" not in result.lower() 246 - data = load_character("test-player", player_dir) 237 + data = load_character("test-player") 247 238 assert data["resources"]["channel_divinity"]["current"] == 1 248 239 249 240 def test_error_message_contains_an_example(self, mira: dict, player_dir: Path): ··· 251 242 result = update_character( 252 243 "test-player", 253 244 {"resources": [{"name": "x"}]}, 254 - base_path=player_dir, 255 245 ) 256 246 # Concrete example helps the LLM correct itself 257 247 assert "channel_divinity" in result or "{" in result ··· 277 267 "refresh": "long_rest"}, 278 268 ], 279 269 })) 280 - data = load_character("test-player", player_dir) 270 + data = load_character("test-player") 281 271 assert isinstance(data["resources"], dict) 282 272 assert "channel_divinity" in data["resources"] 283 273 assert data["resources"]["channel_divinity"]["current"] == 1 ··· 299 289 ], 300 290 })) 301 291 result = adjust_resource( 302 - "test-player", "channel", -1, base_path=player_dir 292 + "test-player", "channel", -1 303 293 ) 304 294 assert "Used 1" in result 305 295 ··· 313 303 "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 314 304 "equipment": ["Longsword", "Shield"], 315 305 })) 316 - data = load_character("test-player", player_dir) 306 + data = load_character("test-player") 317 307 assert isinstance(data["equipment"], dict) 318 308 assert data["equipment"]["on_person"] == ["Longsword", "Shield"] 319 309 ··· 339 329 {"identity": {"classes": [ 340 330 {"class": "Fighter", "level": 3}, 341 331 {"class": "Wizard", "level": 2}, 342 - ]}}, 343 - player_dir, 344 - ) 345 - data = load_character("test-player", player_dir) 332 + ]}}) 333 + data = load_character("test-player") 346 334 assert total_level(data) == 5 347 335 348 336 def test_proficiency_bonus_scaling(self, player_dir: Path): ··· 350 338 (12, 4), (13, 5), (16, 5), (17, 6), (20, 6)]: 351 339 save_character( 352 340 "test-player", 353 - {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, 354 - player_dir, 355 - ) 356 - data = load_character("test-player", player_dir) 341 + {"identity": {"classes": [{"class": "Fighter", "level": level}]}}) 342 + data = load_character("test-player") 357 343 assert proficiency_bonus(data) == expected, f"level {level}" 358 344 359 345 def test_skill_modifier_with_expertise(self, mira: dict): ··· 413 399 def test_effective_hp_with_temp(self, player_dir: Path): 414 400 save_character( 415 401 "test-player", 416 - {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}, 417 - player_dir, 418 - ) 419 - data = load_character("test-player", player_dir) 402 + {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}) 403 + data = load_character("test-player") 420 404 hp = effective_hp(data) 421 405 assert hp["effective"] == 25 422 406 assert hp["current"] == 20 ··· 480 464 self, mira: dict, player_dir: Path 481 465 ): 482 466 update_character( 483 - "test-player", {"advancement_ready": 4}, base_path=player_dir 467 + "test-player", {"advancement_ready": 4} 484 468 ) 485 - data = load_character("test-player", player_dir) 469 + data = load_character("test-player") 486 470 result = format_character_context(data) 487 471 assert "Advancement Ready" in result 488 472 assert "Level 4" in result ··· 614 598 "intelligence": 10, "wisdom": 10, "charisma": 10}, 615 599 hp_max=10, 616 600 ac=10, 617 - base_path=player_dir, 618 601 ) 619 - data = load_character("test-player", player_dir) 602 + data = load_character("test-player") 620 603 result = format_status(data) 621 604 assert "Purse" not in result 622 605 ··· 628 611 update_character( 629 612 "test-player", 630 613 {f"equipment.on_person": [f"item_{i}" for i in range(12)]}, 631 - base_path=player_dir, 632 614 ) 633 - data = load_character("test-player", player_dir) 615 + data = load_character("test-player") 634 616 result = format_status(data) 635 617 assert "and 4 more" in result # 12 items - 8 shown = 4 more 636 618 637 - def test_format_character_display_respects_base_path( 619 + def test_format_character_display_respects_data_home( 638 620 self, mira: dict, player_dir: Path, tmp_path: Path, 639 621 ): 640 - """The /me slash command must read from the passed base_path so 641 - sandbox sessions don't load the cwd's real character.""" 622 + """The /me slash command resolves the character via the 623 + ``storied.paths`` module globals — sandbox sessions get the 624 + sandbox character because the data home was overridden in 625 + ``cmd_play`` before this function is called.""" 642 626 from storied.cli import _format_character_display 627 + from storied.paths import using_data_home 643 628 644 - # mira lives at player_dir/players/test-player; an unrelated other_path 645 - # has no character at all. Asking for the other_path must return None, 646 - # not silently fall back to mira via cwd. 629 + # mira lives at player_dir/players/test-player; an unrelated 630 + # other_path has no character. Pointing data_home at other_path 631 + # must return None instead of silently loading mira from elsewhere. 647 632 other_path = tmp_path / "other" 648 633 (other_path / "players" / "test-player").mkdir(parents=True) 649 634 650 - result = _format_character_display( 651 - "test-player", full=True, base_path=other_path, 652 - ) 635 + with using_data_home(other_path): 636 + result = _format_character_display("test-player", full=True) 653 637 assert result is None, ( 654 - "_format_character_display must use the passed base_path; " 655 - "loading from cwd by default is what caused /me to show the wrong " 656 - "character in sandbox sessions." 638 + "_format_character_display must use the configured data_home; " 639 + "falling back to a stale path is what caused /me to show the " 640 + "wrong character in sandbox sessions." 657 641 ) 658 642 659 - # And it should return real content when given the right base_path 660 - result = _format_character_display( 661 - "test-player", full=True, base_path=player_dir, 662 - ) 643 + # And it should return real content when data_home points at 644 + # the dir mira actually lives in. 645 + with using_data_home(player_dir): 646 + result = _format_character_display("test-player", full=True) 663 647 assert result is not None 664 648 assert "Mira" in result 665 649 ··· 669 653 670 654 class TestDamageHeal: 671 655 def test_damage_subtracts_hp(self, mira: dict, player_dir: Path): 672 - damage("test-player", 5, base_path=player_dir) 673 - data = load_character("test-player", player_dir) 656 + damage("test-player", 5) 657 + data = load_character("test-player") 674 658 assert data["state"]["hp"]["current"] == 19 675 659 676 660 def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): 677 661 update_character( 678 - "test-player", {"state.hp.temp": 5}, base_path=player_dir 662 + "test-player", {"state.hp.temp": 5} 679 663 ) 680 - damage("test-player", 3, base_path=player_dir) 681 - data = load_character("test-player", player_dir) 664 + damage("test-player", 3) 665 + data = load_character("test-player") 682 666 assert data["state"]["hp"]["temp"] == 2 683 667 assert data["state"]["hp"]["current"] == 24 684 668 685 669 def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): 686 670 update_character( 687 - "test-player", {"state.hp.temp": 5}, base_path=player_dir 671 + "test-player", {"state.hp.temp": 5} 688 672 ) 689 - damage("test-player", 8, base_path=player_dir) 690 - data = load_character("test-player", player_dir) 673 + damage("test-player", 8) 674 + data = load_character("test-player") 691 675 assert data["state"]["hp"]["temp"] == 0 692 676 assert data["state"]["hp"]["current"] == 21 693 677 694 678 def test_damage_clamps_to_zero(self, mira: dict, player_dir: Path): 695 - damage("test-player", 100, base_path=player_dir) 696 - data = load_character("test-player", player_dir) 679 + damage("test-player", 100) 680 + data = load_character("test-player") 697 681 assert data["state"]["hp"]["current"] == 0 698 682 699 683 def test_damage_at_zero_mentions_death_saves( 700 684 self, mira: dict, player_dir: Path 701 685 ): 702 - result = damage("test-player", 100, base_path=player_dir) 686 + result = damage("test-player", 100) 703 687 assert "death save" in result.lower() 704 688 705 689 def test_heal_restores_hp(self, mira: dict, player_dir: Path): 706 - damage("test-player", 10, base_path=player_dir) 707 - heal("test-player", 5, base_path=player_dir) 708 - data = load_character("test-player", player_dir) 690 + damage("test-player", 10) 691 + heal("test-player", 5) 692 + data = load_character("test-player") 709 693 assert data["state"]["hp"]["current"] == 19 710 694 711 695 def test_heal_clamped_to_max(self, mira: dict, player_dir: Path): 712 - heal("test-player", 100, base_path=player_dir) 713 - data = load_character("test-player", player_dir) 696 + heal("test-player", 100) 697 + data = load_character("test-player") 714 698 assert data["state"]["hp"]["current"] == 24 715 699 716 700 def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): 717 - result = damage("test-player", 3, damage_type="fire", base_path=player_dir) 701 + result = damage("test-player", 3, damage_type="fire") 718 702 assert "fire" in result 719 703 720 704 def test_damage_ignores_resistances(self, mira: dict, player_dir: Path): ··· 723 707 update_character( 724 708 "test-player", 725 709 {"defenses.resistances": [{"damage": "fire"}]}, 726 - base_path=player_dir, 727 710 ) 728 - damage("test-player", 10, damage_type="fire", base_path=player_dir) 729 - data = load_character("test-player", player_dir) 711 + damage("test-player", 10, damage_type="fire") 712 + data = load_character("test-player") 730 713 # Raw 10, not halved 731 714 assert data["state"]["hp"]["current"] == 14 732 715 ··· 734 717 update_character( 735 718 "test-player", 736 719 {"defenses.vulnerabilities": [{"damage": "radiant"}]}, 737 - base_path=player_dir, 738 720 ) 739 721 damage( 740 - "test-player", 5, damage_type="radiant", base_path=player_dir, 722 + "test-player", 5, damage_type="radiant", 741 723 ) 742 - data = load_character("test-player", player_dir) 724 + data = load_character("test-player") 743 725 # Raw 5, not doubled 744 726 assert data["state"]["hp"]["current"] == 19 745 727 ··· 747 729 update_character( 748 730 "test-player", 749 731 {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, 750 - base_path=player_dir, 751 732 ) 752 733 damage( 753 - "test-player", 12, damage_type="poison", base_path=player_dir, 734 + "test-player", 12, damage_type="poison", 754 735 ) 755 - data = load_character("test-player", player_dir) 736 + data = load_character("test-player") 756 737 # Raw 12, not zeroed 757 738 assert data["state"]["hp"]["current"] == 12 758 739 ··· 766 747 class_name="Rogue", 767 748 new_level=4, 768 749 hp_gain=6, 769 - base_path=player_dir, 770 750 ) 771 - data = load_character("test-player", player_dir) 751 + data = load_character("test-player") 772 752 assert data["identity"]["classes"][0]["level"] == 4 773 753 assert "3 → 4" in result 774 754 ··· 778 758 # mira starts with 24/24 779 759 level_up( 780 760 "test-player", "Rogue", 781 - new_level=4, hp_gain=6, base_path=player_dir, 761 + new_level=4, hp_gain=6, 782 762 ) 783 - data = load_character("test-player", player_dir) 763 + data = load_character("test-player") 784 764 assert data["state"]["hp"]["max"] == 30 785 765 assert data["state"]["hp"]["current"] == 30 786 766 ··· 788 768 self, mira: dict, player_dir: Path, 789 769 ): 790 770 # Wound the character first 791 - damage("test-player", 10, base_path=player_dir) 771 + damage("test-player", 10) 792 772 # HP is now 14/24 793 773 level_up( 794 774 "test-player", "Rogue", 795 - new_level=4, hp_gain=6, base_path=player_dir, 775 + new_level=4, hp_gain=6, 796 776 ) 797 - data = load_character("test-player", player_dir) 777 + data = load_character("test-player") 798 778 # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) 799 779 assert data["state"]["hp"]["max"] == 30 800 780 assert data["state"]["hp"]["current"] == 20 ··· 806 786 "test-player", "Rogue", 807 787 new_level=4, hp_gain=6, 808 788 time_anchor="#d12-1500", 809 - base_path=player_dir, 810 789 ) 811 - data = load_character("test-player", player_dir) 790 + data = load_character("test-player") 812 791 assert data["level_since"] == "#d12-1500" 813 792 814 793 def test_level_up_clears_advancement_ready( ··· 817 796 update_character( 818 797 "test-player", 819 798 {"advancement_ready": 4}, 820 - base_path=player_dir, 821 799 ) 822 800 level_up( 823 801 "test-player", "Rogue", 824 - new_level=4, hp_gain=6, base_path=player_dir, 802 + new_level=4, hp_gain=6, 825 803 ) 826 - data = load_character("test-player", player_dir) 804 + data = load_character("test-player") 827 805 assert data.get("advancement_ready") is None 828 806 829 807 def test_level_up_replaces_features_when_provided( ··· 837 815 "test-player", "Rogue", 838 816 new_level=4, hp_gain=6, 839 817 features=new_features, 840 - base_path=player_dir, 841 818 ) 842 - data = load_character("test-player", player_dir) 819 + data = load_character("test-player") 843 820 assert len(data["features"]) == 2 844 821 assert data["features"][1]["name"] == "Uncanny Dodge" 845 822 ··· 849 826 update_character( 850 827 "test-player", 851 828 {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, 852 - base_path=player_dir, 853 829 ) 854 830 level_up( 855 831 "test-player", "Rogue", 856 - new_level=4, hp_gain=6, base_path=player_dir, 832 + new_level=4, hp_gain=6, 857 833 ) 858 - data = load_character("test-player", player_dir) 834 + data = load_character("test-player") 859 835 assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] 860 836 861 837 def test_level_up_rejects_downgrade( ··· 863 839 ): 864 840 result = level_up( 865 841 "test-player", "Rogue", 866 - new_level=2, hp_gain=0, base_path=player_dir, 842 + new_level=2, hp_gain=0, 867 843 ) 868 844 assert "Refusing" in result 869 - data = load_character("test-player", player_dir) 845 + data = load_character("test-player") 870 846 assert data["identity"]["classes"][0]["level"] == 3 # unchanged 871 847 872 848 def test_level_up_rejects_unknown_class( ··· 874 850 ): 875 851 result = level_up( 876 852 "test-player", "Wizard", 877 - new_level=4, hp_gain=4, base_path=player_dir, 853 + new_level=4, hp_gain=4, 878 854 ) 879 855 assert "No class matching" in result 880 - data = load_character("test-player", player_dir) 856 + data = load_character("test-player") 881 857 assert data["identity"]["classes"][0]["level"] == 3 882 858 883 859 def test_level_up_multiclass_finds_correct_class( ··· 890 866 {"class": "Rogue", "subclass": "Thief", "level": 3}, 891 867 {"class": "Fighter", "subclass": None, "level": 1}, 892 868 ]}, 893 - base_path=player_dir, 894 869 ) 895 870 level_up( 896 871 "test-player", "Fighter", 897 - new_level=2, hp_gain=7, base_path=player_dir, 872 + new_level=2, hp_gain=7, 898 873 ) 899 - data = load_character("test-player", player_dir) 874 + data = load_character("test-player") 900 875 assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged 901 876 assert data["identity"]["classes"][1]["level"] == 2 # Fighter bumped 902 877 ··· 912 887 ): 913 888 result = add_effect( 914 889 "test-player", "Bless", "+1d4 to attacks", 915 - concentration=True, base_path=player_dir, 890 + concentration=True, 916 891 ) 917 892 assert "[Concentration]" in result 918 - data = load_character("test-player", player_dir) 893 + data = load_character("test-player") 919 894 assert data["effects"][0]["concentration"] is True 920 895 921 896 def test_multiple_concentration_effects_allowed( ··· 924 899 """No enforcement — the DM can flag two effects concentration.""" 925 900 add_effect( 926 901 "test-player", "Bless", "+1d4", 927 - concentration=True, base_path=player_dir, 902 + concentration=True, 928 903 ) 929 904 add_effect( 930 905 "test-player", "Hold Person", "paralyzed", 931 - concentration=True, base_path=player_dir, 906 + concentration=True, 932 907 ) 933 - data = load_character("test-player", player_dir) 908 + data = load_character("test-player") 934 909 sources = [e["source"] for e in data["effects"]] 935 910 assert "Bless" in sources 936 911 assert "Hold Person" in sources ··· 941 916 """The DM issues concentration saves manually per the rules.""" 942 917 add_effect( 943 918 "test-player", "Bless", "+1d4", 944 - concentration=True, base_path=player_dir, 919 + concentration=True, 945 920 ) 946 - result = damage("test-player", 6, base_path=player_dir) 921 + result = damage("test-player", 6) 947 922 assert "Concentration save" not in result 948 923 949 924 950 925 class TestEffects: 951 926 def test_add_effect_appends(self, mira: dict, player_dir: Path): 952 - add_effect("test-player", "Bless", "+1d4 to attacks", base_path=player_dir) 953 - data = load_character("test-player", player_dir) 927 + add_effect("test-player", "Bless", "+1d4 to attacks") 928 + data = load_character("test-player") 954 929 assert len(data["effects"]) == 1 955 930 assert data["effects"][0]["source"] == "Bless" 956 931 957 932 def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): 958 933 add_effect( 959 934 "test-player", "Potion", "+10 temp HP", 960 - expires="d1-1430", base_path=player_dir, 935 + expires="d1-1430", 961 936 ) 962 - data = load_character("test-player", player_dir) 937 + data = load_character("test-player") 963 938 assert data["effects"][0]["expires"] == "d1-1430" 964 939 965 940 def test_remove_effect_by_source(self, mira: dict, player_dir: Path): 966 - add_effect("test-player", "Bless", "+1d4", base_path=player_dir) 967 - result = remove_effect("test-player", "bless", base_path=player_dir) 968 - data = load_character("test-player", player_dir) 941 + add_effect("test-player", "Bless", "+1d4") 942 + result = remove_effect("test-player", "bless") 943 + data = load_character("test-player") 969 944 assert len(data["effects"]) == 0 970 945 assert "Bless" in result 971 946 972 947 def test_remove_effect_substring_match(self, mira: dict, player_dir: Path): 973 - add_effect("test-player", "Potion of Heroism", "+10 temp HP", base_path=player_dir) 974 - remove_effect("test-player", "Heroism", base_path=player_dir) 975 - data = load_character("test-player", player_dir) 948 + add_effect("test-player", "Potion of Heroism", "+10 temp HP") 949 + remove_effect("test-player", "Heroism") 950 + data = load_character("test-player") 976 951 assert len(data["effects"]) == 0 977 952 978 953 def test_remove_effect_not_found(self, mira: dict, player_dir: Path): 979 - result = remove_effect("test-player", "Nonexistent", base_path=player_dir) 954 + result = remove_effect("test-player", "Nonexistent") 980 955 assert "no effect matching" in result.lower() 981 956 982 957 983 958 class TestConditions: 984 959 def test_add_condition(self, mira: dict, player_dir: Path): 985 - add_condition("test-player", "Poisoned", base_path=player_dir) 986 - data = load_character("test-player", player_dir) 960 + add_condition("test-player", "Poisoned") 961 + data = load_character("test-player") 987 962 assert "Poisoned" in data["conditions"] 988 963 989 964 def test_add_condition_no_duplicate(self, mira: dict, player_dir: Path): 990 - add_condition("test-player", "Prone", base_path=player_dir) 991 - result = add_condition("test-player", "prone", base_path=player_dir) 992 - data = load_character("test-player", player_dir) 965 + add_condition("test-player", "Prone") 966 + result = add_condition("test-player", "prone") 967 + data = load_character("test-player") 993 968 assert len(data["conditions"]) == 1 994 969 assert "already" in result.lower() 995 970 996 971 def test_remove_condition(self, mira: dict, player_dir: Path): 997 - add_condition("test-player", "Frightened", base_path=player_dir) 998 - remove_condition("test-player", "Frightened", base_path=player_dir) 999 - data = load_character("test-player", player_dir) 972 + add_condition("test-player", "Frightened") 973 + remove_condition("test-player", "Frightened") 974 + data = load_character("test-player") 1000 975 assert "Frightened" not in data["conditions"] 1001 976 1002 977 1003 978 class TestInventory: 1004 979 def test_add_item_to_default_location(self, mira: dict, player_dir: Path): 1005 - add_item("test-player", "Lockpicks", base_path=player_dir) 1006 - data = load_character("test-player", player_dir) 980 + add_item("test-player", "Lockpicks") 981 + data = load_character("test-player") 1007 982 # Should create on_person if no equipment exists 1008 983 all_items = [] 1009 984 for items in data["equipment"].values(): ··· 1013 988 def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): 1014 989 add_item( 1015 990 "test-player", "Rope (50ft)", location="backpack", 1016 - base_path=player_dir, 1017 991 ) 1018 - data = load_character("test-player", player_dir) 992 + data = load_character("test-player") 1019 993 assert "Rope (50ft)" in data["equipment"]["backpack"] 1020 994 1021 995 def test_add_item_substring_location_match(self, mira: dict, player_dir: Path): 1022 - add_item("test-player", "First", location="on_person", base_path=player_dir) 1023 - add_item("test-player", "Second", location="On Person", base_path=player_dir) 1024 - data = load_character("test-player", player_dir) 996 + add_item("test-player", "First", location="on_person") 997 + add_item("test-player", "Second", location="On Person") 998 + data = load_character("test-player") 1025 999 # Both should land in the same location 1026 1000 assert "First" in data["equipment"]["on_person"] 1027 1001 assert "Second" in data["equipment"]["on_person"] 1028 1002 1029 1003 def test_remove_item_substring(self, mira: dict, player_dir: Path): 1030 - add_item("test-player", "Boots of Elvenkind (worn)", base_path=player_dir) 1031 - result = remove_item("test-player", "Boots", base_path=player_dir) 1004 + add_item("test-player", "Boots of Elvenkind (worn)") 1005 + result = remove_item("test-player", "Boots") 1032 1006 assert "Boots of Elvenkind" in result 1033 1007 1034 1008 def test_remove_item_not_found(self, mira: dict, player_dir: Path): 1035 - result = remove_item("test-player", "Nonexistent", base_path=player_dir) 1009 + result = remove_item("test-player", "Nonexistent") 1036 1010 assert "no item matching" in result.lower() 1037 1011 1038 1012 ··· 1040 1014 def test_set_item_status_attuned(self, mira: dict, player_dir: Path): 1041 1015 set_item_status( 1042 1016 "test-player", "Bracer of the Unseen Step", "attuned", 1043 - base_path=player_dir, 1044 1017 ) 1045 - data = load_character("test-player", player_dir) 1018 + data = load_character("test-player") 1046 1019 assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] 1047 1020 1048 1021 def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): 1049 1022 set_item_status( 1050 - "test-player", "Cloak", "carried", base_path=player_dir 1023 + "test-player", "Cloak", "carried" 1051 1024 ) 1052 1025 set_item_status( 1053 - "test-player", "Cloak", "equipped", base_path=player_dir 1026 + "test-player", "Cloak", "equipped" 1054 1027 ) 1055 - data = load_character("test-player", player_dir) 1028 + data = load_character("test-player") 1056 1029 assert "[[Cloak]]" not in data["magic_items"]["carried"] 1057 1030 assert "[[Cloak]]" in data["magic_items"]["equipped"] 1058 1031 1059 1032 def test_set_item_status_invalid(self, mira: dict, player_dir: Path): 1060 1033 result = set_item_status( 1061 - "test-player", "Cloak", "invalid", base_path=player_dir 1034 + "test-player", "Cloak", "invalid" 1062 1035 ) 1063 1036 assert "invalid status" in result.lower() 1064 1037 1065 1038 1066 1039 class TestResources: 1067 1040 def test_adjust_resource_spend_one(self, mira: dict, player_dir: Path): 1068 - adjust_resource("test-player", "hit_dice", -1, base_path=player_dir) 1069 - data = load_character("test-player", player_dir) 1041 + adjust_resource("test-player", "hit_dice", -1) 1042 + data = load_character("test-player") 1070 1043 assert data["resources"]["hit_dice_d8"]["current"] == 2 1071 1044 1072 1045 def test_adjust_resource_spend_multiple( 1073 1046 self, mira: dict, player_dir: Path, 1074 1047 ): 1075 - adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) 1076 - data = load_character("test-player", player_dir) 1048 + adjust_resource("test-player", "hit_dice", -2) 1049 + data = load_character("test-player") 1077 1050 assert data["resources"]["hit_dice_d8"]["current"] == 1 1078 1051 1079 1052 def test_adjust_resource_clamped_to_zero( 1080 1053 self, mira: dict, player_dir: Path, 1081 1054 ): 1082 1055 result = adjust_resource( 1083 - "test-player", "hit_dice", -10, base_path=player_dir 1056 + "test-player", "hit_dice", -10 1084 1057 ) 1085 - data = load_character("test-player", player_dir) 1058 + data = load_character("test-player") 1086 1059 assert data["resources"]["hit_dice_d8"]["current"] == 0 1087 1060 assert "short" in result.lower() 1088 1061 1089 1062 def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): 1090 1063 result = adjust_resource( 1091 - "test-player", "nonexistent", -1, base_path=player_dir 1064 + "test-player", "nonexistent", -1 1092 1065 ) 1093 1066 assert "no resource matching" in result.lower() 1094 1067 1095 1068 def test_adjust_resource_restore_clamped_to_max( 1096 1069 self, mira: dict, player_dir: Path, 1097 1070 ): 1098 - adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) 1071 + adjust_resource("test-player", "hit_dice", -2) 1099 1072 adjust_resource( 1100 - "test-player", "hit_dice", 100, base_path=player_dir 1073 + "test-player", "hit_dice", 100 1101 1074 ) 1102 - data = load_character("test-player", player_dir) 1075 + data = load_character("test-player") 1103 1076 assert data["resources"]["hit_dice_d8"]["current"] == 3 1104 1077 1105 1078 def test_adjust_resource_zero_delta_is_noop( 1106 1079 self, mira: dict, player_dir: Path, 1107 1080 ): 1108 1081 result = adjust_resource( 1109 - "test-player", "hit_dice", 0, base_path=player_dir 1082 + "test-player", "hit_dice", 0 1110 1083 ) 1111 - data = load_character("test-player", player_dir) 1084 + data = load_character("test-player") 1112 1085 assert data["resources"]["hit_dice_d8"]["current"] == 3 1113 1086 assert "no change" in result.lower() 1114 1087 1115 1088 1116 1089 class TestRest: 1117 1090 def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): 1118 - adjust_resource("test-player", "hit_dice", -3, base_path=player_dir) 1119 - rest("test-player", "long", base_path=player_dir) 1120 - data = load_character("test-player", player_dir) 1091 + adjust_resource("test-player", "hit_dice", -3) 1092 + rest("test-player", "long") 1093 + data = load_character("test-player") 1121 1094 assert data["resources"]["hit_dice_d8"]["current"] == 3 1122 1095 1123 1096 def test_long_rest_restores_hp(self, mira: dict, player_dir: Path): 1124 - damage("test-player", 10, base_path=player_dir) 1125 - rest("test-player", "long", base_path=player_dir) 1126 - data = load_character("test-player", player_dir) 1097 + damage("test-player", 10) 1098 + rest("test-player", "long") 1099 + data = load_character("test-player") 1127 1100 assert data["state"]["hp"]["current"] == 24 1128 1101 1129 1102 def test_long_rest_clears_death_saves(self, mira: dict, player_dir: Path): 1130 1103 update_character( 1131 1104 "test-player", 1132 1105 {"state.death_saves.successes": 2, "state.death_saves.failures": 1}, 1133 - base_path=player_dir, 1134 1106 ) 1135 - rest("test-player", "long", base_path=player_dir) 1136 - data = load_character("test-player", player_dir) 1107 + rest("test-player", "long") 1108 + data = load_character("test-player") 1137 1109 assert data["state"]["death_saves"]["successes"] == 0 1138 1110 assert data["state"]["death_saves"]["failures"] == 0 1139 1111 1140 1112 def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): 1141 1113 update_character( 1142 - "test-player", {"state.exhaustion": 3}, base_path=player_dir 1114 + "test-player", {"state.exhaustion": 3} 1143 1115 ) 1144 - rest("test-player", "long", base_path=player_dir) 1145 - data = load_character("test-player", player_dir) 1116 + rest("test-player", "long") 1117 + data = load_character("test-player") 1146 1118 assert data["state"]["exhaustion"] == 2 1147 1119 1148 1120 def test_short_rest_doesnt_refresh_long_rest_resources( 1149 1121 self, mira: dict, player_dir: Path 1150 1122 ): 1151 - adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) 1152 - rest("test-player", "short", base_path=player_dir) 1153 - data = load_character("test-player", player_dir) 1123 + adjust_resource("test-player", "hit_dice", -2) 1124 + rest("test-player", "short") 1125 + data = load_character("test-player") 1154 1126 # hit_dice has refresh: long_rest, so short rest shouldn't refresh it 1155 1127 assert data["resources"]["hit_dice_d8"]["current"] == 1 1156 1128 1157 1129 def test_invalid_rest_type(self, mira: dict, player_dir: Path): 1158 - result = rest("test-player", "epic", base_path=player_dir) 1130 + result = rest("test-player", "epic") 1159 1131 assert "invalid" in result.lower() 1160 1132 1161 1133 1162 1134 class TestCoins: 1163 1135 def test_adjust_coins_spending(self, mira: dict, player_dir: Path): 1164 - adjust_coins("test-player", {"gp": -5}, base_path=player_dir) 1165 - data = load_character("test-player", player_dir) 1136 + adjust_coins("test-player", {"gp": -5}) 1137 + data = load_character("test-player") 1166 1138 assert data["state"]["purse"]["gp"] == 38 1167 1139 1168 1140 def test_adjust_coins_gaining(self, mira: dict, player_dir: Path): 1169 - adjust_coins("test-player", {"gp": 10, "sp": 5}, base_path=player_dir) 1170 - data = load_character("test-player", player_dir) 1141 + adjust_coins("test-player", {"gp": 10, "sp": 5}) 1142 + data = load_character("test-player") 1171 1143 assert data["state"]["purse"]["gp"] == 53 1172 1144 assert data["state"]["purse"]["sp"] == 5 1173 1145 1174 1146 def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path): 1175 1147 result = adjust_coins( 1176 - "test-player", {"gp": -100}, base_path=player_dir 1148 + "test-player", {"gp": -100} 1177 1149 ) 1178 - data = load_character("test-player", player_dir) 1150 + data = load_character("test-player") 1179 1151 assert data["state"]["purse"]["gp"] == 0 1180 1152 assert "short" in result.lower() 1181 1153 1182 1154 1183 1155 class TestNotes: 1184 1156 def test_add_note_creates_file(self, mira: dict, player_dir: Path): 1185 - add_note("test-player", "Found a secret door", base_path=player_dir) 1157 + add_note("test-player", "Found a secret door") 1186 1158 notes_path = player_dir / "players" / "test-player" / "notes.md" 1187 1159 assert notes_path.exists() 1188 1160 assert "secret door" in notes_path.read_text() 1189 1161 1190 1162 def test_add_note_appends(self, mira: dict, player_dir: Path): 1191 - add_note("test-player", "First note", base_path=player_dir) 1192 - add_note("test-player", "Second note", base_path=player_dir) 1163 + add_note("test-player", "First note") 1164 + add_note("test-player", "Second note") 1193 1165 notes_path = player_dir / "players" / "test-player" / "notes.md" 1194 1166 content = notes_path.read_text() 1195 1167 assert "First note" in content ··· 1198 1170 def test_add_note_with_anchor(self, mira: dict, player_dir: Path): 1199 1171 add_note( 1200 1172 "test-player", "Witnessed the heist", 1201 - time_anchor="d28-1330", base_path=player_dir, 1173 + time_anchor="d28-1330", 1202 1174 ) 1203 1175 notes_path = player_dir / "players" / "test-player" / "notes.md" 1204 1176 assert "d28-1330" in notes_path.read_text() ··· 1224 1196 (rest, ("short",)), 1225 1197 (adjust_coins, ({"gp": 5},)), 1226 1198 ]: 1227 - result = fn("missing-player", *args, base_path=player_dir) 1199 + result = fn("missing-player", *args) 1228 1200 assert "no character" in result.lower(), f"{fn.__name__} failed"
+153 -120
tests/test_content.py
··· 1 - """Tests for content layer resolution and search.""" 1 + """Tests for three-layer content resolution (world > user > shipped). 2 + 3 + The autouse ``_isolate_storied_paths`` fixture in ``conftest.py`` already 4 + rebinds ``_data_home`` and ``_user_rules_home`` to ``tmp_path``; these 5 + tests additionally monkeypatch ``shipped_rules_path`` so the "shipped" 6 + layer can be faked per-test under the same tmp dir. 7 + """ 2 8 3 9 from pathlib import Path 4 10 5 11 import pytest 6 12 13 + from storied import paths 7 14 from storied.content import ContentResolver 8 15 9 16 17 + # --------------------------------------------------------------------------- 18 + # Fixtures — build a fake three-layer setup under tmp_path. 19 + # --------------------------------------------------------------------------- 20 + 21 + 10 22 @pytest.fixture 11 - def rules_dir(tmp_path: Path) -> Path: 12 - """Create a mock rules directory.""" 13 - rules = tmp_path / "rules" / "srd-5.2.1" / "sections" 14 - rules.mkdir(parents=True) 23 + def shipped_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: 24 + """Redirect the shipped rules layer to a tmp subdir so tests can write 25 + fake SRD content without touching the real package rules.""" 26 + root = tmp_path / "shipped" 27 + root.mkdir() 28 + monkeypatch.setattr(paths, "shipped_rules_path", lambda: root) 29 + return root 15 30 16 - # Create some monster files 17 - monsters = rules / "monsters" 18 - monsters.mkdir() 19 - (monsters / "goblin.md").write_text( 20 - "# Goblin\n\n_Small Humanoid, Neutral Evil_\n\n**AC** 15 **HP** 7\n" 21 - ) 22 - (monsters / "ancient-red-dragon.md").write_text( 23 - "# Ancient Red Dragon\n\n_Gargantuan Dragon_\n\n**AC** 22 **HP** 507\n" 24 - ) 25 31 26 - # Create some spell files 27 - spells = rules / "spells" 28 - spells.mkdir() 29 - (spells / "fireball.md").write_text( 30 - "# Fireball\n\n_Level 3 Evocation_\n\n8d6 Fire damage\n" 31 - ) 32 - (spells / "magic-missile.md").write_text( 33 - "# Magic Missile\n\n_Level 1 Evocation_\n\nAuto-hit force damage\n" 34 - ) 35 - 36 - return tmp_path 32 + @pytest.fixture 33 + def shipped_goblin(shipped_root: Path) -> Path: 34 + """Standard SRD goblin — the bottom layer.""" 35 + monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" 36 + monsters.mkdir(parents=True) 37 + path = monsters / "goblin.md" 38 + path.write_text("# Goblin\n\n_Small Humanoid, Neutral Evil_\n") 39 + return path 37 40 38 41 39 42 @pytest.fixture 40 - def world_dir(tmp_path: Path) -> Path: 41 - """Create a mock world directory.""" 42 - world = tmp_path / "worlds" / "test-world" 43 - world.mkdir(parents=True) 43 + def shipped_fireball(shipped_root: Path) -> Path: 44 + spells = shipped_root / "srd-5.2.1" / "sections" / "spells" 45 + spells.mkdir(parents=True) 46 + path = spells / "fireball.md" 47 + path.write_text("# Fireball\n\n_Level 3 Evocation_\n") 48 + return path 44 49 45 - # Override the goblin 46 - monsters = world / "monsters" 47 - monsters.mkdir() 48 - (monsters / "goblin.md").write_text( 49 - "# Island Goblin\n\n_Tougher variant_\n\n**AC** 16 **HP** 12\n" 50 - ) 51 50 52 - # World-specific NPC 53 - npcs = world / "npcs" 54 - npcs.mkdir() 55 - (npcs / "captain-vex.md").write_text( 56 - "# Captain Vex\n\nA notorious pirate captain.\n" 57 - ) 58 - 59 - return tmp_path 51 + @pytest.fixture 52 + def user_goblin(tmp_path: Path) -> Path: 53 + """User homebrew goblin — middle layer.""" 54 + monsters = tmp_path / "rules" / "monsters" 55 + monsters.mkdir(parents=True) 56 + path = monsters / "goblin.md" 57 + path.write_text("# Homebrew Goblin\n\n_Slightly meaner_\n") 58 + return path 60 59 61 60 62 61 @pytest.fixture 63 - def resolver(rules_dir: Path) -> ContentResolver: 64 - """Create a resolver with rules only.""" 65 - return ContentResolver(base_path=rules_dir) 62 + def world_goblin(tmp_path: Path) -> Path: 63 + """World-specific goblin override — top layer.""" 64 + monsters = tmp_path / "worlds" / "test-world" / "monsters" 65 + monsters.mkdir(parents=True) 66 + path = monsters / "goblin.md" 67 + path.write_text("# Island Goblin\n\n_Tougher variant_\n") 68 + return path 66 69 67 70 68 71 @pytest.fixture 69 - def world_resolver(world_dir: Path, rules_dir: Path) -> ContentResolver: 70 - """Create a resolver with world and rules.""" 71 - # Copy rules into world_dir since they share tmp_path 72 - return ContentResolver(base_path=world_dir, world_id="test-world") 72 + def world_vera(tmp_path: Path) -> Path: 73 + """Narrative content only exists in the world layer.""" 74 + npcs = tmp_path / "worlds" / "test-world" / "npcs" 75 + npcs.mkdir(parents=True) 76 + path = npcs / "vera.md" 77 + path.write_text("# Vera\n\nInnkeeper at the Rusty Anchor.\n") 78 + return path 73 79 74 80 75 - class TestFindContent: 76 - """Tests for finding content files.""" 81 + # --------------------------------------------------------------------------- 82 + # Layer priority tests. 83 + # --------------------------------------------------------------------------- 77 84 78 - def test_find_monster_in_rules(self, resolver: ContentResolver): 79 - path = resolver.find("goblin", content_type="monsters") 80 - assert path is not None 81 - assert path.name == "goblin.md" 82 85 83 - def test_find_spell_in_rules(self, resolver: ContentResolver): 84 - path = resolver.find("fireball", content_type="spells") 85 - assert path is not None 86 - assert path.name == "fireball.md" 86 + class TestLayerPriority: 87 + def test_shipped_only(self, shipped_goblin: Path): 88 + resolver = ContentResolver(world_id="test-world") 89 + hit = resolver.find("goblin", content_type="monsters") 90 + assert hit == shipped_goblin 87 91 88 - def test_find_not_found(self, resolver: ContentResolver): 89 - path = resolver.find("nonexistent", content_type="monsters") 90 - assert path is None 92 + def test_user_overrides_shipped( 93 + self, shipped_goblin: Path, user_goblin: Path, 94 + ): 95 + resolver = ContentResolver(world_id="test-world") 96 + hit = resolver.find("goblin", content_type="monsters") 97 + assert hit == user_goblin 98 + assert "Homebrew" in hit.read_text() 91 99 92 - def test_find_without_content_type(self, resolver: ContentResolver): 93 - # Should search all categories 94 - path = resolver.find("goblin") 95 - assert path is not None 96 - assert "goblin" in path.name 100 + def test_world_overrides_user( 101 + self, shipped_goblin: Path, user_goblin: Path, world_goblin: Path, 102 + ): 103 + resolver = ContentResolver(world_id="test-world") 104 + hit = resolver.find("goblin", content_type="monsters") 105 + assert hit == world_goblin 106 + assert "Island Goblin" in hit.read_text() 97 107 98 - def test_find_with_hyphenated_name(self, resolver: ContentResolver): 99 - path = resolver.find("ancient-red-dragon", content_type="monsters") 100 - assert path is not None 101 - assert path.name == "ancient-red-dragon.md" 102 - 108 + def test_world_overrides_shipped_without_user( 109 + self, shipped_goblin: Path, world_goblin: Path, 110 + ): 111 + resolver = ContentResolver(world_id="test-world") 112 + hit = resolver.find("goblin", content_type="monsters") 113 + assert hit == world_goblin 103 114 104 - class TestLayerResolution: 105 - """Tests for world layer overriding rules layer.""" 115 + def test_user_overrides_shipped_without_world( 116 + self, shipped_goblin: Path, user_goblin: Path, 117 + ): 118 + resolver = ContentResolver(world_id="test-world") 119 + hit = resolver.find("goblin", content_type="monsters") 120 + assert hit == user_goblin 106 121 107 - def test_world_overrides_rules(self, world_dir: Path): 108 - # Create rules in same base 109 - rules = world_dir / "rules" / "srd-5.2.1" / "sections" / "monsters" 110 - rules.mkdir(parents=True) 111 - (rules / "goblin.md").write_text("# Standard Goblin\n") 112 122 113 - resolver = ContentResolver(base_path=world_dir, world_id="test-world") 114 - path = resolver.find("goblin", content_type="monsters") 123 + # --------------------------------------------------------------------------- 124 + # Narrative content (world-only) tests. 125 + # --------------------------------------------------------------------------- 115 126 116 - assert path is not None 117 - content = path.read_text() 118 - assert "Island Goblin" in content # World version, not Standard 119 127 120 - def test_falls_back_to_rules(self, world_dir: Path): 121 - # Create rules with dragon that world doesn't have 122 - rules = world_dir / "rules" / "srd-5.2.1" / "sections" / "monsters" 123 - rules.mkdir(parents=True) 124 - (rules / "dragon.md").write_text("# Dragon\n") 128 + class TestNarrativeContent: 129 + def test_find_world_npc(self, world_vera: Path): 130 + resolver = ContentResolver(world_id="test-world") 131 + hit = resolver.find("vera", content_type="npcs") 132 + assert hit == world_vera 125 133 126 - resolver = ContentResolver(base_path=world_dir, world_id="test-world") 127 - path = resolver.find("dragon", content_type="monsters") 134 + def test_narrative_misses_fall_through_cleanly(self, shipped_root: Path): 135 + """Looking up a narrative entity that doesn't exist returns None 136 + without error, even though the user/shipped layers have no 137 + ``npcs/`` subdir.""" 138 + resolver = ContentResolver(world_id="test-world") 139 + hit = resolver.find("nonexistent", content_type="npcs") 140 + assert hit is None 128 141 129 - assert path is not None 130 - assert "Dragon" in path.read_text() 142 + def test_no_world_id_still_searches_rule_layers( 143 + self, shipped_fireball: Path, 144 + ): 145 + """A resolver with no world still finds rule content at the 146 + user and shipped layers.""" 147 + resolver = ContentResolver() 148 + hit = resolver.find("fireball", content_type="spells") 149 + assert hit == shipped_fireball 131 150 132 - def test_world_only_content(self, world_dir: Path): 133 - resolver = ContentResolver(base_path=world_dir, world_id="test-world") 134 - path = resolver.find("captain-vex", content_type="npcs") 135 151 136 - assert path is not None 137 - assert "Captain Vex" in path.read_text() 152 + # --------------------------------------------------------------------------- 153 + # Loading content (parsed dict). 154 + # --------------------------------------------------------------------------- 138 155 139 156 140 157 class TestLoadContent: 141 - """Tests for loading and parsing content files.""" 142 - 143 - def test_load_returns_content(self, resolver: ContentResolver): 144 - content = resolver.load("goblin", content_type="monsters") 145 - assert content is not None 146 - assert "body" in content 147 - assert "Goblin" in content["body"] 158 + def test_load_plain_markdown(self, shipped_goblin: Path): 159 + resolver = ContentResolver(world_id="test-world") 160 + data = resolver.load("goblin", content_type="monsters") 161 + assert data is not None 162 + assert "Goblin" in data["body"] 148 163 149 - def test_load_not_found(self, resolver: ContentResolver): 150 - content = resolver.load("nonexistent", content_type="monsters") 151 - assert content is None 152 - 153 - def test_load_with_frontmatter(self, rules_dir: Path): 154 - # Create file with YAML frontmatter 155 - monsters = rules_dir / "rules" / "srd-5.2.1" / "sections" / "monsters" 164 + def test_load_with_frontmatter(self, shipped_root: Path): 165 + monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" 166 + monsters.mkdir(parents=True) 156 167 (monsters / "orc.md").write_text( 157 168 "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n# Orc\n\nBig and mean.\n" 158 169 ) 159 170 160 - resolver = ContentResolver(base_path=rules_dir) 161 - content = resolver.load("orc", content_type="monsters") 171 + resolver = ContentResolver(world_id="test-world") 172 + data = resolver.load("orc", content_type="monsters") 162 173 163 - assert content is not None 164 - assert content.get("type") == "monster" 165 - assert content.get("cr") == 0.5 166 - assert content.get("tags") == ["humanoid"] 167 - assert "Orc" in content["body"] 174 + assert data is not None 175 + assert data.get("type") == "monster" 176 + assert data.get("cr") == 0.5 177 + assert data.get("tags") == ["humanoid"] 178 + assert "Orc" in data["body"] 179 + 180 + def test_load_not_found(self, shipped_root: Path): 181 + resolver = ContentResolver(world_id="test-world") 182 + data = resolver.load("nonexistent", content_type="monsters") 183 + assert data is None 168 184 169 185 186 + # --------------------------------------------------------------------------- 187 + # Untyped search (walks every content_type subdir in every layer). 188 + # --------------------------------------------------------------------------- 189 + 190 + 191 + class TestUntypedSearch: 192 + def test_finds_goblin_without_content_type(self, shipped_goblin: Path): 193 + resolver = ContentResolver(world_id="test-world") 194 + hit = resolver.find("goblin") 195 + assert hit == shipped_goblin 196 + 197 + def test_world_wins_in_untyped_search( 198 + self, shipped_goblin: Path, world_goblin: Path, 199 + ): 200 + resolver = ContentResolver(world_id="test-world") 201 + hit = resolver.find("goblin") 202 + assert hit == world_goblin
+23 -27
tests/test_engine.py
··· 89 89 return DMEngine( 90 90 world_id="test", 91 91 player_id="default", 92 - base_path=tmp_path, 93 92 prompt_name="dm-system", 94 93 ) 95 94 ··· 97 96 context = engine._build_context() 98 97 assert "Style" not in engine._context_parts 99 98 100 - def test_build_context_with_style(self, engine): 101 - style_path = engine.base_path / "worlds" / "test" / "style.md" 99 + def test_build_context_with_style(self, engine, tmp_path: Path): 100 + style_path = tmp_path / "worlds" / "test" / "style.md" 102 101 style_path.write_text("# Style\n\nMore intrigue, less combat.\n") 103 102 104 103 context = engine._build_context() ··· 106 105 assert "Style" in engine._context_parts 107 106 assert "intrigue" in engine._context_parts["Style"] 108 107 109 - def test_time_is_first_context_part(self, engine): 108 + def test_time_is_first_context_part(self, engine, tmp_path: Path): 110 109 """The ambient clock header goes at the top of every turn's 111 110 context so the DM can't miss it. Style comes immediately after.""" 112 - style_path = engine.base_path / "worlds" / "test" / "style.md" 111 + style_path = tmp_path / "worlds" / "test" / "style.md" 113 112 style_path.write_text("# Style\n\nDark tone.\n") 114 113 115 114 engine._build_context() ··· 193 192 engine = DMEngine( 194 193 world_id="test", 195 194 player_id="default", 196 - base_path=tmp_path, 197 195 prompt_name="dm-system", 198 196 transcript_path=transcript_path, 199 197 ) ··· 228 226 engine.reset() 229 227 assert engine._session_id is None 230 228 231 - def test_build_context_with_character(self, engine): 229 + def test_build_context_with_character(self, engine, tmp_path: Path): 232 230 # Drop a character file in place; _build_context should pick it up 233 231 from storied.character import create_character 234 - (engine.base_path / "players" / "default").mkdir(parents=True) 232 + (tmp_path / "players" / "default").mkdir(parents=True) 235 233 create_character( 236 234 player_id="default", 237 235 name="Mira", ··· 242 240 "intelligence": 14, "wisdom": 12, "charisma": 16}, 243 241 hp_max=24, 244 242 ac=16, 245 - base_path=engine.base_path, 246 243 ) 247 244 engine._build_context() 248 245 assert "Character" in engine._context_parts 249 246 assert "Mira" in engine._context_parts["Character"] 250 247 251 - def test_build_context_with_session(self, engine): 248 + def test_build_context_with_session(self, engine, tmp_path: Path): 252 249 from storied.session import save_session 253 - (engine.base_path / "players" / "default").mkdir(parents=True) 250 + (tmp_path / "players" / "default").mkdir(parents=True) 254 251 save_session("default", { 255 252 "location": "The Tavern", 256 253 "body": "## Present\n- [[Vera]]", 257 254 "situation": "Resting", 258 - }, engine.base_path) 255 + }) 259 256 engine._build_context() 260 257 assert "Session" in engine._context_parts 261 258 262 - def test_build_context_loads_present_entities(self, engine): 259 + def test_build_context_loads_present_entities(self, engine, tmp_path: Path): 263 260 from storied.session import save_session 264 261 from storied.tools.entities import _do_establish 265 262 266 - (engine.base_path / "players" / "default").mkdir(parents=True) 263 + (tmp_path / "players" / "default").mkdir(parents=True) 267 264 268 265 # Establish an NPC and put them in the session's present list 269 266 _do_establish( 270 267 "npcs", "Vera", "Tavern owner.", "[[The Tavern]]", 271 268 None, None, None, 272 - engine.base_path, "test", 269 + "test", 273 270 engine._mcp.ctx.entity_index, 274 271 type("FakeIdx", (), {"upsert": lambda *a, **k: None})(), 275 272 ) 276 273 save_session("default", { 277 274 "location": "The Tavern", 278 275 "body": "## Present\n- [[Vera]]", 279 - }, engine.base_path) 276 + }) 280 277 281 278 engine._build_context() 282 279 # The Vera entity should have been loaded into the DM context ··· 287 284 result = engine._load_player_knowledge() 288 285 assert result is None 289 286 290 - def test_load_player_knowledge_aggregates_files(self, engine): 287 + def test_load_player_knowledge_aggregates_files(self, engine, tmp_path: Path): 291 288 knowledge = ( 292 - engine.base_path / "players" / "default" / "worlds" / "test" / "npcs" 289 + tmp_path / "players" / "default" / "worlds" / "test" / "npcs" 293 290 ) 294 291 knowledge.mkdir(parents=True) 295 292 (knowledge / "vera.md").write_text( ··· 300 297 assert "Vera Blackwater" in result 301 298 assert "tavern owner" in result 302 299 303 - def test_find_entity_via_index(self, engine): 300 + def test_find_entity_via_index(self, engine, tmp_path: Path): 304 301 # Drop an entity file directly and rebuild the index so the lookup hits 305 - npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" 302 + npc_path = tmp_path / "worlds" / "test" / "npcs" / "Vera.md" 306 303 npc_path.parent.mkdir(parents=True, exist_ok=True) 307 304 npc_path.write_text("# Vera\n\nA tavern owner.") 308 305 engine._mcp.ctx.entity_index.register("Vera", npc_path) ··· 316 313 def test_find_entity_returns_none_when_missing(self, engine): 317 314 assert engine._find_entity("Nobody") is None 318 315 319 - def test_build_context_loads_location_and_one_hop_linked(self, engine): 316 + def test_build_context_loads_location_and_one_hop_linked(self, engine, tmp_path: Path): 320 317 """When the session points at a location, _build_context should load 321 318 the location, then one-hop into entities the location wikilinks.""" 322 319 from storied.session import save_session 323 320 324 - (engine.base_path / "players" / "default").mkdir(parents=True) 321 + (tmp_path / "players" / "default").mkdir(parents=True) 325 322 326 323 # Location wikilinks to a related NPC 327 - loc_path = engine.base_path / "worlds" / "test" / "locations" / "Tavern.md" 324 + loc_path = tmp_path / "worlds" / "test" / "locations" / "Tavern.md" 328 325 loc_path.parent.mkdir(parents=True, exist_ok=True) 329 326 loc_path.write_text( 330 327 "# Tavern\n\nA cozy spot where [[Vera]] holds court." ··· 332 329 engine._mcp.ctx.entity_index.register("Tavern", loc_path) 333 330 334 331 # The linked NPC 335 - npc_path = engine.base_path / "worlds" / "test" / "npcs" / "Vera.md" 332 + npc_path = tmp_path / "worlds" / "test" / "npcs" / "Vera.md" 336 333 npc_path.parent.mkdir(parents=True, exist_ok=True) 337 334 npc_path.write_text("# Vera\n\nTavern owner.") 338 335 engine._mcp.ctx.entity_index.register("Vera", npc_path) ··· 340 337 save_session("default", { 341 338 "location": "Tavern", 342 339 "body": "Player just walked in.", 343 - }, engine.base_path) 340 + }) 344 341 345 342 engine._build_context() 346 343 # Location should be loaded ··· 355 352 from storied import notifications 356 353 357 354 notifications.append( 358 - engine.world_id, engine.base_path, 359 - "World tick: Vera left the tavern", 355 + engine.world_id, "World tick: Vera left the tavern", 360 356 ) 361 357 engine._build_context() 362 358 assert "Notifications" in engine._context_parts
+72 -72
tests/test_entities.py
··· 38 38 class TestEstablish: 39 39 """Tests for the establish tool.""" 40 40 41 - def test_establish_creates_npc(self, ctx: ToolContext): 41 + def test_establish_creates_npc(self, ctx: ToolContext, tmp_path: Path): 42 42 result = establish( 43 43 entity_type="npcs", 44 44 name="Vera Blackwater", ··· 50 50 ) 51 51 52 52 assert "Established" in result 53 - npc_file = ctx.base_path / "worlds/test-world/npcs/Vera Blackwater.md" 53 + npc_file = tmp_path / "worlds/test-world/npcs/Vera Blackwater.md" 54 54 assert npc_file.exists() 55 55 56 - def test_establish_file_format(self, ctx: ToolContext): 56 + def test_establish_file_format(self, ctx: ToolContext, tmp_path: Path): 57 57 establish( 58 58 entity_type="npcs", 59 59 name="Test NPC", ··· 64 64 will=["If X → do Y"], 65 65 ) 66 66 67 - content = (ctx.base_path / "worlds/test-world/npcs/Test NPC.md").read_text() 67 + content = (tmp_path / "worlds/test-world/npcs/Test NPC.md").read_text() 68 68 69 69 assert "# Test NPC" in content 70 70 assert "## Is" in content ··· 77 77 assert "### Will" in content 78 78 assert "- If X → do Y" in content 79 79 80 - def test_establish_location(self, ctx: ToolContext): 80 + def test_establish_location(self, ctx: ToolContext, tmp_path: Path): 81 81 establish( 82 82 entity_type="locations", 83 83 name="The Rusty Anchor", ··· 88 88 will=["If searched → reveal tunnel"], 89 89 ) 90 90 91 - loc_file = ctx.base_path / "worlds/test-world/locations/The Rusty Anchor.md" 91 + loc_file = tmp_path / "worlds/test-world/locations/The Rusty Anchor.md" 92 92 assert loc_file.exists() 93 93 content = loc_file.read_text() 94 94 assert "Hidden tunnel in cellar" in content 95 95 96 - def test_establish_map(self, ctx: ToolContext): 96 + def test_establish_map(self, ctx: ToolContext, tmp_path: Path): 97 97 establish( 98 98 entity_type="maps", 99 99 name="Ashenmere District Map", 100 100 ctx=ctx, 101 101 description="```map\n┌────┐\n│ A │\n└────┘\n```", 102 102 ) 103 - map_file = ctx.base_path / "worlds/test-world/maps/Ashenmere District Map.md" 103 + map_file = tmp_path / "worlds/test-world/maps/Ashenmere District Map.md" 104 104 assert map_file.exists() 105 105 106 - def test_establish_item(self, ctx: ToolContext): 106 + def test_establish_item(self, ctx: ToolContext, tmp_path: Path): 107 107 establish( 108 108 entity_type="items", 109 109 name="The Skeleton Key", ··· 114 114 will=["Unlock any non-magical lock"], 115 115 ) 116 116 117 - item_file = ctx.base_path / "worlds/test-world/items/The Skeleton Key.md" 117 + item_file = tmp_path / "worlds/test-world/items/The Skeleton Key.md" 118 118 assert item_file.exists() 119 119 120 - def test_establish_partial_update(self, ctx: ToolContext): 120 + def test_establish_partial_update(self, ctx: ToolContext, tmp_path: Path): 121 121 # Create initial entity 122 122 establish( 123 123 entity_type="npcs", ··· 137 137 description="A stern city guard, recently promoted to sergeant.", 138 138 ) 139 139 140 - content = (ctx.base_path / "worlds/test-world/npcs/Guard Mara.md").read_text() 140 + content = (tmp_path / "worlds/test-world/npcs/Guard Mara.md").read_text() 141 141 assert "recently promoted" in content 142 142 assert "Patrol routes" in content # Preserved from original 143 143 144 - def test_establish_with_wikilinks(self, ctx: ToolContext): 144 + def test_establish_with_wikilinks(self, ctx: ToolContext, tmp_path: Path): 145 145 establish( 146 146 entity_type="npcs", 147 147 name="Captain Harrik", ··· 152 152 will=[], 153 153 ) 154 154 155 - content = (ctx.base_path / "worlds/test-world/npcs/Captain Harrik.md").read_text() 155 + content = (tmp_path / "worlds/test-world/npcs/Captain Harrik.md").read_text() 156 156 assert "[[The Rusty Anchor]]" in content 157 157 assert "[[Vera Blackwater]]" in content 158 158 159 - def test_establish_empty_sections_omitted(self, ctx: ToolContext): 159 + def test_establish_empty_sections_omitted(self, ctx: ToolContext, tmp_path: Path): 160 160 establish( 161 161 entity_type="npcs", 162 162 name="Simple NPC", ··· 167 167 will=[], 168 168 ) 169 169 170 - content = (ctx.base_path / "worlds/test-world/npcs/Simple NPC.md").read_text() 170 + content = (tmp_path / "worlds/test-world/npcs/Simple NPC.md").read_text() 171 171 assert "### Knows" not in content 172 172 assert "### Wants" not in content 173 173 assert "### Will" not in content 174 174 175 - def test_establish_with_location(self, ctx: ToolContext): 175 + def test_establish_with_location(self, ctx: ToolContext, tmp_path: Path): 176 176 establish( 177 177 entity_type="npcs", 178 178 name="Garrick the Jailer", ··· 182 182 knows=["Where the keys are kept"], 183 183 ) 184 184 185 - content = (ctx.base_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() 185 + content = (tmp_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() 186 186 assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content 187 187 assert "Heavyset man in his fifties." in content 188 188 189 - def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext): 189 + def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext, tmp_path: Path): 190 190 from storied.character import create_character 191 191 192 192 create_character( ··· 198 198 abilities={"strength": 10, "dexterity": 16, "constitution": 12, 199 199 "intelligence": 12, "wisdom": 12, "charisma": 14}, 200 200 hp_max=24, ac=16, 201 - base_path=ctx.base_path, 202 201 ) 203 202 204 203 result = establish( ··· 210 209 211 210 assert "Refused" in result 212 211 assert "player character" in result 213 - assert not (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() 212 + assert not (tmp_path / "worlds/test-world/npcs/Mira.md").exists() 214 213 215 - def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext): 214 + def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext, tmp_path: Path): 216 215 """The guard is NPC-scoped — an NPC can't share the PC's name, but 217 216 a location or thread happening to be named 'Mira' is fine.""" 218 217 from storied.character import create_character ··· 226 225 abilities={"strength": 10, "dexterity": 16, "constitution": 12, 227 226 "intelligence": 12, "wisdom": 12, "charisma": 14}, 228 227 hp_max=8, ac=14, 229 - base_path=ctx.base_path, 230 228 ) 231 229 232 230 result = establish( ··· 237 235 ) 238 236 239 237 assert "Established" in result 240 - assert (ctx.base_path / "worlds/test-world/locations/Mira.md").exists() 238 + assert (tmp_path / "worlds/test-world/locations/Mira.md").exists() 241 239 242 240 def test_establish_allows_npc_matching_pc_name_with_no_character( 243 - self, ctx: ToolContext, 241 + self, ctx: ToolContext, tmp_path: Path, 244 242 ): 245 243 """Without a character sheet on disk, the guard should not fire.""" 246 244 result = establish( ··· 251 249 ) 252 250 253 251 assert "Established" in result 254 - assert (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() 252 + assert (tmp_path / "worlds/test-world/npcs/Mira.md").exists() 255 253 256 - def test_establish_location_preserved_on_update(self, ctx: ToolContext): 254 + def test_establish_location_preserved_on_update(self, ctx: ToolContext, tmp_path: Path): 257 255 # Create with location 258 256 establish( 259 257 entity_type="npcs", ··· 271 269 description="A weary traveler.", 272 270 ) 273 271 274 - content = (ctx.base_path / "worlds/test-world/npcs/Wanderer.md").read_text() 272 + content = (tmp_path / "worlds/test-world/npcs/Wanderer.md").read_text() 275 273 assert "**Location:** [[The Rusty Anchor]]" in content 276 274 assert "weary traveler" in content 277 275 ··· 279 277 class TestMark: 280 278 """Tests for the mark tool.""" 281 279 282 - def test_mark_appends_to_was(self, ctx: ToolContext): 280 + def test_mark_appends_to_was(self, ctx: ToolContext, tmp_path: Path): 283 281 # Create entity first 284 282 establish( 285 283 entity_type="npcs", ··· 299 297 ) 300 298 301 299 assert "Marked" in result 302 - content = (ctx.base_path / "worlds/test-world/npcs/Vera Blackwater.md").read_text() 300 + content = (tmp_path / "worlds/test-world/npcs/Vera Blackwater.md").read_text() 303 301 assert "## Was" in content 304 302 assert "Met the player" in content 305 303 306 - def test_mark_includes_timestamp(self, ctx: ToolContext): 304 + def test_mark_includes_timestamp(self, ctx: ToolContext, tmp_path: Path): 307 305 establish( 308 306 entity_type="npcs", 309 307 name="Test NPC", ··· 318 316 ctx=ctx, 319 317 ) 320 318 321 - content = (ctx.base_path / "worlds/test-world/npcs/Test NPC.md").read_text() 319 + content = (tmp_path / "worlds/test-world/npcs/Test NPC.md").read_text() 322 320 # Should have timestamp anchor format 323 321 assert "#d" in content 324 322 assert "|" in content 325 323 326 - def test_mark_resolves_will_trigger(self, ctx: ToolContext): 324 + def test_mark_resolves_will_trigger(self, ctx: ToolContext, tmp_path: Path): 327 325 establish( 328 326 entity_type="npcs", 329 327 name="Vera Blackwater", ··· 342 340 resolves=["If trusted → introduce to Harrik"], 343 341 ) 344 342 345 - content = (ctx.base_path / "worlds/test-world/npcs/Vera Blackwater.md").read_text() 343 + content = (tmp_path / "worlds/test-world/npcs/Vera Blackwater.md").read_text() 346 344 # Resolved trigger should be removed 347 345 assert "If trusted → introduce to Harrik" not in content 348 346 # Other trigger should remain 349 347 assert "If threatened → tip off guild" in content 350 348 351 - def test_mark_resolves_multiple_triggers(self, ctx: ToolContext): 349 + def test_mark_resolves_multiple_triggers(self, ctx: ToolContext, tmp_path: Path): 352 350 establish( 353 351 entity_type="npcs", 354 352 name="Complex NPC", ··· 371 369 resolves=["If friendly → share rumors", "If trusted → reveal secret"], 372 370 ) 373 371 374 - content = (ctx.base_path / "worlds/test-world/npcs/Complex NPC.md").read_text() 372 + content = (tmp_path / "worlds/test-world/npcs/Complex NPC.md").read_text() 375 373 # Both resolved triggers should be removed 376 374 assert "If friendly → share rumors" not in content 377 375 assert "If trusted → reveal secret" not in content ··· 380 378 # Result should mention resolving multiple 381 379 assert "resolved 2 triggers" in result 382 380 383 - def test_mark_multiple_events(self, ctx: ToolContext): 381 + def test_mark_multiple_events(self, ctx: ToolContext, tmp_path: Path): 384 382 establish( 385 383 entity_type="locations", 386 384 name="The Docks", ··· 405 403 ctx=ctx, 406 404 ) 407 405 408 - content = (ctx.base_path / "worlds/test-world/locations/The Docks.md").read_text() 406 + content = (tmp_path / "worlds/test-world/locations/The Docks.md").read_text() 409 407 assert "Player arrived" in content 410 408 assert "witnessed smugglers" in content 411 409 ··· 419 417 420 418 assert "not found" in result.lower() 421 419 422 - def test_mark_with_when_backdates_the_entry(self, ctx: ToolContext): 420 + def test_mark_with_when_backdates_the_entry(self, ctx: ToolContext, tmp_path: Path): 423 421 # Advance the clock to d5-1400 so the current time is clearly 424 422 # distinct from the backdated time we're about to pass. 425 423 ctx.campaign_log.append_entry("Clock advance", "5 days") ··· 439 437 ctx=ctx, 440 438 ) 441 439 442 - content = (ctx.base_path / "worlds/test-world/npcs/Dortha Cray.md").read_text() 440 + content = (tmp_path / "worlds/test-world/npcs/Dortha Cray.md").read_text() 443 441 # The timestamp on the Was entry should be the backdated one, 444 442 # not the current clock time. 445 443 assert "#d1-0900" in content 446 444 # And crucially there is NO double-timestamp prefix. 447 445 assert "#d1-0900 | #" not in content 448 446 449 - def test_mark_with_when_accepts_hash_prefix(self, ctx: ToolContext): 447 + def test_mark_with_when_accepts_hash_prefix(self, ctx: ToolContext, tmp_path: Path): 450 448 establish(entity_type="npcs", name="Somebody", ctx=ctx, description="x") 451 449 mark( 452 450 entity_type="npcs", ··· 455 453 when="#d2-1430", 456 454 ctx=ctx, 457 455 ) 458 - content = (ctx.base_path / "worlds/test-world/npcs/Somebody.md").read_text() 456 + content = (tmp_path / "worlds/test-world/npcs/Somebody.md").read_text() 459 457 assert "#d2-1430" in content 460 458 461 459 def test_mark_with_invalid_when_falls_back_gracefully( ··· 478 476 class TestAmendMark: 479 477 """Tests for the amend_mark tool — replaces the most recent Was entry.""" 480 478 481 - def test_amend_replaces_most_recent_entry(self, ctx: ToolContext): 479 + def test_amend_replaces_most_recent_entry(self, ctx: ToolContext, tmp_path: Path): 482 480 establish(entity_type="npcs", name="Vera", ctx=ctx, description="x") 483 481 mark( 484 482 entity_type="npcs", name="Vera", ··· 492 490 ) 493 491 494 492 assert "Amended" in result 495 - content = (ctx.base_path / "worlds/test-world/npcs/Vera.md").read_text() 493 + content = (tmp_path / "worlds/test-world/npcs/Vera.md").read_text() 496 494 assert "full truth" in content 497 495 assert "half-truth" not in content 498 496 499 - def test_amend_preserves_anchor(self, ctx: ToolContext): 497 + def test_amend_preserves_anchor(self, ctx: ToolContext, tmp_path: Path): 500 498 establish(entity_type="npcs", name="Tam", ctx=ctx, description="x") 501 499 mark( 502 500 entity_type="npcs", name="Tam", ··· 508 506 event="Corrected beat", ctx=ctx, 509 507 ) 510 508 511 - content = (ctx.base_path / "worlds/test-world/npcs/Tam.md").read_text() 509 + content = (tmp_path / "worlds/test-world/npcs/Tam.md").read_text() 512 510 assert "#d5-1200 | Corrected beat" in content 513 511 514 - def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext): 512 + def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext, tmp_path: Path): 515 513 establish(entity_type="npcs", name="Oben", ctx=ctx, description="x") 516 514 mark(entity_type="npcs", name="Oben", event="First beat", ctx=ctx) 517 515 ctx.campaign_log.append_entry("advance", "1 hour") ··· 522 520 event="Second beat, corrected", ctx=ctx, 523 521 ) 524 522 525 - content = (ctx.base_path / "worlds/test-world/npcs/Oben.md").read_text() 523 + content = (tmp_path / "worlds/test-world/npcs/Oben.md").read_text() 526 524 assert "First beat" in content 527 525 assert "Second beat, corrected" in content 528 526 assert "- Second beat\n" not in content # old unamended line gone ··· 563 561 links = extract_wiki_links(text) 564 562 assert links == ["Vera", "Vera", "Bob"] 565 563 566 - def test_resolve_wiki_link_npc(self, ctx: ToolContext): 564 + def test_resolve_wiki_link_npc(self, ctx: ToolContext, tmp_path: Path): 567 565 establish( 568 566 entity_type="npcs", 569 567 name="Vera Blackwater", ··· 571 569 description="Test.", 572 570 ) 573 571 574 - path = resolve_wiki_link("Vera Blackwater", "test-world", ctx.base_path) 572 + path = resolve_wiki_link("Vera Blackwater", "test-world") 575 573 assert path is not None 576 574 assert path.name == "Vera Blackwater.md" 577 575 assert "npcs" in str(path) 578 576 579 - def test_resolve_wiki_link_location(self, ctx: ToolContext): 577 + def test_resolve_wiki_link_location(self, ctx: ToolContext, tmp_path: Path): 580 578 establish( 581 579 entity_type="locations", 582 580 name="The Rusty Anchor", ··· 584 582 description="Test.", 585 583 ) 586 584 587 - path = resolve_wiki_link("The Rusty Anchor", "test-world", ctx.base_path) 585 + path = resolve_wiki_link("The Rusty Anchor", "test-world") 588 586 assert path is not None 589 587 assert "locations" in str(path) 590 588 591 - def test_resolve_wiki_link_not_found(self, ctx: ToolContext): 592 - path = resolve_wiki_link("Nonexistent", "test-world", ctx.base_path) 589 + def test_resolve_wiki_link_not_found(self, ctx: ToolContext, tmp_path: Path): 590 + path = resolve_wiki_link("Nonexistent", "test-world") 593 591 assert path is None 594 592 595 - def test_resolve_wiki_link_priority_order(self, ctx: ToolContext): 593 + def test_resolve_wiki_link_priority_order(self, ctx: ToolContext, tmp_path: Path): 596 594 # Create same name in multiple directories - npcs should win 597 - (ctx.base_path / "worlds/test-world/npcs").mkdir(parents=True, exist_ok=True) 598 - (ctx.base_path / "worlds/test-world/locations").mkdir(parents=True, exist_ok=True) 595 + (tmp_path / "worlds/test-world/npcs").mkdir(parents=True, exist_ok=True) 596 + (tmp_path / "worlds/test-world/locations").mkdir(parents=True, exist_ok=True) 599 597 600 - (ctx.base_path / "worlds/test-world/npcs/Ambiguous.md").write_text("# NPC version") 601 - (ctx.base_path / "worlds/test-world/locations/Ambiguous.md").write_text( 598 + (tmp_path / "worlds/test-world/npcs/Ambiguous.md").write_text("# NPC version") 599 + (tmp_path / "worlds/test-world/locations/Ambiguous.md").write_text( 602 600 "# Location version" 603 601 ) 604 602 605 - path = resolve_wiki_link("Ambiguous", "test-world", ctx.base_path) 603 + path = resolve_wiki_link("Ambiguous", "test-world") 606 604 assert path is not None 607 605 assert "npcs" in str(path) # NPCs have priority over locations 608 606 ··· 610 608 class TestLoadEntityContent: 611 609 """Tests for loading entity content by name.""" 612 610 613 - def test_load_entity_content(self, ctx: ToolContext): 611 + def test_load_entity_content(self, ctx: ToolContext, tmp_path: Path): 614 612 establish( 615 613 entity_type="npcs", 616 614 name="Test Character", ··· 619 617 knows=["A secret"], 620 618 ) 621 619 622 - entity = load_entity_content("Test Character", "test-world", ctx.base_path) 620 + entity = load_entity_content("Test Character", "test-world") 623 621 assert entity is not None 624 622 assert entity["name"] == "Test Character" 625 623 assert entity["entity_type"] == "npcs" 626 624 assert "A test." in entity["content"] 627 625 assert "A secret" in entity["content"] 628 626 629 - def test_load_entity_content_not_found(self, ctx: ToolContext): 630 - entity = load_entity_content("Nobody", "test-world", ctx.base_path) 627 + def test_load_entity_content_not_found(self, ctx: ToolContext, tmp_path: Path): 628 + entity = load_entity_content("Nobody", "test-world") 631 629 assert entity is None 632 630 633 631 ··· 635 633 636 634 637 635 @pytest.fixture 638 - def indexed_world(ctx: ToolContext) -> tuple[Path, EntityIndex]: 636 + def indexed_world( 637 + ctx: ToolContext, tmp_path: Path, 638 + ) -> tuple[Path, EntityIndex]: 639 639 """Create a world with entities and build an index.""" 640 - world_dir = ctx.base_path / "worlds" / "test-world" 640 + world_dir = tmp_path / "worlds" / "test-world" 641 641 for etype, name in [("npcs", "Vera"), ("locations", "Tavern"), ("items", "Sword")]: 642 642 d = world_dir / etype 643 643 d.mkdir(parents=True, exist_ok=True) ··· 698 698 class TestEstablishWithIndex: 699 699 """Tests that establish writes through to the index and cache.""" 700 700 701 - def test_establish_registers_in_index(self, ctx: ToolContext): 702 - ctx.entity_index = EntityIndex(ctx.base_path / "worlds" / ctx.world_id) 701 + def test_establish_registers_in_index(self, ctx: ToolContext, tmp_path: Path): 702 + ctx.entity_index = EntityIndex(tmp_path / "worlds" / ctx.world_id) 703 703 establish( 704 704 entity_type="npcs", 705 705 name="New NPC", ··· 708 708 ) 709 709 assert ctx.entity_index.resolve("New NPC") is not None 710 710 711 - def test_establish_caches_entity(self, ctx: ToolContext): 712 - ctx.entity_index = EntityIndex(ctx.base_path / "worlds" / ctx.world_id) 711 + def test_establish_caches_entity(self, ctx: ToolContext, tmp_path: Path): 712 + ctx.entity_index = EntityIndex(tmp_path / "worlds" / ctx.world_id) 713 713 establish( 714 714 entity_type="npcs", 715 715 name="Cached NPC", ··· 723 723 assert cached["description"] == "Cached." 724 724 assert cached["knows"] == ["a secret"] 725 725 726 - def test_mark_updates_cache(self, ctx: ToolContext): 727 - ctx.entity_index = EntityIndex(ctx.base_path / "worlds" / ctx.world_id) 726 + def test_mark_updates_cache(self, ctx: ToolContext, tmp_path: Path): 727 + ctx.entity_index = EntityIndex(tmp_path / "worlds" / ctx.world_id) 728 728 establish( 729 729 entity_type="npcs", 730 730 name="Markable",
+28 -26
tests/test_execute_tool.py
··· 4 4 (claude → MCP → tool function), but in-process via fastmcp.Client. 5 5 """ 6 6 7 + from pathlib import Path 8 + 7 9 import asyncio 8 10 from typing import Any 9 11 ··· 111 113 112 114 113 115 class TestUpdateCharacter: 114 - def test_landed_in_state_hp_current(self, ctx: ToolContext): 116 + def test_landed_in_state_hp_current(self, ctx: ToolContext, tmp_path: Path): 115 117 call("create_character", { 116 118 "name": "Test", "race": "Human", "char_class": "Fighter", 117 119 "level": 1, "abilities": { ··· 123 125 result = call("update_character", {"updates": {"state.hp.current": 8}}) 124 126 assert "updated" in result.lower() 125 127 126 - data = load_character(ctx.player_id, ctx.base_path) 128 + data = load_character(ctx.player_id) 127 129 assert data["state"]["hp"]["current"] == 8, ( 128 130 "update_character should write to state.hp.current with the new schema, " 129 131 f"but state.hp.current is {data['state']['hp']['current']}" ··· 136 138 class TestNoteDiscoveryDirect: 137 139 """Direct (non-MCP) calls to note_discovery exercise the wrapper itself.""" 138 140 139 - def test_creates_knowledge_file(self, ctx: ToolContext): 141 + def test_creates_knowledge_file(self, ctx: ToolContext, tmp_path: Path): 140 142 call_tool(_note_discovery, entity="The Rusty Anchor", 141 143 content="A seedy tavern on the docks") 142 144 knowledge_dir = ( 143 - ctx.base_path / "players" / ctx.player_id / "worlds" 145 + tmp_path / "players" / ctx.player_id / "worlds" 144 146 / ctx.world_id / "lore" 145 147 ) 146 148 assert any(knowledge_dir.iterdir()) 147 149 148 - def test_with_content_type(self, ctx: ToolContext): 150 + def test_with_content_type(self, ctx: ToolContext, tmp_path: Path): 149 151 call_tool(_note_discovery, entity="Vera", content="Tavern owner", 150 152 content_type="npcs") 151 153 knowledge_dir = ( 152 - ctx.base_path / "players" / ctx.player_id / "worlds" 154 + tmp_path / "players" / ctx.player_id / "worlds" 153 155 / ctx.world_id / "npcs" 154 156 ) 155 157 assert any(knowledge_dir.iterdir()) 156 158 157 - def test_with_tags(self, ctx: ToolContext): 159 + def test_with_tags(self, ctx: ToolContext, tmp_path: Path): 158 160 call_tool(_note_discovery, entity="Old Map", 159 161 content="Shows a hidden passage", tags=["quest"]) 160 162 knowledge_dir = ( 161 - ctx.base_path / "players" / ctx.player_id / "worlds" 163 + tmp_path / "players" / ctx.player_id / "worlds" 162 164 / ctx.world_id / "lore" 163 165 ) 164 166 content = next(knowledge_dir.iterdir()).read_text() ··· 183 185 184 186 185 187 class TestRecall: 186 - def test_recall_finds_indexed_entity(self, ctx: ToolContext): 187 - entity_dir = ctx.base_path / "worlds" / ctx.world_id / "npcs" 188 + def test_recall_finds_indexed_entity(self, ctx: ToolContext, tmp_path: Path): 189 + entity_dir = tmp_path / "worlds" / ctx.world_id / "npcs" 188 190 entity_dir.mkdir(parents=True, exist_ok=True) 189 191 entity_file = entity_dir / "Vera Blackwater.md" 190 192 entity_file.write_text("# Vera Blackwater\n\nTavern owner.\n") ··· 233 235 assert "3" in result 234 236 assert ctx.initiative._find("Goblin").hp == 4 235 237 236 - def test_damage_syncs_player_hp(self, ctx: ToolContext): 238 + def test_damage_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): 237 239 call("create_character", { 238 240 "name": "Kira", "race": "Human", "char_class": "Fighter", 239 241 "level": 1, "abilities": { ··· 251 253 ) 252 254 253 255 assert "synced" in result 254 - char = load_character(ctx.player_id, ctx.base_path) 256 + char = load_character(ctx.player_id) 255 257 assert char["state"]["hp"]["current"] == 18, ( 256 258 "_sync_player_hp must write to state.hp.current with the new schema" 257 259 ) 258 260 259 - def test_heal_syncs_player_hp(self, ctx: ToolContext): 261 + def test_heal_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): 260 262 call("create_character", { 261 263 "name": "Kira", "race": "Human", "char_class": "Fighter", 262 264 "level": 1, "abilities": { ··· 275 277 ) 276 278 277 279 assert "synced" in result 278 - char = load_character(ctx.player_id, ctx.base_path) 280 + char = load_character(ctx.player_id) 279 281 assert char["state"]["hp"]["current"] == 23, ( 280 282 "_sync_player_hp must write to state.hp.current with the new schema" 281 283 ) ··· 473 475 def test_damage_player_by_name(self, kira: ToolContext): 474 476 result = call("damage", {"target": "Kira", "amount": 3}) 475 477 assert "3" in result 476 - char = load_character("default", kira.base_path) 478 + char = load_character("default") 477 479 assert char["state"]["hp"]["current"] == 9 478 480 479 481 def test_damage_with_type(self, kira: ToolContext): ··· 484 486 call("damage", {"target": "Kira", "amount": 5}) 485 487 result = call("heal", {"target": "Kira", "amount": 3}) 486 488 assert result 487 - char = load_character("default", kira.base_path) 489 + char = load_character("default") 488 490 assert char["state"]["hp"]["current"] == 10 489 491 490 492 def test_adjust_coins(self, kira: ToolContext): 491 493 result = call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) 492 494 assert "10" in result 493 - char = load_character("default", kira.base_path) 495 + char = load_character("default") 494 496 assert char["state"]["purse"]["gp"] == 10 495 497 assert char["state"]["purse"]["sp"] == 5 496 498 ··· 498 500 # Spending only gp; the zero-delta filter exercises the comprehension branch 499 501 call("adjust_coins", {"deltas": {"gp": 5}}) 500 502 result = call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) 501 - char = load_character("default", kira.base_path) 503 + char = load_character("default") 502 504 assert char["state"]["purse"]["gp"] == 2 503 505 assert "silver" not in result.lower() 504 506 ··· 618 620 assert "Auto-marked: Vera" in result 619 621 620 622 def test_auto_mark_cooldown_suppresses_near_repeats( 621 - self, ctx: ToolContext, 623 + self, ctx: ToolContext, tmp_path: Path, 622 624 ): 623 625 """A second set_scene with the same present entity within the 624 626 cooldown window should NOT append another Was entry.""" ··· 640 642 641 643 assert "Auto-marked" not in result2 642 644 content = ( 643 - ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Margit.md" 645 + tmp_path / "worlds" / ctx.world_id / "npcs" / "Margit.md" 644 646 ).read_text() 645 647 assert content.count("Met Mira at the candle shop") == 1 646 648 assert "Walked together to dinner" not in content ··· 670 672 671 673 assert "Auto-marked: Aldric" in result3 672 674 673 - def test_auto_mark_skips_operational_events(self, ctx: ToolContext): 675 + def test_auto_mark_skips_operational_events(self, ctx: ToolContext, tmp_path: Path): 674 676 """Session-lifecycle events (Session resumed, etc) must never land 675 677 in an entity's history.""" 676 678 call("establish", { ··· 685 687 686 688 assert "Auto-marked" not in result 687 689 content = ( 688 - ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md" 690 + tmp_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md" 689 691 ).read_text() 690 692 assert "Session resumed" not in content 691 693 ··· 702 704 result = call("set_scene", {}) 703 705 assert result == "No updates" 704 706 705 - def test_tune_writes_style_file(self, ctx: ToolContext): 707 + def test_tune_writes_style_file(self, ctx: ToolContext, tmp_path: Path): 706 708 result = call("tune", {"tuning": "Lean into intrigue and slow pacing."}) 707 709 assert "updated" in result.lower() 708 - style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 710 + style_path = tmp_path / "worlds" / ctx.world_id / "style.md" 709 711 assert style_path.exists() 710 712 assert "intrigue" in style_path.read_text() 711 713 ··· 720 722 }) 721 723 assert result == "SESSION_ENDED" 722 724 723 - def test_notify_dm_appends_to_queue(self, ctx: ToolContext): 725 + def test_notify_dm_appends_to_queue(self, ctx: ToolContext, tmp_path: Path): 724 726 result = call("notify_dm", {"message": "Background world has shifted"}) 725 727 assert "queued" in result.lower() 726 - path = ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 728 + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 727 729 assert path.exists() 728 730 assert "Background world has shifted" in path.read_text() 729 731
+48 -10
tests/test_mcp_server.py
··· 284 284 285 285 286 286 class TestPopulateIndex: 287 - """Cover the SRD-seeding helper without launching a real server.""" 287 + """Cover the SRD-seeding helper without launching a real server. 288 + 289 + Tests pass an explicit ``srd_root`` pointed at a tmp path so the 290 + real package rules directory isn't touched. 291 + """ 288 292 289 293 def test_no_srd_no_world_dir(self, tmp_path): 290 294 from unittest.mock import MagicMock ··· 292 296 from storied.mcp_server import _populate_index 293 297 294 298 vi = MagicMock() 295 - _populate_index(tmp_path, tmp_path / "worlds" / "missing", vi) 299 + _populate_index( 300 + tmp_path / "worlds" / "missing", 301 + vi, 302 + srd_root=tmp_path / "srd-missing", 303 + ) 296 304 # No SRD seed, no SRD sections, no world dir → nothing should be called 297 305 vi.reseed.assert_not_called() 298 306 vi.reindex_directory.assert_not_called() ··· 305 313 world_dir = tmp_path / "worlds" / "test" 306 314 world_dir.mkdir(parents=True) 307 315 vi = MagicMock() 308 - _populate_index(tmp_path, world_dir, vi) 316 + _populate_index( 317 + world_dir, vi, srd_root=tmp_path / "srd-missing", 318 + ) 309 319 vi.reindex_directory.assert_called_once_with(world_dir, source="world") 310 320 311 321 def test_srd_sections_dir(self, tmp_path): ··· 313 323 314 324 from storied.mcp_server import _populate_index 315 325 316 - srd_dir = tmp_path / "rules" / "srd-5.2.1" / "sections" 326 + srd_root = tmp_path / "srd-5.2.1" 327 + srd_dir = srd_root / "sections" 317 328 srd_dir.mkdir(parents=True) 318 329 world_dir = tmp_path / "worlds" / "test" 319 330 vi = MagicMock() 320 - _populate_index(tmp_path, world_dir, vi) 321 - # SRD sections present → reindex SRD; no world dir → no second call 331 + _populate_index(world_dir, vi, srd_root=srd_root) 332 + # SRD sections present → reindex SRD; no user layer, no world dir 322 333 assert vi.reindex_directory.call_count == 1 323 334 vi.reindex_directory.assert_called_with(srd_dir, source="srd") 324 335 336 + def test_user_layer_indexed(self, tmp_path): 337 + """When the user homebrew directory exists, _populate_index 338 + reindexes it with source='user' after the shipped SRD.""" 339 + from unittest.mock import MagicMock 340 + 341 + from storied.mcp_server import _populate_index 342 + 343 + # The autouse fixture sets _user_rules_home = tmp_path / "rules". 344 + user_dir = tmp_path / "rules" 345 + user_dir.mkdir(parents=True) 346 + (user_dir / "monsters").mkdir() 347 + (user_dir / "monsters" / "homebrew.md").write_text("# Homebrew") 348 + 349 + world_dir = tmp_path / "worlds" / "test" 350 + world_dir.mkdir(parents=True) 351 + 352 + vi = MagicMock() 353 + _populate_index( 354 + world_dir, vi, srd_root=tmp_path / "srd-missing", 355 + ) 356 + # Should have indexed user and world, in that order 357 + calls = vi.reindex_directory.call_args_list 358 + assert len(calls) == 2 359 + assert calls[0] == ((user_dir,), {"source": "user"}) 360 + assert calls[1] == ((world_dir,), {"source": "world"}) 361 + 325 362 def test_flip_helpers_no_op_when_root_unset(self): 326 363 """The combat-tag flip helpers must not crash when no top-level 327 364 server has been registered (e.g. when the combat module is imported ··· 345 382 346 383 from storied.mcp_server import _populate_index 347 384 348 - srd_seed = tmp_path / "rules" / "srd-5.2.1" / "search.db" 349 - srd_seed.parent.mkdir(parents=True) 385 + srd_root = tmp_path / "srd-5.2.1" 386 + srd_root.mkdir(parents=True) 387 + srd_seed = srd_root / "search.db" 350 388 srd_seed.write_bytes(b"sqlite stub") 351 389 # Also create the sections dir to verify the seed wins 352 - (tmp_path / "rules" / "srd-5.2.1" / "sections").mkdir() 390 + (srd_root / "sections").mkdir() 353 391 world_dir = tmp_path / "worlds" / "test" 354 392 vi = MagicMock() 355 - _populate_index(tmp_path, world_dir, vi) 393 + _populate_index(world_dir, vi, srd_root=srd_root) 356 394 vi.reseed.assert_called_once_with(srd_seed) 357 395 # When the seed exists, we don't also reindex SRD sections 358 396 vi.reindex_directory.assert_not_called()
+28 -33
tests/test_notifications.py
··· 8 8 9 9 10 10 @pytest.fixture 11 - def world(tmp_path: Path) -> tuple[str, Path]: 12 - """Return (world_id, base_path) with world directory created.""" 11 + def world(tmp_path: Path) -> str: 12 + """Return the world_id with world directory created under the 13 + autouse-isolated data home.""" 13 14 world_dir = tmp_path / "worlds" / "test-world" 14 15 world_dir.mkdir(parents=True) 15 - return "test-world", tmp_path 16 + return "test-world" 16 17 17 18 18 19 class TestAppendAndDrain: 19 - def test_drain_empty(self, world: tuple[str, Path]): 20 - world_id, base_path = world 21 - assert notifications.drain(world_id, base_path) == [] 20 + def test_drain_empty(self, world: str): 21 + assert notifications.drain(world) == [] 22 22 23 - def test_append_then_drain(self, world: tuple[str, Path]): 24 - world_id, base_path = world 25 - notifications.append(world_id, base_path, "Something happened") 23 + def test_append_then_drain(self, world: str): 24 + notifications.append(world, "Something happened") 26 25 27 - messages = notifications.drain(world_id, base_path) 26 + messages = notifications.drain(world) 28 27 assert messages == ["Something happened"] 29 28 30 - def test_drain_clears(self, world: tuple[str, Path]): 31 - world_id, base_path = world 32 - notifications.append(world_id, base_path, "First") 29 + def test_drain_clears(self, world: str): 30 + notifications.append(world, "First") 33 31 34 - notifications.drain(world_id, base_path) 35 - assert notifications.drain(world_id, base_path) == [] 32 + notifications.drain(world) 33 + assert notifications.drain(world) == [] 36 34 37 - def test_multiple_messages(self, world: tuple[str, Path]): 38 - world_id, base_path = world 39 - notifications.append(world_id, base_path, "First thing") 40 - notifications.append(world_id, base_path, "Second thing") 41 - notifications.append(world_id, base_path, "Third thing") 35 + def test_multiple_messages(self, world: str): 36 + notifications.append(world, "First thing") 37 + notifications.append(world, "Second thing") 38 + notifications.append(world, "Third thing") 42 39 43 - messages = notifications.drain(world_id, base_path) 40 + messages = notifications.drain(world) 44 41 assert messages == ["First thing", "Second thing", "Third thing"] 45 42 46 - def test_file_removed_after_drain(self, world: tuple[str, Path]): 47 - world_id, base_path = world 48 - notifications.append(world_id, base_path, "Temporary") 43 + def test_file_removed_after_drain(self, world: str, tmp_path: Path): 44 + notifications.append(world, "Temporary") 49 45 50 - notifications.drain(world_id, base_path) 51 - path = base_path / "worlds" / "test-world" / "dm_notifications.md" 46 + notifications.drain(world) 47 + path = tmp_path / "worlds" / "test-world" / "dm_notifications.md" 52 48 assert not path.exists() 53 49 54 - def test_creates_world_dir_if_missing(self, tmp_path: Path): 55 - notifications.append("new-world", tmp_path, "Hello") 50 + def test_creates_world_dir_if_missing(self): 51 + notifications.append("new-world", "Hello") 56 52 57 - messages = notifications.drain("new-world", tmp_path) 53 + messages = notifications.drain("new-world") 58 54 assert messages == ["Hello"] 59 55 60 56 def test_drain_empty_existing_file_returns_empty( 61 - self, world: tuple[str, Path], 57 + self, world: str, tmp_path: Path, 62 58 ): 63 59 """An existing notifications file with only whitespace drains 64 60 as empty and is removed.""" 65 - world_id, base_path = world 66 - path = base_path / "worlds" / "test-world" / "dm_notifications.md" 61 + path = tmp_path / "worlds" / "test-world" / "dm_notifications.md" 67 62 path.write_text(" \n \n") 68 63 69 - assert notifications.drain(world_id, base_path) == [] 64 + assert notifications.drain(world) == [] 70 65 assert not path.exists()
+32 -55
tests/test_planner.py
··· 61 61 class TestEntityRichness: 62 62 """Tests for richness scoring.""" 63 63 64 - def test_empty_entity_scores_zero(self, ctx: ToolContext): 64 + def test_empty_entity_scores_zero(self, ctx: ToolContext, tmp_path: Path): 65 65 call_tool(establish, entity_type="npcs", name="Empty") 66 - path = ctx.base_path / "worlds/test-world/npcs/Empty.md" 66 + path = tmp_path / "worlds/test-world/npcs/Empty.md" 67 67 assert entity_richness(path) == 0.0 68 68 69 - def test_description_only(self, ctx: ToolContext): 69 + def test_description_only(self, ctx: ToolContext, tmp_path: Path): 70 70 call_tool( 71 71 establish, 72 72 entity_type="npcs", 73 73 name="Described", 74 74 description="A tall warrior.", 75 75 ) 76 - path = ctx.base_path / "worlds/test-world/npcs/Described.md" 76 + path = tmp_path / "worlds/test-world/npcs/Described.md" 77 77 assert entity_richness(path) == pytest.approx(0.2) 78 78 79 - def test_fully_rich_entity(self, ctx: ToolContext): 79 + def test_fully_rich_entity(self, ctx: ToolContext, tmp_path: Path): 80 80 call_tool( 81 81 establish, 82 82 entity_type="npcs", ··· 92 92 name="Rich NPC", 93 93 event="Something happened", 94 94 ) 95 - path = ctx.base_path / "worlds/test-world/npcs/Rich NPC.md" 95 + path = tmp_path / "worlds/test-world/npcs/Rich NPC.md" 96 96 score = entity_richness(path) 97 97 assert score == pytest.approx(1.0) 98 98 99 - def test_partial_richness(self, ctx: ToolContext): 99 + def test_partial_richness(self, ctx: ToolContext, tmp_path: Path): 100 100 call_tool( 101 101 establish, 102 102 entity_type="npcs", ··· 104 104 description="Has description and knows.", 105 105 knows=["A secret"], 106 106 ) 107 - path = ctx.base_path / "worlds/test-world/npcs/Partial.md" 107 + path = tmp_path / "worlds/test-world/npcs/Partial.md" 108 108 score = entity_richness(path) 109 109 # description (0.2) + knows (0.2) = 0.4 110 110 assert score == pytest.approx(0.4) 111 111 112 - def test_wikilinks_contribute(self, ctx: ToolContext): 112 + def test_wikilinks_contribute(self, ctx: ToolContext, tmp_path: Path): 113 113 call_tool( 114 114 establish, 115 115 entity_type="npcs", 116 116 name="Linked", 117 117 description="Hangs out at [[The Tavern]] with [[Bob]].", 118 118 ) 119 - path = ctx.base_path / "worlds/test-world/npcs/Linked.md" 119 + path = tmp_path / "worlds/test-world/npcs/Linked.md" 120 120 score = entity_richness(path) 121 121 # description (0.2) + wikilinks (0.1) = 0.3 122 122 assert score == pytest.approx(0.3) ··· 130 130 "location": "Town Square", 131 131 "body": "", 132 132 } 133 - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) 133 + nearby = find_nearby_entities(session, "test-world") 134 134 names = {name for name, _ in nearby} 135 135 assert "Old Gregor" in names 136 136 assert "Millford" in names ··· 140 140 "location": "Town Square", 141 141 "body": "## Present\n- [[Thin NPC]]", 142 142 } 143 - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) 143 + nearby = find_nearby_entities(session, "test-world") 144 144 names = {name for name, _ in nearby} 145 145 assert "Thin NPC" in names 146 146 ··· 149 149 "location": "Town Square", 150 150 "body": "", 151 151 } 152 - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) 152 + nearby = find_nearby_entities(session, "test-world") 153 153 names = {name for name, _ in nearby} 154 154 assert "Town Square" in names 155 155 ··· 158 158 "location": "Town Square", 159 159 "body": "## Present\n- [[Old Gregor]]", 160 160 } 161 - nearby = find_nearby_entities(session, "test-world", populated_world.base_path) 161 + nearby = find_nearby_entities(session, "test-world") 162 162 names = [name for name, _ in nearby] 163 163 assert names.count("Old Gregor") == 1 164 164 165 165 def test_empty_session(self, ctx: ToolContext): 166 166 session = {"body": ""} 167 - nearby = find_nearby_entities(session, "test-world", ctx.base_path) 167 + nearby = find_nearby_entities(session, "test-world") 168 168 assert nearby == [] 169 169 170 170 ··· 215 215 "location": "Town Square", 216 216 "body": "## Situation\nThe player just arrived.\n\n## Open Threads\n- Find the missing cat", 217 217 }, 218 - populated_world.base_path, 219 218 ) 220 219 context = build_planning_context( 221 220 world_id=populated_world.world_id, 222 221 player_id=populated_world.player_id, 223 - base_path=populated_world.base_path, 224 - candidates=[("Thin NPC", populated_world.base_path / "worlds/test-world/npcs/Thin NPC.md")], 222 + candidates=[], 225 223 ) 226 224 assert "Town Square" in context 227 225 assert "missing cat" in context 228 226 229 - def test_includes_candidate_content(self, populated_world: ToolContext): 227 + def test_includes_candidate_content( 228 + self, populated_world: ToolContext, tmp_path: Path, 229 + ): 230 230 save_session( 231 231 "default", 232 232 {"location": "Town Square", "body": ""}, 233 - populated_world.base_path, 234 233 ) 235 - path = populated_world.base_path / "worlds/test-world/npcs/Thin NPC.md" 234 + path = tmp_path / "worlds/test-world/npcs/Thin NPC.md" 236 235 context = build_planning_context( 237 236 world_id=populated_world.world_id, 238 237 player_id=populated_world.player_id, 239 - base_path=populated_world.base_path, 240 238 candidates=[("Thin NPC", path)], 241 239 ) 242 240 assert "Thin NPC" in context ··· 246 244 save_session( 247 245 "default", 248 246 {"location": "Town Square", "body": ""}, 249 - populated_world.base_path, 250 247 ) 251 248 context = build_planning_context( 252 249 world_id=populated_world.world_id, 253 250 player_id=populated_world.player_id, 254 - base_path=populated_world.base_path, 255 251 candidates=[], 256 252 ) 257 253 assert "No entities" in context ··· 260 256 save_session( 261 257 "default", 262 258 {"location": "Town Square", "body": ""}, 263 - populated_world.base_path, 264 259 ) 265 260 populated_world.campaign_log.append_entry("Fought three goblins in the clearing", "5 rounds") 266 261 populated_world.campaign_log.append_entry("Spotted two lookouts near the old mill", "30 min") ··· 268 263 context = build_planning_context( 269 264 world_id=populated_world.world_id, 270 265 player_id=populated_world.player_id, 271 - base_path=populated_world.base_path, 272 266 candidates=[], 273 267 ) 274 268 assert "Recent Events" in context ··· 286 280 "location": "Town Square", 287 281 "body": "## Present\n- [[Thin NPC]]", 288 282 }, 289 - populated_world.base_path, 290 283 ) 291 284 result = plan_world( 292 285 world_id=populated_world.world_id, 293 286 player_id=populated_world.player_id, 294 - base_path=populated_world.base_path, 295 287 dry_run=True, 296 288 ) 297 289 assert isinstance(result, PlanResult) ··· 304 296 save_session( 305 297 "default", 306 298 {"location": "Town Square", "body": ""}, 307 - populated_world.base_path, 308 299 ) 309 300 result = plan_world( 310 301 world_id=populated_world.world_id, 311 302 player_id=populated_world.player_id, 312 - base_path=populated_world.base_path, 313 303 dry_run=True, 314 304 threshold=0.0, 315 305 ) ··· 319 309 save_session( 320 310 "default", 321 311 {"location": "Town Square", "body": ""}, 322 - populated_world.base_path, 323 312 ) 324 313 result = plan_world( 325 314 world_id=populated_world.world_id, 326 315 player_id=populated_world.player_id, 327 - base_path=populated_world.base_path, 328 316 dry_run=True, 329 317 max_entities=1, 330 318 ) ··· 334 322 result = plan_world( 335 323 world_id=ctx.world_id, 336 324 player_id=ctx.player_id, 337 - base_path=ctx.base_path, 338 325 dry_run=True, 339 326 ) 340 327 assert len(result.candidates) == 0 ··· 347 334 "location": "Town Square", 348 335 "body": "## Present\n- [[Thin NPC]]", 349 336 }, 350 - populated_world.base_path, 351 337 ) 352 338 353 339 # Mock subprocess returning a result event ··· 368 354 result = plan_world( 369 355 world_id=populated_world.world_id, 370 356 player_id=populated_world.player_id, 371 - base_path=populated_world.base_path, 372 357 model="claude-opus-4-6", 373 358 ) 374 359 ··· 385 370 "location": "Town Square", 386 371 "body": "## Present\n- [[Thin NPC]]", 387 372 }, 388 - populated_world.base_path, 389 373 ) 390 374 391 375 # Stream with tool_use events followed by result ··· 417 401 result = plan_world( 418 402 world_id=populated_world.world_id, 419 403 player_id=populated_world.player_id, 420 - base_path=populated_world.base_path, 421 404 model="claude-opus-4-6", 422 405 ) 423 406 ··· 428 411 429 412 430 413 class TestFindEntitiesWithWill: 431 - def test_returns_empty_when_world_missing(self, ctx: ToolContext, tmp_path: Path): 432 - results = _find_entities_with_will("nonexistent", tmp_path) 414 + def test_returns_empty_when_world_missing(self, ctx: ToolContext): 415 + results = _find_entities_with_will("nonexistent") 433 416 assert results == [] 434 417 435 418 def test_finds_entities_with_will_triggers(self, populated_world: ToolContext): ··· 448 431 description="No triggers.", 449 432 ) 450 433 results = _find_entities_with_will( 451 - populated_world.world_id, populated_world.base_path, 434 + populated_world.world_id, 452 435 ) 453 436 names = {name for name, _ in results} 454 437 assert "Triggered" in names ··· 460 443 # The fixture creates npcs and locations but not items/factions/threads. 461 444 # The function should iterate without crashing. 462 445 results = _find_entities_with_will( 463 - populated_world.world_id, populated_world.base_path, 446 + populated_world.world_id, 464 447 ) 465 448 assert isinstance(results, list) 466 449 ··· 468 451 class TestBuildTickContext: 469 452 def test_includes_current_time(self, populated_world: ToolContext): 470 453 ctx_str = build_tick_context( 471 - populated_world.world_id, populated_world.player_id, 472 - populated_world.base_path, entities=[], 454 + populated_world.world_id, populated_world.player_id, entities=[], 473 455 ) 474 456 assert "Current Game Time" in ctx_str 475 457 ··· 477 459 save_session("default", { 478 460 "location": "Town Square", 479 461 "body": "## Present\n- [[Old Gregor]]", 480 - }, populated_world.base_path) 462 + }) 481 463 482 464 ctx_str = build_tick_context( 483 - populated_world.world_id, populated_world.player_id, 484 - populated_world.base_path, entities=[], 465 + populated_world.world_id, populated_world.player_id, entities=[], 485 466 ) 486 467 assert "Town Square" in ctx_str 487 468 assert "Old Gregor" in ctx_str ··· 494 475 "Found the secret door", "5 min", 495 476 ) 496 477 ctx_str = build_tick_context( 497 - populated_world.world_id, populated_world.player_id, 498 - populated_world.base_path, entities=[], 478 + populated_world.world_id, populated_world.player_id, entities=[], 499 479 ) 500 480 assert "Recent Events" in ctx_str 501 481 assert "Met the merchant" in ctx_str ··· 509 489 will=["If alone → emerge"], 510 490 ) 511 491 triggers = _find_entities_with_will( 512 - populated_world.world_id, populated_world.base_path, 492 + populated_world.world_id, 513 493 ) 514 494 ctx_str = build_tick_context( 515 - populated_world.world_id, populated_world.player_id, 516 - populated_world.base_path, entities=triggers, 495 + populated_world.world_id, populated_world.player_id, entities=triggers, 517 496 ) 518 497 assert "Active Triggers" in ctx_str 519 498 assert "Lurker" in ctx_str ··· 530 509 531 510 def test_init_stores_state(self, tmp_path: Path): 532 511 ticker = BackgroundTicker( 533 - world_id="test", player_id="default", base_path=tmp_path, 512 + world_id="test", player_id="default", 534 513 ) 535 514 assert ticker._world_id == "test" 536 515 assert ticker._last_tick_day == 0 ··· 542 521 ticker = BackgroundTicker( 543 522 world_id=populated_world.world_id, 544 523 player_id=populated_world.player_id, 545 - base_path=populated_world.base_path, 546 524 ) 547 525 ticker._last_tick_day = populated_world.campaign_log.current_day 548 526 ticker.maybe_tick(populated_world.campaign_log) ··· 555 533 ticker = BackgroundTicker( 556 534 world_id=ctx.world_id, 557 535 player_id=ctx.player_id, 558 - base_path=ctx.base_path, 559 536 ) 560 537 # Advance the day so the day-changed guard passes 561 538 ctx.campaign_log.append_entry("Travel", "18 hours") ··· 565 542 566 543 def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): 567 544 ticker = BackgroundTicker( 568 - world_id="test", player_id="default", base_path=tmp_path, 545 + world_id="test", player_id="default", 569 546 ) 570 547 assert ticker.pop_result() is None
+1 -1
tests/test_sandbox.py
··· 157 157 sigs = build_tool_signatures() 158 158 159 159 # None of the Dependency-class instances should appear as defaults 160 - for marker in ("Combat()", "Lore()", "StorageRoot()", "Player()", 160 + for marker in ("Combat()", "Lore()", "Player()", 161 161 "Timekeeper()", "Entities()", "World()"): 162 162 assert marker not in sigs
-6
tests/test_seeder.py
··· 54 54 purse={"gp": 50}, 55 55 equipment={"on_person": ["Longsword", "Chain mail", "Shield"]}, 56 56 backstory="A former soldier haunted by a battle gone wrong.", 57 - base_path=tmp_path, 58 57 ) 59 58 return tmp_path 60 59 ··· 83 82 seed_world( 84 83 world_id="default", 85 84 player_id="default", 86 - base_path=character_world, 87 85 ) 88 86 89 87 # Verify the subprocess was called with claude args ··· 133 131 result = seed_world( 134 132 world_id="default", 135 133 player_id="default", 136 - base_path=character_world, 137 134 ) 138 135 139 136 assert result.tool_calls == 2 ··· 159 156 result = seed_world( 160 157 world_id="default", 161 158 player_id="default", 162 - base_path=character_world, 163 159 ) 164 160 165 161 assert isinstance(result, SeedResult) ··· 200 196 seed_world( 201 197 world_id="default", 202 198 player_id="default", 203 - base_path=character_world, 204 199 on_progress=progress_messages.append, 205 200 ) 206 201 ··· 210 205 result = seed_world( 211 206 world_id="default", 212 207 player_id="default", 213 - base_path=tmp_path, 214 208 ) 215 209 assert isinstance(result, SeedResult) 216 210 assert result.tool_calls == 0
+19 -25
tests/test_session.py
··· 39 39 "- Find the missing merchant" 40 40 ), 41 41 } 42 - save_session("test-player", data, session_base) 43 - return load_session("test-player", session_base) 42 + save_session("test-player", data) 43 + return load_session("test-player") 44 44 45 45 46 46 # ── Parsing ────────────────────────────────────────────────────────────── ··· 67 67 68 68 class TestLoadSaveSession: 69 69 def test_save_and_load(self, session_base: Path): 70 - save_session("test-player", {"location": "docks", "body": "At the docks."}, session_base) 71 - loaded = load_session("test-player", session_base) 70 + save_session("test-player", {"location": "docks", "body": "At the docks."}) 71 + loaded = load_session("test-player") 72 72 assert loaded["location"] == "docks" 73 73 assert "At the docks" in loaded["body"] 74 74 75 75 def test_load_nonexistent(self, session_base: Path): 76 - assert load_session("nobody", session_base) is None 76 + assert load_session("nobody") is None 77 77 78 78 def test_save_adds_timestamp(self, session_base: Path): 79 - save_session("test-player", {"body": ""}, session_base) 80 - loaded = load_session("test-player", session_base) 79 + save_session("test-player", {"body": ""}) 80 + loaded = load_session("test-player") 81 81 assert "updated" in loaded 82 82 83 83 ··· 89 89 result = update_session( 90 90 "test-player", 91 91 {"situation": "Escaped to the harbor."}, 92 - session_base, 93 92 ) 94 93 assert "Updated situation" in result 95 - loaded = load_session("test-player", session_base) 94 + loaded = load_session("test-player") 96 95 assert "Escaped to the harbor" in loaded["body"] 97 96 98 97 def test_update_threads_from_list(self, session_base: Path, saved_session: dict): 99 98 update_session( 100 99 "test-player", 101 100 {"threads": ["New thread one", "New thread two"]}, 102 - session_base, 103 101 ) 104 - loaded = load_session("test-player", session_base) 102 + loaded = load_session("test-player") 105 103 assert "- New thread one" in loaded["body"] 106 104 assert "- New thread two" in loaded["body"] 107 105 ··· 109 107 update_session( 110 108 "test-player", 111 109 {"present": ["[[Captain Harrik]]"]}, 112 - session_base, 113 110 ) 114 - loaded = load_session("test-player", session_base) 111 + loaded = load_session("test-player") 115 112 assert "Captain Harrik" in loaded["body"] 116 113 117 114 def test_update_location(self, session_base: Path, saved_session: dict): 118 115 result = update_session( 119 116 "test-player", 120 117 {"location": "harbor"}, 121 - session_base, 122 118 ) 123 119 assert "location = harbor" in result 124 - loaded = load_session("test-player", session_base) 120 + loaded = load_session("test-player") 125 121 assert loaded["location"] == "harbor" 126 122 127 123 def test_creates_session_if_missing(self, session_base: Path): 128 124 update_session( 129 125 "test-player", 130 126 {"situation": "Starting fresh."}, 131 - session_base, 132 127 ) 133 - loaded = load_session("test-player", session_base) 128 + loaded = load_session("test-player") 134 129 assert "Starting fresh" in loaded["body"] 135 130 136 131 def test_no_changes(self, session_base: Path, saved_session: dict): 137 - result = update_session("test-player", {}, session_base) 132 + result = update_session("test-player", {}) 138 133 assert "No changes" in result 139 134 140 135 def test_appends_new_section(self, session_base: Path): 141 - save_session("test-player", {"body": ""}, session_base) 136 + save_session("test-player", {"body": ""}) 142 137 update_session( 143 138 "test-player", 144 139 {"situation": "Brand new situation."}, 145 - session_base, 146 140 ) 147 - loaded = load_session("test-player", session_base) 141 + loaded = load_session("test-player") 148 142 assert "## Situation" in loaded["body"] 149 143 assert "Brand new situation" in loaded["body"] 150 144 ··· 172 166 npc_dir = tmp_path / "worlds" / "default" / "npcs" 173 167 npc_dir.mkdir(parents=True) 174 168 (npc_dir / "Vera Blackwater.md").write_text("---\nname: Vera\n---\n") 175 - result = resolve_wiki_link("Vera Blackwater", "default", tmp_path) 169 + result = resolve_wiki_link("Vera Blackwater", "default") 176 170 assert result is not None 177 171 assert result.name == "Vera Blackwater.md" 178 172 ··· 180 174 loc_dir = tmp_path / "worlds" / "default" / "locations" 181 175 loc_dir.mkdir(parents=True) 182 176 (loc_dir / "The Rusty Anchor.md").write_text("---\nname: The Rusty Anchor\n---\n") 183 - result = resolve_wiki_link("The Rusty Anchor", "default", tmp_path) 177 + result = resolve_wiki_link("The Rusty Anchor", "default") 184 178 assert result is not None 185 179 186 180 def test_not_found(self, tmp_path: Path): 187 181 (tmp_path / "worlds" / "default").mkdir(parents=True) 188 - assert resolve_wiki_link("Nobody", "default", tmp_path) is None 182 + assert resolve_wiki_link("Nobody", "default") is None 189 183 190 184 def test_priority_order(self, tmp_path: Path): 191 185 """NPCs are checked before locations.""" ··· 193 187 d = tmp_path / "worlds" / "default" / entity_type 194 188 d.mkdir(parents=True) 195 189 (d / "Ambiguous.md").write_text(f"---\ntype: {entity_type}\n---\n") 196 - result = resolve_wiki_link("Ambiguous", "default", tmp_path) 190 + result = resolve_wiki_link("Ambiguous", "default") 197 191 assert "npcs" in str(result) 198 192 199 193
+10 -8
tests/test_tune.py
··· 1 1 """Tests for the DM style tuning system.""" 2 2 3 + from pathlib import Path 4 + 3 5 from storied.tools import ToolContext 4 6 from storied.tools.scene import tune as _tune 5 7 ··· 13 15 class TestTune: 14 16 """Tests for the tune tool.""" 15 17 16 - def test_tune_creates_style_file(self, ctx: ToolContext): 18 + def test_tune_creates_style_file(self, ctx: ToolContext, tmp_path: Path): 17 19 tune("Lean into intrigue and social encounters.") 18 20 19 - style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 21 + style_path = tmp_path / "worlds" / ctx.world_id / "style.md" 20 22 assert style_path.exists() 21 23 22 - def test_tune_writes_content(self, ctx: ToolContext): 24 + def test_tune_writes_content(self, ctx: ToolContext, tmp_path: Path): 23 25 tune("More exploration, less combat.") 24 26 25 - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 27 + content = (tmp_path / "worlds" / ctx.world_id / "style.md").read_text() 26 28 assert "More exploration, less combat." in content 27 29 28 - def test_tune_has_heading(self, ctx: ToolContext): 30 + def test_tune_has_heading(self, ctx: ToolContext, tmp_path: Path): 29 31 tune("Keep pacing slow.") 30 32 31 - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 33 + content = (tmp_path / "worlds" / ctx.world_id / "style.md").read_text() 32 34 assert content.startswith("# Style\n") 33 35 34 - def test_tune_replaces_existing(self, ctx: ToolContext): 36 + def test_tune_replaces_existing(self, ctx: ToolContext, tmp_path: Path): 35 37 tune("Lots of combat.") 36 38 tune("Actually, less combat.") 37 39 38 - content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 40 + content = (tmp_path / "worlds" / ctx.world_id / "style.md").read_text() 39 41 assert "Actually, less combat." in content 40 42 assert "Lots of combat." not in content 41 43
worlds/.gitkeep

This is a binary file and will not be displayed.