A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Tighten quality gates and refactor the test_execute_tool fixtures

A repo-review pass turned up a few drift points worth fixing in one
go:

- The 100% coverage claim in CLAUDE.md didn't match pyproject's 95%
gate or the silent cli.py / srd/* omits. Documented the exclusions
honestly and moved the gate into pytest addopts at the current
floor (96%) — bumping the floor as coverage improves is the new
rule.
- No pre-commit was running, so 119 latent ruff issues had piled up.
Cleared them, added PT/RUF, and ignored RUF001-3 since em dashes
are house style.
- Stood up .pre-commit-config.yaml + loq.toml (with a baseline for
the 14 pre-existing oversized files) so future regressions get
caught at commit time.
- Added pyright matching the docket/uncalled-for setup. Strict mode
was 1300+ errors mostly from dict/yaml-heavy code, so settled on
basic + per-file pragmas for the FastMCP DI markers (`world: str
= World()` looks like a type error but FastMCP resolves it at
call time) and the test files where load_character() returns
Optional and tests subscript directly. Real null-narrowing gaps
in display.py / planner.py / cli.py / srd/extract.py are fixed.
- Refactored test_execute_tool.py to lift the asyncio + Client +
combat-flip scaffolding into mcp_call / mcp_call_in_combat
fixtures, deleting four inline async _run blocks with try/finally
cleanup. The for i in range(3) loop became a fixture too.
- Added pytest-xdist (-n 4) and codespell (with the SRD content
skipped — codespell really doesn't like Wight, Sting, or Cant).
- Renamed prompts/cold-draft.md → arc-cold-draft.md so it sorts
next to its sibling arc-architect.md (they're a two-stage pipeline
for the planner's plot_arc pass).
- Deleted plans/architecture-implementation.md (Milestone 0–4 doc
from before any code existed; predates the streaming-CLI pivot
away from Textual).

CLI thinning into src/storied/commands/* and the actual climb from
96% → 100% coverage are deferred — both are their own focused
workstreams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+3223 -2026
+35
.pre-commit-config.yaml
··· 1 + repos: 2 + - repo: https://github.com/pre-commit/pre-commit-hooks 3 + rev: v4.5.0 4 + hooks: 5 + - id: trailing-whitespace 6 + - id: end-of-file-fixer 7 + - id: check-yaml 8 + - id: check-toml 9 + - id: check-added-large-files 10 + 11 + - repo: https://github.com/codespell-project/codespell 12 + rev: v2.2.6 13 + hooks: 14 + - id: codespell 15 + 16 + - repo: https://github.com/astral-sh/ruff-pre-commit 17 + rev: v0.14.14 18 + hooks: 19 + - id: ruff 20 + args: [--fix, --exit-non-zero-on-fix, --show-fixes] 21 + - id: ruff-format 22 + 23 + - repo: local 24 + hooks: 25 + - id: loq 26 + name: loq (file size limits) 27 + entry: uv run loq check 28 + language: system 29 + pass_filenames: false 30 + - id: pyright 31 + name: pyright (source and tests) 32 + entry: uv run pyright src/storied tests 33 + language: system 34 + types: [python] 35 + pass_filenames: false
+10 -1
CLAUDE.md
··· 144 144 145 145 - Python 3.12+, full type hints 146 146 - TDD: write tests first 147 - - 100% test coverage required 147 + - Target 100% test coverage with two documented exclusions: 148 + - `src/storied/cli.py` — thin argparse + dispatch only; the real 149 + work belongs in domain modules that are tested directly (the 150 + CLI is currently still doing too much; thinning is in flight) 151 + - `src/storied/srd/*` — one-shot PDF pipeline run via `make srd` 152 + 153 + The pytest gate (`--cov-fail-under` in `pyproject.toml`) is set to 154 + the current floor on the included code, not the target. Raise it as 155 + coverage improves; never lower the floor without a specific reason 156 + that's worth writing down. 148 157 - Use `uv sync` to install dependencies 149 158 150 159 **Run commands directly** - the `.envrc` activates the venv, so use `storied` not `.venv/bin/storied` or `uv run storied`.
+71
loq.toml
··· 1 + default_max_lines = 500 2 + respect_gitignore = true 3 + 4 + # Exclude generated content and curated data files where the line count 5 + # is incidental: uv.lock, the SRD section markdown produced by `make srd`, 6 + # the long-form DM system prompt, and the curated phoneme-inventory 7 + # literals. Per-file Python violations are tracked via `loq baseline` 8 + # rules below (regenerate with `uv run loq baseline`). 9 + exclude = [ 10 + "**/uv.lock", 11 + "**/.git/**", 12 + "**/rules/**", 13 + "**/prompts/dm-system.md", 14 + "**/src/storied/names/data/inventories.py", 15 + ] 16 + 17 + [[rules]] 18 + path = "src/storied/engine.py" 19 + max_lines = 636 20 + 21 + [[rules]] 22 + path = "src/storied/cli.py" 23 + max_lines = 1265 24 + 25 + [[rules]] 26 + path = "tests/test_mcp_server.py" 27 + max_lines = 631 28 + 29 + [[rules]] 30 + path = "tests/test_character.py" 31 + max_lines = 1387 32 + 33 + [[rules]] 34 + path = "src/storied/tools/entities.py" 35 + max_lines = 703 36 + 37 + [[rules]] 38 + path = "src/storied/log.py" 39 + max_lines = 575 40 + 41 + [[rules]] 42 + path = "tests/test_search.py" 43 + max_lines = 501 44 + 45 + [[rules]] 46 + path = "tests/test_initiative.py" 47 + max_lines = 530 48 + 49 + [[rules]] 50 + path = "src/storied/tools/character.py" 51 + max_lines = 611 52 + 53 + [[rules]] 54 + path = "tests/test_planner.py" 55 + max_lines = 592 56 + 57 + [[rules]] 58 + path = "src/storied/character/operations.py" 59 + max_lines = 518 60 + 61 + [[rules]] 62 + path = "tests/test_execute_tool.py" 63 + max_lines = 895 64 + 65 + [[rules]] 66 + path = "src/storied/planner.py" 67 + max_lines = 775 68 + 69 + [[rules]] 70 + path = "tests/test_entities.py" 71 + max_lines = 786
-386
plans/architecture-implementation.md
··· 1 - # Storied: AI-Powered Text Adventure RPG 2 - 3 - ## Concept 4 - A text-based storytelling RPG where a frontier LLM serves as the DM, embracing creative generation to weave compelling narratives. The world builds itself lazily as players explore, with all content persisted as markdown files for continuity. 5 - 6 - ## Key Decisions 7 - - **Stack**: Python (Textual for TUI, future web interface) 8 - - **Players**: Single-player first, architected for multiplayer 9 - - **Mechanics**: Adaptive/light - AI decides when dice matter, rolls shown contextually 10 - - **Dice modes**: Implicit (AI rolls), or player brings IRL dice 11 - - **Interface**: TUI first, API-based core enables future web 12 - 13 - --- 14 - 15 - ## Architecture Overview 16 - 17 - ``` 18 - ┌─────────────────────────────────────────────────────────┐ 19 - │ TUI Interface │ 20 - │ ┌─────────────────┐ ┌──────────┐ ┌──────────────────┐ │ 21 - │ │ Narrative Pane │ │ Dice │ │ Character Status │ │ 22 - │ │ │ │ Display │ │ │ │ 23 - │ └─────────────────┘ └──────────┘ └──────────────────┘ │ 24 - │ ┌─────────────────────────────────────────────────────┐│ 25 - │ │ Input Area ││ 26 - │ └─────────────────────────────────────────────────────┘│ 27 - └─────────────────────────────────────────────────────────┘ 28 - 29 - 30 - ┌─────────────────────────────────────────────────────────┐ 31 - │ DM Engine Core │ 32 - │ • Interprets player actions │ 33 - │ • Maintains narrative context │ 34 - │ • Invokes rules when appropriate │ 35 - │ • Reads/writes world files │ 36 - │ • Generates tools on demand │ 37 - └─────────────────────────────────────────────────────────┘ 38 - │ │ │ 39 - ▼ ▼ ▼ 40 - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 41 - │ World State │ │ Rules Engine│ │ Tool System │ 42 - │ (Markdown) │ │ (5e SRD) │ │ (Generated) │ 43 - └─────────────┘ └─────────────┘ └─────────────┘ 44 - ``` 45 - 46 - --- 47 - 48 - ## Component Details 49 - 50 - ### 1. DM Engine Core 51 - The heart of the system - an agentic LLM loop that: 52 - - Receives player input 53 - - Loads relevant world context (locations, NPCs, history) 54 - - Decides on narrative response + any mechanical resolution 55 - - Generates new content as needed (lazy world-building) 56 - - Persists changes to world files 57 - - Returns narrative + any dice results to display 58 - 59 - **Context Management**: Needs smart retrieval - can't load entire world into context. Options: 60 - - Semantic search over world files 61 - - Graph-based retrieval (connected locations/characters) 62 - - Recency + relevance scoring 63 - 64 - ### 2. World State Layer 65 - Markdown files with YAML frontmatter for structured data: 66 - 67 - ```markdown 68 - --- 69 - type: location 70 - name: The Rusty Anchor 71 - region: Portside District 72 - connections: 73 - - harbor_main 74 - - fish_market 75 - - back_alley 76 - tags: [tavern, social, quest-hook] 77 - first_visited: 2025-01-15 78 - --- 79 - 80 - # The Rusty Anchor 81 - 82 - A weathered dockside tavern where sailors swap stories... 83 - 84 - ## Notable Features 85 - - The bar is a repurposed ship's hull 86 - - A mysterious map hangs behind the counter 87 - 88 - ## NPCs Present 89 - - [[Mara Saltwind]] - the owner, former pirate 90 - - [[Old Tam]] - regular, knows everyone's business 91 - ``` 92 - 93 - **Directory Structure:** 94 - ``` 95 - worlds/{world_name}/ 96 - ├── locations/ 97 - ├── characters/ 98 - ├── items/ 99 - ├── lore/ 100 - ├── factions/ 101 - ├── quests/ 102 - ├── tools/ # Generated procedural tools 103 - ├── sessions/ # Session logs 104 - └── world.yaml # World config + meta 105 - ``` 106 - 107 - ### 3. Player State (Separate from World) 108 - ``` 109 - players/{player_id}/ 110 - ├── character.yaml # Stats, class, abilities 111 - ├── inventory.yaml # Items carried 112 - ├── journal.md # Personal notes, discoveries 113 - ├── relationships.yaml # NPC relationship tracking 114 - └── session_log.md # Running narrative history 115 - ``` 116 - 117 - Separation enables: 118 - - Multiple characters in same world 119 - - Future multiplayer (each player has own state) 120 - - Clean rollback/save points 121 - 122 - ### 4. Rules Engine (5e SRD) 123 - Pinned to 5e SRD - no pluggable abstraction needed. 124 - 125 - - Character creation/leveling 126 - - Skill checks with modifiers 127 - - Combat resolution 128 - - Spell effects 129 - - Condition tracking 130 - 131 - **Dice Display:** 132 - - AI rolls implicitly, results shown in dice pane 133 - - Narrative describes outcomes without explicit numbers 134 - - (Future: optional manual dice entry mode) 135 - 136 - ### 5. Tool Generation System 137 - The meta-capability: AI generates utilities that become world content. 138 - 139 - **Example: Cave System Generator** 140 - When story needs caves, AI writes: 141 - ```python 142 - # worlds/myworld/tools/cave_generator.py 143 - """Generates connected cave networks for the Underdark regions.""" 144 - 145 - def generate_cave_system( 146 - num_chambers: int, 147 - connectivity: float = 0.3, 148 - seed: str | None = None 149 - ) -> CaveSystem: 150 - ... 151 - ``` 152 - 153 - Generated tools are: 154 - - Stored in `worlds/{world}/tools/` 155 - - Documented with their purpose 156 - - Reusable for similar future needs 157 - - Part of the world's "DNA" 158 - 159 - ### 6. TUI Interface (Textual) 160 - ``` 161 - ┌─ Storied ──────────────────────────────────────────────┐ 162 - │ ┌─ The Rusty Anchor ─────────────────────────────────┐ │ 163 - │ │ You push through the heavy oak door. The smell of │ │ 164 - │ │ salt and stale ale washes over you. A one-eyed │ │ 165 - │ │ woman behind the bar looks up, her hand drifting │ │ 166 - │ │ toward something beneath the counter. │ │ 167 - │ │ │ │ 168 - │ │ "We don't get many strangers here," she says. │ │ 169 - │ │ "State your business." │ │ 170 - │ └────────────────────────────────────────────────────┘ │ 171 - │ ┌─ Dice ──────┐ ┌─ Status ────────────────────────────┐│ 172 - │ │ ⚄ Insight │ │ Kira Stoneheart HP: 24/24 AC: 16 ││ 173 - │ │ 14 + 3 = 17 │ │ Fighter 3 GP: 47 ││ 174 - │ └─────────────┘ └─────────────────────────────────────┘│ 175 - │ ┌────────────────────────────────────────────────────┐ │ 176 - │ │ > I approach the bar carefully, hands visible... │ │ 177 - │ └────────────────────────────────────────────────────┘ │ 178 - └────────────────────────────────────────────────────────┘ 179 - ``` 180 - 181 - --- 182 - 183 - ## Lazy World Generation 184 - 185 - The world expands as explored: 186 - 187 - 1. **Reference Phase**: Location mentioned in passing (name only) 188 - 2. **Sketch Phase**: Player asks about it, basic details generated 189 - 3. **Detail Phase**: Player visits, full description + NPCs + connections 190 - 4. **Living Phase**: Events occur, state changes, history accumulates 191 - 192 - Each phase writes more detail to the markdown file. Previously mentioned details become constraints for future generation. 193 - 194 - --- 195 - 196 - ## Decided 197 - 198 - - **LLM Provider**: Anthropic API (Claude) 199 - - **Context Strategy**: Hybrid - graph-based for structure + semantic search for lore 200 - - **Tool Execution**: Sandboxed (restricted environment, output-only) 201 - 202 - ## Open Questions 203 - 204 - 1. **Session Continuity**: How much narrative history to maintain? 205 - 2. **Conflict Resolution**: When world files contradict, which wins? 206 - 3. **Embedding Model**: Which model for semantic search? (local vs API) 207 - 208 - --- 209 - 210 - ## Implementation Plan: Vertical Slice First 211 - 212 - ### Milestone 0: Project Foundation 213 - Establish the repo and design documentation. 214 - 215 - **0.1 Repository Setup** 216 - - Initialize git repo 217 - - Create `.gitignore` (Python, venv, IDE files, etc.) 218 - - Basic `pyproject.toml` with project metadata 219 - - `README.md` with project overview 220 - - `CLAUDE.md` - project context for Claude Code sessions: 221 - - Project overview and philosophy 222 - - Key architecture decisions 223 - - File structure and conventions 224 - - Pointers to design docs 225 - 226 - **0.2 Design Documentation** 227 - - Create `design/` directory 228 - - Move this architecture doc to `design/architecture.md` 229 - - `design/worldfiles.md` - specification for world file format 230 - - `design/dm-engine.md` - DM engine behavior and prompts 231 - 232 - **0.3 Project Structure** 233 - ``` 234 - storied/ 235 - ├── design/ # Architecture and design docs 236 - ├── plans/ # Implementation plans (in-progress work) 237 - ├── src/storied/ # Python package (empty for now) 238 - ├── rules/ # SRD and game rules (processed) 239 - ├── worlds/ # World content (gitignored) 240 - ├── players/ # Player state (gitignored) 241 - ├── tests/ # Test directory 242 - ├── pyproject.toml 243 - ├── README.md 244 - └── .gitignore 245 - ``` 246 - 247 - --- 248 - 249 - ### Milestone 0.5: SRD Processing Pipeline 250 - Build our own system to extract and structure the 5e SRD PDF. 251 - 252 - **0.5.1 Setup** 253 - - Create `rules/` directory structure 254 - - Download SRD 5.2.1 PDF to `rules/sources/SRD_CC_v5.2.1.pdf` 255 - - Add PDF processing dependency (pymupdf or pdfplumber) 256 - 257 - **0.5.2 PDF Extraction** 258 - - Extract text from PDF preserving structure 259 - - Identify section headers, tables, stat blocks 260 - - Handle multi-column layouts 261 - 262 - **0.5.3 Markdown Generation** 263 - - Output structured markdown to `rules/srd-5.2.1/` 264 - - Organize by topic: 265 - ``` 266 - rules/srd-5.2.1/ 267 - ├── races/ 268 - ├── classes/ 269 - ├── backgrounds/ 270 - ├── equipment/ 271 - ├── spells/ 272 - ├── monsters/ 273 - ├── combat/ 274 - ├── adventuring/ 275 - └── index.yaml # Structured index for queries 276 - ``` 277 - 278 - **0.5.4 Structured Data** 279 - - Generate YAML/JSON indexes for programmatic access 280 - - Spell lists with levels, schools, components 281 - - Monster stat blocks as structured data 282 - - Equipment tables 283 - 284 - **Milestone 0.5 Deliverable**: Markdown + structured data from SRD that the DM engine can reference. 285 - 286 - --- 287 - 288 - ### Milestone 1: Playable Prototype 289 - Get a working game loop as fast as possible. 290 - 291 - **1.1 Add Dependencies** 292 - - Add to pyproject.toml: `textual`, `anthropic`, `pyyaml` 293 - - Create initial module files: 294 - - `src/storied/__init__.py` 295 - - `src/storied/main.py` - entry point 296 - - `src/storied/engine.py` - DM engine core 297 - - `src/storied/world.py` - world file I/O 298 - - `src/storied/tui.py` - Textual interface 299 - 300 - **1.2 Minimal TUI** 301 - - Single screen: narrative pane + input box 302 - - No dice display yet, no status bar yet 303 - - Just get text flowing 304 - 305 - **1.3 Basic DM Engine** 306 - - Simple prompt that establishes the DM role 307 - - Receives player input, returns narrative 308 - - No world persistence yet - just vibes 309 - 310 - **1.4 World Persistence** 311 - - Create/read location markdown files 312 - - DM engine loads current location context 313 - - New locations written when visited 314 - - Simple `current_location` tracking 315 - 316 - **Milestone 1 Deliverable**: You can start a game, explore, and locations persist between sessions. 317 - 318 - --- 319 - 320 - ### Milestone 2: Character & Mechanics 321 - 322 - **2.1 Character Creation (AI-Assisted)** 323 - Start simple - player describes what they want, AI generates the character: 324 - 325 - - "I want to play a grumpy dwarf fighter" → AI generates valid 5e stats 326 - - Backstory emerges lazily during play (AI asks when relevant) 327 - - Character file grows with narrative details over time 328 - 329 - *(Future: Add traditional wizard, quick templates as alternative paths)* 330 - 331 - **2.2 Character System** 332 - - Character YAML file (name, race, class, stats, level, HP, inventory) 333 - - Display character status in TUI 334 - - Backstory section grows as story unfolds 335 - 336 - **2.3 Dice System** 337 - - Dice roller utility 338 - - AI rolls implicitly, results shown in dice pane 339 - - Narrative focuses on outcomes, not numbers 340 - 341 - **2.4 Basic 5e Integration** 342 - - Skill checks (ability + proficiency) 343 - - Simple combat (attack rolls, damage) 344 - - Death saves, resting 345 - 346 - --- 347 - 348 - ### Milestone 3: Richer World 349 - 350 - **3.1 NPCs & Relationships** 351 - - NPC markdown files 352 - - Relationship tracking per player 353 - - NPCs persist state/mood 354 - 355 - **3.2 Context Retrieval** 356 - - Graph-based: load connected locations 357 - - Semantic: embed world files, search for relevant lore 358 - - Combine for DM context window 359 - 360 - **3.3 Session Management** 361 - - Session logs 362 - - Save/restore game state 363 - - Multiple save slots 364 - 365 - --- 366 - 367 - ### Milestone 4: Tool Generation 368 - 369 - **4.1 Tool Framework** 370 - - Sandboxed execution environment 371 - - Tool registry (discover generated tools) 372 - - Tool invocation from DM engine 373 - 374 - **4.2 Example Tools** 375 - - Procedural name generator 376 - - Simple dungeon/cave generator 377 - - Loot table roller 378 - 379 - --- 380 - 381 - ### Future Considerations 382 - - Web interface (API already supports it) 383 - - Multiplayer / shared worlds 384 - - Alternative rule systems 385 - - Voice input/output 386 - - Map visualization
prompts/cold-draft.md prompts/arc-cold-draft.md
-1
prompts/dm-system.md
··· 613 613 - You notice the player consistently gravitating toward or away from certain kinds of play (e.g., skipping combat, seeking out NPCs) 614 614 615 615 Read your current style from context (the "Style" section, if present), integrate new observations, and write the full replacement. Don't discard preferences the player hasn't contradicted. Acknowledge explicit feedback briefly; for self-tuning, no announcement needed. 616 -
+31 -5
pyproject.toml
··· 46 46 testpaths = ["tests"] 47 47 pythonpath = ["src"] 48 48 addopts = [ 49 + "-n", "4", 49 50 "--cov=src/storied", 50 51 "--cov=tests", 51 52 "--cov-report=term-missing:skip-covered", 52 53 "--cov-branch", 54 + # Target: 100%. Floor: 96% (the current reality on included code). 55 + # Raise the floor as coverage improves; never lower it without a 56 + # specific reason that's worth writing down. 57 + "--cov-fail-under=96", 53 58 "-m", "not slow", 54 59 ] 55 60 markers = ["slow: slow tests that build real search indices"] ··· 63 68 line-length = 88 64 69 65 70 [tool.ruff.lint] 66 - select = ["E", "F", "I", "UP", "B", "SIM"] 71 + select = ["E", "F", "I", "UP", "B", "SIM", "PT", "RUF"] 67 72 # B008 fires on the FastMCP + uncalled_for dependency-injection idiom 68 73 # (`root: Path = StorageRoot()` etc). Those aren't mutable defaults — 69 74 # they're DI markers resolved at call time. Suppressing project-wide 70 75 # so real B008 regressions would still surface elsewhere. 71 - ignore = ["B008"] 76 + # 77 + # RUF001/002/003 flag ambiguous Unicode (em dashes, curly quotes, ×). 78 + # These are intentional throughout the project's prose, prompts, and 79 + # comments — the house style uses em dashes. 80 + ignore = ["B008", "RUF001", "RUF002", "RUF003"] 81 + 82 + [tool.codespell] 83 + # Skip the SRD content (5e creature/feature names trip the spell-check) 84 + # and uv.lock. Allow specific intentional terms elsewhere: phonological 85 + # diphthongs, partial streaming-test fragments, and valid spelling 86 + # variants. 87 + skip = "rules/**,uv.lock" 88 + ignore-words-list = "ue,ie,hel,wit,unparseable" 89 + 90 + [tool.pyright] 91 + include = ["src", "tests"] 92 + # Strict surfaces ~1300 errors here, mostly reportUnknownX from 93 + # dict/yaml/dynamic-prompt code that pre-dates a strict typing pass. 94 + # Basic catches the real bugs without requiring every locals to be 95 + # explicitly typed. Tighten as the codebase becomes more annotated. 96 + typeCheckingMode = "basic" 97 + venvPath = "." 98 + venv = ".venv" 72 99 73 100 [tool.coverage.run] 74 101 source = ["src/storied"] 75 102 branch = true 76 103 omit = ["src/storied/cli.py", "src/storied/srd/*"] 77 104 78 - [tool.coverage.report] 79 - fail_under = 95 80 - 81 105 [dependency-groups] 82 106 dev = [ 83 107 "mypy>=1.19.1", 108 + "pyright>=1.1.408", 84 109 "pytest>=9.0.2", 85 110 "pytest-cov>=7.0.0", 111 + "pytest-xdist>=3.8.0", 86 112 "ruff>=0.14.10", 87 113 ]
+2 -3
src/storied/advancement.py
··· 3 3 import time 4 4 from collections.abc import Callable 5 5 from dataclasses import dataclass 6 - from pathlib import Path 7 6 from threading import Thread 8 7 9 8 from storied import notifications ··· 80 79 def evaluate_advancement( 81 80 world_id: str = "default", 82 81 player_id: str = "default", 83 - model: str = "claude-opus-4-6", 82 + model: str = "claude-opus-4-7", 84 83 on_progress: Callable[[str], None] | None = None, 85 84 ) -> AdvancementResult: 86 85 """Evaluate whether the character has earned a level-up.""" ··· 156 155 self, 157 156 world_id: str, 158 157 player_id: str, 159 - model: str = "claude-opus-4-6", 158 + model: str = "claude-opus-4-7", 160 159 interval: int = 5, 161 160 ): 162 161 self._world_id = world_id
+1 -1
src/storied/character/__init__.py
··· 47 47 set_item_status, 48 48 ) 49 49 50 - __all__ = [ 50 + __all__ = [ # noqa: RUF022 — grouped by submodule, not alphabetized 51 51 # Data 52 52 "DEFAULT_SCHEMA", 53 53 "create_character",
+8 -2
src/storied/character/compute.py
··· 8 8 doesn't have to. See `design/architecture.md` for the invariant. 9 9 """ 10 10 11 - 12 11 # Maps each skill to its governing ability 13 12 SKILL_TO_ABILITY: dict[str, str] = { 14 13 "acrobatics": "dexterity", ··· 33 32 34 33 ALL_SKILLS = list(SKILL_TO_ABILITY.keys()) 35 34 36 - ABILITIES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] 35 + ABILITIES = [ 36 + "strength", 37 + "dexterity", 38 + "constitution", 39 + "intelligence", 40 + "wisdom", 41 + "charisma", 42 + ] 37 43 38 44 39 45 def ability_modifier(score: int) -> int:
+10 -22
src/storied/character/data.py
··· 8 8 from storied.character.schema import coerce_character, validate_for_write 9 9 from storied.paths import player_path 10 10 11 - 12 11 # Default character schema — used when creating a new character 13 12 DEFAULT_SCHEMA: dict = { 14 13 "identity": { ··· 97 96 return md_path.read_text() 98 97 99 98 100 - def save_character( 101 - player_id: str, data: dict 102 - ) -> None: 99 + def save_character(player_id: str, data: dict) -> None: 103 100 """Save character data back to character.yaml.""" 104 101 yaml_path = _character_yaml_path(player_id) 105 102 yaml_path.parent.mkdir(parents=True, exist_ok=True) ··· 108 105 ) 109 106 110 107 111 - def save_character_prose( 112 - player_id: str, prose: str 113 - ) -> None: 108 + def save_character_prose(player_id: str, prose: str) -> None: 114 109 """Save the character's free-text prose to character.md.""" 115 110 md_path = _character_md_path(player_id) 116 111 md_path.parent.mkdir(parents=True, exist_ok=True) ··· 127 122 def _deep_update(target: dict, source: dict) -> None: 128 123 """Recursively update target with values from source.""" 129 124 for key, value in source.items(): 130 - if ( 131 - key in target 132 - and isinstance(target[key], dict) 133 - and isinstance(value, dict) 134 - ): 125 + if key in target and isinstance(target[key], dict) and isinstance(value, dict): 135 126 _deep_update(target[key], value) 136 127 else: 137 128 target[key] = value 138 129 139 130 140 - def update_character( 141 - player_id: str, 142 - updates: dict 143 - ) -> str: 131 + def update_character(player_id: str, updates: dict) -> str: 144 132 """Update fields in character.yaml using dot notation. 145 133 146 134 Example: {"state.hp.current": 5, "identity.classes.0.level": 4} ··· 178 166 179 167 error = validate_for_write(data) 180 168 if error: 181 - return ( 182 - f"Update rejected — {error}. " 183 - f"Character on disk is unchanged." 184 - ) 169 + return f"Update rejected — {error}. Character on disk is unchanged." 185 170 186 171 save_character(player_id, data) 187 172 return "Character updated: " + ", ".join(changes) 188 173 189 174 190 175 def _set_nested(data: dict, key: str, value) -> None: 191 - """Set a nested value using dot notation. Supports list indices like 'classes.0.level'.""" 176 + """Set a nested value using dot notation. 177 + 178 + Supports list indices like 'classes.0.level'. 179 + """ 192 180 parts = key.split(".") 193 181 current = data 194 182 ··· 225 213 proficiencies: dict | None = None, 226 214 features: list[dict] | None = None, 227 215 equipment: dict | None = None, 228 - backstory: str | None = None 216 + backstory: str | None = None, 229 217 ) -> str: 230 218 """Create a new character with the new schema. 231 219
+13 -2
src/storied/character/display.py
··· 181 181 speed = state.get("speed", 30) 182 182 init = initiative_modifier(char) 183 183 pb = proficiency_bonus(char) 184 - parts = [f"HP {hp_str}", f"AC {ac}", f"Speed {speed}", f"Init {init:+d}", f"PB +{pb}"] 184 + parts = [ 185 + f"HP {hp_str}", 186 + f"AC {ac}", 187 + f"Speed {speed}", 188 + f"Init {init:+d}", 189 + f"PB +{pb}", 190 + ] 185 191 exhaustion = state.get("exhaustion", 0) 186 192 if exhaustion: 187 193 parts.append(f"Exhaustion {exhaustion}") ··· 252 258 lines.extend(def_lines) 253 259 lines.append("") 254 260 255 - for section_func in (_format_effects, _format_resources, _format_magic_items, _format_features): 261 + for section_func in ( 262 + _format_effects, 263 + _format_resources, 264 + _format_magic_items, 265 + _format_features, 266 + ): 256 267 section = section_func(data) 257 268 if section: 258 269 lines.extend(section)
+16 -65
src/storied/character/operations.py
··· 5 5 implementations behind the DM's bookkeeping tools. 6 6 """ 7 7 8 - from pathlib import Path 9 - 10 8 from storied.character.data import ( 11 9 load_character, 12 10 save_character, 13 11 ) 14 12 from storied.paths import player_path 15 - 16 13 17 14 # --- HP operations --- 18 15 19 16 20 - def damage( 21 - player_id: str, 22 - amount: int, 23 - damage_type: str | None = None 24 - ) -> str: 17 + def damage(player_id: str, amount: int, damage_type: str | None = None) -> str: 25 18 """Apply raw damage to the character. 26 19 27 20 Temp HP soaks first, then current HP. `damage_type` is metadata for ··· 61 54 return ". ".join(parts) 62 55 63 56 64 - def heal( 65 - player_id: str, 66 - amount: int 67 - ) -> str: 57 + def heal(player_id: str, amount: int) -> str: 68 58 """Heal the character, clamped to max HP.""" 69 59 data = load_character(player_id) 70 60 if data is None: ··· 89 79 source: str, 90 80 description: str, 91 81 expires: str | None = None, 92 - concentration: bool = False 82 + concentration: bool = False, 93 83 ) -> str: 94 84 """Add a temporary effect to the character. 95 85 ··· 121 111 return " ".join(parts) 122 112 123 113 124 - def remove_effect( 125 - player_id: str, 126 - source: str 127 - ) -> str: 114 + def remove_effect(player_id: str, source: str) -> str: 128 115 """Remove an effect by source name (case-insensitive substring match).""" 129 116 data = load_character(player_id) 130 117 if data is None: ··· 145 132 # --- Condition operations --- 146 133 147 134 148 - def add_condition( 149 - player_id: str, 150 - name: str 151 - ) -> str: 135 + def add_condition(player_id: str, name: str) -> str: 152 136 """Add a condition to the character (no duplicates).""" 153 137 data = load_character(player_id) 154 138 if data is None: ··· 164 148 return f"Condition added: {name}" 165 149 166 150 167 - def remove_condition( 168 - player_id: str, 169 - name: str 170 - ) -> str: 151 + def remove_condition(player_id: str, name: str) -> str: 171 152 """Remove a condition (case-insensitive).""" 172 153 data = load_character(player_id) 173 154 if data is None: ··· 187 168 # --- Inventory operations --- 188 169 189 170 190 - def add_item( 191 - player_id: str, 192 - item: str, 193 - location: str | None = None 194 - ) -> str: 171 + def add_item(player_id: str, item: str, location: str | None = None) -> str: 195 172 """Add an item to a location in the equipment dict. 196 173 197 174 Substring match on existing location keys. Creates the location if it ··· 228 205 return f"Added '{item}' to {target_key}" 229 206 230 207 231 - def remove_item( 232 - player_id: str, 233 - item: str 234 - ) -> str: 208 + def remove_item(player_id: str, item: str) -> str: 235 209 """Remove an item by case-insensitive substring match across all locations.""" 236 210 data = load_character(player_id) 237 211 if data is None: ··· 250 224 return f"No item matching '{item}' found" 251 225 252 226 253 - def set_item_status( 254 - player_id: str, 255 - item: str, 256 - status: str 257 - ) -> str: 227 + def set_item_status(player_id: str, item: str, status: str) -> str: 258 228 """Set a magic item's status (attuned, equipped, carried). 259 229 260 230 The item is referenced by its world entity name. The function manages ··· 273 243 ) 274 244 275 245 # Normalize to wikilink format 276 - if not item.startswith("[["): 277 - wikilink = f"[[{item}]]" 278 - else: 279 - wikilink = item 246 + wikilink = item if item.startswith("[[") else f"[[{item}]]" 280 247 281 248 # Remove from all current statuses 282 249 for s in valid_statuses: ··· 294 261 # --- Resource operations --- 295 262 296 263 297 - def adjust_resource( 298 - player_id: str, 299 - name: str, 300 - delta: int 301 - ) -> str: 264 + def adjust_resource(player_id: str, name: str, delta: int) -> str: 302 265 """Adjust a resource pool by a delta. 303 266 304 267 Negative = use (clamped to 0), positive = restore (clamped to max). ··· 342 305 return f"No change to {notes} ({new_current}/{maximum})" 343 306 344 307 345 - def rest( 346 - player_id: str, 347 - rest_type: str 348 - ) -> str: 308 + def rest(player_id: str, rest_type: str) -> str: 349 309 """Take a short or long rest. Refreshes resources by refresh type. 350 310 351 311 short rest: refreshes resources with refresh: short_rest ··· 401 361 # --- Coin and notes operations --- 402 362 403 363 404 - def adjust_coins( 405 - player_id: str, 406 - deltas: dict[str, int] 407 - ) -> str: 364 + def adjust_coins(player_id: str, deltas: dict[str, int]) -> str: 408 365 """Apply relative coin changes (positive=gain, negative=spend). 409 366 410 367 Atomic: if any denomination would go below zero, the entire call is ··· 430 387 old = purse.get(denom, 0) 431 388 new = old + delta 432 389 if new < 0: 433 - shortfalls.append( 434 - f"{denom}: have {old}, need {-delta} (short {-new})" 435 - ) 390 + shortfalls.append(f"{denom}: have {old}, need {-delta} (short {-new})") 436 391 proposed[denom] = new 437 392 438 393 if shortfalls: ··· 476 431 new_level: int, 477 432 hp_gain: int, 478 433 features: list[dict] | None = None, 479 - time_anchor: str | None = None 434 + time_anchor: str | None = None, 480 435 ) -> str: 481 436 """Atomically level up the character. 482 437 ··· 547 502 return " ".join(parts) 548 503 549 504 550 - def add_note( 551 - player_id: str, 552 - text: str, 553 - time_anchor: str | None = None 554 - ) -> str: 505 + def add_note(player_id: str, text: str, time_anchor: str | None = None) -> str: 555 506 """Append a note to the player's notes.md file.""" 556 507 notes_path = player_path(player_id) / "notes.md" 557 508 notes_path.parent.mkdir(parents=True, exist_ok=True)
+1 -2
src/storied/character/schema.py
··· 13 13 14 14 from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator 15 15 16 - 17 16 # --- Sub-models ------------------------------------------------------------- 18 17 19 18 ··· 170 169 msg = err["msg"] 171 170 # Pydantic prefixes "Value error, " on raised ValueErrors — strip it 172 171 if msg.startswith("Value error, "): 173 - msg = msg[len("Value error, "):] 172 + msg = msg[len("Value error, ") :] 174 173 parts.append(f"{loc}: {msg}") 175 174 return "; ".join(parts) 176 175
+18 -10
src/storied/claude.py
··· 96 96 """Build args for a claude -p call with MCP tools and stream-json I/O.""" 97 97 args = [ 98 98 _find_claude(), 99 - "--tools", "", 99 + "--tools", 100 + "", 100 101 "-p", 101 - "--input-format", "stream-json", 102 - "--output-format", "stream-json", 102 + "--input-format", 103 + "stream-json", 104 + "--output-format", 105 + "stream-json", 103 106 "--include-partial-messages", 104 107 "--verbose", 105 108 "--dangerously-skip-permissions", 106 109 "--disable-slash-commands", 107 110 "--strict-mcp-config", 108 - "--mcp-config", mcp_config, 109 - "--effort", effort, 111 + "--mcp-config", 112 + mcp_config, 113 + "--effort", 114 + effort, 110 115 # Keep per-machine sections (cwd, env, memory paths, git 111 116 # status) out of the system prompt. We control the entire 112 117 # prompt explicitly via --system-prompt, and the dynamic ··· 201 206 user_message: str, 202 207 mcp_url: str, 203 208 *, 204 - model: str = "claude-opus-4-6", 209 + model: str = "claude-opus-4-7", 205 210 effort: str = "medium", 206 211 resume_session_id: str | None = None, 207 212 cwd: Path | None = None, ··· 264 269 user_message: str, 265 270 mcp_url: str, 266 271 *, 267 - model: str = "claude-opus-4-6", 272 + model: str = "claude-opus-4-7", 268 273 effort: str = "high", 269 274 on_tool_start: Callable[[str], None] | None = None, 270 275 cwd: Path | None = None, ··· 358 363 return None 359 364 360 365 args = [ 361 - claude_path, "-p", 362 - "--model", model, 363 - "--system-prompt", system_prompt, 366 + claude_path, 367 + "-p", 368 + "--model", 369 + model, 370 + "--system-prompt", 371 + system_prompt, 364 372 "--no-session-persistence", 365 373 "--dangerously-skip-permissions", 366 374 "--exclude-dynamic-system-prompt-sections",
+95 -85
src/storied/cli.py
··· 25 25 "/context": "Show token usage", 26 26 "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", 27 27 "/note": ( 28 - "Add a note to your character sheet " 29 - "(e.g. /note remember the prayer words)" 28 + "Add a note to your character sheet (e.g. /note remember the prayer words)" 30 29 ), 31 30 } 31 + 32 32 33 33 def _format_character_display( 34 34 player_id: str, ··· 274 274 list(engine.stream_action(save_msg)) 275 275 except Exception: 276 276 pass 277 - console.print( 278 - "[yellow]Session saved. Farewell, adventurer![/yellow]" 279 - ) 277 + console.print("[yellow]Session saved. Farewell, adventurer![/yellow]") 280 278 281 279 try: 282 280 while True: ··· 316 314 else: 317 315 context_k = stats["context_total"] / 1000 318 316 usage_str = ( 319 - f"~{context_k:.1f}k/{limit_k:.0f}k " 320 - "system tokens (estimated)" 317 + f"~{context_k:.1f}k/{limit_k:.0f}k system tokens (estimated)" 321 318 ) 322 319 console.print( 323 320 f"[bold]Context Usage[/bold] [dim]({game_time})[/dim]" ··· 352 349 if key.startswith("Entity:") or key.startswith("Linked:"): 353 350 label = key.split(":", 1)[1] 354 351 color = ( 355 - "magenta" 356 - if key.startswith("Entity:") 357 - else "dark_magenta" 352 + "magenta" if key.startswith("Entity:") else "dark_magenta" 358 353 ) 359 354 else: 360 355 label = key ··· 402 397 403 398 for name, tokens, color in legend_items: 404 399 tokens_str = ( 405 - f"~{tokens:,}" 406 - if tokens < 1_000 407 - else f"~{tokens / 1_000:.1f}k" 400 + f"~{tokens:,}" if tokens < 1_000 else f"~{tokens / 1_000:.1f}k" 408 401 ) 409 402 console.print( 410 - f" [{color}]█[/{color}] {name}: " 411 - f"[dim]{tokens_str}[/dim]" 403 + f" [{color}]█[/{color}] {name}: [dim]{tokens_str}[/dim]" 412 404 ) 413 405 414 406 if stats["total_input"] > 0: ··· 431 423 if cache_write: 432 424 breakdown_parts.append(f"{cache_write:,} cache-write") 433 425 breakdown = ( 434 - f" ({' · '.join(breakdown_parts)})" 435 - if breakdown_parts 436 - else "" 426 + f" ({' · '.join(breakdown_parts)})" if breakdown_parts else "" 437 427 ) 438 428 console.print( 439 429 f" [dim]Last turn: {last_in} in" ··· 450 440 if action.strip().lower() in ("/status", "/me"): 451 441 is_full = action.strip().lower() == "/me" 452 442 formatted = _format_character_display( 453 - player_id, full=is_full, 443 + player_id, 444 + full=is_full, 454 445 ) 455 446 if formatted: 456 447 console.print() ··· 473 464 # Handle /save command 474 465 if action.strip().lower() == "/save": 475 466 from storied.session import load_session 467 + 476 468 console.print() 477 469 game_time = engine.get_current_time() 478 470 session = load_session(player_id) ··· 506 498 console.print("[dim]Usage: /note <what to remember>[/dim]") 507 499 continue 508 500 from storied.character import add_note as char_add_note 501 + 509 502 time_anchor = engine._campaign_log.get_current_time().to_anchor() 510 503 char_add_note( 511 504 player_id, ··· 513 506 time_anchor=time_anchor, 514 507 ) 515 508 console.print() 516 - console.print( 517 - f"[dim]Noted ({time_anchor}): {note_msg}[/dim]" 518 - ) 509 + console.print(f"[dim]Noted ({time_anchor}): {note_msg}[/dim]") 519 510 continue 520 511 521 512 try: ··· 594 585 595 586 if engine.session_ended: 596 587 if is_onboarding: 597 - console.print( 598 - "[green]Onboarding complete.[/green]" 599 - ) 588 + console.print("[green]Onboarding complete.[/green]") 600 589 else: 601 590 console.print( 602 591 "[yellow]Session saved. Farewell, adventurer![/yellow]" ··· 629 618 630 619 from storied.character import load_character 631 620 from storied.engine import DMEngine 621 + 632 622 history_file = Path.home() / ".storied_history" 633 623 with contextlib.suppress(FileNotFoundError): 634 624 readline.read_history_file(history_file) ··· 668 658 user_rules_home=Path.home() / ".storied" / "rules", 669 659 ) 670 660 else: 671 - configure( 672 - data_home=resolve_data_home(getattr(args, "base_path", None)) 673 - ) 661 + configure(data_home=resolve_data_home(getattr(args, "base_path", None))) 674 662 data_home().mkdir(parents=True, exist_ok=True) 675 663 676 664 # Export STORIED_HOME so any subprocess that re-enters storied 677 665 # (e.g. via run_code) sees the same data directory. 678 666 import os 667 + 679 668 os.environ["STORIED_HOME"] = str(data_home()) 680 669 681 670 from storied.paths import world_path ··· 694 683 # DM calls end_session, we fall through to the normal seed + 695 684 # play path in the same invocation. 696 685 if cold_start: 697 - console.print(Panel.fit( 698 - "[bold]Welcome to Storied![/bold]\n" 699 - "Let's figure out the kind of adventure you want\n" 700 - "and build your character.", 701 - title="New Campaign", 702 - border_style="yellow", 703 - )) 686 + console.print( 687 + Panel.fit( 688 + "[bold]Welcome to Storied![/bold]\n" 689 + "Let's figure out the kind of adventure you want\n" 690 + "and build your character.", 691 + title="New Campaign", 692 + border_style="yellow", 693 + ) 694 + ) 704 695 console.print(f"[dim]World: {world_id}[/dim]") 705 696 console.print() 706 697 ··· 742 733 missing.append("world style") 743 734 if missing: 744 735 console.print() 745 - console.print(Panel.fit( 746 - f"[yellow]Onboarding incomplete — missing: " 747 - f"{', '.join(missing)}.[/yellow]\n" 748 - f"Run [cyan]storied play[/cyan] again to continue.", 749 - border_style="yellow", 750 - )) 736 + console.print( 737 + Panel.fit( 738 + f"[yellow]Onboarding incomplete — missing: " 739 + f"{', '.join(missing)}.[/yellow]\n" 740 + f"Run [cyan]storied play[/cyan] again to continue.", 741 + border_style="yellow", 742 + ) 743 + ) 751 744 return 0 752 745 753 746 # Welcome panel for continuing play (skipped when we just 754 747 # finished onboarding — the transition into seeding + play 755 748 # speaks for itself). 756 749 if sandbox: 757 - console.print(Panel.fit( 758 - "[bold]Storied Sandbox[/bold]\n" 759 - "No character, no world — just you and the DM.\n" 760 - "Type [cyan]Ctrl+D[/cyan] to quit.", 761 - title="Sandbox", 762 - border_style="cyan", 763 - )) 750 + console.print( 751 + Panel.fit( 752 + "[bold]Storied Sandbox[/bold]\n" 753 + "No character, no world — just you and the DM.\n" 754 + "Type [cyan]Ctrl+D[/cyan] to quit.", 755 + title="Sandbox", 756 + border_style="cyan", 757 + ) 758 + ) 764 759 console.print(f"[dim]World: {world_id} (sandbox)[/dim]") 765 760 console.print() 766 761 elif not cold_start: 767 - console.print(Panel.fit( 768 - "[bold]Welcome to Storied![/bold]\n" 769 - "Let the DM know when you're ready to quit.\n" 770 - "Type [cyan]/context[/cyan] to see token usage.", 771 - title="Storied", 772 - border_style="green", 773 - )) 762 + console.print( 763 + Panel.fit( 764 + "[bold]Welcome to Storied![/bold]\n" 765 + "Let the DM know when you're ready to quit.\n" 766 + "Type [cyan]/context[/cyan] to see token usage.", 767 + title="Storied", 768 + border_style="green", 769 + ) 770 + ) 774 771 console.print(f"[dim]World: {world_id}[/dim]") 775 772 console.print() 776 773 ··· 789 786 if character is not None and not arc_path.exists(): 790 787 from storied.planner import plot_arc 791 788 792 - console.print( 793 - "[dim]Plotting the shape of your story...[/dim]" 794 - ) 789 + console.print("[dim]Plotting the shape of your story...[/dim]") 795 790 796 791 def on_arc_progress(msg: str) -> None: 797 792 console.print(f"[dim] {msg}[/dim]") ··· 886 881 shutil.rmtree(sandbox_dir, ignore_errors=True) 887 882 888 883 return 0 889 - 890 884 891 885 892 886 def cmd_index_srd(args: argparse.Namespace) -> int: ··· 1057 1051 help="URL to download from", 1058 1052 ) 1059 1053 download_parser.add_argument( 1060 - "--output", "-o", 1054 + "--output", 1055 + "-o", 1061 1056 help="Output path (default: rules/sources/SRD_CC_v5.2.1.pdf)", 1062 1057 ) 1063 1058 download_parser.add_argument( 1064 - "--force", "-f", 1059 + "--force", 1060 + "-f", 1065 1061 action="store_true", 1066 1062 help="Download even if file exists", 1067 1063 ) ··· 1076 1072 help="Path to PDF (default: rules/sources/SRD_CC_v5.2.1.pdf)", 1077 1073 ) 1078 1074 convert_parser.add_argument( 1079 - "--output", "-o", 1075 + "--output", 1076 + "-o", 1080 1077 help="Output path (default: rules/srd-5.2.1/srd.md)", 1081 1078 ) 1082 1079 convert_parser.set_defaults(func=cmd_srd_convert) ··· 1086 1083 "split", help="Split SRD markdown into sections" 1087 1084 ) 1088 1085 split_parser.add_argument( 1089 - "--input", "-i", 1086 + "--input", 1087 + "-i", 1090 1088 help="Input markdown file (default: rules/srd-5.2.1/srd.md)", 1091 1089 ) 1092 1090 split_parser.add_argument( 1093 - "--output", "-o", 1091 + "--output", 1092 + "-o", 1094 1093 help="Output directory (default: rules/srd-5.2.1/sections)", 1095 1094 ) 1096 1095 split_parser.set_defaults(func=cmd_srd_split) ··· 1100 1099 "clean", help="Clean up extracted markdown files" 1101 1100 ) 1102 1101 clean_parser.add_argument( 1103 - "--dir", "-d", 1102 + "--dir", 1103 + "-d", 1104 1104 help="Sections directory (default: rules/srd-5.2.1/sections)", 1105 1105 ) 1106 1106 clean_parser.set_defaults(func=cmd_srd_clean) ··· 1108 1108 # play command 1109 1109 play_parser = subparsers.add_parser("play", help="Start an interactive DM session") 1110 1110 play_parser.add_argument( 1111 - "--world", "-w", 1111 + "--world", 1112 + "-w", 1112 1113 help="World ID to use for world-specific content", 1113 1114 ) 1114 1115 play_parser.add_argument( 1115 - "--debug", "-d", 1116 + "--debug", 1117 + "-d", 1116 1118 action="store_true", 1117 1119 help="Show token usage after each response", 1118 1120 ) 1119 1121 play_parser.add_argument( 1120 - "--transcript", "-t", 1122 + "--transcript", 1123 + "-t", 1121 1124 help="Path to write full debug transcript (JSONL format)", 1122 1125 ) 1123 1126 play_parser.add_argument( 1124 - "--sandbox", "-s", 1127 + "--sandbox", 1128 + "-s", 1125 1129 action="store_true", 1126 1130 help="Throwaway session — no character, no world state", 1127 1131 ) ··· 1138 1142 # reset command 1139 1143 reset_parser = subparsers.add_parser("reset", help="Reset player and world state") 1140 1144 reset_parser.add_argument( 1141 - "--world", "-w", 1145 + "--world", 1146 + "-w", 1142 1147 help="World ID to reset (default: default)", 1143 1148 ) 1144 1149 reset_parser.add_argument( 1145 - "--player", "-p", 1150 + "--player", 1151 + "-p", 1146 1152 help="Player ID to reset (default: default)", 1147 1153 ) 1148 1154 reset_parser.add_argument( 1149 - "--force", "-f", 1155 + "--force", 1156 + "-f", 1150 1157 action="store_true", 1151 1158 help="Skip confirmation prompt", 1152 1159 ) ··· 1154 1161 "--base-path", 1155 1162 type=Path, 1156 1163 help=( 1157 - "Where worlds and players live (defaults to $STORIED_HOME or " 1158 - "~/.storied/)" 1164 + "Where worlds and players live (defaults to $STORIED_HOME or ~/.storied/)" 1159 1165 ), 1160 1166 ) 1161 1167 reset_parser.set_defaults(func=cmd_reset) ··· 1165 1171 "seed", help="Seed an empty world from a character sheet" 1166 1172 ) 1167 1173 seed_parser.add_argument( 1168 - "--world", "-w", 1174 + "--world", 1175 + "-w", 1169 1176 default="default", 1170 1177 help="World ID (default: default)", 1171 1178 ) 1172 1179 seed_parser.add_argument( 1173 - "--player", "-p", 1180 + "--player", 1181 + "-p", 1174 1182 default="default", 1175 1183 help="Player ID (default: default)", 1176 1184 ) 1177 1185 seed_parser.add_argument( 1178 - "--model", "-m", 1179 - default="claude-opus-4-6", 1180 - help="Model to use for seeding (default: claude-opus-4-6)", 1186 + "--model", 1187 + "-m", 1188 + default="claude-opus-4-7", 1189 + help="Model to use for seeding (default: claude-opus-4-7)", 1181 1190 ) 1182 1191 seed_parser.add_argument( 1183 - "--force", "-f", 1192 + "--force", 1193 + "-f", 1184 1194 action="store_true", 1185 1195 help="Re-seed even if a session already exists", 1186 1196 ) ··· 1200 1210 index_world_parser = index_subparsers.add_parser( 1201 1211 "world", help="Build search index for a world (includes SRD)" 1202 1212 ) 1203 - index_world_parser.add_argument( 1204 - "--world", "-w", help="World ID (default: default)" 1205 - ) 1213 + index_world_parser.add_argument("--world", "-w", help="World ID (default: default)") 1206 1214 index_world_parser.set_defaults(func=cmd_index_world) 1207 1215 1208 1216 index_status_parser = index_subparsers.add_parser( ··· 1234 1242 parser.print_help() 1235 1243 return 0 1236 1244 1237 - if args.command in ("srd", "index") and not getattr( 1238 - args, f"{args.command}_command", None 1245 + if ( 1246 + args.command in ("srd", "index") 1247 + and not getattr(args, f"{args.command}_command", None) 1248 + and parser._subparsers is not None 1239 1249 ): 1240 1250 for action in parser._subparsers._actions: 1241 1251 if isinstance(action, argparse._SubParsersAction):
+9 -9
src/storied/content.py
··· 1 - """Content layer resolution — finds and loads content across world / user / rules layers.""" 1 + """Content layer resolution. 2 + 3 + Finds and loads content across world / user / rules layers. 4 + """ 2 5 3 6 import re 4 7 from pathlib import Path ··· 16 19 — campaign-specific overrides and narrative content. 17 20 2. **User layer** (flat): ``<user_rules>/{content_type}/*.md`` — your 18 21 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. 22 + 3. **Shipped layer** (nested): 23 + ``<shipped_rules>/{rules_system}/sections/{content_type}/*.md`` — 24 + the stock 5e SRD bundled with the repo. 21 25 22 26 Narrative content (npcs, locations, factions, threads, lore, maps) 23 27 only exists at the world layer; layers 2 and 3 miss and the lookup ··· 46 50 if self.world_id: 47 51 roots.append(paths.world_path(self.world_id)) 48 52 roots.append(paths.user_rules_path()) 49 - roots.append( 50 - paths.shipped_rules_path() / self.rules_system / "sections" 51 - ) 53 + roots.append(paths.shipped_rules_path() / self.rules_system / "sections") 52 54 return roots 53 55 54 - def _search_dirs( 55 - self, content_type: str | None 56 - ) -> list[tuple[Path, str]]: 56 + def _search_dirs(self, content_type: str | None) -> list[tuple[Path, str]]: 57 57 """Directories to search for content files, paired with their 58 58 content type label. Walks the three layers in priority order. 59 59 """
+25 -13
src/storied/display.py
··· 6 6 """ 7 7 8 8 import re 9 - from io import TextIOBase 10 9 11 10 from rich import box as rich_box 12 11 from rich.align import Align ··· 14 13 from rich.panel import Panel 15 14 from rich.rule import Rule 16 15 17 - BLOCK_TYPES = ("map", "aside", "item", "scroll", "letter", "sign", "lore", "verse", "dream") 16 + BLOCK_TYPES = ( 17 + "map", 18 + "aside", 19 + "item", 20 + "scroll", 21 + "letter", 22 + "sign", 23 + "lore", 24 + "verse", 25 + "dream", 26 + ) 18 27 19 28 # (border_style, box, content_style) 20 29 BLOCK_STYLES: dict[str, tuple[str, rich_box.Box, str]] = { 21 - "map": ("green", rich_box.HEAVY, ""), 22 - "aside": ("yellow", rich_box.ROUNDED, ""), 23 - "item": ("magenta", rich_box.DOUBLE, ""), 24 - "scroll": ("dark_goldenrod", rich_box.DOUBLE, "navajo_white1 on grey7"), 25 - "letter": ("grey50", rich_box.ROUNDED, ""), 26 - "sign": ("bright_white", rich_box.HEAVY, "bold"), 27 - "lore": ("cornflower_blue", rich_box.ROUNDED, ""), 28 - "verse": ("pale_turquoise1", rich_box.ROUNDED, "italic"), 29 - "dream": ("medium_purple1", rich_box.ROUNDED, "dim italic"), 30 + "map": ("green", rich_box.HEAVY, ""), 31 + "aside": ("yellow", rich_box.ROUNDED, ""), 32 + "item": ("magenta", rich_box.DOUBLE, ""), 33 + "scroll": ("dark_goldenrod", rich_box.DOUBLE, "navajo_white1 on grey7"), 34 + "letter": ("grey50", rich_box.ROUNDED, ""), 35 + "sign": ("bright_white", rich_box.HEAVY, "bold"), 36 + "lore": ("cornflower_blue", rich_box.ROUNDED, ""), 37 + "verse": ("pale_turquoise1", rich_box.ROUNDED, "italic"), 38 + "dream": ("medium_purple1", rich_box.ROUNDED, "dim italic"), 30 39 } 31 40 32 41 _FENCE_RE = re.compile(r"^```(" + "|".join(BLOCK_TYPES) + r")\s*(.*)") ··· 52 61 def make_panel(kind: str, title: str, content: str) -> Panel: 53 62 """Create a styled Panel for a display block.""" 54 63 border_style, border_box, content_style = BLOCK_STYLES.get( 55 - kind, ("white", rich_box.ROUNDED, ""), 64 + kind, 65 + ("white", rich_box.ROUNDED, ""), 56 66 ) 57 67 kwargs: dict = dict( 58 68 title=title or None, ··· 82 92 83 93 def __init__(self, console: Console) -> None: 84 94 self._console = console 85 - self._out: TextIOBase = console.file 95 + self._out = console.file 86 96 87 97 # Inline markdown state 88 98 self._bold = False ··· 145 155 # ── Block mode ─────────────────────────────────────────────────── 146 156 147 157 def _feed_block(self, char: str) -> None: 158 + assert self._block is not None # caller guarantees we're in block mode 148 159 if char == "\n": 149 160 if self._block_line.strip() == "```": 150 161 self._close_block() ··· 155 166 self._block_line += char 156 167 157 168 def _close_block(self) -> None: 169 + assert self._block is not None # caller guarantees we're in block mode 158 170 kind = self._block["kind"] 159 171 title = self._block["title"] 160 172 content = "\n".join(self._block["lines"])
+20 -16
src/storied/engine.py
··· 72 72 self, 73 73 world_id: str = "default", 74 74 player_id: str = "default", 75 - model: str = "claude-opus-4-6", 75 + model: str = "claude-opus-4-7", 76 76 prompt_name: str = "dm-system", 77 77 transcript_path: Path | None = None, 78 78 ): ··· 208 208 # compose time; the engine reveals it for the duration of any 209 209 # turn where `advancement_ready` is set on the sheet. 210 210 from storied.tools.character import refresh_advancement_visibility 211 + 211 212 refresh_advancement_visibility(character) 212 213 213 214 # 2. Campaign log ··· 303 304 304 305 # Terminal width so the DM can size display blocks 305 306 import os 307 + 306 308 try: 307 309 term_width = os.get_terminal_size().columns 308 310 except OSError: ··· 316 318 if not self.world_id: 317 319 return None 318 320 319 - knowledge_dir = ( 320 - player_path(self.player_id) / "worlds" / self.world_id 321 - ) 321 + knowledge_dir = player_path(self.player_id) / "worlds" / self.world_id 322 322 if not knowledge_dir.exists(): 323 323 return None 324 324 ··· 449 449 context_breakdown[name] = self._estimate_tokens(content) 450 450 451 451 context_total = ( 452 - base_prompt_tokens 453 - + tool_surface_tokens 454 - + sum(context_breakdown.values()) 452 + base_prompt_tokens + tool_surface_tokens + sum(context_breakdown.values()) 455 453 ) 456 454 457 455 # Usage from the last result event. The Anthropic API splits ··· 541 539 self.combat_ended = True 542 540 543 541 is_deferred = ( 544 - short in ("roll", "run_code") 545 - or short in DEFERRED_FORMATTERS 542 + short in ("roll", "run_code") or short in DEFERRED_FORMATTERS 546 543 ) 547 544 if is_deferred and not self.debug: 548 545 # Signal the CLI to flush the renderer before we ··· 601 598 ) 602 599 self._total_input_tokens += real_in 603 600 self._total_output_tokens += r.usage.get("output_tokens", 0) 604 - self._log_transcript("result", { 605 - "session_id": r.session_id, 606 - "usage": r.usage, 607 - }) 601 + self._log_transcript( 602 + "result", 603 + { 604 + "session_id": r.session_id, 605 + "usage": r.usage, 606 + }, 607 + ) 608 608 609 609 # Write conversation turn to transcript 610 610 dm_response = "".join(dm_text_parts) ··· 618 618 self._mcp.ctx.vector_index.upsert( 619 619 f"transcript:transcripts/day+{game_time.day:03d}.md:0", 620 620 day_path.read_text(), 621 - {"source": "transcript", "content_type": "transcripts", 622 - "path": str(day_path), "title": f"Day {game_time.day}", 623 - "game_day": game_time.day}, 621 + { 622 + "source": "transcript", 623 + "content_type": "transcripts", 624 + "path": str(day_path), 625 + "title": f"Day {game_time.day}", 626 + "game_day": game_time.day, 627 + }, 624 628 ) 625 629 626 630 def reset(self) -> None:
+17 -16
src/storied/initiative.py
··· 75 75 self.active = True 76 76 77 77 first = self.combatants[0] 78 - lines = [f"Initiative started — Round 1", ""] 78 + lines = ["Initiative started — Round 1", ""] 79 79 lines.append(self._format_order()) 80 80 lines.append("") 81 81 lines.append(f"**{first.name}** goes first.") ··· 175 175 return f"Combatant '{target}' not found." 176 176 177 177 tc = TrackedCondition( 178 - name=condition, source=source, duration=duration, ends_on=ends_on, 178 + name=condition, 179 + source=source, 180 + duration=duration, 181 + ends_on=ends_on, 179 182 ) 180 183 combatant.conditions.append(tc) 181 184 ··· 212 215 self.current_index += 1 213 216 214 217 return ( 215 - f"{combatant.name} joins initiative " 216 - f"(initiative {combatant.initiative})" 218 + f"{combatant.name} joins initiative (initiative {combatant.initiative})" 217 219 ) 218 220 219 221 def remove_combatant(self, name: str) -> str: ··· 233 235 if not self.combatants: 234 236 self.active = False 235 237 return ( 236 - f"{removed.name} removed. No combatants remain — " 237 - f"initiative ended." 238 + f"{removed.name} removed. No combatants remain — initiative ended." 238 239 ) 239 240 240 241 if idx < self.current_index: 241 242 self.current_index -= 1 242 - elif idx == self.current_index: 243 - if self.current_index >= len(self.combatants): 244 - self.current_index = 0 245 - self.round += 1 243 + elif idx == self.current_index and self.current_index >= len( 244 + self.combatants 245 + ): 246 + self.current_index = 0 247 + self.round += 1 246 248 247 249 return f"{removed.name} removed from initiative." 248 250 ··· 255 257 duration_sec = rounds * 6 256 258 257 259 lines = [ 258 - f"Initiative ended after {rounds} rounds " 259 - f"({duration_sec} seconds)." 260 + f"Initiative ended after {rounds} rounds ({duration_sec} seconds)." 260 261 ] 261 262 262 263 if defeated: ··· 301 302 if c.defeated: 302 303 name = f"~~{c.name}~~" 303 304 hp_str = f"{c.hp}/{c.hp_max}" 304 - conds = ", ".join( 305 - self._format_condition(co) for co in c.conditions 306 - ) 305 + conds = ", ".join(self._format_condition(co) for co in c.conditions) 307 306 if c.defeated and not conds: 308 307 conds = "Defeated" 309 308 lines.append( ··· 397 396 def _format_order(self) -> str: 398 397 parts = [] 399 398 for c in self.combatants: 400 - parts.append(f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})") 399 + parts.append( 400 + f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})" 401 + ) 401 402 return "\n".join(parts)
+9 -8
src/storied/log.py
··· 460 460 if last_idx == -1: 461 461 return all_entries 462 462 463 - return all_entries[last_idx + 1:] 463 + return all_entries[last_idx + 1 :] 464 464 465 465 def find_tag_entries(self, tag: str) -> list[LogEntry]: 466 466 """Find all entries with a given tag, across all days.""" ··· 524 524 return self.transcript_dir / f"day{day:04d}.md" 525 525 526 526 def append_turn( 527 - self, player_input: str, dm_response: str, game_time: "GameTime", 527 + self, 528 + player_input: str, 529 + dm_response: str, 530 + game_time: GameTime, 528 531 ) -> None: 529 532 """Append a turn to the current day's transcript.""" 530 533 # Skip system messages (session starting, save requests, etc.) ··· 537 540 # Blockquote the player input 538 541 quoted = "\n".join(f"> {line}" for line in player_input.splitlines()) 539 542 540 - turn = ( 541 - f"\n### {game_time}\n\n" 542 - f"{quoted}\n\n" 543 - f"{dm_response.strip()}\n" 544 - ) 543 + turn = f"\n### {game_time}\n\n{quoted}\n\n{dm_response.strip()}\n" 545 544 546 545 with path.open("a") as f: 547 546 f.write(turn) 548 547 549 548 def recent_turns( 550 - self, current_day: int, n: int = 10, 549 + self, 550 + current_day: int, 551 + n: int = 10, 551 552 ) -> str: 552 553 """Load the last N turns across recent days for context injection.""" 553 554 turns: list[str] = []
+4 -1
src/storied/mcp_server.py
··· 120 120 # by the engine after each turn with source="transcript". 121 121 if world_dir.exists(): 122 122 vi.reindex_directory( 123 - world_dir, source="world", skip_subdirs=frozenset({"transcripts"}), 123 + world_dir, 124 + source="world", 125 + skip_subdirs=frozenset({"transcripts"}), 124 126 ) 125 127 126 128 ··· 169 171 global _tool_signatures 170 172 if _tool_signatures is None: 171 173 from storied.sandbox import build_tool_signatures 174 + 172 175 _tool_signatures = build_tool_signatures() 173 176 for tool in await server.list_tools(): 174 177 if tool.description and "{tool_signatures}" in tool.description:
+369 -60
src/storied/names/data/inventories.py
··· 32 32 PhonemeInventory( 33 33 name="welsh", 34 34 consonants=[ 35 - "n", "r", "l", "d", "s", "h", "g", "m", "k", 36 - "th", "dh", "rh", "ll", "f", "p", "t", "b", "ch", "w", 35 + "n", 36 + "r", 37 + "l", 38 + "d", 39 + "s", 40 + "h", 41 + "g", 42 + "m", 43 + "k", 44 + "th", 45 + "dh", 46 + "rh", 47 + "ll", 48 + "f", 49 + "p", 50 + "t", 51 + "b", 52 + "ch", 53 + "w", 37 54 ], 38 55 vowels=["a", "e", "i", "o", "u", "y", "ae", "wy", "aw"], 39 56 liquids=["l", "r", "ll", "rh"], ··· 46 63 PhonemeInventory( 47 64 name="gaelic", 48 65 consonants=[ 49 - "n", "r", "l", "s", "d", "t", "g", "b", "m", "k", 50 - "ch", "mh", "bh", "f", "p", "h", "th", 66 + "n", 67 + "r", 68 + "l", 69 + "s", 70 + "d", 71 + "t", 72 + "g", 73 + "b", 74 + "m", 75 + "k", 76 + "ch", 77 + "mh", 78 + "bh", 79 + "f", 80 + "p", 81 + "h", 82 + "th", 51 83 ], 52 84 vowels=["a", "i", "e", "o", "u", "ai", "ei", "io", "ua"], 53 85 liquids=["l", "r"], ··· 60 92 PhonemeInventory( 61 93 name="cornish", 62 94 consonants=[ 63 - "n", "r", "l", "d", "s", "t", "k", "g", "m", "p", 64 - "h", "w", "th", "gh", "f", "v", 95 + "n", 96 + "r", 97 + "l", 98 + "d", 99 + "s", 100 + "t", 101 + "k", 102 + "g", 103 + "m", 104 + "p", 105 + "h", 106 + "w", 107 + "th", 108 + "gh", 109 + "f", 110 + "v", 65 111 ], 66 112 vowels=["a", "e", "i", "o", "u", "y", "oe", "eu"], 67 113 liquids=["l", "r"], ··· 70 116 ), 71 117 {"coastal", "pastoral", "ancient"}, 72 118 ), 73 - 74 119 # ----- Germanic family ---------------------------------------------- 75 120 ( 76 121 PhonemeInventory( 77 122 name="old-norse", 78 123 consonants=[ 79 - "r", "n", "s", "t", "k", "l", "d", "g", "m", "h", 80 - "th", "v", "f", "p", "b", "j", 124 + "r", 125 + "n", 126 + "s", 127 + "t", 128 + "k", 129 + "l", 130 + "d", 131 + "g", 132 + "m", 133 + "h", 134 + "th", 135 + "v", 136 + "f", 137 + "p", 138 + "b", 139 + "j", 81 140 ], 82 141 vowels=["a", "i", "e", "u", "o", "ø", "y", "ei", "au"], 83 142 liquids=["r", "l"], ··· 90 149 PhonemeInventory( 91 150 name="old-english", 92 151 consonants=[ 93 - "n", "r", "s", "t", "l", "d", "h", "w", "k", "g", 94 - "m", "th", "f", "b", "p", "sh", 152 + "n", 153 + "r", 154 + "s", 155 + "t", 156 + "l", 157 + "d", 158 + "h", 159 + "w", 160 + "k", 161 + "g", 162 + "m", 163 + "th", 164 + "f", 165 + "b", 166 + "p", 167 + "sh", 95 168 ], 96 169 vowels=["a", "e", "i", "o", "u", "y", "ae", "ea", "eo"], 97 170 liquids=["r", "l"], ··· 104 177 PhonemeInventory( 105 178 name="germanic-soot", 106 179 consonants=[ 107 - "r", "n", "s", "t", "k", "l", "h", "g", "d", "m", 108 - "f", "p", "b", "ch", "z", "sh", 180 + "r", 181 + "n", 182 + "s", 183 + "t", 184 + "k", 185 + "l", 186 + "h", 187 + "g", 188 + "d", 189 + "m", 190 + "f", 191 + "p", 192 + "b", 193 + "ch", 194 + "z", 195 + "sh", 109 196 ], 110 197 vowels=["a", "e", "i", "o", "u", "ä", "ö", "ü"], 111 198 liquids=["r", "l"], ··· 114 201 ), 115 202 {"industrial", "highborn"}, 116 203 ), 117 - 118 204 # ----- Romance / Latin-derived -------------------------------------- 119 205 ( 120 206 PhonemeInventory( 121 207 name="latin-clerical", 122 208 consonants=[ 123 - "s", "n", "r", "t", "l", "i", "k", "m", "d", "p", 124 - "g", "b", "f", "v", "h", 209 + "s", 210 + "n", 211 + "r", 212 + "t", 213 + "l", 214 + "i", 215 + "k", 216 + "m", 217 + "d", 218 + "p", 219 + "g", 220 + "b", 221 + "f", 222 + "v", 223 + "h", 125 224 ], 126 225 vowels=["a", "e", "i", "o", "u", "ae", "oe", "au"], 127 226 liquids=["l", "r"], ··· 134 233 PhonemeInventory( 135 234 name="iberian", 136 235 consonants=[ 137 - "r", "n", "s", "l", "t", "d", "k", "m", "g", "p", 138 - "ch", "ñ", "ll", "j", "f", "rr", 236 + "r", 237 + "n", 238 + "s", 239 + "l", 240 + "t", 241 + "d", 242 + "k", 243 + "m", 244 + "g", 245 + "p", 246 + "ch", 247 + "ñ", 248 + "ll", 249 + "j", 250 + "f", 251 + "rr", 139 252 ], 140 253 vowels=["a", "e", "i", "o", "u"], 141 254 liquids=["l", "r", "ll", "rr"], ··· 144 257 ), 145 258 {"coastal", "pastoral", "highborn"}, 146 259 ), 147 - 148 260 # ----- Slavic family ------------------------------------------------ 149 261 ( 150 262 PhonemeInventory( 151 263 name="slavic-east", 152 264 consonants=[ 153 - "n", "r", "s", "t", "l", "k", "d", "v", "m", "p", 154 - "z", "g", "ch", "sh", "zh", "h", 265 + "n", 266 + "r", 267 + "s", 268 + "t", 269 + "l", 270 + "k", 271 + "d", 272 + "v", 273 + "m", 274 + "p", 275 + "z", 276 + "g", 277 + "ch", 278 + "sh", 279 + "zh", 280 + "h", 155 281 ], 156 282 vowels=["a", "o", "e", "i", "u", "y", "ya", "yu"], 157 283 liquids=["l", "r"], ··· 160 286 ), 161 287 {"highland", "pastoral", "ancient"}, 162 288 ), 163 - 164 289 # ----- Semitic / Levantine ------------------------------------------ 165 290 ( 166 291 PhonemeInventory( 167 292 name="aramaic", 168 293 consonants=[ 169 - "l", "n", "r", "m", "s", "t", "k", "h", "d", "b", 170 - "sh", "kh", "th", "q", "ts", "p", "z", "gh", 294 + "l", 295 + "n", 296 + "r", 297 + "m", 298 + "s", 299 + "t", 300 + "k", 301 + "h", 302 + "d", 303 + "b", 304 + "sh", 305 + "kh", 306 + "th", 307 + "q", 308 + "ts", 309 + "p", 310 + "z", 311 + "gh", 171 312 ], 172 313 vowels=["a", "i", "u", "e", "o"], 173 314 liquids=["l", "r"], ··· 180 321 PhonemeInventory( 181 322 name="arabic-port", 182 323 consonants=[ 183 - "l", "r", "n", "m", "s", "t", "k", "h", "d", "b", 184 - "sh", "kh", "q", "j", "z", "f", "gh", "ts", 324 + "l", 325 + "r", 326 + "n", 327 + "m", 328 + "s", 329 + "t", 330 + "k", 331 + "h", 332 + "d", 333 + "b", 334 + "sh", 335 + "kh", 336 + "q", 337 + "j", 338 + "z", 339 + "f", 340 + "gh", 341 + "ts", 185 342 ], 186 343 vowels=["a", "i", "u", "aa", "ii", "uu"], 187 344 liquids=["l", "r"], ··· 190 347 ), 191 348 {"coastal", "desert", "ancient"}, 192 349 ), 193 - 194 350 # ----- Hellenic ----------------------------------------------------- 195 351 ( 196 352 PhonemeInventory( 197 353 name="hellenic", 198 354 consonants=[ 199 - "s", "n", "r", "t", "l", "k", "p", "m", "d", "th", 200 - "ph", "ch", "h", "g", "x", "ps", 355 + "s", 356 + "n", 357 + "r", 358 + "t", 359 + "l", 360 + "k", 361 + "p", 362 + "m", 363 + "d", 364 + "th", 365 + "ph", 366 + "ch", 367 + "h", 368 + "g", 369 + "x", 370 + "ps", 201 371 ], 202 372 vowels=["a", "o", "e", "i", "u", "ai", "oi", "eu"], 203 373 liquids=["l", "r"], ··· 206 376 ), 207 377 {"coastal", "highborn", "liturgical", "ancient"}, 208 378 ), 209 - 210 379 # ----- Uralic / Finnic ---------------------------------------------- 211 380 ( 212 381 PhonemeInventory( 213 382 name="finnic", 214 383 consonants=[ 215 - "n", "l", "t", "s", "k", "r", "i", "m", "h", "p", 216 - "v", "j", "y", 384 + "n", 385 + "l", 386 + "t", 387 + "s", 388 + "k", 389 + "r", 390 + "i", 391 + "m", 392 + "h", 393 + "p", 394 + "v", 395 + "j", 396 + "y", 217 397 ], 218 398 vowels=["a", "i", "e", "o", "u", "ä", "ö", "y", "ai", "äi"], 219 399 liquids=["l", "r"], ··· 222 402 ), 223 403 {"forest", "pastoral", "ancient"}, 224 404 ), 225 - 226 405 # ----- Turkic / Steppe ---------------------------------------------- 227 406 ( 228 407 PhonemeInventory( 229 408 name="turkic", 230 409 consonants=[ 231 - "n", "r", "l", "t", "k", "s", "m", "d", "y", "b", 232 - "g", "sh", "ch", "z", "h", 410 + "n", 411 + "r", 412 + "l", 413 + "t", 414 + "k", 415 + "s", 416 + "m", 417 + "d", 418 + "y", 419 + "b", 420 + "g", 421 + "sh", 422 + "ch", 423 + "z", 424 + "h", 233 425 ], 234 426 vowels=["a", "e", "i", "o", "u", "ı", "ö", "ü"], 235 427 liquids=["l", "r"], ··· 242 434 PhonemeInventory( 243 435 name="mongolic", 244 436 consonants=[ 245 - "n", "r", "l", "g", "t", "k", "s", "d", "m", "b", 246 - "y", "kh", "ch", "j", "h", 437 + "n", 438 + "r", 439 + "l", 440 + "g", 441 + "t", 442 + "k", 443 + "s", 444 + "d", 445 + "m", 446 + "b", 447 + "y", 448 + "kh", 449 + "ch", 450 + "j", 451 + "h", 247 452 ], 248 453 vowels=["a", "o", "u", "e", "i"], 249 454 liquids=["l", "r"], ··· 252 457 ), 253 458 {"steppe", "ancient", "pastoral"}, 254 459 ), 255 - 256 460 # ----- Caucasian --------------------------------------------------- 257 461 ( 258 462 PhonemeInventory( 259 463 name="kartvelian", 260 464 consonants=[ 261 - "r", "n", "l", "t", "k", "s", "m", "d", "g", "b", 262 - "ts", "ch", "kh", "ph", "q", "z", "v", "sh", 465 + "r", 466 + "n", 467 + "l", 468 + "t", 469 + "k", 470 + "s", 471 + "m", 472 + "d", 473 + "g", 474 + "b", 475 + "ts", 476 + "ch", 477 + "kh", 478 + "ph", 479 + "q", 480 + "z", 481 + "v", 482 + "sh", 263 483 ], 264 484 vowels=["a", "e", "i", "o", "u"], 265 485 liquids=["l", "r"], ··· 268 488 ), 269 489 {"highland", "ancient", "highborn"}, 270 490 ), 271 - 272 491 # ----- Pacific ----------------------------------------------------- 273 492 ( 274 493 PhonemeInventory( ··· 285 504 PhonemeInventory( 286 505 name="austronesian", 287 506 consonants=[ 288 - "n", "t", "k", "l", "s", "m", "r", "p", "d", "g", 289 - "ng", "h", "w", "y", "b", 507 + "n", 508 + "t", 509 + "k", 510 + "l", 511 + "s", 512 + "m", 513 + "r", 514 + "p", 515 + "d", 516 + "g", 517 + "ng", 518 + "h", 519 + "w", 520 + "y", 521 + "b", 290 522 ], 291 523 vowels=["a", "i", "u", "e", "o"], 292 524 liquids=["l", "r"], ··· 295 527 ), 296 528 {"coastal", "forest", "pastoral"}, 297 529 ), 298 - 299 530 # ----- East Asian -------------------------------------------------- 300 531 ( 301 532 PhonemeInventory( 302 533 name="japonic", 303 534 consonants=[ 304 - "n", "k", "t", "s", "r", "m", "h", "g", "d", "b", 305 - "y", "w", "z", "p", "sh", "ch", 535 + "n", 536 + "k", 537 + "t", 538 + "s", 539 + "r", 540 + "m", 541 + "h", 542 + "g", 543 + "d", 544 + "b", 545 + "y", 546 + "w", 547 + "z", 548 + "p", 549 + "sh", 550 + "ch", 306 551 ], 307 552 vowels=["a", "i", "u", "e", "o"], 308 553 liquids=["r"], ··· 311 556 ), 312 557 {"coastal", "highborn", "ancient"}, 313 558 ), 314 - 315 559 # ----- South Asian ------------------------------------------------- 316 560 ( 317 561 PhonemeInventory( 318 562 name="dravidian", 319 563 consonants=[ 320 - "n", "r", "t", "k", "l", "m", "p", "v", "y", "ch", 321 - "th", "d", "g", "ng", "ny", "zh", 564 + "n", 565 + "r", 566 + "t", 567 + "k", 568 + "l", 569 + "m", 570 + "p", 571 + "v", 572 + "y", 573 + "ch", 574 + "th", 575 + "d", 576 + "g", 577 + "ng", 578 + "ny", 579 + "zh", 322 580 ], 323 581 vowels=["a", "i", "u", "e", "o", "aa", "ii", "uu"], 324 582 liquids=["l", "r", "zh"], ··· 327 585 ), 328 586 {"forest", "ancient", "highborn"}, 329 587 ), 330 - 331 588 # ----- Andean ------------------------------------------------------ 332 589 ( 333 590 PhonemeInventory( 334 591 name="quechuan", 335 592 consonants=[ 336 - "n", "k", "t", "p", "s", "r", "m", "ch", "y", "w", 337 - "l", "h", "ll", "q", "kh", "ph", 593 + "n", 594 + "k", 595 + "t", 596 + "p", 597 + "s", 598 + "r", 599 + "m", 600 + "ch", 601 + "y", 602 + "w", 603 + "l", 604 + "h", 605 + "ll", 606 + "q", 607 + "kh", 608 + "ph", 338 609 ], 339 610 vowels=["a", "i", "u"], 340 611 liquids=["l", "r", "ll"], ··· 343 614 ), 344 615 {"highland", "ancient", "pastoral"}, 345 616 ), 346 - 347 617 # ----- Mesoamerican ------------------------------------------------ 348 618 ( 349 619 PhonemeInventory( 350 620 name="nahuatl", 351 621 consonants=[ 352 - "n", "t", "k", "l", "s", "ch", "m", "y", "w", "p", 353 - "tz", "x", "h", "ts", "tl", 622 + "n", 623 + "t", 624 + "k", 625 + "l", 626 + "s", 627 + "ch", 628 + "m", 629 + "y", 630 + "w", 631 + "p", 632 + "tz", 633 + "x", 634 + "h", 635 + "ts", 636 + "tl", 354 637 ], 355 638 vowels=["a", "i", "e", "o", "u"], 356 639 liquids=["l"], ··· 359 642 ), 360 643 {"forest", "highland", "ancient"}, 361 644 ), 362 - 363 645 # ----- West African ------------------------------------------------ 364 646 ( 365 647 PhonemeInventory( 366 648 name="yoruboid", 367 649 consonants=[ 368 - "n", "l", "r", "k", "b", "t", "s", "m", "y", "g", 369 - "p", "f", "j", "w", "sh", 650 + "n", 651 + "l", 652 + "r", 653 + "k", 654 + "b", 655 + "t", 656 + "s", 657 + "m", 658 + "y", 659 + "g", 660 + "p", 661 + "f", 662 + "j", 663 + "w", 664 + "sh", 370 665 ], 371 666 vowels=["a", "e", "i", "o", "u", "ẹ", "ọ"], 372 667 liquids=["l", "r"], ··· 379 674 PhonemeInventory( 380 675 name="bantoid", 381 676 consonants=[ 382 - "n", "m", "l", "k", "t", "b", "s", "g", "p", "d", 383 - "w", "y", "z", "v", "ng", "ny", 677 + "n", 678 + "m", 679 + "l", 680 + "k", 681 + "t", 682 + "b", 683 + "s", 684 + "g", 685 + "p", 686 + "d", 687 + "w", 688 + "y", 689 + "z", 690 + "v", 691 + "ng", 692 + "ny", 384 693 ], 385 694 vowels=["a", "i", "u", "e", "o"], 386 695 liquids=["l"],
+53 -8
src/storied/names/engine/clusters.py
··· 37 37 # enough to ensure each culture rejects a few combinations and 38 38 # they vary across cultures. 39 39 FORBIDDEN_BANK: list[str] = [ 40 - "#sr", "#tl", "#sl", "#dl", "#tn", "#mn", "#km", "#nl", "#nr", 41 - "#ml", "#mr", "#mlr", "#lr", "#rl", "#nz", "#kn", "#gn", 42 - "tl#", "rl#", "lr#", "nm#", "mn#", "ml#", "mr#", "nl#", 43 - "sshs", "tttt", "kkk", "rrrr", "llll", "ghgh", "khkh", 44 - "tlt", "rlr", "lrl", "nmn", "mnm", "rnr", "lnl", 45 - "qq", "xx", "zz", "ngng", "ththth", 46 - "ghgh", "ghgh", "mlm", "mrm", "rlr", 47 - "mlm", "mrm", "rlw", "rly", 40 + "#sr", 41 + "#tl", 42 + "#sl", 43 + "#dl", 44 + "#tn", 45 + "#mn", 46 + "#km", 47 + "#nl", 48 + "#nr", 49 + "#ml", 50 + "#mr", 51 + "#mlr", 52 + "#lr", 53 + "#rl", 54 + "#nz", 55 + "#kn", 56 + "#gn", 57 + "tl#", 58 + "rl#", 59 + "lr#", 60 + "nm#", 61 + "mn#", 62 + "ml#", 63 + "mr#", 64 + "nl#", 65 + "sshs", 66 + "tttt", 67 + "kkk", 68 + "rrrr", 69 + "llll", 70 + "ghgh", 71 + "khkh", 72 + "tlt", 73 + "rlr", 74 + "lrl", 75 + "nmn", 76 + "mnm", 77 + "rnr", 78 + "lnl", 79 + "qq", 80 + "xx", 81 + "zz", 82 + "ngng", 83 + "ththth", 84 + "ghgh", 85 + "ghgh", 86 + "mlm", 87 + "mrm", 88 + "rlr", 89 + "mlm", 90 + "mrm", 91 + "rlw", 92 + "rly", 48 93 ]
+8 -2
src/storied/names/engine/generator.py
··· 52 52 """Sample a phoneme sequence that passes all the engine's checks.""" 53 53 for _ in range(max_attempts): 54 54 phonemes = sample_word( 55 - self._parsed, self.inventory, rng, self.syllable_count, 55 + self._parsed, 56 + self.inventory, 57 + rng, 58 + self.syllable_count, 56 59 ) 57 60 if not phonemes: 58 61 continue ··· 68 71 return phonemes 69 72 # Fallback: relax constraints rather than loop forever 70 73 return sample_word( 71 - self._parsed, self.inventory, rng, self.syllable_count, 74 + self._parsed, 75 + self.inventory, 76 + rng, 77 + self.syllable_count, 72 78 ) 73 79 74 80 def name(self, seed: int | None = None) -> str:
+20 -3
src/storied/names/engine/inventory.py
··· 80 80 from its source inventory — a Welsh-derived culture is not Welsh, 81 81 it's a phonotactic cousin. 82 82 """ 83 + 83 84 def _trim(items: list[str]) -> list[str]: 84 85 if len(items) <= 3: 85 86 return list(items) 86 - drop_n = max(0, int(round(len(items) * drop_fraction))) 87 + drop_n = max(0, round(len(items) * drop_fraction)) 87 88 keep = list(items) 88 89 for _ in range(drop_n): 89 90 idx = rng.randrange(len(keep)) ··· 103 104 _LIQUID_HEURISTIC = {"l", "r", "ll", "rr", "rh", "lh", "ɾ", "ʎ", "ʟ"} 104 105 _NASAL_HEURISTIC = {"m", "n", "ng", "ñ", "ŋ", "nh", "mh"} 105 106 _FRICATIVE_HEURISTIC = { 106 - "s", "z", "f", "v", "sh", "zh", "th", "dh", "h", "x", "ch", 107 - "ʃ", "ʒ", "θ", "ð", "χ", "ɣ", "ħ", 107 + "s", 108 + "z", 109 + "f", 110 + "v", 111 + "sh", 112 + "zh", 113 + "th", 114 + "dh", 115 + "h", 116 + "x", 117 + "ch", 118 + "ʃ", 119 + "ʒ", 120 + "θ", 121 + "ð", 122 + "χ", 123 + "ɣ", 124 + "ħ", 108 125 } 109 126 110 127
+13 -7
src/storied/names/forge.py
··· 214 214 rng = random.Random(seed) 215 215 216 216 # Pick an inventory (biased by feel if given) 217 - candidates = ( 218 - inventories_for_feel(feel) if feel else all_inventories() 219 - ) 217 + candidates = inventories_for_feel(feel) if feel else all_inventories() 220 218 source = rng.choice(candidates) 221 219 222 220 # Trim it for distinctiveness, but only if it's big enough ··· 238 236 # Pick 3-5 forbidden clusters from the bank 239 237 n_forbidden = rng.randint(3, 5) 240 238 forbidden = rng.sample( 241 - FORBIDDEN_BANK, k=min(n_forbidden, len(FORBIDDEN_BANK)), 239 + FORBIDDEN_BANK, 240 + k=min(n_forbidden, len(FORBIDDEN_BANK)), 242 241 ) 243 242 244 243 # Pick 0-2 rewrite rules ··· 304 303 return _ascii_slug(sub_engine.name(seed=seed)) 305 304 306 305 def _generate_morphology( 307 - self, engine: Engine, rng: random.Random, 306 + self, 307 + engine: Engine, 308 + rng: random.Random, 308 309 ) -> Morphology: 309 310 """Generate gendered suffixes from the engine, or none.""" 310 311 if rng.random() < 0.4: ··· 322 323 ) 323 324 324 325 def _suffixes( 325 - self, engine: Engine, rng: random.Random, count: int, 326 + self, 327 + engine: Engine, 328 + rng: random.Random, 329 + count: int, 326 330 ) -> list[str]: 327 331 """Generate `count` short suffixes from the engine. 328 332 ··· 349 353 return sorted(out) 350 354 351 355 def _generate_place_suffixes( 352 - self, engine: Engine, rng: random.Random, 356 + self, 357 + engine: Engine, 358 + rng: random.Random, 353 359 ) -> list[str]: 354 360 """Generate place-name suffixes from the engine.""" 355 361 return self._suffixes(engine, rng, count=5)
+2 -1
src/storied/names/generator.py
··· 33 33 34 34 def _forge(self) -> CultureForge: 35 35 return CultureForge( 36 - world_path=self.world_path, cultures_subdir=self.cultures_subdir, 36 + world_path=self.world_path, 37 + cultures_subdir=self.cultures_subdir, 37 38 ) 38 39 39 40 def load_culture(self, name: str) -> ForgedCulture | None:
+1 -6
src/storied/notifications.py
··· 12 12 13 13 from storied.paths import world_path 14 14 15 - 16 15 _lock = threading.Lock() 17 16 18 17 ··· 43 42 # Clear the file 44 43 path.unlink(missing_ok=True) 45 44 46 - return [ 47 - line.lstrip("- ").strip() 48 - for line in content.splitlines() 49 - if line.strip() 50 - ] 45 + return [line.lstrip("- ").strip() for line in content.splitlines() if line.strip()]
+1 -2
src/storied/paths.py
··· 32 32 from __future__ import annotations 33 33 34 34 import os 35 + from collections.abc import Iterator 35 36 from contextlib import contextmanager 36 37 from pathlib import Path 37 - from typing import Iterator 38 - 39 38 40 39 _DEFAULT_DATA_HOME = Path.home() / ".storied" 41 40
+15 -23
src/storied/planner.py
··· 12 12 from storied.claude import run_prompt, run_with_tools 13 13 from storied.engine import load_prompt 14 14 from storied.log import CampaignLog 15 - from storied.tools._context import get_or_create_ctx 16 15 from storied.mcp_server import start_server as start_mcp_server 17 16 from storied.paths import data_home, world_path 18 17 from storied.session import ( ··· 21 20 resolve_wiki_link, 22 21 ) 23 22 from storied.tools import EntityIndex, _load_entity 23 + from storied.tools._context import get_or_create_ctx 24 24 25 25 # Shared empty index for read-only entity parsing (no cache needed) 26 26 _EMPTY_INDEX = EntityIndex() ··· 88 88 89 89 # Seed from current location 90 90 location = session.get("location") 91 - if location: 91 + if isinstance(location, str): 92 92 add_entity(location) 93 93 94 94 # Seed from present entities in session body 95 95 body = session.get("body", "") 96 - for name in extract_wiki_links(body): 97 - add_entity(name) 96 + if isinstance(body, str): 97 + for name in extract_wiki_links(body): 98 + add_entity(name) 98 99 99 100 # Follow links from all discovered entities (breadth-first, one pass) 100 101 i = 0 ··· 234 235 "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " 235 236 "Apply them as you decide what to add to each thin entity — " 236 237 "they're lenses for finding less-obvious choices, not " 237 - "things you must incorporate:\n\n" 238 - + "\n".join(f"- {s}" for s in obliques) 238 + "things you must incorporate:\n\n" + "\n".join(f"- {s}" for s in obliques) 239 239 ) 240 240 parts.append(oblique_text) 241 241 ··· 292 292 def plan_world( 293 293 world_id: str = "default", 294 294 player_id: str = "default", 295 - model: str = "claude-opus-4-6", 295 + model: str = "claude-opus-4-7", 296 296 threshold: float = 0.7, 297 297 max_entities: int = 8, 298 298 dry_run: bool = False, ··· 385 385 def seed_world( 386 386 world_id: str = "default", 387 387 player_id: str = "default", 388 - model: str = "claude-opus-4-6", 388 + model: str = "claude-opus-4-7", 389 389 on_progress: Callable[[str], None] | None = None, 390 390 ) -> SeedResult: 391 391 """Build the initial world from a character sheet.""" ··· 416 416 "Weave at least three of these into the entities you build. " 417 417 "They should appear as concrete details — an item an NPC " 418 418 "carries, a feature of a location, the texture of a thread:" 419 - "\n\n" 420 - + "\n".join(f"- {c}" for c in concepts) 421 - + "\n\n---\n\n" 419 + "\n\n" + "\n".join(f"- {c}" for c in concepts) + "\n\n---\n\n" 422 420 ) 423 421 424 422 obliques = _draw_oblique_strategies(count=3) ··· 427 425 "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " 428 426 "Apply them as you decide what to establish — they're lenses " 429 427 "for finding less-obvious choices about what each entity is " 430 - "and what it wants:\n\n" 431 - + "\n".join(f"- {s}" for s in obliques) 432 - + "\n\n---\n\n" 428 + "and what it wants:\n\n" + "\n".join(f"- {s}" for s in obliques) + "\n\n---\n\n" 433 429 ) 434 430 435 431 context = ( ··· 471 467 def plot_arc( 472 468 world_id: str = "default", 473 469 player_id: str = "default", 474 - model: str = "claude-opus-4-6", 470 + model: str = "claude-opus-4-7", 475 471 on_progress: Callable[[str], None] | None = None, 476 472 ) -> SeedResult: 477 473 """Two-pass arc planning: cold draft → architect commit. ··· 506 502 # ---- Pass A: cold draft (no tools, no anti-rut help) ---- 507 503 progress("Drafting the default treatment (Pass A)...") 508 504 cold_draft = run_prompt( 509 - system_prompt=load_prompt("cold-draft"), 505 + system_prompt=load_prompt("arc-cold-draft"), 510 506 user_message=style_block + char_block, 511 507 model=model, 512 508 effort="max", ··· 550 546 ) 551 547 552 548 context = ( 553 - style_block 554 - + cold_draft_block 555 - + concept_block 556 - + oblique_block 557 - + char_block 549 + style_block + cold_draft_block + concept_block + oblique_block + char_block 558 550 ) 559 551 560 552 mcp = start_mcp_server(world_id, player_id, "arc_architect") ··· 668 660 def tick_world( # pragma: no cover 669 661 world_id: str = "default", 670 662 player_id: str = "default", 671 - model: str = "claude-opus-4-6", 663 + model: str = "claude-opus-4-7", 672 664 on_progress: Callable[[str], None] | None = None, 673 665 ) -> TickResult: 674 666 """Advance the world by evaluating Will triggers and adding small changes. ··· 738 730 self, 739 731 world_id: str, 740 732 player_id: str, 741 - model: str = "claude-opus-4-6", 733 + model: str = "claude-opus-4-7", 742 734 ): 743 735 self._world_id = world_id 744 736 self._player_id = player_id
+5 -2
src/storied/sandbox.py
··· 14 14 from typing import Any 15 15 16 16 import pydantic_monty 17 - from uncalled_for import Dependency, get_dependency_parameters, resolved_dependencies 17 + from uncalled_for import get_dependency_parameters, resolved_dependencies 18 18 19 19 from storied.dice import roll as dice_roll 20 20 from storied.tools import character, combat, entities, mechanics, scene ··· 46 46 resolved values back into the call. The sandbox passes its own kwargs 47 47 for the LLM-visible params. 48 48 """ 49 + 49 50 def wrapper(**kwargs: Any) -> Any: 50 51 async def _run() -> Any: 51 52 async with resolved_dependencies(fn, kwargs) as deps: 52 53 return fn(**{**kwargs, **deps}) 54 + 53 55 return asyncio.run(_run()) 56 + 54 57 wrapper.__name__ = fn.__name__ 55 58 wrapper.__doc__ = fn.__doc__ 56 59 return wrapper ··· 73 76 if not hasattr(tool, "fn"): 74 77 continue # skip non-function components (resources, prompts) 75 78 seen.add(name) 76 - pairs.append((name, tool.fn)) 79 + pairs.append((name, tool.fn)) # pyright: ignore[reportAttributeAccessIssue] 77 80 return pairs 78 81 79 82
+48 -48
src/storied/search.py
··· 12 12 from dataclasses import dataclass 13 13 from pathlib import Path 14 14 15 - import pysqlite3 as sqlite3 15 + import pysqlite3 as sqlite3 # pyright: ignore[reportMissingTypeStubs] 16 16 import sqlite_vec 17 17 from fastembed import TextEmbedding 18 18 ··· 22 22 23 23 # Splitting patterns, tried in order from coarsest to finest. 24 24 _SPLIT_PATTERNS = [ 25 - r"\n(?=## )", # ## headings 26 - r"\n(?=### )", # ### headings 27 - r"\n(?=#### )", # #### headings (class features, combat sub-topics) 28 - r"\n(?=\*\*[A-Z])", # bold definitions (glossary entries, level features) 29 - r"\n\n", # paragraph breaks (table rows, final resort) 25 + r"\n(?=## )", # ## headings 26 + r"\n(?=### )", # ### headings 27 + r"\n(?=#### )", # #### headings (class features, combat sub-topics) 28 + r"\n(?=\*\*[A-Z])", # bold definitions (glossary entries, level features) 29 + r"\n\n", # paragraph breaks (table rows, final resort) 30 30 ] 31 31 32 32 ··· 53 53 54 54 55 55 def _split_oversized( 56 - sections: list[str], threshold: int, patterns: list[str], 56 + sections: list[str], 57 + threshold: int, 58 + patterns: list[str], 57 59 ) -> list[str]: 58 60 """Recursively split sections that exceed threshold using finer patterns.""" 59 61 if not patterns: ··· 73 75 else: 74 76 result.extend( 75 77 _split_oversized( 76 - [p for p in parts if p.strip()], threshold, remaining, 78 + [p for p in parts if p.strip()], 79 + threshold, 80 + remaining, 77 81 ) 78 82 ) 79 83 ··· 99 103 return [(0, content)] 100 104 101 105 raw_sections = _split_oversized( 102 - [content], CHUNK_CHAR_THRESHOLD, _SPLIT_PATTERNS, 106 + [content], 107 + CHUNK_CHAR_THRESHOLD, 108 + _SPLIT_PATTERNS, 103 109 ) 104 110 105 111 chunks: list[tuple[int, str]] = [] ··· 130 136 return [vec.tolist() for vec in _default_embed._model.embed(texts)] # type: ignore[attr-defined] 131 137 132 138 133 - def _connect(db_path: str) -> sqlite3.Connection: 139 + def _connect(db_path: str) -> sqlite3.Connection: # pyright: ignore[reportAttributeAccessIssue] 134 140 """Open a SQLite connection with sqlite-vec loaded.""" 135 - conn = sqlite3.connect(db_path, check_same_thread=False) 141 + conn = sqlite3.connect(db_path, check_same_thread=False) # pyright: ignore[reportAttributeAccessIssue] 136 142 conn.enable_load_extension(True) 137 143 sqlite_vec.load(conn) 138 144 conn.enable_load_extension(False) ··· 195 201 with self._lock: 196 202 self._conn.close() 197 203 198 - def _open_or_recreate(self) -> sqlite3.Connection: 204 + def _open_or_recreate(self) -> sqlite3.Connection: # pyright: ignore[reportAttributeAccessIssue] 199 205 """Open the database, recreating if corrupt.""" 200 206 self._db_path.parent.mkdir(parents=True, exist_ok=True) 201 207 try: ··· 207 213 self._db_path.unlink() 208 214 return self._create_fresh() 209 215 210 - def _create_fresh(self) -> sqlite3.Connection: 216 + def _create_fresh(self) -> sqlite3.Connection: # pyright: ignore[reportAttributeAccessIssue] 211 217 """Create a new database with the required schema.""" 212 218 self._db_path.parent.mkdir(parents=True, exist_ok=True) 213 219 conn = _connect(str(self._db_path)) ··· 244 250 preview = text[:200].strip() 245 251 246 252 with self._lock: 247 - self._conn.execute( 248 - "DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,) 249 - ) 250 - self._conn.execute( 251 - "DELETE FROM documents WHERE doc_id = ?", (doc_id,) 252 - ) 253 + self._conn.execute("DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,)) 254 + self._conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,)) 253 255 254 256 self._conn.execute( 255 257 """INSERT INTO documents ··· 277 279 def delete(self, doc_id: str) -> None: 278 280 """Remove a document and its embedding.""" 279 281 with self._lock: 280 - self._conn.execute( 281 - "DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,) 282 - ) 283 - self._conn.execute( 284 - "DELETE FROM documents WHERE doc_id = ?", (doc_id,) 285 - ) 282 + self._conn.execute("DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,)) 283 + self._conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,)) 286 284 self._conn.commit() 287 285 288 286 def search( ··· 350 348 ): 351 349 score *= age_decay(decay_ref, game_day) 352 350 353 - hits.append(SearchHit( 354 - doc_id=doc_id, 355 - path=Path(path), 356 - score=score, 357 - snippet=preview or "", 358 - source=source, 359 - content_type=ctype or "", 360 - )) 351 + hits.append( 352 + SearchHit( 353 + doc_id=doc_id, 354 + path=Path(path), 355 + score=score, 356 + snippet=preview or "", 357 + source=source, 358 + content_type=ctype or "", 359 + ) 360 + ) 361 361 362 362 hits.sort(key=lambda h: h.score, reverse=True) 363 363 return hits[:limit] ··· 409 409 410 410 title_match = re.match(r"^#\s+(.+)", content) 411 411 title = ( 412 - title_match.group(1).strip() 413 - if title_match 414 - else md_file.stem 412 + title_match.group(1).strip() if title_match else md_file.stem 415 413 ) 416 414 417 415 game_day = None ··· 419 417 if day_match: 420 418 game_day = int(day_match.group(1)) 421 419 422 - self.upsert(doc_id, chunk_text, { 423 - "source": source, 424 - "content_type": content_type, 425 - "path": str(md_file), 426 - "title": title, 427 - "chunk_index": chunk_idx, 428 - "game_day": game_day, 429 - "updated_at": mtime, 430 - }) 420 + self.upsert( 421 + doc_id, 422 + chunk_text, 423 + { 424 + "source": source, 425 + "content_type": content_type, 426 + "path": str(md_file), 427 + "title": title, 428 + "chunk_index": chunk_idx, 429 + "game_day": game_day, 430 + "updated_at": mtime, 431 + }, 432 + ) 431 433 count += 1 432 434 433 435 stale = set(existing.keys()) - seen_doc_ids ··· 439 441 def stats(self) -> dict: 440 442 """Return index statistics.""" 441 443 with self._lock: 442 - total = self._conn.execute( 443 - "SELECT count(*) FROM documents" 444 - ).fetchone()[0] 444 + total = self._conn.execute("SELECT count(*) FROM documents").fetchone()[0] 445 445 446 446 by_source: dict[str, int] = {} 447 447 for source, cnt in self._conn.execute(
+10 -13
src/storied/session.py
··· 75 75 data["body"] = body 76 76 77 77 78 - def update_session( 79 - player_id: str, 80 - updates: dict 81 - ) -> str: 78 + def update_session(player_id: str, updates: dict) -> str: 82 79 """Update specific fields in the session state. 83 80 84 81 Args: ··· 163 160 164 161 # Priority order for wikilink resolution 165 162 ENTITY_TYPES = [ 166 - "npcs", "locations", "items", "factions", "threads", "lore", "maps", 163 + "npcs", 164 + "locations", 165 + "items", 166 + "factions", 167 + "threads", 168 + "lore", 169 + "maps", 167 170 "cultures", 168 171 ] 169 172 170 173 171 - def resolve_wiki_link( 172 - name: str, 173 - world_id: str 174 - ) -> Path | None: 174 + def resolve_wiki_link(name: str, world_id: str) -> Path | None: 175 175 """Resolve a wikilink name to a file path. 176 176 177 177 Searches entity directories in priority order and returns the first match. ··· 195 195 return None 196 196 197 197 198 - def load_entity_content( 199 - name: str, 200 - world_id: str 201 - ) -> dict | None: 198 + def load_entity_content(name: str, world_id: str) -> dict | None: 202 199 """Load an entity's content by resolving its wikilink. 203 200 204 201 Args:
+3 -1
src/storied/srd/extract.py
··· 24 24 Returns: 25 25 Markdown text of the document 26 26 """ 27 - return pymupdf4llm.to_markdown(str(pdf_path), pages=pages) 27 + result = pymupdf4llm.to_markdown(str(pdf_path), pages=pages) 28 + assert isinstance(result, str) 29 + return result 28 30 29 31 30 32 def extract_pages(pdf_path: Path) -> list[PageContent]:
+7 -9
src/storied/srd/split.py
··· 139 139 elif section.slug == "magic-items": 140 140 written.extend(split_magic_items(section, section_dir)) 141 141 else: 142 - # Other item sections (classes, feats, etc.) - write subsections as files 142 + # Other item sections (classes, feats, etc.): 143 + # write subsections as files 143 144 for subsection in section.subsections: 144 145 file_path = section_dir / f"{subsection.slug}.md" 145 146 content = format_section(subsection) ··· 197 198 spell_content = section.content 198 199 199 200 # Split on spell headers: **Spell Name** followed by newline and italicized level 200 - spell_pattern = re.compile( 201 - r"^\*\*([A-Z][^*]+)\*\*\s*\n_([^_]+)_", 202 - re.MULTILINE 203 - ) 201 + spell_pattern = re.compile(r"^\*\*([A-Z][^*]+)\*\*\s*\n_([^_]+)_", re.MULTILINE) 204 202 205 203 # Find all spell starts 206 204 matches = list(spell_pattern.finditer(spell_content)) ··· 214 212 slug = slugify(spell_name) 215 213 file_path = output_dir / f"{slug}.md" 216 214 217 - # Format with proper header 218 - formatted = f"# {spell_name}\n\n{spell_text[len(match.group(0)):].strip()}\n" 219 - # Add the level line back 220 - formatted = f"# {spell_name}\n\n_{match.group(2).strip()}_\n\n{spell_text[len(match.group(0)):].strip()}\n" 215 + # Format with proper header and level line 216 + body = spell_text[len(match.group(0)) :].strip() 217 + level_line = match.group(2).strip() 218 + formatted = f"# {spell_name}\n\n_{level_line}_\n\n{body}\n" 221 219 222 220 file_path.write_text(formatted) 223 221 written.append(file_path)
+2
src/storied/testing.py
··· 27 27 direct calls leave the Dependency instances as parameter defaults 28 28 rather than resolving them. 29 29 """ 30 + 30 31 async def _run() -> Any: 31 32 async with resolved_dependencies(fn, kwargs) as deps: 32 33 return fn(**{**kwargs, **deps}) 34 + 33 35 return asyncio.run(_run())
+6 -1
src/storied/tools/_context.py
··· 11 11 from storied.log import CampaignLog 12 12 from storied.search import VectorIndex 13 13 14 - 15 14 # Per-file locks for thread-safe entity writes (establish, mark) 16 15 _file_locks: dict[Path, threading.Lock] = {} 17 16 _file_locks_lock = threading.Lock() ··· 191 190 192 191 class World(Dependency[str]): 193 192 """The current world ID.""" 193 + 194 194 single = True 195 195 196 196 async def __aenter__(self) -> str: ··· 199 199 200 200 class Player(Dependency[str]): 201 201 """The current player ID.""" 202 + 202 203 single = True 203 204 204 205 async def __aenter__(self) -> str: ··· 207 208 208 209 class Timekeeper(Dependency[CampaignLog]): 209 210 """The campaign log: game time, world events, recent history.""" 211 + 210 212 single = True 211 213 212 214 async def __aenter__(self) -> CampaignLog: ··· 215 217 216 218 class Entities(Dependency[EntityIndex]): 217 219 """The entity name→path index with parsed-entity cache.""" 220 + 218 221 single = True 219 222 220 223 async def __aenter__(self) -> EntityIndex: ··· 223 226 224 227 class Lore(Dependency[VectorIndex]): 225 228 """The semantic search index over rules + world content.""" 229 + 226 230 single = True 227 231 228 232 async def __aenter__(self) -> VectorIndex: ··· 231 235 232 236 class Combat(Dependency[InitiativeTracker]): 233 237 """The active initiative tracker.""" 238 + 234 239 single = True 235 240 236 241 async def __aenter__(self) -> InitiativeTracker:
+12 -7
src/storied/tools/character.py
··· 1 + # pyright: reportArgumentType=false 2 + # DI markers like `world: str = World()` look like type errors to pyright 3 + # but FastMCP resolves them to the typed value at call time. 1 4 """Character management tools — bookkeeping primitives for the DM.""" 2 5 3 6 from __future__ import annotations 4 7 5 - from pathlib import Path 6 8 from typing import Literal 7 9 8 10 from fastmcp import FastMCP 9 11 from pydantic import JsonValue 10 12 11 - from storied.character.schema import Abilities, CoinDelta, Purse 12 13 from storied.character import ( 13 14 add_condition as char_add_condition, 14 15 ) ··· 60 61 from storied.character import ( 61 62 update_character as char_update, 62 63 ) 64 + from storied.character.schema import Abilities, CoinDelta, Purse 63 65 from storied.initiative import InitiativeTracker 64 66 from storied.log import CampaignLog 65 67 from storied.tools._context import ( ··· 206 208 purse=purse.model_dump() if purse else None, 207 209 subclass=subclass, 208 210 backstory=backstory, 209 - ) 211 + ) 210 212 211 213 212 214 # --- HP operations (unified — work in or out of combat) --- ··· 354 356 old one first. 355 357 356 358 Args: 357 - source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") 359 + source: Where the effect comes from (e.g., "Potion of Heroism"). 358 360 description: What the effect does in narrative + mechanical terms 359 361 expires: Optional game time anchor when the effect ends (e.g., "d28-1430") 360 362 concentration: Metadata flag for concentration-bound effects. Defaults to False. ··· 363 365 Confirmation 364 366 """ 365 367 return char_add_effect( 366 - player, source, description, 367 - expires=expires, concentration=concentration, 368 + player, 369 + source, 370 + description, 371 + expires=expires, 372 + concentration=concentration, 368 373 ) 369 374 370 375 ··· 579 584 hp_gain=hp_gain, 580 585 features=features, 581 586 time_anchor=time_anchor, 582 - ) 587 + ) 583 588 584 589 585 590 # --- Notes ---
+24 -6
src/storied/tools/combat.py
··· 1 + # pyright: reportArgumentType=false 2 + # DI markers like `combat: InitiativeTracker = Combat()` look like type 3 + # errors to pyright but FastMCP resolves them to the typed value at call 4 + # time. 1 5 """FastMCP combat tool surface — initiative tools that flip the visibility 2 6 of `combat`-tagged tools on the composed top-level server. 3 7 ··· 34 38 default=False, 35 39 description="True for the player character; false for NPCs and monsters", 36 40 ) 41 + 37 42 38 43 # The composed top-level server and the set of combat tool keys to hide 39 44 # when leaving combat — both registered at start_server() time so the ··· 109 114 110 115 @mcp.tool(tags={"dm", "combat"}) 111 116 def next_turn(combat: InitiativeTracker = Combat()) -> str: 112 - """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" 117 + """Advance to the next combatant's turn. 118 + 119 + Skips defeated. Call after resolving actions. 120 + """ 113 121 return combat.next_turn() 114 122 115 123 ··· 125 133 ) -> str: 126 134 """Add a combatant (reinforcements, surprised creatures waking up).""" 127 135 c = Combatant( 128 - name=name, initiative=initiative, hp=hp, 129 - hp_max=hp_max, ac=ac, is_player=is_player, 136 + name=name, 137 + initiative=initiative, 138 + hp=hp, 139 + hp_max=hp_max, 140 + ac=ac, 141 + is_player=is_player, 130 142 ) 131 143 return combat.add_combatant(c) 132 144 ··· 165 177 if action == "remove": 166 178 return combat.remove_condition(target, condition) 167 179 return combat.add_condition( 168 - target=target, condition=condition, 169 - duration=duration, ends_on=ends_on, source=source, 180 + target=target, 181 + condition=condition, 182 + duration=duration, 183 + ends_on=ends_on, 184 + source=source, 170 185 ) 171 186 172 187 173 188 @mcp.tool(tags={"dm", "combat", "combat_control"}) 174 189 def end_initiative(combat: InitiativeTracker = Combat()) -> str: 175 - """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" 190 + """End initiative and return to narrative. 191 + 192 + Returns a summary with rounds, defeated, and survivor HP. 193 + """ 176 194 result = combat.end() 177 195 _flip_out_of_combat() 178 196 return result
+82 -20
src/storied/tools/entities.py
··· 1 + # pyright: reportArgumentType=false 2 + # DI markers like `world: str = World()` look like type errors to pyright 3 + # but FastMCP resolves them to the typed value at call time. 1 4 """World entity tools — establish, mark, note_discovery.""" 2 5 3 6 import re ··· 25 28 # Entity-type enums exposed to the LLM via JSON Schema. Each tool's set is 26 29 # slightly different — only the kinds that make sense for that operation. 27 30 EstablishType = Literal[ 28 - "npcs", "locations", "items", "factions", "threads", "lore", "maps", 31 + "npcs", 32 + "locations", 33 + "items", 34 + "factions", 35 + "threads", 36 + "lore", 37 + "maps", 29 38 "cultures", 30 39 ] 31 40 MarkType = Literal[ 32 - "npcs", "locations", "items", "factions", "threads", "maps", "cultures", 41 + "npcs", 42 + "locations", 43 + "items", 44 + "factions", 45 + "threads", 46 + "maps", 47 + "cultures", 33 48 ] 34 49 DiscoveryType = Literal["npcs", "locations", "factions", "lore", "cultures"] 35 50 ··· 68 83 69 84 section_re = r"### {label}\n\n?(.*?)(?=\n### |\n## |\Z)" 70 85 knows_match = re.search( 71 - section_re.format(label="Knows"), is_content, re.DOTALL, 86 + section_re.format(label="Knows"), 87 + is_content, 88 + re.DOTALL, 72 89 ) 73 90 if knows_match: 74 91 result["knows"] = _parse_list_items(knows_match.group(1)) 75 92 76 93 wants_match = re.search( 77 - section_re.format(label="Wants"), is_content, re.DOTALL, 94 + section_re.format(label="Wants"), 95 + is_content, 96 + re.DOTALL, 78 97 ) 79 98 if wants_match: 80 99 result["wants"] = _parse_list_items(wants_match.group(1)) 81 100 82 101 will_match = re.search( 83 - section_re.format(label="Will"), is_content, re.DOTALL, 102 + section_re.format(label="Will"), 103 + is_content, 104 + re.DOTALL, 84 105 ) 85 106 if will_match: 86 107 result["will"] = _parse_list_items(will_match.group(1)) ··· 164 185 ) -> None: 165 186 """Write an entity to disk and update all indexes.""" 166 187 file_content = _format_entity( 167 - name, data["description"], data["location"], 168 - data["knows"], data["wants"], data["will"], data["was"], 188 + name, 189 + data["description"], 190 + data["location"], 191 + data["knows"], 192 + data["wants"], 193 + data["will"], 194 + data["was"], 169 195 ) 170 196 file_path.write_text(file_content) 171 197 entity_index.register(name, file_path) ··· 173 199 lore.upsert( 174 200 f"world:{entity_type}/{name}.md:0", 175 201 file_content, 176 - {"source": "world", "content_type": entity_type, 177 - "path": str(file_path), "title": name}, 202 + { 203 + "source": "world", 204 + "content_type": entity_type, 205 + "path": str(file_path), 206 + "title": name, 207 + }, 178 208 ) 179 209 180 210 ··· 213 243 was = existing.get("was", []) 214 244 215 245 data = { 216 - "description": description, "location": location, 217 - "knows": knows, "wants": wants, "will": will, "was": was, 246 + "description": description, 247 + "location": location, 248 + "knows": knows, 249 + "wants": wants, 250 + "will": will, 251 + "was": was, 218 252 } 219 253 _write_entity(file_path, name, entity_type, data, entity_index, lore) 220 254 ··· 231 265 dropping it. 232 266 """ 233 267 from storied.log import GameTime 268 + 234 269 try: 235 270 return GameTime.from_anchor(when).to_anchor() 236 271 except ValueError: ··· 329 364 330 365 331 366 def _minutes_since( 332 - anchor: str, now_hhmm_days: tuple[int, int, int], 367 + anchor: str, 368 + now_hhmm_days: tuple[int, int, int], 333 369 ) -> int | None: 334 370 """Minutes from `anchor` to the given (day, hour, minute) tuple. 335 371 ··· 338 374 want the cooldown to fire). 339 375 """ 340 376 from storied.log import GameTime 377 + 341 378 try: 342 379 then = GameTime.from_anchor(anchor) 343 380 except ValueError: ··· 404 441 405 442 entity_type = file_path.parent.name 406 443 _do_mark( 407 - entity_type, name, event, None, 408 - world_id, entity_index, lore, timekeeper, 444 + entity_type, 445 + name, 446 + event, 447 + None, 448 + world_id, 449 + entity_index, 450 + lore, 451 + timekeeper, 409 452 ) 410 453 marked.append(name) 411 454 ··· 470 513 ) 471 514 472 515 return _do_establish( 473 - entity_type, name, description, location, knows, wants, will, 474 - world, entities, lore, 516 + entity_type, 517 + name, 518 + description, 519 + location, 520 + knows, 521 + wants, 522 + will, 523 + world, 524 + entities, 525 + lore, 475 526 ) 476 527 477 528 ··· 512 563 Confirmation message 513 564 """ 514 565 return _do_mark( 515 - entity_type, name, event, resolves, 516 - world, entities, lore, timekeeper, when=when, 566 + entity_type, 567 + name, 568 + event, 569 + resolves, 570 + world, 571 + entities, 572 + lore, 573 + timekeeper, 574 + when=when, 517 575 ) 518 576 519 577 ··· 634 692 lore.upsert( 635 693 f"player:{content_type}/{slug}.md:0", 636 694 file_content, 637 - {"source": "player", "content_type": content_type, 638 - "path": str(file_path), "title": entity}, 695 + { 696 + "source": "player", 697 + "content_type": content_type, 698 + "path": str(file_path), 699 + "title": entity, 700 + }, 639 701 ) 640 702 641 703 return f"Noted: player learned about '{entity}'"
+6 -1
src/storied/tools/mechanics.py
··· 1 + # pyright: reportArgumentType=false 2 + # DI markers like `world: str = World()` look like type errors to pyright 3 + # but FastMCP resolves them to the typed value at call time. 1 4 """Dice and rules-lookup tools.""" 2 5 3 6 from pathlib import Path ··· 84 87 85 88 current_day = timekeeper.get_current_time().day 86 89 hits = lore.search( 87 - query, limit=5, source_filter=source_filter, 90 + query, 91 + limit=5, 92 + source_filter=source_filter, 88 93 decay_ref=current_day, 89 94 ) 90 95 if hits:
+4 -1
src/storied/tools/names.py
··· 1 + # pyright: reportArgumentType=false 2 + # DI markers like `world: str = World()` look like type errors to pyright 3 + # but FastMCP resolves them to the typed value at call time. 1 4 """MCP tool adapter for storied.names — fantasy name generation. 2 5 3 6 This is the ONLY file allowed to import from `storied.names`. It ··· 88 91 description = ( 89 92 f"A freshly-forged culture awaiting placement in the world" 90 93 f"{feel_text}. Sample names: {sample_str}. Use " 91 - f"`generate_names(culture=\"{culture.name}\", ...)` to draw more " 94 + f'`generate_names(culture="{culture.name}", ...)` to draw more ' 92 95 f"names. Flesh out this entity with `establish` to give the " 93 96 f"culture its place in the story." 94 97 )
+1
src/storied/tools/run_code.py
··· 31 31 code: Python code to execute 32 32 """ 33 33 from storied.sandbox import execute as sandbox_execute 34 + 34 35 return sandbox_execute(code)
+9 -1
src/storied/tools/scene.py
··· 1 + # pyright: reportArgumentType=false 2 + # DI markers like `world: str = World()` look like type errors to pyright 3 + # but FastMCP resolves them to the typed value at call time. 1 4 """Scene management, session, style tuning, and DM notification tools.""" 2 5 3 6 from fastmcp import FastMCP ··· 82 85 83 86 if event and present: 84 87 marked = _auto_mark_present( 85 - present, event, world, entities, lore, timekeeper, 88 + present, 89 + event, 90 + world, 91 + entities, 92 + lore, 93 + timekeeper, 86 94 ) 87 95 if marked: 88 96 parts.append(f"Auto-marked: {', '.join(marked)}")
+68 -2
tests/conftest.py
··· 21 21 matters. 22 22 """ 23 23 24 + import asyncio 24 25 import hashlib 25 - from collections.abc import Iterator 26 + from collections.abc import Callable, Iterator 26 27 from pathlib import Path 28 + from typing import Any 27 29 28 30 import pytest 31 + from fastmcp import Client 29 32 30 33 from storied import paths 34 + from storied.initiative import Combatant 31 35 from storied.log import CampaignLog 36 + from storied.mcp_server import _compose_server 32 37 from storied.search import VectorIndex 33 38 from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx 39 + from storied.tools.combat import _flip_into_combat, _flip_out_of_combat 34 40 35 41 36 42 @pytest.fixture(autouse=True) 37 43 def _isolate_storied_paths( 38 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 44 + tmp_path: Path, 45 + monkeypatch: pytest.MonkeyPatch, 39 46 ) -> Iterator[Path]: 40 47 """Point storied's data home and user rules at ``tmp_path``. 41 48 ··· 101 108 yield context 102 109 finally: 103 110 reset_ctx() 111 + 112 + 113 + # --- MCP in-memory client helpers ------------------------------------------- 114 + # 115 + # Tests that exercise the FastMCP tool surface drive the same call path the 116 + # production server uses (claude → MCP → tool function), but in-process via 117 + # fastmcp.Client. These fixtures hide the asyncio.run + Client(server) 118 + # scaffolding so individual tests stay flat. 119 + 120 + 121 + McpCall = Callable[[str, dict[str, Any] | None], str] 122 + McpCallInCombat = Callable[[str, dict[str, Any], list[Combatant] | None], str] 123 + 124 + 125 + @pytest.fixture 126 + def mcp_call() -> McpCall: 127 + """Sync helper: call a tool on the composed dm server, return its text.""" 128 + 129 + def _call(tool_name: str, args: dict[str, Any] | None = None) -> str: 130 + async def _run() -> str: 131 + server = await _compose_server("dm") 132 + async with Client(server) as client: 133 + result = await client.call_tool(tool_name, args or {}) 134 + return result.data if result.data is not None else "" 135 + 136 + return asyncio.run(_run()) 137 + 138 + return _call 139 + 140 + 141 + @pytest.fixture 142 + def mcp_call_in_combat(ctx: ToolContext) -> McpCallInCombat: 143 + """Sync helper: begin initiative, flip combat tools visible, call a tool. 144 + 145 + Pass ``combatants`` to start a fresh initiative; pass ``None`` when the 146 + test has already begun initiative (or wired up state) on its own. 147 + """ 148 + 149 + def _call( 150 + tool_name: str, 151 + args: dict[str, Any], 152 + combatants: list[Combatant] | None = None, 153 + ) -> str: 154 + if combatants is not None: 155 + ctx.initiative.begin(combatants) 156 + 157 + async def _run() -> str: 158 + server = await _compose_server("dm") 159 + _flip_into_combat() 160 + try: 161 + async with Client(server) as client: 162 + result = await client.call_tool(tool_name, args) 163 + return result.data if result.data is not None else "" 164 + finally: 165 + _flip_out_of_combat() 166 + 167 + return asyncio.run(_run()) 168 + 169 + return _call
+34 -55
tests/test_advancement.py
··· 6 6 7 7 import pytest 8 8 9 - from storied.character import load_character, save_character 10 - from storied.log import CampaignLog 11 9 from storied.advancement import ( 12 10 AdvancementResult, 13 11 BackgroundAdvancement, 14 12 build_advancement_context, 15 13 evaluate_advancement, 16 14 ) 15 + from storied.character import save_character 16 + from storied.log import CampaignLog 17 17 from storied.session import save_session 18 + from storied.testing import call_tool 18 19 from storied.tools import ToolContext 19 20 from storied.tools.scene import notify_dm as _notify_dm 20 - 21 - from storied.testing import call_tool 22 21 23 22 24 23 def notify_dm(message: str, ctx: ToolContext) -> str: ··· 102 101 103 102 class TestBuildAdvancementContext: 104 103 def test_returns_none_without_character(self, ctx: ToolContext, tmp_path: Path): 105 - result = build_advancement_context( 106 - ctx.world_id, ctx.player_id 107 - ) 104 + result = build_advancement_context(ctx.world_id, ctx.player_id) 108 105 assert result is None 109 106 110 - def test_includes_character_info( 111 - self, ctx: ToolContext, character: dict 112 - ): 113 - context = build_advancement_context( 114 - ctx.world_id, ctx.player_id 115 - ) 107 + def test_includes_character_info(self, ctx: ToolContext, character: dict): 108 + context = build_advancement_context(ctx.world_id, ctx.player_id) 116 109 assert context is not None 117 110 assert "Kira" in context 118 111 assert "Rogue" in context ··· 124 117 character: dict, 125 118 campaign_with_events: CampaignLog, 126 119 ): 127 - context = build_advancement_context( 128 - ctx.world_id, ctx.player_id 129 - ) 120 + context = build_advancement_context(ctx.world_id, ctx.player_id) 130 121 assert context is not None 131 122 assert "warehouse" in context 132 123 assert "smuggling ring" in context 133 124 134 - def test_includes_entries_since_level_tag( 135 - self, ctx: ToolContext, character: dict 136 - ): 125 + def test_includes_entries_since_level_tag(self, ctx: ToolContext, character: dict): 137 126 log = ctx.campaign_log 138 127 log.append_entry("Old event before level-up", "1 hour") 139 128 log.append_entry("Leveled up to 3", "5 min", tags=["level"]) 140 129 log.append_entry("New adventure begins", "30 min") 141 130 log.append_entry("Fought a dragon", "5 rounds", tags=["combat"]) 142 131 143 - context = build_advancement_context( 144 - ctx.world_id, ctx.player_id 145 - ) 132 + context = build_advancement_context(ctx.world_id, ctx.player_id) 146 133 assert context is not None 147 134 assert "Old event before level-up" not in context 148 135 assert "New adventure begins" in context 149 136 assert "Fought a dragon" in context 150 137 151 - def test_includes_advancement_history( 152 - self, ctx: ToolContext, character: dict 153 - ): 138 + def test_includes_advancement_history(self, ctx: ToolContext, character: dict): 154 139 log = ctx.campaign_log 155 140 log.append_entry("Reached level 2", "5 min", tags=["level"]) 156 141 log.append_entry("Adventured more", "2 hours") 157 142 log.append_entry("Reached level 3", "5 min", tags=["level"]) 158 143 log.append_entry("Recent events", "1 hour") 159 144 160 - context = build_advancement_context( 161 - ctx.world_id, ctx.player_id 162 - ) 145 + context = build_advancement_context(ctx.world_id, ctx.player_id) 163 146 assert context is not None 164 147 assert "Advancement History" in context 165 148 assert "Reached level 2" in context 166 149 assert "Reached level 3" in context 167 150 168 - def test_includes_session_state( 169 - self, ctx: ToolContext, character: dict 170 - ): 151 + def test_includes_session_state(self, ctx: ToolContext, character: dict): 171 152 save_session( 172 153 "default", 173 154 { ··· 176 157 }, 177 158 ) 178 159 179 - context = build_advancement_context( 180 - ctx.world_id, ctx.player_id 181 - ) 160 + context = build_advancement_context(ctx.world_id, ctx.player_id) 182 161 assert context is not None 183 162 assert "missing merchant" in context 184 163 ··· 187 166 188 167 189 168 class TestLogTagMethods: 190 - def test_get_entries_since_tag_returns_all_when_no_tag( 191 - self, ctx: ToolContext 192 - ): 169 + def test_get_entries_since_tag_returns_all_when_no_tag(self, ctx: ToolContext): 193 170 log = ctx.campaign_log 194 171 log.append_entry("Event one", "10 min") 195 172 log.append_entry("Event two", "10 min") ··· 197 174 entries = log.get_entries_since_tag("level") 198 175 assert len(entries) == 2 199 176 200 - def test_get_entries_since_tag_returns_after_last_tag( 201 - self, ctx: ToolContext 202 - ): 177 + def test_get_entries_since_tag_returns_after_last_tag(self, ctx: ToolContext): 203 178 log = ctx.campaign_log 204 179 log.append_entry("Before level", "10 min") 205 180 log.append_entry("Level up!", "5 min", tags=["level"]) ··· 209 184 assert len(entries) == 1 210 185 assert entries[0].event == "After level" 211 186 212 - def test_get_entries_since_tag_uses_last_occurrence( 213 - self, ctx: ToolContext 214 - ): 187 + def test_get_entries_since_tag_uses_last_occurrence(self, ctx: ToolContext): 215 188 log = ctx.campaign_log 216 189 log.append_entry("First level", "5 min", tags=["level"]) 217 190 log.append_entry("Between levels", "1 hour") ··· 265 238 assert result.evaluated is False 266 239 267 240 def test_posts_reminder_when_advancement_pending( 268 - self, ctx: ToolContext, character: dict, tmp_path: Path, 241 + self, 242 + ctx: ToolContext, 243 + character: dict, 244 + tmp_path: Path, 269 245 ): 270 246 character["advancement_ready"] = 4 271 247 save_character("default", character) ··· 276 252 ) 277 253 278 254 assert result.evaluated is False 279 - path = ( 280 - tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 281 - ) 255 + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 282 256 assert path.exists() 283 257 contents = path.read_text() 284 258 assert "Kira" in contents ··· 292 266 character: dict, 293 267 campaign_with_events: CampaignLog, 294 268 ): 295 - result_line = json.dumps({ 296 - "type": "result", 297 - "session_id": "sess-adv", 298 - "usage": {"input_tokens": 500, "output_tokens": 100}, 299 - "duration_ms": 2000, 300 - }) 269 + result_line = json.dumps( 270 + { 271 + "type": "result", 272 + "session_id": "sess-adv", 273 + "usage": {"input_tokens": 500, "output_tokens": 100}, 274 + "duration_ms": 2000, 275 + } 276 + ) 301 277 mock_proc = MagicMock() 302 278 mock_proc.stdin = MagicMock() 303 279 mock_proc.stdout = iter([result_line.encode() + b"\n"]) ··· 364 340 365 341 def test_pop_result_returns_none_when_no_thread(self): 366 342 adv = BackgroundAdvancement( 367 - world_id="test", player_id="default", 343 + world_id="test", 344 + player_id="default", 368 345 ) 369 346 assert adv.pop_result() is None 370 347 ··· 388 365 389 366 def test_maybe_evaluate_skips_when_already_running(self): 390 367 adv = BackgroundAdvancement( 391 - world_id="test", player_id="default", 368 + world_id="test", 369 + player_id="default", 392 370 ) 393 371 # Stub a fake "still running" thread on the instance 394 372 from unittest.mock import MagicMock 373 + 395 374 fake_thread = MagicMock() 396 375 fake_thread.is_alive.return_value = True 397 376 adv._thread = fake_thread
+56 -29
tests/test_arc_planner.py
··· 37 37 assert pools == {"Custom": ["alpha", "beta", "gamma"]} 38 38 39 39 def test_returns_empty_when_neither_exists( 40 - self, tmp_path: Path, monkeypatch, 40 + self, 41 + tmp_path: Path, 42 + monkeypatch, 41 43 ): 42 44 monkeypatch.setattr( 43 - paths, "user_rules_path", lambda: tmp_path / "missing-user", 45 + paths, 46 + "user_rules_path", 47 + lambda: tmp_path / "missing-user", 44 48 ) 45 49 from storied import planner 50 + 46 51 monkeypatch.setattr( 47 - planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", 52 + planner, 53 + "_REPO_PROMPTS", 54 + tmp_path / "missing-shipped", 48 55 ) 49 56 50 57 pools = _load_concept_pools() ··· 99 106 def test_count_exceeding_categories_returns_all(self, tmp_path: Path): 100 107 user_dir = paths.user_rules_path() 101 108 user_dir.mkdir(parents=True, exist_ok=True) 102 - (user_dir / "concept_pools.md").write_text( 103 - "## A\n- a1\n\n## B\n- b1\n" 104 - ) 109 + (user_dir / "concept_pools.md").write_text("## A\n- a1\n\n## B\n- b1\n") 105 110 106 111 picks = _pick_random_concepts(count=10) 107 112 assert len(picks) == 2 108 113 109 114 def test_empty_pools_returns_empty(self, tmp_path: Path, monkeypatch): 110 115 monkeypatch.setattr( 111 - paths, "user_rules_path", lambda: tmp_path / "missing-user", 116 + paths, 117 + "user_rules_path", 118 + lambda: tmp_path / "missing-user", 112 119 ) 113 120 from storied import planner 121 + 114 122 monkeypatch.setattr( 115 - planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", 123 + planner, 124 + "_REPO_PROMPTS", 125 + tmp_path / "missing-shipped", 116 126 ) 117 127 118 128 assert _pick_random_concepts(count=8) == [] ··· 146 156 def test_count_exceeding_deck_returns_all(self, tmp_path: Path): 147 157 user_dir = paths.user_rules_path() 148 158 user_dir.mkdir(parents=True, exist_ok=True) 149 - (user_dir / "oblique_strategies.md").write_text( 150 - "- only\n- two\n" 151 - ) 159 + (user_dir / "oblique_strategies.md").write_text("- only\n- two\n") 152 160 153 161 drawn = _draw_oblique_strategies(count=10) 154 162 assert len(drawn) == 2 155 163 156 164 def test_empty_deck_returns_empty(self, tmp_path: Path, monkeypatch): 157 165 monkeypatch.setattr( 158 - paths, "user_rules_path", lambda: tmp_path / "missing-user", 166 + paths, 167 + "user_rules_path", 168 + lambda: tmp_path / "missing-user", 159 169 ) 160 170 from storied import planner 171 + 161 172 monkeypatch.setattr( 162 - planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", 173 + planner, 174 + "_REPO_PROMPTS", 175 + tmp_path / "missing-shipped", 163 176 ) 164 177 165 178 assert _draw_oblique_strategies(count=4) == [] ··· 224 237 char_class="Ranger", 225 238 level=1, 226 239 abilities={ 227 - "strength": 10, "dexterity": 16, "constitution": 12, 228 - "intelligence": 14, "wisdom": 13, "charisma": 14, 240 + "strength": 10, 241 + "dexterity": 16, 242 + "constitution": 12, 243 + "intelligence": 14, 244 + "wisdom": 13, 245 + "charisma": 14, 229 246 }, 230 247 hp_max=11, 231 248 ac=14, ··· 260 277 # Pass A: cold draft via run_prompt 261 278 mock_run_prompt.assert_called_once() 262 279 assert mock_run_prompt.call_args.kwargs["effort"] == "max" 263 - assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-6" 280 + assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-7" 264 281 265 282 # Pass B: architect via run_with_tools 266 283 mock_run_with_tools.assert_called_once() ··· 341 358 342 359 @patch("storied.planner.run_prompt") 343 360 def test_no_character_returns_empty_result( 344 - self, mock_run_prompt: MagicMock, tmp_path: Path, 361 + self, 362 + mock_run_prompt: MagicMock, 363 + tmp_path: Path, 345 364 ): 346 365 result = plot_arc(world_id="default", player_id="default") 347 366 assert result.tool_calls == 0 ··· 412 431 """ 413 432 414 433 def test_planner_context_includes_obliques( 415 - self, character_world: Path, 434 + self, 435 + character_world: Path, 416 436 ): 417 437 from storied.planner import build_planning_context 418 438 from storied.session import save_session 419 439 420 - save_session("default", { 421 - "location": "Tavern", 422 - "world": "default", 423 - "body": "## Situation\nSeren is at the bar.", 424 - }) 440 + save_session( 441 + "default", 442 + { 443 + "location": "Tavern", 444 + "world": "default", 445 + "body": "## Situation\nSeren is at the bar.", 446 + }, 447 + ) 425 448 426 449 context = build_planning_context( 427 450 world_id="default", ··· 433 456 assert "Oblique Strategies" in context 434 457 435 458 def test_planner_context_does_not_include_concept_seeds( 436 - self, character_world: Path, 459 + self, 460 + character_world: Path, 437 461 ): 438 462 from storied.planner import build_planning_context 439 463 from storied.session import save_session 440 464 441 - save_session("default", { 442 - "location": "Tavern", 443 - "world": "default", 444 - "body": "## Situation\nSeren is at the bar.", 445 - }) 465 + save_session( 466 + "default", 467 + { 468 + "location": "Tavern", 469 + "world": "default", 470 + "body": "## Situation\nSeren is at the bar.", 471 + }, 472 + ) 446 473 447 474 context = build_planning_context( 448 475 world_id="default",
+313 -167
tests/test_character.py
··· 1 + # pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false 2 + # pyright: reportArgumentType=false, reportOperatorIssue=false, reportReturnType=false 3 + # Tests immediately subscript dicts returned by load_character() without 4 + # the assert-not-None dance — the test setup guarantees the file exists. 1 5 """Tests for the character system: data, computation, display, and operations.""" 2 6 3 7 from pathlib import Path ··· 39 43 total_level, 40 44 update_character, 41 45 ) 42 - 43 46 44 47 # --- Fixtures --- 45 48 ··· 90 93 "test-player", 91 94 { 92 95 "resources.hit_dice_d8": { 93 - "current": 3, "max": 3, "refresh": "long_rest", 96 + "current": 3, 97 + "max": 3, 98 + "refresh": "long_rest", 94 99 "notes": "Hit Dice (d8)", 95 100 }, 96 101 }, ··· 112 117 race="Human", 113 118 char_class="Barbarian", 114 119 level=1, 115 - abilities={"strength": 18, "dexterity": 14, "constitution": 16, 116 - "intelligence": 8, "wisdom": 10, "charisma": 12}, 120 + abilities={ 121 + "strength": 18, 122 + "dexterity": 14, 123 + "constitution": 16, 124 + "intelligence": 8, 125 + "wisdom": 10, 126 + "charisma": 12, 127 + }, 117 128 hp_max=15, 118 129 ac=14, 119 130 ) ··· 140 151 race="Half-Elf", 141 152 char_class="Bard", 142 153 level=1, 143 - abilities={"strength": 8, "dexterity": 14, "constitution": 12, 144 - "intelligence": 13, "wisdom": 10, "charisma": 16}, 154 + abilities={ 155 + "strength": 8, 156 + "dexterity": 14, 157 + "constitution": 12, 158 + "intelligence": 13, 159 + "wisdom": 10, 160 + "charisma": 16, 161 + }, 145 162 hp_max=9, 146 163 ac=12, 147 164 backstory="A wandering minstrel with secrets.", ··· 157 174 assert data["state"]["ac"] == 17 158 175 159 176 def test_update_nested_via_dot(self, mira: dict, player_dir: Path): 160 - update_character( 161 - "test-player", {"state.hp.max": 30} 162 - ) 177 + update_character("test-player", {"state.hp.max": 30}) 163 178 data = load_character("test-player") 164 179 assert data["state"]["hp"]["max"] == 30 165 180 166 181 def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): 167 - update_character( 168 - "test-player", {"state.hp.current": -5} 169 - ) 182 + update_character("test-player", {"state.hp.current": -5}) 170 183 data = load_character("test-player") 171 184 assert data["state"]["hp"]["current"] == 0 172 185 173 186 def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): 174 - update_character( 175 - "test-player", {"state.hp.current": 100} 176 - ) 187 + update_character("test-player", {"state.hp.current": 100}) 177 188 data = load_character("test-player") 178 189 assert data["state"]["hp"]["current"] == 24 179 190 180 191 def test_negative_coins_clamped(self, mira: dict, player_dir: Path): 181 - update_character( 182 - "test-player", {"state.purse.sp": -20} 183 - ) 192 + update_character("test-player", {"state.purse.sp": -20}) 184 193 data = load_character("test-player") 185 194 assert data["state"]["purse"]["sp"] == 0 186 195 ··· 218 227 result = update_character( 219 228 "test-player", 220 229 {"state.hp": 24}, # missing required fields 221 - ) 230 + ) 222 231 assert "rejected" in result.lower() 223 232 # Original HP block is preserved 224 233 data = load_character("test-player") ··· 228 237 def test_valid_resources_update_succeeds(self, mira: dict, player_dir: Path): 229 238 result = update_character( 230 239 "test-player", 231 - {"resources.channel_divinity": { 232 - "current": 1, "max": 1, "refresh": "short_rest", 233 - "notes": "Channel Divinity", 234 - }}, 240 + { 241 + "resources.channel_divinity": { 242 + "current": 1, 243 + "max": 1, 244 + "refresh": "short_rest", 245 + "notes": "Channel Divinity", 246 + } 247 + }, 235 248 ) 236 249 assert "rejected" not in result.lower() 237 250 data = load_character("test-player") ··· 253 266 254 267 def test_load_coerces_resources_list_to_dict(self, player_dir: Path): 255 268 import yaml 269 + 256 270 # Hand-write a character with resources as a list (the bad shape) 257 271 path = player_dir / "players" / "test-player" / "character.yaml" 258 - path.write_text(yaml.dump({ 259 - "identity": {"name": "Damaged", "classes": [{"class": "Cleric", "level": 3}]}, 260 - "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, 261 - "intelligence": 10, "wisdom": 14, "charisma": 10}, 262 - "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 263 - "resources": [ 264 - {"name": "Channel Divinity", "current": 1, "max": 1, 265 - "refresh": "short_rest"}, 266 - {"name": "Lay on Hands", "current": 15, "max": 15, 267 - "refresh": "long_rest"}, 268 - ], 269 - })) 272 + path.write_text( 273 + yaml.dump( 274 + { 275 + "identity": { 276 + "name": "Damaged", 277 + "classes": [{"class": "Cleric", "level": 3}], 278 + }, 279 + "abilities": { 280 + "strength": 10, 281 + "dexterity": 10, 282 + "constitution": 10, 283 + "intelligence": 10, 284 + "wisdom": 14, 285 + "charisma": 10, 286 + }, 287 + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 288 + "resources": [ 289 + { 290 + "name": "Channel Divinity", 291 + "current": 1, 292 + "max": 1, 293 + "refresh": "short_rest", 294 + }, 295 + { 296 + "name": "Lay on Hands", 297 + "current": 15, 298 + "max": 15, 299 + "refresh": "long_rest", 300 + }, 301 + ], 302 + } 303 + ) 304 + ) 270 305 data = load_character("test-player") 271 306 assert isinstance(data["resources"], dict) 272 307 assert "channel_divinity" in data["resources"] ··· 277 312 """End-to-end: a character with bad-shape resources on disk should 278 313 be usable via adjust_resource after load coercion.""" 279 314 import yaml 315 + 280 316 path = player_dir / "players" / "test-player" / "character.yaml" 281 - path.write_text(yaml.dump({ 282 - "identity": {"name": "Damaged"}, 283 - "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, 284 - "intelligence": 10, "wisdom": 10, "charisma": 10}, 285 - "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 286 - "resources": [ 287 - {"name": "Channel Divinity", "current": 1, "max": 1, 288 - "refresh": "short_rest", "notes": "Channel Divinity"}, 289 - ], 290 - })) 291 - result = adjust_resource( 292 - "test-player", "channel", -1 317 + path.write_text( 318 + yaml.dump( 319 + { 320 + "identity": {"name": "Damaged"}, 321 + "abilities": { 322 + "strength": 10, 323 + "dexterity": 10, 324 + "constitution": 10, 325 + "intelligence": 10, 326 + "wisdom": 10, 327 + "charisma": 10, 328 + }, 329 + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 330 + "resources": [ 331 + { 332 + "name": "Channel Divinity", 333 + "current": 1, 334 + "max": 1, 335 + "refresh": "short_rest", 336 + "notes": "Channel Divinity", 337 + }, 338 + ], 339 + } 340 + ) 293 341 ) 342 + result = adjust_resource("test-player", "channel", -1) 294 343 assert "Used 1" in result 295 344 296 345 def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): 297 346 import yaml 347 + 298 348 path = player_dir / "players" / "test-player" / "character.yaml" 299 - path.write_text(yaml.dump({ 300 - "identity": {"name": "Damaged"}, 301 - "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, 302 - "intelligence": 10, "wisdom": 10, "charisma": 10}, 303 - "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 304 - "equipment": ["Longsword", "Shield"], 305 - })) 349 + path.write_text( 350 + yaml.dump( 351 + { 352 + "identity": {"name": "Damaged"}, 353 + "abilities": { 354 + "strength": 10, 355 + "dexterity": 10, 356 + "constitution": 10, 357 + "intelligence": 10, 358 + "wisdom": 10, 359 + "charisma": 10, 360 + }, 361 + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 362 + "equipment": ["Longsword", "Shield"], 363 + } 364 + ) 365 + ) 306 366 data = load_character("test-player") 307 367 assert isinstance(data["equipment"], dict) 308 368 assert data["equipment"]["on_person"] == ["Longsword", "Shield"] ··· 326 386 def test_total_level_multiclass(self, player_dir: Path): 327 387 save_character( 328 388 "test-player", 329 - {"identity": {"classes": [ 330 - {"class": "Fighter", "level": 3}, 331 - {"class": "Wizard", "level": 2}, 332 - ]}}) 389 + { 390 + "identity": { 391 + "classes": [ 392 + {"class": "Fighter", "level": 3}, 393 + {"class": "Wizard", "level": 2}, 394 + ] 395 + } 396 + }, 397 + ) 333 398 data = load_character("test-player") 334 399 assert total_level(data) == 5 335 400 336 401 def test_proficiency_bonus_scaling(self, player_dir: Path): 337 - for level, expected in [(1, 2), (4, 2), (5, 3), (8, 3), (9, 4), 338 - (12, 4), (13, 5), (16, 5), (17, 6), (20, 6)]: 402 + for level, expected in [ 403 + (1, 2), 404 + (4, 2), 405 + (5, 3), 406 + (8, 3), 407 + (9, 4), 408 + (12, 4), 409 + (13, 5), 410 + (16, 5), 411 + (17, 6), 412 + (20, 6), 413 + ]: 339 414 save_character( 340 415 "test-player", 341 - {"identity": {"classes": [{"class": "Fighter", "level": level}]}}) 416 + {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, 417 + ) 342 418 data = load_character("test-player") 343 419 assert proficiency_bonus(data) == expected, f"level {level}" 344 420 ··· 398 474 399 475 def test_effective_hp_with_temp(self, player_dir: Path): 400 476 save_character( 401 - "test-player", 402 - {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}) 477 + "test-player", {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}} 478 + ) 403 479 data = load_character("test-player") 404 480 hp = effective_hp(data) 405 481 assert hp["effective"] == 25 ··· 463 539 def test_format_character_context_includes_advancement( 464 540 self, mira: dict, player_dir: Path 465 541 ): 466 - update_character( 467 - "test-player", {"advancement_ready": 4} 468 - ) 542 + update_character("test-player", {"advancement_ready": 4}) 469 543 data = load_character("test-player") 470 544 result = format_character_context(data) 471 545 assert "Advancement Ready" in result ··· 502 576 def test_format_sheet_renders_resources_with_die(self, mira: dict): 503 577 mira["resources"] = { 504 578 "bardic_inspiration": { 505 - "current": 3, "max": 3, "refresh": "long_rest", 506 - "notes": "Bardic Inspiration", "die": "d8", 579 + "current": 3, 580 + "max": 3, 581 + "refresh": "long_rest", 582 + "notes": "Bardic Inspiration", 583 + "die": "d8", 507 584 }, 508 585 } 509 586 result = format_sheet(mira) ··· 567 644 def test_format_sheet_shows_inspiration_when_available(self, mira: dict): 568 645 mira["state"]["inspiration"] = True 569 646 sheet = format_sheet(mira) 570 - assert "Inspiration" in sheet and "available" in sheet 647 + assert "Inspiration" in sheet 648 + assert "available" in sheet 571 649 572 650 def test_format_sheet_shows_exhaustion_reminder_when_nonzero( 573 - self, mira: dict, 651 + self, 652 + mira: dict, 574 653 ): 575 654 """When exhaustion is set, the sheet shows a reminder that the DM 576 655 applies the effect — it does NOT fold a numeric penalty into the ··· 594 673 race="Human", 595 674 char_class="Fighter", 596 675 level=1, 597 - abilities={"strength": 10, "dexterity": 10, "constitution": 10, 598 - "intelligence": 10, "wisdom": 10, "charisma": 10}, 676 + abilities={ 677 + "strength": 10, 678 + "dexterity": 10, 679 + "constitution": 10, 680 + "intelligence": 10, 681 + "wisdom": 10, 682 + "charisma": 10, 683 + }, 599 684 hp_max=10, 600 685 ac=10, 601 686 ) ··· 604 689 assert "Purse" not in result 605 690 606 691 def test_format_status_truncates_equipment_over_eight_items( 607 - self, mira: dict, player_dir: Path, 692 + self, 693 + mira: dict, 694 + player_dir: Path, 608 695 ): 609 696 # Add lots of items so the truncation branch fires 610 697 for i in range(12): 611 698 update_character( 612 699 "test-player", 613 - {f"equipment.on_person": [f"item_{i}" for i in range(12)]}, 700 + {"equipment.on_person": [f"item_{i}" for i in range(12)]}, 614 701 ) 615 702 data = load_character("test-player") 616 703 result = format_status(data) 617 704 assert "and 4 more" in result # 12 items - 8 shown = 4 more 618 705 619 706 def test_format_character_display_respects_data_home( 620 - self, mira: dict, player_dir: Path, tmp_path: Path, 707 + self, 708 + mira: dict, 709 + player_dir: Path, 710 + tmp_path: Path, 621 711 ): 622 712 """The /me slash command resolves the character via the 623 713 ``storied.paths`` module globals — sandbox sessions get the ··· 658 748 assert data["state"]["hp"]["current"] == 19 659 749 660 750 def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): 661 - update_character( 662 - "test-player", {"state.hp.temp": 5} 663 - ) 751 + update_character("test-player", {"state.hp.temp": 5}) 664 752 damage("test-player", 3) 665 753 data = load_character("test-player") 666 754 assert data["state"]["hp"]["temp"] == 2 667 755 assert data["state"]["hp"]["current"] == 24 668 756 669 757 def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): 670 - update_character( 671 - "test-player", {"state.hp.temp": 5} 672 - ) 758 + update_character("test-player", {"state.hp.temp": 5}) 673 759 damage("test-player", 8) 674 760 data = load_character("test-player") 675 761 assert data["state"]["hp"]["temp"] == 0 ··· 680 766 data = load_character("test-player") 681 767 assert data["state"]["hp"]["current"] == 0 682 768 683 - def test_damage_at_zero_mentions_death_saves( 684 - self, mira: dict, player_dir: Path 685 - ): 769 + def test_damage_at_zero_mentions_death_saves(self, mira: dict, player_dir: Path): 686 770 result = damage("test-player", 100) 687 771 assert "death save" in result.lower() 688 772 ··· 719 803 {"defenses.vulnerabilities": [{"damage": "radiant"}]}, 720 804 ) 721 805 damage( 722 - "test-player", 5, damage_type="radiant", 806 + "test-player", 807 + 5, 808 + damage_type="radiant", 723 809 ) 724 810 data = load_character("test-player") 725 811 # Raw 5, not doubled ··· 731 817 {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, 732 818 ) 733 819 damage( 734 - "test-player", 12, damage_type="poison", 820 + "test-player", 821 + 12, 822 + damage_type="poison", 735 823 ) 736 824 data = load_character("test-player") 737 825 # Raw 12, not zeroed ··· 740 828 741 829 class TestLevelUp: 742 830 def test_level_up_increments_class_level( 743 - self, mira: dict, player_dir: Path, 831 + self, 832 + mira: dict, 833 + player_dir: Path, 744 834 ): 745 835 result = level_up( 746 836 "test-player", ··· 753 843 assert "3 → 4" in result 754 844 755 845 def test_level_up_adds_hp_to_max_and_current( 756 - self, mira: dict, player_dir: Path, 846 + self, 847 + mira: dict, 848 + player_dir: Path, 757 849 ): 758 850 # mira starts with 24/24 759 851 level_up( 760 - "test-player", "Rogue", 761 - new_level=4, hp_gain=6, 852 + "test-player", 853 + "Rogue", 854 + new_level=4, 855 + hp_gain=6, 762 856 ) 763 857 data = load_character("test-player") 764 858 assert data["state"]["hp"]["max"] == 30 765 859 assert data["state"]["hp"]["current"] == 30 766 860 767 861 def test_level_up_preserves_wounded_current_relative( 768 - self, mira: dict, player_dir: Path, 862 + self, 863 + mira: dict, 864 + player_dir: Path, 769 865 ): 770 866 # Wound the character first 771 867 damage("test-player", 10) 772 868 # HP is now 14/24 773 869 level_up( 774 - "test-player", "Rogue", 775 - new_level=4, hp_gain=6, 870 + "test-player", 871 + "Rogue", 872 + new_level=4, 873 + hp_gain=6, 776 874 ) 777 875 data = load_character("test-player") 778 876 # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) ··· 780 878 assert data["state"]["hp"]["current"] == 20 781 879 782 880 def test_level_up_sets_level_since( 783 - self, mira: dict, player_dir: Path, 881 + self, 882 + mira: dict, 883 + player_dir: Path, 784 884 ): 785 885 level_up( 786 - "test-player", "Rogue", 787 - new_level=4, hp_gain=6, 886 + "test-player", 887 + "Rogue", 888 + new_level=4, 889 + hp_gain=6, 788 890 time_anchor="#d12-1500", 789 891 ) 790 892 data = load_character("test-player") 791 893 assert data["level_since"] == "#d12-1500" 792 894 793 895 def test_level_up_clears_advancement_ready( 794 - self, mira: dict, player_dir: Path, 896 + self, 897 + mira: dict, 898 + player_dir: Path, 795 899 ): 796 900 update_character( 797 901 "test-player", 798 902 {"advancement_ready": 4}, 799 903 ) 800 904 level_up( 801 - "test-player", "Rogue", 802 - new_level=4, hp_gain=6, 905 + "test-player", 906 + "Rogue", 907 + new_level=4, 908 + hp_gain=6, 803 909 ) 804 910 data = load_character("test-player") 805 911 assert data.get("advancement_ready") is None 806 912 807 913 def test_level_up_replaces_features_when_provided( 808 - self, mira: dict, player_dir: Path, 914 + self, 915 + mira: dict, 916 + player_dir: Path, 809 917 ): 810 918 new_features = [ 811 919 {"name": "Sneak Attack", "text": "2d6"}, 812 920 {"name": "Uncanny Dodge", "text": "Reaction for half damage"}, 813 921 ] 814 922 level_up( 815 - "test-player", "Rogue", 816 - new_level=4, hp_gain=6, 923 + "test-player", 924 + "Rogue", 925 + new_level=4, 926 + hp_gain=6, 817 927 features=new_features, 818 928 ) 819 929 data = load_character("test-player") ··· 821 931 assert data["features"][1]["name"] == "Uncanny Dodge" 822 932 823 933 def test_level_up_preserves_features_when_omitted( 824 - self, mira: dict, player_dir: Path, 934 + self, 935 + mira: dict, 936 + player_dir: Path, 825 937 ): 826 938 update_character( 827 939 "test-player", 828 940 {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, 829 941 ) 830 942 level_up( 831 - "test-player", "Rogue", 832 - new_level=4, hp_gain=6, 943 + "test-player", 944 + "Rogue", 945 + new_level=4, 946 + hp_gain=6, 833 947 ) 834 948 data = load_character("test-player") 835 949 assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] 836 950 837 951 def test_level_up_rejects_downgrade( 838 - self, mira: dict, player_dir: Path, 952 + self, 953 + mira: dict, 954 + player_dir: Path, 839 955 ): 840 956 result = level_up( 841 - "test-player", "Rogue", 842 - new_level=2, hp_gain=0, 957 + "test-player", 958 + "Rogue", 959 + new_level=2, 960 + hp_gain=0, 843 961 ) 844 962 assert "Refusing" in result 845 963 data = load_character("test-player") 846 964 assert data["identity"]["classes"][0]["level"] == 3 # unchanged 847 965 848 966 def test_level_up_rejects_unknown_class( 849 - self, mira: dict, player_dir: Path, 967 + self, 968 + mira: dict, 969 + player_dir: Path, 850 970 ): 851 971 result = level_up( 852 - "test-player", "Wizard", 853 - new_level=4, hp_gain=4, 972 + "test-player", 973 + "Wizard", 974 + new_level=4, 975 + hp_gain=4, 854 976 ) 855 977 assert "No class matching" in result 856 978 data = load_character("test-player") 857 979 assert data["identity"]["classes"][0]["level"] == 3 858 980 859 981 def test_level_up_multiclass_finds_correct_class( 860 - self, mira: dict, player_dir: Path, 982 + self, 983 + mira: dict, 984 + player_dir: Path, 861 985 ): 862 986 # Add a Fighter level to make Mira multiclass 863 987 update_character( 864 988 "test-player", 865 - {"identity.classes": [ 866 - {"class": "Rogue", "subclass": "Thief", "level": 3}, 867 - {"class": "Fighter", "subclass": None, "level": 1}, 868 - ]}, 989 + { 990 + "identity.classes": [ 991 + {"class": "Rogue", "subclass": "Thief", "level": 3}, 992 + {"class": "Fighter", "subclass": None, "level": 1}, 993 + ] 994 + }, 869 995 ) 870 996 level_up( 871 - "test-player", "Fighter", 872 - new_level=2, hp_gain=7, 997 + "test-player", 998 + "Fighter", 999 + new_level=2, 1000 + hp_gain=7, 873 1001 ) 874 1002 data = load_character("test-player") 875 1003 assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged ··· 883 1011 The DM decides when to drop a concentration effect.""" 884 1012 885 1013 def test_add_concentration_effect_flags_it( 886 - self, mira: dict, player_dir: Path, 1014 + self, 1015 + mira: dict, 1016 + player_dir: Path, 887 1017 ): 888 1018 result = add_effect( 889 - "test-player", "Bless", "+1d4 to attacks", 1019 + "test-player", 1020 + "Bless", 1021 + "+1d4 to attacks", 890 1022 concentration=True, 891 1023 ) 892 1024 assert "[Concentration]" in result ··· 894 1026 assert data["effects"][0]["concentration"] is True 895 1027 896 1028 def test_multiple_concentration_effects_allowed( 897 - self, mira: dict, player_dir: Path, 1029 + self, 1030 + mira: dict, 1031 + player_dir: Path, 898 1032 ): 899 1033 """No enforcement — the DM can flag two effects concentration.""" 900 1034 add_effect( 901 - "test-player", "Bless", "+1d4", 1035 + "test-player", 1036 + "Bless", 1037 + "+1d4", 902 1038 concentration=True, 903 1039 ) 904 1040 add_effect( 905 - "test-player", "Hold Person", "paralyzed", 1041 + "test-player", 1042 + "Hold Person", 1043 + "paralyzed", 906 1044 concentration=True, 907 1045 ) 908 1046 data = load_character("test-player") ··· 911 1049 assert "Hold Person" in sources 912 1050 913 1051 def test_damage_does_not_emit_concentration_save_hint( 914 - self, mira: dict, player_dir: Path, 1052 + self, 1053 + mira: dict, 1054 + player_dir: Path, 915 1055 ): 916 1056 """The DM issues concentration saves manually per the rules.""" 917 1057 add_effect( 918 - "test-player", "Bless", "+1d4", 1058 + "test-player", 1059 + "Bless", 1060 + "+1d4", 919 1061 concentration=True, 920 1062 ) 921 1063 result = damage("test-player", 6) ··· 931 1073 932 1074 def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): 933 1075 add_effect( 934 - "test-player", "Potion", "+10 temp HP", 1076 + "test-player", 1077 + "Potion", 1078 + "+10 temp HP", 935 1079 expires="d1-1430", 936 1080 ) 937 1081 data = load_character("test-player") ··· 987 1131 988 1132 def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): 989 1133 add_item( 990 - "test-player", "Rope (50ft)", location="backpack", 1134 + "test-player", 1135 + "Rope (50ft)", 1136 + location="backpack", 991 1137 ) 992 1138 data = load_character("test-player") 993 1139 assert "Rope (50ft)" in data["equipment"]["backpack"] ··· 1013 1159 class TestMagicItems: 1014 1160 def test_set_item_status_attuned(self, mira: dict, player_dir: Path): 1015 1161 set_item_status( 1016 - "test-player", "Bracer of the Unseen Step", "attuned", 1162 + "test-player", 1163 + "Bracer of the Unseen Step", 1164 + "attuned", 1017 1165 ) 1018 1166 data = load_character("test-player") 1019 1167 assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] 1020 1168 1021 1169 def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): 1022 - set_item_status( 1023 - "test-player", "Cloak", "carried" 1024 - ) 1025 - set_item_status( 1026 - "test-player", "Cloak", "equipped" 1027 - ) 1170 + set_item_status("test-player", "Cloak", "carried") 1171 + set_item_status("test-player", "Cloak", "equipped") 1028 1172 data = load_character("test-player") 1029 1173 assert "[[Cloak]]" not in data["magic_items"]["carried"] 1030 1174 assert "[[Cloak]]" in data["magic_items"]["equipped"] 1031 1175 1032 1176 def test_set_item_status_invalid(self, mira: dict, player_dir: Path): 1033 - result = set_item_status( 1034 - "test-player", "Cloak", "invalid" 1035 - ) 1177 + result = set_item_status("test-player", "Cloak", "invalid") 1036 1178 assert "invalid status" in result.lower() 1037 1179 1038 1180 ··· 1043 1185 assert data["resources"]["hit_dice_d8"]["current"] == 2 1044 1186 1045 1187 def test_adjust_resource_spend_multiple( 1046 - self, mira: dict, player_dir: Path, 1188 + self, 1189 + mira: dict, 1190 + player_dir: Path, 1047 1191 ): 1048 1192 adjust_resource("test-player", "hit_dice", -2) 1049 1193 data = load_character("test-player") 1050 1194 assert data["resources"]["hit_dice_d8"]["current"] == 1 1051 1195 1052 1196 def test_adjust_resource_clamped_to_zero( 1053 - self, mira: dict, player_dir: Path, 1197 + self, 1198 + mira: dict, 1199 + player_dir: Path, 1054 1200 ): 1055 - result = adjust_resource( 1056 - "test-player", "hit_dice", -10 1057 - ) 1201 + result = adjust_resource("test-player", "hit_dice", -10) 1058 1202 data = load_character("test-player") 1059 1203 assert data["resources"]["hit_dice_d8"]["current"] == 0 1060 1204 assert "short" in result.lower() 1061 1205 1062 1206 def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): 1063 - result = adjust_resource( 1064 - "test-player", "nonexistent", -1 1065 - ) 1207 + result = adjust_resource("test-player", "nonexistent", -1) 1066 1208 assert "no resource matching" in result.lower() 1067 1209 1068 1210 def test_adjust_resource_restore_clamped_to_max( 1069 - self, mira: dict, player_dir: Path, 1211 + self, 1212 + mira: dict, 1213 + player_dir: Path, 1070 1214 ): 1071 1215 adjust_resource("test-player", "hit_dice", -2) 1072 - adjust_resource( 1073 - "test-player", "hit_dice", 100 1074 - ) 1216 + adjust_resource("test-player", "hit_dice", 100) 1075 1217 data = load_character("test-player") 1076 1218 assert data["resources"]["hit_dice_d8"]["current"] == 3 1077 1219 1078 1220 def test_adjust_resource_zero_delta_is_noop( 1079 - self, mira: dict, player_dir: Path, 1221 + self, 1222 + mira: dict, 1223 + player_dir: Path, 1080 1224 ): 1081 - result = adjust_resource( 1082 - "test-player", "hit_dice", 0 1083 - ) 1225 + result = adjust_resource("test-player", "hit_dice", 0) 1084 1226 data = load_character("test-player") 1085 1227 assert data["resources"]["hit_dice_d8"]["current"] == 3 1086 1228 assert "no change" in result.lower() 1087 1229 1088 1230 1089 1231 class TestRest: 1090 - def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): 1232 + def test_long_rest_refreshes_long_rest_resources( 1233 + self, mira: dict, player_dir: Path 1234 + ): 1091 1235 adjust_resource("test-player", "hit_dice", -3) 1092 1236 rest("test-player", "long") 1093 1237 data = load_character("test-player") ··· 1110 1254 assert data["state"]["death_saves"]["failures"] == 0 1111 1255 1112 1256 def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): 1113 - update_character( 1114 - "test-player", {"state.exhaustion": 3} 1115 - ) 1257 + update_character("test-player", {"state.exhaustion": 3}) 1116 1258 rest("test-player", "long") 1117 1259 data = load_character("test-player") 1118 1260 assert data["state"]["exhaustion"] == 2 ··· 1145 1287 1146 1288 def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path): 1147 1289 before = load_character("test-player")["state"]["purse"]["gp"] 1148 - result = adjust_coins( 1149 - "test-player", {"gp": -100} 1150 - ) 1290 + result = adjust_coins("test-player", {"gp": -100}) 1151 1291 data = load_character("test-player") 1152 1292 # Rejected — purse is unchanged. 1153 1293 assert data["state"]["purse"]["gp"] == before ··· 1155 1295 assert "insufficient" in result.lower() 1156 1296 1157 1297 def test_adjust_coins_rejects_partial_underflow( 1158 - self, mira: dict, player_dir: Path, 1298 + self, 1299 + mira: dict, 1300 + player_dir: Path, 1159 1301 ): 1160 1302 # Mira has gp but not enough cp. The mixed delta should be 1161 1303 # rejected as a whole — neither denomination should change. 1162 1304 before = load_character("test-player")["state"]["purse"] 1163 - result = adjust_coins( 1164 - "test-player", {"gp": -1, "cp": -100} 1165 - ) 1305 + result = adjust_coins("test-player", {"gp": -1, "cp": -100}) 1166 1306 data = load_character("test-player") 1167 1307 assert data["state"]["purse"]["gp"] == before["gp"] 1168 1308 assert data["state"]["purse"]["cp"] == before["cp"] ··· 1170 1310 1171 1311 def test_adjust_coins_making_change(self, player_dir: Path): 1172 1312 from storied.character.data import create_character 1313 + 1173 1314 create_character( 1174 1315 player_id="test-player", 1175 1316 name="Coin Test", ··· 1177 1318 char_class="Fighter", 1178 1319 level=1, 1179 1320 abilities={ 1180 - "strength": 10, "dexterity": 10, "constitution": 10, 1181 - "intelligence": 10, "wisdom": 10, "charisma": 10, 1321 + "strength": 10, 1322 + "dexterity": 10, 1323 + "constitution": 10, 1324 + "intelligence": 10, 1325 + "wisdom": 10, 1326 + "charisma": 10, 1182 1327 }, 1183 1328 hp_max=10, 1184 1329 ac=10, ··· 1210 1355 1211 1356 def test_add_note_with_anchor(self, mira: dict, player_dir: Path): 1212 1357 add_note( 1213 - "test-player", "Witnessed the heist", 1358 + "test-player", 1359 + "Witnessed the heist", 1214 1360 time_anchor="d28-1330", 1215 1361 ) 1216 1362 notes_path = player_dir / "players" / "test-player" / "notes.md"
+65 -46
tests/test_claude.py
··· 25 25 assert "storied" in parsed["mcpServers"] 26 26 27 27 def test_url_set(self): 28 - config = json.loads( 29 - build_mcp_config("http://127.0.0.1:8080/sse") 30 - ) 28 + config = json.loads(build_mcp_config("http://127.0.0.1:8080/sse")) 31 29 server = config["mcpServers"]["storied"] 32 30 assert server["url"] == "http://127.0.0.1:8080/sse" 33 31 assert server["type"] == "sse" ··· 45 43 def test_resume_session_includes_system_prompt(self, monkeypatch): 46 44 monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") 47 45 args = build_claude_args( 48 - "sonnet", "You are a DM", "{}", 46 + "sonnet", 47 + "You are a DM", 48 + "{}", 49 49 resume_session_id="abc-123", 50 50 ) 51 51 assert "--resume" in args ··· 95 95 96 96 class TestParseEvent: 97 97 def test_text_delta(self): 98 - line = json.dumps({ 99 - "type": "stream_event", 100 - "event": { 101 - "type": "content_block_delta", 102 - "index": 0, 103 - "delta": {"type": "text_delta", "text": "Hello"}, 104 - }, 105 - }) 98 + line = json.dumps( 99 + { 100 + "type": "stream_event", 101 + "event": { 102 + "type": "content_block_delta", 103 + "index": 0, 104 + "delta": {"type": "text_delta", "text": "Hello"}, 105 + }, 106 + } 107 + ) 106 108 event = parse_event(line) 107 109 assert isinstance(event, TextDelta) 108 110 assert event.text == "Hello" 109 111 110 112 def test_tool_start(self): 111 - line = json.dumps({ 112 - "type": "stream_event", 113 - "event": { 114 - "type": "content_block_start", 115 - "index": 1, 116 - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__roll"}, 117 - }, 118 - }) 113 + line = json.dumps( 114 + { 115 + "type": "stream_event", 116 + "event": { 117 + "type": "content_block_start", 118 + "index": 1, 119 + "content_block": { 120 + "type": "tool_use", 121 + "id": "t1", 122 + "name": "mcp__storied__roll", 123 + }, 124 + }, 125 + } 126 + ) 119 127 event = parse_event(line) 120 128 assert isinstance(event, ToolStart) 121 129 assert event.name == "mcp__storied__roll" 122 130 123 131 def test_tool_input_delta(self): 124 - line = json.dumps({ 125 - "type": "stream_event", 126 - "event": { 127 - "type": "content_block_delta", 128 - "index": 1, 129 - "delta": {"type": "input_json_delta", "partial_json": '{"notation"'}, 130 - }, 131 - }) 132 + line = json.dumps( 133 + { 134 + "type": "stream_event", 135 + "event": { 136 + "type": "content_block_delta", 137 + "index": 1, 138 + "delta": { 139 + "type": "input_json_delta", 140 + "partial_json": '{"notation"', 141 + }, 142 + }, 143 + } 144 + ) 132 145 event = parse_event(line) 133 146 assert isinstance(event, ToolInputDelta) 134 147 assert event.json_fragment == '{"notation"' 135 148 136 149 def test_tool_stop(self): 137 - line = json.dumps({ 138 - "type": "stream_event", 139 - "event": {"type": "content_block_stop", "index": 1}, 140 - }) 150 + line = json.dumps( 151 + { 152 + "type": "stream_event", 153 + "event": {"type": "content_block_stop", "index": 1}, 154 + } 155 + ) 141 156 event = parse_event(line) 142 157 assert isinstance(event, ToolStop) 143 158 144 159 def test_result(self): 145 - line = json.dumps({ 146 - "type": "result", 147 - "session_id": "sess-1", 148 - "usage": {"input_tokens": 100, "output_tokens": 50}, 149 - "duration_ms": 1234, 150 - }) 160 + line = json.dumps( 161 + { 162 + "type": "result", 163 + "session_id": "sess-1", 164 + "usage": {"input_tokens": 100, "output_tokens": 50}, 165 + "duration_ms": 1234, 166 + } 167 + ) 151 168 event = parse_event(line) 152 169 assert isinstance(event, Result) 153 170 assert event.session_id == "sess-1" ··· 164 181 assert parse_event(json.dumps({"type": "unknown"})) is None 165 182 166 183 def test_text_block_start_ignored(self): 167 - line = json.dumps({ 168 - "type": "stream_event", 169 - "event": { 170 - "type": "content_block_start", 171 - "index": 0, 172 - "content_block": {"type": "text"}, 173 - }, 174 - }) 184 + line = json.dumps( 185 + { 186 + "type": "stream_event", 187 + "event": { 188 + "type": "content_block_start", 189 + "index": 0, 190 + "content_block": {"type": "text"}, 191 + }, 192 + } 193 + ) 175 194 assert parse_event(line) is None
+8 -17
tests/test_cli.py
··· 41 41 def test_cold_start_when_nothing_exists(self): 42 42 assert _is_cold_start("default", "default") is True 43 43 44 - def test_cold_start_when_only_character_exists( 45 - self, 46 - _minimal_character: None, 47 - ): 44 + @pytest.mark.usefixtures("_minimal_character") 45 + def test_cold_start_when_only_character_exists(self): 48 46 assert _is_cold_start("default", "default") is True 49 47 50 - def test_cold_start_when_only_style_exists( 51 - self, 52 - _world_style: None, 53 - ): 48 + @pytest.mark.usefixtures("_world_style") 49 + def test_cold_start_when_only_style_exists(self): 54 50 assert _is_cold_start("default", "default") is True 55 51 56 - def test_not_cold_start_when_both_exist( 57 - self, 58 - _minimal_character: None, 59 - _world_style: None, 60 - ): 52 + @pytest.mark.usefixtures("_minimal_character", "_world_style") 53 + def test_not_cold_start_when_both_exist(self): 61 54 assert _is_cold_start("default", "default") is False 62 55 63 - def test_not_cold_start_ignores_other_worlds( 64 - self, 65 - _minimal_character: None, 66 - ): 56 + @pytest.mark.usefixtures("_minimal_character") 57 + def test_not_cold_start_ignores_other_worlds(self): 67 58 # Style lives in a different world — doesn't count for this one. 68 59 world_dir = world_path("other") 69 60 world_dir.mkdir(parents=True, exist_ok=True)
+22 -8
tests/test_content.py
··· 1 + # pyright: reportOptionalMemberAccess=false 2 + # Tests reach into ContentResolver.load() results without null-narrowing. 1 3 """Tests for three-layer content resolution (world > user > shipped). 2 4 3 5 The autouse ``_isolate_storied_paths`` fixture in ``conftest.py`` already ··· 12 14 13 15 from storied import paths 14 16 from storied.content import ContentResolver 15 - 16 17 17 18 # --------------------------------------------------------------------------- 18 19 # Fixtures — build a fake three-layer setup under tmp_path. ··· 90 91 assert hit == shipped_goblin 91 92 92 93 def test_user_overrides_shipped( 93 - self, shipped_goblin: Path, user_goblin: Path, 94 + self, 95 + shipped_goblin: Path, 96 + user_goblin: Path, 94 97 ): 95 98 resolver = ContentResolver(world_id="test-world") 96 99 hit = resolver.find("goblin", content_type="monsters") ··· 98 101 assert "Homebrew" in hit.read_text() 99 102 100 103 def test_world_overrides_user( 101 - self, shipped_goblin: Path, user_goblin: Path, world_goblin: Path, 104 + self, 105 + shipped_goblin: Path, 106 + user_goblin: Path, 107 + world_goblin: Path, 102 108 ): 103 109 resolver = ContentResolver(world_id="test-world") 104 110 hit = resolver.find("goblin", content_type="monsters") ··· 106 112 assert "Island Goblin" in hit.read_text() 107 113 108 114 def test_world_overrides_shipped_without_user( 109 - self, shipped_goblin: Path, world_goblin: Path, 115 + self, 116 + shipped_goblin: Path, 117 + world_goblin: Path, 110 118 ): 111 119 resolver = ContentResolver(world_id="test-world") 112 120 hit = resolver.find("goblin", content_type="monsters") 113 121 assert hit == world_goblin 114 122 115 123 def test_user_overrides_shipped_without_world( 116 - self, shipped_goblin: Path, user_goblin: Path, 124 + self, 125 + shipped_goblin: Path, 126 + user_goblin: Path, 117 127 ): 118 128 resolver = ContentResolver(world_id="test-world") 119 129 hit = resolver.find("goblin", content_type="monsters") ··· 140 150 assert hit is None 141 151 142 152 def test_no_world_id_still_searches_rule_layers( 143 - self, shipped_fireball: Path, 153 + self, 154 + shipped_fireball: Path, 144 155 ): 145 156 """A resolver with no world still finds rule content at the 146 157 user and shipped layers.""" ··· 165 176 monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" 166 177 monsters.mkdir(parents=True) 167 178 (monsters / "orc.md").write_text( 168 - "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n# Orc\n\nBig and mean.\n" 179 + "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n" 180 + "# Orc\n\nBig and mean.\n" 169 181 ) 170 182 171 183 resolver = ContentResolver(world_id="test-world") ··· 195 207 assert hit == shipped_goblin 196 208 197 209 def test_world_wins_in_untyped_search( 198 - self, shipped_goblin: Path, world_goblin: Path, 210 + self, 211 + shipped_goblin: Path, 212 + world_goblin: Path, 199 213 ): 200 214 resolver = ContentResolver(world_id="test-world") 201 215 hit = resolver.find("goblin")
+1 -1
tests/test_dice.py
··· 2 2 3 3 import pytest 4 4 5 - from storied.dice import DiceRoll, RollResult, parse_notation, roll 5 + from storied.dice import RollResult, parse_notation, roll 6 6 7 7 8 8 class TestParseNotation:
+39 -27
tests/test_display.py
··· 37 37 38 38 39 39 class TestBold: 40 - 41 40 def test_bold_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): 42 41 renderer.feed("**hello**\n") 43 42 text = rendered(renderer, out) ··· 61 60 62 61 63 62 class TestItalic: 64 - 65 63 def test_italic_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): 66 64 renderer.feed("*hello*\n") 67 65 text = rendered(renderer, out) ··· 78 76 79 77 80 78 class TestBoldItalic: 81 - 82 79 def test_bold_italic(self, renderer: StreamRenderer, out: io.StringIO): 83 80 renderer.feed("***both***\n") 84 81 text = rendered(renderer, out) ··· 96 93 97 94 98 95 class TestCode: 99 - 100 96 def test_inline_code(self, renderer: StreamRenderer, out: io.StringIO): 101 97 renderer.feed("`code`\n") 102 98 text = rendered(renderer, out) ··· 106 102 107 103 108 104 class TestPlainText: 109 - 110 - def test_plain_text_passes_through(self, renderer: StreamRenderer, out: io.StringIO): 105 + def test_plain_text_passes_through( 106 + self, renderer: StreamRenderer, out: io.StringIO 107 + ): 111 108 renderer.feed("hello world\n") 112 109 text = rendered(renderer, out) 113 110 assert "hello world" in text ··· 128 125 129 126 130 127 class TestHorizontalRule: 131 - 132 128 def test_rule(self, renderer: StreamRenderer, out: io.StringIO): 133 129 renderer.feed("---\n") 134 130 text = rendered(renderer, out) ··· 137 133 138 134 139 135 class TestHeadings: 140 - 141 136 def test_h1(self, renderer: StreamRenderer, out: io.StringIO): 142 137 renderer.feed("# Title\n") 143 138 text = rendered(renderer, out) ··· 164 159 165 160 166 161 class TestBulletList: 167 - 168 162 def test_dash_bullet(self, renderer: StreamRenderer, out: io.StringIO): 169 163 renderer.feed("- first item\n") 170 164 text = rendered(renderer, out) ··· 179 173 180 174 181 175 class TestBlockquote: 182 - 183 176 def test_blockquote(self, renderer: StreamRenderer, out: io.StringIO): 184 177 renderer.feed("> quoted text\n") 185 178 text = rendered(renderer, out) ··· 193 186 194 187 195 188 class TestNumberedList: 196 - 197 189 def test_numbered(self, renderer: StreamRenderer, out: io.StringIO): 198 190 renderer.feed("1. first\n") 199 191 text = rendered(renderer, out) ··· 205 197 206 198 207 199 class TestBlocks: 208 - 209 200 def test_map_block_renders_panel(self, renderer: StreamRenderer, out: io.StringIO): 210 201 renderer.feed("```map Tavern\n+-+\n|X|\n+-+\n```\n") 211 202 text = rendered(renderer, out) ··· 225 216 assert "+-+" in text 226 217 assert "After" in text 227 218 228 - def test_regular_code_block_passes_through(self, renderer: StreamRenderer, out: io.StringIO): 219 + def test_regular_code_block_passes_through( 220 + self, renderer: StreamRenderer, out: io.StringIO 221 + ): 229 222 renderer.feed("```python\nprint('hi')\n```\n") 230 223 text = rendered(renderer, out) 231 224 assert "python" in text ··· 244 237 245 238 246 239 class TestSOLClassification: 247 - 248 - def test_text_starting_with_hash_no_space(self, renderer: StreamRenderer, out: io.StringIO): 240 + def test_text_starting_with_hash_no_space( 241 + self, renderer: StreamRenderer, out: io.StringIO 242 + ): 249 243 renderer.feed("#hashtag\n") 250 244 text = rendered(renderer, out) 251 245 assert "#hashtag" in text 252 246 assert BOLD_ON not in text 253 247 254 - def test_text_starting_with_dash_no_space(self, renderer: StreamRenderer, out: io.StringIO): 248 + def test_text_starting_with_dash_no_space( 249 + self, renderer: StreamRenderer, out: io.StringIO 250 + ): 255 251 renderer.feed("-not a bullet\n") 256 252 text = rendered(renderer, out) 257 253 assert "-not a bullet" in text ··· 261 257 text = rendered(renderer, out) 262 258 assert "--not a rule" in text 263 259 264 - def test_line_starting_with_letter(self, renderer: StreamRenderer, out: io.StringIO): 260 + def test_line_starting_with_letter( 261 + self, renderer: StreamRenderer, out: io.StringIO 262 + ): 265 263 renderer.feed("The quick brown fox\n") 266 264 text = rendered(renderer, out) 267 265 assert "The quick brown fox" in text ··· 271 269 272 270 273 271 class TestFlush: 274 - 275 - def test_flush_emits_buffered_star_as_italic(self, renderer: StreamRenderer, out: io.StringIO): 272 + def test_flush_emits_buffered_star_as_italic( 273 + self, renderer: StreamRenderer, out: io.StringIO 274 + ): 276 275 renderer.feed("trailing*") 277 276 text = rendered(renderer, out) 278 277 assert "trailing" in text ··· 300 299 301 300 302 301 class TestWordWrap: 303 - 304 - def test_short_line_no_wrap(self, narrow_renderer: StreamRenderer, out: io.StringIO): 302 + def test_short_line_no_wrap( 303 + self, narrow_renderer: StreamRenderer, out: io.StringIO 304 + ): 305 305 narrow_renderer.feed("hello world\n") 306 306 text = rendered(narrow_renderer, out) 307 307 assert "hello world" in text 308 308 assert text.count("\n") == 1 # just the trailing newline 309 309 310 - def test_wraps_at_word_boundary(self, narrow_renderer: StreamRenderer, out: io.StringIO): 310 + def test_wraps_at_word_boundary( 311 + self, narrow_renderer: StreamRenderer, out: io.StringIO 312 + ): 311 313 # "one two three four" = 18 chars, fits. Add "five" and it wraps. 312 314 narrow_renderer.feed("one two three four five\n") 313 315 text = rendered(narrow_renderer, out) ··· 324 326 for line in text.split("\n"): 325 327 assert "ccccccc" not in line or "ccccccccccccc" in line 326 328 327 - def test_word_longer_than_width_overflows(self, narrow_renderer: StreamRenderer, out: io.StringIO): 329 + def test_word_longer_than_width_overflows( 330 + self, narrow_renderer: StreamRenderer, out: io.StringIO 331 + ): 328 332 # A single word longer than 20 chars just overflows (no crash) 329 333 narrow_renderer.feed("superlongwordthatexceedstwentycharacters end\n") 330 334 text = rendered(narrow_renderer, out) 331 335 assert "superlongwordthatexceedstwentycharacters" in text 332 336 assert "end" in text 333 337 334 - def test_wrap_preserves_bold(self, narrow_renderer: StreamRenderer, out: io.StringIO): 338 + def test_wrap_preserves_bold( 339 + self, narrow_renderer: StreamRenderer, out: io.StringIO 340 + ): 335 341 narrow_renderer.feed("aaa bbb **ccc ddd eee fff** ggg\n") 336 342 text = rendered(narrow_renderer, out) 337 343 assert BOLD_ON in text ··· 340 346 for word in ("aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg"): 341 347 assert word in text 342 348 343 - def test_wrap_preserves_italic(self, narrow_renderer: StreamRenderer, out: io.StringIO): 349 + def test_wrap_preserves_italic( 350 + self, narrow_renderer: StreamRenderer, out: io.StringIO 351 + ): 344 352 narrow_renderer.feed("aaa bbb *ccc ddd eee fff* ggg\n") 345 353 text = rendered(narrow_renderer, out) 346 354 assert ITALIC_ON in text 347 355 assert ITALIC_OFF in text 348 356 349 - def test_bullet_wrap_accounts_for_prefix(self, narrow_renderer: StreamRenderer, out: io.StringIO): 357 + def test_bullet_wrap_accounts_for_prefix( 358 + self, narrow_renderer: StreamRenderer, out: io.StringIO 359 + ): 350 360 # " • " = 4 chars, so only 16 chars of content before wrap 351 361 narrow_renderer.feed("- aaa bbb ccc ddd eee\n") 352 362 text = rendered(narrow_renderer, out) ··· 354 364 lines = text.strip().split("\n") 355 365 assert len(lines) >= 2 # should wrap 356 366 357 - def test_streaming_chunks_wrap_correctly(self, narrow_renderer: StreamRenderer, out: io.StringIO): 367 + def test_streaming_chunks_wrap_correctly( 368 + self, narrow_renderer: StreamRenderer, out: io.StringIO 369 + ): 358 370 # Words arrive across multiple chunks 359 371 narrow_renderer.feed("one two thr") 360 372 narrow_renderer.feed("ee four five ")
+102 -57
tests/test_engine.py
··· 1 + # pyright: reportArgumentType=false 2 + # Tests pass dict literals where typed inputs are stricter than runtime needs. 1 3 """Tests for engine helper functions and context building.""" 2 4 3 5 from pathlib import Path ··· 13 15 14 16 15 17 class TestLoadPrompt: 16 - 17 18 def test_loads_dm_system(self): 18 19 result = load_prompt("dm-system") 19 20 assert "Dungeon Master" in result ··· 30 31 31 32 32 33 class TestExtractRollReason: 33 - 34 34 def test_extracts_reason(self): 35 35 assert _extract_roll_reason('{"reason": "Athletics"}') == "Athletics" 36 36 ··· 45 45 46 46 47 47 class TestToolNotification: 48 - 49 48 def test_known_tool(self): 50 49 assert "Rolling" in _tool_notification("roll") 51 50 ··· 79 78 from storied.log import CampaignLog 80 79 from storied.tools import EntityIndex 81 80 82 - mock_mcp.return_value = type("Handle", (), { 83 - "url": "http://localhost:0/sse", 84 - "ctx": type("Ctx", (), { 85 - "campaign_log": CampaignLog("test"), 86 - "entity_index": EntityIndex(world_dir), 87 - "vector_index": None, 88 - "initiative": InitiativeTracker(), 89 - })(), 90 - })() 81 + mock_mcp.return_value = type( 82 + "Handle", 83 + (), 84 + { 85 + "url": "http://localhost:0/sse", 86 + "ctx": type( 87 + "Ctx", 88 + (), 89 + { 90 + "campaign_log": CampaignLog("test"), 91 + "entity_index": EntityIndex(world_dir), 92 + "vector_index": None, 93 + "initiative": InitiativeTracker(), 94 + }, 95 + )(), 96 + }, 97 + )() 91 98 return DMEngine( 92 99 world_id="test", 93 100 player_id="default", ··· 126 133 assert "cosmic-prisoner" in engine._context_parts["Arc"] 127 134 128 135 def test_arc_appears_after_style_in_context_order( 129 - self, engine, tmp_path: Path, 136 + self, 137 + engine, 138 + tmp_path: Path, 130 139 ): 131 140 style_path = tmp_path / "worlds" / "test" / "style.md" 132 141 style_path.write_text("# Style\n\nDark tone.\n") ··· 165 174 166 175 def test_estimate_tokens(self): 167 176 from storied.engine import DMEngine 177 + 168 178 assert DMEngine._estimate_tokens("a" * 400) == 100 169 179 170 180 def test_format_entity(self, engine): 171 - result = engine._format_entity("Npc", { 172 - "name": "Vera", "body": "Tavern owner.", 173 - }) 181 + result = engine._format_entity( 182 + "Npc", 183 + { 184 + "name": "Vera", 185 + "body": "Tavern owner.", 186 + }, 187 + ) 174 188 assert "## Npc: Vera" in result 175 189 assert "Tavern owner." in result 176 190 ··· 188 202 assert result["body"] == "Tavern owner." 189 203 190 204 def test_parse_knowledge_file_malformed_frontmatter( 191 - self, engine, tmp_path: Path, 205 + self, 206 + engine, 207 + tmp_path: Path, 192 208 ): 193 209 f = tmp_path / "broken.md" 194 210 f.write_text("---\nnot: [valid yaml\n---\n\nBody.") ··· 218 234 with patch("storied.engine.start_mcp_server") as mock_mcp: 219 235 from storied.log import CampaignLog 220 236 221 - mock_mcp.return_value = type("Handle", (), { 222 - "url": "http://localhost:0/sse", 223 - "ctx": type("Ctx", (), { 224 - "campaign_log": CampaignLog("test"), 225 - "entity_index": EntityIndex(tmp_path / "worlds" / "test"), 226 - "vector_index": None, 227 - "initiative": InitiativeTracker(), 228 - })(), 229 - })() 237 + mock_mcp.return_value = type( 238 + "Handle", 239 + (), 240 + { 241 + "url": "http://localhost:0/sse", 242 + "ctx": type( 243 + "Ctx", 244 + (), 245 + { 246 + "campaign_log": CampaignLog("test"), 247 + "entity_index": EntityIndex(tmp_path / "worlds" / "test"), 248 + "vector_index": None, 249 + "initiative": InitiativeTracker(), 250 + }, 251 + )(), 252 + }, 253 + )() 230 254 engine = DMEngine( 231 255 world_id="test", 232 256 player_id="default", ··· 267 291 def test_build_context_with_character(self, engine, tmp_path: Path): 268 292 # Drop a character file in place; _build_context should pick it up 269 293 from storied.character import create_character 294 + 270 295 (tmp_path / "players" / "default").mkdir(parents=True) 271 296 create_character( 272 297 player_id="default", ··· 274 299 race="Human", 275 300 char_class="Rogue", 276 301 level=3, 277 - abilities={"strength": 10, "dexterity": 18, "constitution": 14, 278 - "intelligence": 14, "wisdom": 12, "charisma": 16}, 302 + abilities={ 303 + "strength": 10, 304 + "dexterity": 18, 305 + "constitution": 14, 306 + "intelligence": 14, 307 + "wisdom": 12, 308 + "charisma": 16, 309 + }, 279 310 hp_max=24, 280 311 ac=16, 281 312 ) ··· 285 316 286 317 def test_build_context_with_session(self, engine, tmp_path: Path): 287 318 from storied.session import save_session 319 + 288 320 (tmp_path / "players" / "default").mkdir(parents=True) 289 - save_session("default", { 290 - "location": "The Tavern", 291 - "body": "## Present\n- [[Vera]]", 292 - "situation": "Resting", 293 - }) 321 + save_session( 322 + "default", 323 + { 324 + "location": "The Tavern", 325 + "body": "## Present\n- [[Vera]]", 326 + "situation": "Resting", 327 + }, 328 + ) 294 329 engine._build_context() 295 330 assert "Session" in engine._context_parts 296 331 ··· 302 337 303 338 # Establish an NPC and put them in the session's present list 304 339 _do_establish( 305 - "npcs", "Vera", "Tavern owner.", "[[The Tavern]]", 306 - None, None, None, 340 + "npcs", 341 + "Vera", 342 + "Tavern owner.", 343 + "[[The Tavern]]", 344 + None, 345 + None, 346 + None, 307 347 "test", 308 348 engine._mcp.ctx.entity_index, 309 349 type("FakeIdx", (), {"upsert": lambda *a, **k: None})(), 310 350 ) 311 - save_session("default", { 312 - "location": "The Tavern", 313 - "body": "## Present\n- [[Vera]]", 314 - }) 351 + save_session( 352 + "default", 353 + { 354 + "location": "The Tavern", 355 + "body": "## Present\n- [[Vera]]", 356 + }, 357 + ) 315 358 316 359 engine._build_context() 317 360 # The Vera entity should have been loaded into the DM context ··· 323 366 assert result is None 324 367 325 368 def test_load_player_knowledge_aggregates_files(self, engine, tmp_path: Path): 326 - knowledge = ( 327 - tmp_path / "players" / "default" / "worlds" / "test" / "npcs" 328 - ) 369 + knowledge = tmp_path / "players" / "default" / "worlds" / "test" / "npcs" 329 370 knowledge.mkdir(parents=True) 330 371 (knowledge / "vera.md").write_text( 331 372 "---\nname: Vera Blackwater\n---\n\nA tavern owner." ··· 352 393 assert engine._find_entity("Nobody") is None 353 394 354 395 def test_build_context_loads_location_and_one_hop_linked( 355 - self, engine, tmp_path: Path, 396 + self, 397 + engine, 398 + tmp_path: Path, 356 399 ): 357 400 """When the session points at a location, _build_context should load 358 401 the location, then one-hop into entities the location wikilinks.""" ··· 363 406 # Location wikilinks to a related NPC 364 407 loc_path = tmp_path / "worlds" / "test" / "locations" / "Tavern.md" 365 408 loc_path.parent.mkdir(parents=True, exist_ok=True) 366 - loc_path.write_text( 367 - "# Tavern\n\nA cozy spot where [[Vera]] holds court." 368 - ) 409 + loc_path.write_text("# Tavern\n\nA cozy spot where [[Vera]] holds court.") 369 410 engine._mcp.ctx.entity_index.register("Tavern", loc_path) 370 411 371 412 # The linked NPC ··· 374 415 npc_path.write_text("# Vera\n\nTavern owner.") 375 416 engine._mcp.ctx.entity_index.register("Vera", npc_path) 376 417 377 - save_session("default", { 378 - "location": "Tavern", 379 - "body": "Player just walked in.", 380 - }) 418 + save_session( 419 + "default", 420 + { 421 + "location": "Tavern", 422 + "body": "Player just walked in.", 423 + }, 424 + ) 381 425 382 426 engine._build_context() 383 427 # Location should be loaded 384 428 assert "Location" in engine._context_parts 385 429 # One-hop linked entity should be picked up via Linked: prefix 386 - linked_keys = [ 387 - k for k in engine._context_parts if k.startswith("Linked:") 388 - ] 430 + linked_keys = [k for k in engine._context_parts if k.startswith("Linked:")] 389 431 assert any("Vera" in k for k in linked_keys) 390 432 391 433 def test_build_context_includes_notifications(self, engine): 392 434 from storied import notifications 393 435 394 436 notifications.append( 395 - engine.world_id, "World tick: Vera left the tavern", 437 + engine.world_id, 438 + "World tick: Vera left the tavern", 396 439 ) 397 440 engine._build_context() 398 441 assert "Notifications" in engine._context_parts ··· 401 444 def test_build_context_injects_initiative_when_active(self, engine): 402 445 from storied.initiative import Combatant 403 446 404 - engine._mcp.ctx.initiative.begin([ 405 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 406 - ]) 447 + engine._mcp.ctx.initiative.begin( 448 + [ 449 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 450 + ] 451 + ) 407 452 engine._build_context() 408 453 assert "Initiative" in engine._context_parts 409 454 assert "Goblin" in engine._context_parts["Initiative"]
+74 -30
tests/test_entities.py
··· 1 + # pyright: reportArgumentType=false, reportOptionalSubscript=false 2 + # Tests use loose dict types and trust load results without null-narrowing. 1 3 """Tests for the entity model - establish, mark, and wikilink resolution.""" 2 4 3 5 from pathlib import Path ··· 9 11 load_entity_content, 10 12 resolve_wiki_link, 11 13 ) 14 + from storied.testing import call_tool 12 15 from storied.tools import EntityIndex, ToolContext 13 16 from storied.tools.entities import amend_mark as _amend_mark 14 17 from storied.tools.entities import establish as _establish 15 18 from storied.tools.entities import mark as _mark 16 - 17 - from storied.testing import call_tool 18 19 19 20 20 21 def establish(**kwargs): ··· 182 183 knows=["Where the keys are kept"], 183 184 ) 184 185 185 - content = (tmp_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() 186 + content = ( 187 + tmp_path / "worlds/test-world/npcs/Garrick the Jailer.md" 188 + ).read_text() 186 189 assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content 187 190 assert "Heavyset man in his fifties." in content 188 191 189 - def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext, tmp_path: Path): 192 + def test_establish_refuses_player_character_as_npc( 193 + self, ctx: ToolContext, tmp_path: Path 194 + ): 190 195 from storied.character import create_character 191 196 192 197 create_character( ··· 195 200 race="Human", 196 201 char_class="Rogue", 197 202 level=3, 198 - abilities={"strength": 10, "dexterity": 16, "constitution": 12, 199 - "intelligence": 12, "wisdom": 12, "charisma": 14}, 200 - hp_max=24, ac=16, 203 + abilities={ 204 + "strength": 10, 205 + "dexterity": 16, 206 + "constitution": 12, 207 + "intelligence": 12, 208 + "wisdom": 12, 209 + "charisma": 14, 210 + }, 211 + hp_max=24, 212 + ac=16, 201 213 ) 202 214 203 215 result = establish( ··· 211 223 assert "player character" in result 212 224 assert not (tmp_path / "worlds/test-world/npcs/Mira.md").exists() 213 225 214 - def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext, tmp_path: Path): 226 + def test_establish_allows_player_name_for_non_npc( 227 + self, ctx: ToolContext, tmp_path: Path 228 + ): 215 229 """The guard is NPC-scoped — an NPC can't share the PC's name, but 216 230 a location or thread happening to be named 'Mira' is fine.""" 217 231 from storied.character import create_character ··· 222 236 race="Human", 223 237 char_class="Rogue", 224 238 level=1, 225 - abilities={"strength": 10, "dexterity": 16, "constitution": 12, 226 - "intelligence": 12, "wisdom": 12, "charisma": 14}, 227 - hp_max=8, ac=14, 239 + abilities={ 240 + "strength": 10, 241 + "dexterity": 16, 242 + "constitution": 12, 243 + "intelligence": 12, 244 + "wisdom": 12, 245 + "charisma": 14, 246 + }, 247 + hp_max=8, 248 + ac=14, 228 249 ) 229 250 230 251 result = establish( ··· 238 259 assert (tmp_path / "worlds/test-world/locations/Mira.md").exists() 239 260 240 261 def test_establish_allows_npc_matching_pc_name_with_no_character( 241 - self, ctx: ToolContext, tmp_path: Path, 262 + self, 263 + ctx: ToolContext, 264 + tmp_path: Path, 242 265 ): 243 266 """Without a character sheet on disk, the guard should not fire.""" 244 267 result = establish( ··· 251 274 assert "Established" in result 252 275 assert (tmp_path / "worlds/test-world/npcs/Mira.md").exists() 253 276 254 - def test_establish_location_preserved_on_update(self, ctx: ToolContext, tmp_path: Path): 277 + def test_establish_location_preserved_on_update( 278 + self, ctx: ToolContext, tmp_path: Path 279 + ): 255 280 # Create with location 256 281 establish( 257 282 entity_type="npcs", ··· 457 482 assert "#d2-1430" in content 458 483 459 484 def test_mark_with_invalid_when_falls_back_gracefully( 460 - self, ctx: ToolContext, 485 + self, 486 + ctx: ToolContext, 461 487 ): 462 488 # A garbage `when` value should not crash — the worst case is the 463 489 # literal text landing in the Was prefix. Callers can tell they ··· 479 505 def test_amend_replaces_most_recent_entry(self, ctx: ToolContext, tmp_path: Path): 480 506 establish(entity_type="npcs", name="Vera", ctx=ctx, description="x") 481 507 mark( 482 - entity_type="npcs", name="Vera", 483 - event="Told Mira a half-truth", ctx=ctx, 508 + entity_type="npcs", 509 + name="Vera", 510 + event="Told Mira a half-truth", 511 + ctx=ctx, 484 512 ) 485 513 486 514 result = amend_mark( 487 - entity_type="npcs", name="Vera", 515 + entity_type="npcs", 516 + name="Vera", 488 517 event="Told Mira the full truth about the smuggling ring", 489 518 ctx=ctx, 490 519 ) ··· 497 526 def test_amend_preserves_anchor(self, ctx: ToolContext, tmp_path: Path): 498 527 establish(entity_type="npcs", name="Tam", ctx=ctx, description="x") 499 528 mark( 500 - entity_type="npcs", name="Tam", 501 - event="Original beat", when="d5-1200", ctx=ctx, 529 + entity_type="npcs", 530 + name="Tam", 531 + event="Original beat", 532 + when="d5-1200", 533 + ctx=ctx, 502 534 ) 503 535 504 536 amend_mark( 505 - entity_type="npcs", name="Tam", 506 - event="Corrected beat", ctx=ctx, 537 + entity_type="npcs", 538 + name="Tam", 539 + event="Corrected beat", 540 + ctx=ctx, 507 541 ) 508 542 509 543 content = (tmp_path / "worlds/test-world/npcs/Tam.md").read_text() 510 544 assert "#d5-1200 | Corrected beat" in content 511 545 512 - def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext, tmp_path: Path): 546 + def test_amend_leaves_older_entries_untouched( 547 + self, ctx: ToolContext, tmp_path: Path 548 + ): 513 549 establish(entity_type="npcs", name="Oben", ctx=ctx, description="x") 514 550 mark(entity_type="npcs", name="Oben", event="First beat", ctx=ctx) 515 551 ctx.campaign_log.append_entry("advance", "1 hour") 516 552 mark(entity_type="npcs", name="Oben", event="Second beat", ctx=ctx) 517 553 518 554 amend_mark( 519 - entity_type="npcs", name="Oben", 520 - event="Second beat, corrected", ctx=ctx, 555 + entity_type="npcs", 556 + name="Oben", 557 + event="Second beat, corrected", 558 + ctx=ctx, 521 559 ) 522 560 523 561 content = (tmp_path / "worlds/test-world/npcs/Oben.md").read_text() ··· 526 564 assert "- Second beat\n" not in content # old unamended line gone 527 565 528 566 def test_amend_on_entity_with_no_history_returns_error( 529 - self, ctx: ToolContext, 567 + self, 568 + ctx: ToolContext, 530 569 ): 531 570 establish(entity_type="npcs", name="Fresh", ctx=ctx, description="x") 532 571 result = amend_mark( 533 - entity_type="npcs", name="Fresh", 534 - event="Something", ctx=ctx, 572 + entity_type="npcs", 573 + name="Fresh", 574 + event="Something", 575 + ctx=ctx, 535 576 ) 536 577 assert "no history" in result.lower() 537 578 538 579 def test_amend_on_nonexistent_entity_returns_error(self, ctx: ToolContext): 539 580 result = amend_mark( 540 - entity_type="npcs", name="Ghost", 541 - event="Something", ctx=ctx, 581 + entity_type="npcs", 582 + name="Ghost", 583 + event="Something", 584 + ctx=ctx, 542 585 ) 543 586 assert "not found" in result.lower() 544 587 ··· 634 677 635 678 @pytest.fixture 636 679 def indexed_world( 637 - ctx: ToolContext, tmp_path: Path, 680 + ctx: ToolContext, 681 + tmp_path: Path, 638 682 ) -> tuple[Path, EntityIndex]: 639 683 """Create a world with entities and build an index.""" 640 684 world_dir = tmp_path / "worlds" / "test-world"
+541 -395
tests/test_execute_tool.py
··· 1 + # pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false 2 + # pyright: reportCallIssue=false 3 + # Tests subscript load_character / ctx.initiative._find results without 4 + # null-narrowing — setup guarantees the values exist. 1 5 """Tests for tool dispatch via the FastMCP in-memory client. 2 6 3 7 These tests exercise the same call path the production server uses ··· 6 10 7 11 from pathlib import Path 8 12 9 - import asyncio 10 - from typing import Any 11 - 12 13 import pytest 13 - from fastmcp import Client 14 + from conftest import McpCall, McpCallInCombat 14 15 15 16 from storied.character import load_character 16 17 from storied.initiative import Combatant 17 - from storied.mcp_server import _compose_server 18 + from storied.testing import call_tool 18 19 from storied.tools import ToolContext 19 - from storied.tools.combat import _flip_into_combat, _flip_out_of_combat 20 20 from storied.tools.entities import note_discovery as _note_discovery 21 21 from storied.tools.scene import end_session as _end_session 22 22 23 - from storied.testing import call_tool 24 - 25 - 26 - # --- Helpers ---------------------------------------------------------------- 27 - 28 - 29 - def call(tool_name: str, args: dict[str, Any] | None = None) -> str: 30 - """Compose a DM server, call a tool through the in-memory client, return text.""" 31 - async def _run() -> str: 32 - server = await _compose_server("dm") 33 - async with Client(server) as client: 34 - result = await client.call_tool(tool_name, args or {}) 35 - return result.data if result.data is not None else "" 36 - 37 - return asyncio.run(_run()) 38 - 39 - 40 - def call_in_combat( 41 - tool_name: str, 42 - args: dict[str, Any], 43 - combatants: list[Combatant], 44 - ) -> str: 45 - """Variant that begins initiative on the process-global tracker first. 46 - 47 - The composed server starts with combat tools hidden; we flip them on so 48 - in-combat tools (next_turn, condition, etc.) become callable. 49 - """ 50 - from storied.tools._context import _require 51 - _require().initiative.begin(combatants) 52 - 53 - async def _run() -> str: 54 - server = await _compose_server("dm") 55 - _flip_into_combat() 56 - try: 57 - async with Client(server) as client: 58 - result = await client.call_tool(tool_name, args) 59 - return result.data if result.data is not None else "" 60 - finally: 61 - _flip_out_of_combat() 62 - 63 - return asyncio.run(_run()) 64 - 65 - 66 23 # --- Dispatch through the in-memory client ---------------------------------- 67 24 68 25 69 26 class TestToolDispatch: 70 - def test_roll_with_modifier(self, ctx: ToolContext): 71 - result = call("roll", {"notation": "1d20+5", "reason": "attack"}) 27 + def test_roll_with_modifier(self, ctx: ToolContext, mcp_call: McpCall): 28 + result = mcp_call("roll", {"notation": "1d20+5", "reason": "attack"}) 72 29 assert "Rolled" in result 73 30 assert "1d20+5" in result 74 31 75 - def test_roll_no_modifier(self, ctx: ToolContext): 76 - result = call("roll", {"notation": "1d6", "reason": "damage"}) 32 + def test_roll_no_modifier(self, ctx: ToolContext, mcp_call: McpCall): 33 + result = mcp_call("roll", {"notation": "1d6", "reason": "damage"}) 77 34 assert "Rolled" in result 78 35 79 - def test_recall_finds_nothing(self, ctx: ToolContext): 80 - result = call("recall", {"query": "nonexistent"}) 36 + def test_recall_finds_nothing(self, ctx: ToolContext, mcp_call: McpCall): 37 + result = mcp_call("recall", {"query": "nonexistent"}) 81 38 assert "Nothing found" in result 82 39 83 - def test_set_scene(self, ctx: ToolContext): 84 - result = call("set_scene", {"event": "Spoke with guards", "duration": "10 min"}) 40 + def test_set_scene(self, ctx: ToolContext, mcp_call: McpCall): 41 + result = mcp_call( 42 + "set_scene", {"event": "Spoke with guards", "duration": "10 min"} 43 + ) 85 44 assert result 86 45 87 - def test_establish(self, ctx: ToolContext): 88 - result = call("establish", {"entity_type": "npcs", "name": "Test NPC"}) 46 + def test_establish(self, ctx: ToolContext, mcp_call: McpCall): 47 + result = mcp_call("establish", {"entity_type": "npcs", "name": "Test NPC"}) 89 48 assert "Established" in result 90 49 91 - def test_mark(self, ctx: ToolContext): 92 - call("establish", {"entity_type": "npcs", "name": "Vera"}) 93 - result = call("mark", { 94 - "entity_type": "npcs", "name": "Vera", 95 - "event": "Revealed her secret", 96 - }) 50 + def test_mark(self, ctx: ToolContext, mcp_call: McpCall): 51 + mcp_call("establish", {"entity_type": "npcs", "name": "Vera"}) 52 + result = mcp_call( 53 + "mark", 54 + { 55 + "entity_type": "npcs", 56 + "name": "Vera", 57 + "event": "Revealed her secret", 58 + }, 59 + ) 97 60 assert "Marked" in result 98 61 99 - def test_note_discovery(self, ctx: ToolContext): 100 - result = call("note_discovery", { 101 - "entity": "Vera Blackwater", 102 - "content": "She used to be a smuggler", 103 - }) 62 + def test_note_discovery(self, ctx: ToolContext, mcp_call: McpCall): 63 + result = mcp_call( 64 + "note_discovery", 65 + { 66 + "entity": "Vera Blackwater", 67 + "content": "She used to be a smuggler", 68 + }, 69 + ) 104 70 assert "Noted" in result 105 71 106 - def test_end_session(self, ctx: ToolContext): 107 - result = call("end_session", {"situation": "In the tavern"}) 72 + def test_end_session(self, ctx: ToolContext, mcp_call: McpCall): 73 + result = mcp_call("end_session", {"situation": "In the tavern"}) 108 74 assert result == "SESSION_ENDED" 109 75 110 - def test_unknown_tool_raises(self, ctx: ToolContext): 111 - with pytest.raises(Exception): 112 - call("nonexistent", {}) 76 + def test_unknown_tool_raises(self, ctx: ToolContext, mcp_call: McpCall): 77 + with pytest.raises(Exception, match="nonexistent"): 78 + mcp_call("nonexistent", {}) 113 79 114 80 115 81 class TestUpdateCharacter: 116 - def test_landed_in_state_hp_current(self, ctx: ToolContext, tmp_path: Path): 117 - call("create_character", { 118 - "name": "Test", "race": "Human", "char_class": "Fighter", 119 - "level": 1, "abilities": { 120 - "strength": 16, "dexterity": 12, "constitution": 14, 121 - "intelligence": 10, "wisdom": 13, "charisma": 8, 82 + def test_landed_in_state_hp_current( 83 + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall 84 + ): 85 + mcp_call( 86 + "create_character", 87 + { 88 + "name": "Test", 89 + "race": "Human", 90 + "char_class": "Fighter", 91 + "level": 1, 92 + "abilities": { 93 + "strength": 16, 94 + "dexterity": 12, 95 + "constitution": 14, 96 + "intelligence": 10, 97 + "wisdom": 13, 98 + "charisma": 8, 99 + }, 100 + "hp_max": 12, 101 + "ac": 16, 122 102 }, 123 - "hp_max": 12, "ac": 16, 124 - }) 125 - result = call("update_character", {"updates": {"state.hp.current": 8}}) 103 + ) 104 + result = mcp_call("update_character", {"updates": {"state.hp.current": 8}}) 126 105 assert "updated" in result.lower() 127 106 128 107 data = load_character(ctx.player_id) ··· 139 118 """Direct (non-MCP) calls to note_discovery exercise the wrapper itself.""" 140 119 141 120 def test_creates_knowledge_file(self, ctx: ToolContext, tmp_path: Path): 142 - call_tool(_note_discovery, entity="The Rusty Anchor", 143 - content="A seedy tavern on the docks") 121 + call_tool( 122 + _note_discovery, 123 + entity="The Rusty Anchor", 124 + content="A seedy tavern on the docks", 125 + ) 144 126 knowledge_dir = ( 145 - tmp_path / "players" / ctx.player_id / "worlds" 146 - / ctx.world_id / "lore" 127 + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" 147 128 ) 148 129 assert any(knowledge_dir.iterdir()) 149 130 150 131 def test_with_content_type(self, ctx: ToolContext, tmp_path: Path): 151 - call_tool(_note_discovery, entity="Vera", content="Tavern owner", 152 - content_type="npcs") 132 + call_tool( 133 + _note_discovery, entity="Vera", content="Tavern owner", content_type="npcs" 134 + ) 153 135 knowledge_dir = ( 154 - tmp_path / "players" / ctx.player_id / "worlds" 155 - / ctx.world_id / "npcs" 136 + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "npcs" 156 137 ) 157 138 assert any(knowledge_dir.iterdir()) 158 139 159 140 def test_with_tags(self, ctx: ToolContext, tmp_path: Path): 160 - call_tool(_note_discovery, entity="Old Map", 161 - content="Shows a hidden passage", tags=["quest"]) 141 + call_tool( 142 + _note_discovery, 143 + entity="Old Map", 144 + content="Shows a hidden passage", 145 + tags=["quest"], 146 + ) 162 147 knowledge_dir = ( 163 - tmp_path / "players" / ctx.player_id / "worlds" 164 - / ctx.world_id / "lore" 148 + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" 165 149 ) 166 150 content = next(knowledge_dir.iterdir()).read_text() 167 151 assert "quest" in content ··· 184 168 # --- Recall with indexed content -------------------------------------------- 185 169 186 170 171 + @pytest.fixture 172 + def three_indexed_npcs(ctx: ToolContext) -> list[str]: 173 + """Index 3 dock-loitering NPCs and return their names.""" 174 + names = [f"NPC {i}" for i in range(3)] 175 + for i, name in enumerate(names): 176 + ctx.vector_index.upsert( 177 + f"world:npcs/npc{i}.md:0", 178 + f"NPC number {i} who hangs around the docks", 179 + { 180 + "source": "world", 181 + "content_type": "npcs", 182 + "path": f"/fake/npc{i}.md", 183 + "title": name, 184 + }, 185 + ) 186 + return names 187 + 188 + 187 189 class TestRecall: 188 - def test_recall_finds_indexed_entity(self, ctx: ToolContext, tmp_path: Path): 190 + def test_recall_finds_indexed_entity( 191 + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall 192 + ): 189 193 entity_dir = tmp_path / "worlds" / ctx.world_id / "npcs" 190 194 entity_dir.mkdir(parents=True, exist_ok=True) 191 195 entity_file = entity_dir / "Vera Blackwater.md" ··· 194 198 ctx.vector_index.upsert( 195 199 "world:npcs/Vera Blackwater.md:0", 196 200 "Vera Blackwater. Tavern owner, former smuggler.", 197 - {"source": "world", "content_type": "npcs", 198 - "path": str(entity_file), "title": "Vera Blackwater"}, 201 + { 202 + "source": "world", 203 + "content_type": "npcs", 204 + "path": str(entity_file), 205 + "title": "Vera Blackwater", 206 + }, 199 207 ) 200 208 201 - result = call("recall", {"query": "Vera Blackwater"}) 209 + result = mcp_call("recall", {"query": "Vera Blackwater"}) 202 210 assert "Vera Blackwater" in result 203 211 204 - def test_recall_rules_scope(self, ctx: ToolContext): 205 - result = call("recall", {"query": "fireball", "scope": "rules"}) 212 + def test_recall_rules_scope(self, ctx: ToolContext, mcp_call: McpCall): 213 + result = mcp_call("recall", {"query": "fireball", "scope": "rules"}) 206 214 assert "Nothing found" in result 207 215 208 - def test_recall_world_scope(self, ctx: ToolContext): 209 - result = call("recall", {"query": "something", "scope": "world"}) 216 + def test_recall_world_scope(self, ctx: ToolContext, mcp_call: McpCall): 217 + result = mcp_call("recall", {"query": "something", "scope": "world"}) 210 218 assert "Nothing found" in result 211 219 212 - def test_recall_multiple_hits(self, ctx: ToolContext): 213 - for i in range(3): 214 - ctx.vector_index.upsert( 215 - f"world:npcs/npc{i}.md:0", 216 - f"NPC number {i} who hangs around the docks", 217 - {"source": "world", "content_type": "npcs", 218 - "path": f"/fake/npc{i}.md", "title": f"NPC {i}"}, 219 - ) 220 - 221 - result = call("recall", {"query": "docks NPC"}) 220 + @pytest.mark.usefixtures("three_indexed_npcs") 221 + def test_recall_multiple_hits(self, ctx: ToolContext, mcp_call: McpCall): 222 + result = mcp_call("recall", {"query": "docks NPC"}) 222 223 assert "Found" in result or "Nothing found" in result 223 224 224 225 ··· 226 227 227 228 228 229 class TestDamageHealCombat: 229 - def test_damage_combatant_in_initiative(self, ctx: ToolContext): 230 - result = call_in_combat( 230 + def test_damage_combatant_in_initiative( 231 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 232 + ): 233 + result = mcp_call_in_combat( 231 234 "damage", 232 235 {"target": "Goblin", "amount": 3}, 233 236 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], ··· 235 238 assert "3" in result 236 239 assert ctx.initiative._find("Goblin").hp == 4 237 240 238 - def test_damage_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): 239 - call("create_character", { 240 - "name": "Kira", "race": "Human", "char_class": "Fighter", 241 - "level": 1, "abilities": { 242 - "strength": 16, "dexterity": 12, "constitution": 14, 243 - "intelligence": 10, "wisdom": 13, "charisma": 8, 241 + def test_damage_syncs_player_hp( 242 + self, 243 + ctx: ToolContext, 244 + tmp_path: Path, 245 + mcp_call: McpCall, 246 + mcp_call_in_combat: McpCallInCombat, 247 + ): 248 + mcp_call( 249 + "create_character", 250 + { 251 + "name": "Kira", 252 + "race": "Human", 253 + "char_class": "Fighter", 254 + "level": 1, 255 + "abilities": { 256 + "strength": 16, 257 + "dexterity": 12, 258 + "constitution": 14, 259 + "intelligence": 10, 260 + "wisdom": 13, 261 + "charisma": 8, 262 + }, 263 + "hp_max": 25, 264 + "ac": 16, 244 265 }, 245 - "hp_max": 25, "ac": 16, 246 - }) 266 + ) 247 267 248 - result = call_in_combat( 268 + result = mcp_call_in_combat( 249 269 "damage", 250 270 {"target": "Kira", "amount": 7}, 251 - [Combatant(name="Kira", initiative=18, hp=25, hp_max=25, 252 - ac=16, is_player=True)], 271 + [ 272 + Combatant( 273 + name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True 274 + ) 275 + ], 253 276 ) 254 277 255 278 assert "synced" in result ··· 258 281 "_sync_player_hp must write to state.hp.current with the new schema" 259 282 ) 260 283 261 - def test_heal_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): 262 - call("create_character", { 263 - "name": "Kira", "race": "Human", "char_class": "Fighter", 264 - "level": 1, "abilities": { 265 - "strength": 16, "dexterity": 12, "constitution": 14, 266 - "intelligence": 10, "wisdom": 13, "charisma": 8, 284 + def test_heal_syncs_player_hp( 285 + self, 286 + ctx: ToolContext, 287 + tmp_path: Path, 288 + mcp_call: McpCall, 289 + mcp_call_in_combat: McpCallInCombat, 290 + ): 291 + mcp_call( 292 + "create_character", 293 + { 294 + "name": "Kira", 295 + "race": "Human", 296 + "char_class": "Fighter", 297 + "level": 1, 298 + "abilities": { 299 + "strength": 16, 300 + "dexterity": 12, 301 + "constitution": 14, 302 + "intelligence": 10, 303 + "wisdom": 13, 304 + "charisma": 8, 305 + }, 306 + "hp_max": 25, 307 + "ac": 16, 267 308 }, 268 - "hp_max": 25, "ac": 16, 269 - }) 270 - call("update_character", {"updates": {"state.hp.current": 20}}) 309 + ) 310 + mcp_call("update_character", {"updates": {"state.hp.current": 20}}) 271 311 272 - result = call_in_combat( 312 + result = mcp_call_in_combat( 273 313 "heal", 274 314 {"target": "Kira", "amount": 3}, 275 - [Combatant(name="Kira", initiative=18, hp=20, hp_max=25, 276 - ac=16, is_player=True)], 315 + [ 316 + Combatant( 317 + name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True 318 + ) 319 + ], 277 320 ) 278 321 279 322 assert "synced" in result ··· 282 325 "_sync_player_hp must write to state.hp.current with the new schema" 283 326 ) 284 327 285 - def test_damage_no_sync_for_non_player(self, ctx: ToolContext): 286 - result = call_in_combat( 328 + def test_damage_no_sync_for_non_player( 329 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 330 + ): 331 + result = mcp_call_in_combat( 287 332 "damage", 288 333 {"target": "Goblin", "amount": 3}, 289 334 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 290 335 ) 291 336 assert "synced" not in result 292 337 293 - def test_unknown_target_returns_error(self, ctx: ToolContext): 294 - result = call("damage", {"target": "Nobody", "amount": 5}) 338 + def test_unknown_target_returns_error(self, ctx: ToolContext, mcp_call: McpCall): 339 + result = mcp_call("damage", {"target": "Nobody", "amount": 5}) 295 340 assert "No such target" in result 296 341 297 342 ··· 306 351 so the wrapper bodies (not just the underlying tracker) get exercised. 307 352 """ 308 353 309 - def test_enter_initiative_starts_combat(self, ctx: ToolContext): 310 - result = call_in_combat( 354 + def test_enter_initiative_starts_combat( 355 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 356 + ): 357 + result = mcp_call_in_combat( 311 358 "next_turn", 312 359 {}, 313 360 [ 314 - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, 315 - is_player=True), 361 + Combatant( 362 + name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True 363 + ), 316 364 Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 317 365 ], 318 366 ) ··· 320 368 # next_turn from Kira should advance to Goblin 321 369 assert "Goblin" in result 322 370 323 - def test_enter_initiative_via_client(self, ctx: ToolContext): 371 + def test_enter_initiative_via_client( 372 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 373 + ): 324 374 """Drive enter_initiative through the in-memory client so the 325 375 parsing + tracker.begin + flip path runs end-to-end.""" 326 - async def _run() -> str: 327 - from fastmcp import Client 328 - server = await _compose_server("dm") 329 - try: 330 - async with Client(server) as client: 331 - r = await client.call_tool("enter_initiative", { 332 - "combatants": [ 333 - {"name": "Kira", "initiative": 18, "hp": 25, 334 - "hp_max": 25, "ac": 16, "is_player": True}, 335 - {"name": "Goblin", "initiative": 10, "hp": 7, 336 - "hp_max": 7, "ac": 15}, 337 - ], 338 - }) 339 - return r.data 340 - finally: 341 - _flip_out_of_combat() 342 - 343 - result = asyncio.run(_run()) 376 + result = mcp_call_in_combat( 377 + "enter_initiative", 378 + { 379 + "combatants": [ 380 + { 381 + "name": "Kira", 382 + "initiative": 18, 383 + "hp": 25, 384 + "hp_max": 25, 385 + "ac": 16, 386 + "is_player": True, 387 + }, 388 + { 389 + "name": "Goblin", 390 + "initiative": 10, 391 + "hp": 7, 392 + "hp_max": 7, 393 + "ac": 15, 394 + }, 395 + ], 396 + }, 397 + ) 344 398 assert "Initiative started" in result or "Round 1" in result 345 399 assert ctx.initiative.active 346 400 347 - def test_enter_initiative_when_already_active_errors(self, ctx: ToolContext): 401 + def test_enter_initiative_when_already_active_errors( 402 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 403 + ): 348 404 # Start combat manually so the wrapper hits the early-return guard 349 - ctx.initiative.begin([ 350 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 351 - ]) 352 - 353 - async def _run() -> str: 354 - from fastmcp import Client 355 - server = await _compose_server("dm") 356 - _flip_into_combat() 357 - try: 358 - async with Client(server) as client: 359 - r = await client.call_tool("enter_initiative", { 360 - "combatants": [{ 361 - "name": "X", "initiative": 1, "hp": 1, "hp_max": 1, 362 - "ac": 10, 363 - }], 364 - }) 365 - return r.data 366 - finally: 367 - _flip_out_of_combat() 368 - 369 - result = asyncio.run(_run()) 405 + result = mcp_call_in_combat( 406 + "enter_initiative", 407 + { 408 + "combatants": [ 409 + { 410 + "name": "X", 411 + "initiative": 1, 412 + "hp": 1, 413 + "hp_max": 1, 414 + "ac": 10, 415 + } 416 + ], 417 + }, 418 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 419 + ) 370 420 assert "already active" in result.lower() 371 421 372 - def test_add_combatant_inserts_into_initiative(self, ctx: ToolContext): 373 - result = call_in_combat( 422 + def test_add_combatant_inserts_into_initiative( 423 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 424 + ): 425 + result = mcp_call_in_combat( 374 426 "add_combatant", 375 - {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, 376 - "ac": 14}, 427 + {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, "ac": 14}, 377 428 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 378 429 ) 379 430 assert "Reinforcement" in result 380 431 assert ctx.initiative._find("Reinforcement") is not None 381 432 382 - def test_remove_combatant_removes_from_initiative(self, ctx: ToolContext): 383 - result = call_in_combat( 433 + def test_remove_combatant_removes_from_initiative( 434 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 435 + ): 436 + result = mcp_call_in_combat( 384 437 "remove_combatant", 385 438 {"name": "Goblin"}, 386 439 [ ··· 391 444 assert "removed" in result.lower() 392 445 assert ctx.initiative._find("Goblin") is None 393 446 394 - def test_condition_add(self, ctx: ToolContext): 395 - result = call_in_combat( 447 + def test_condition_add(self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat): 448 + result = mcp_call_in_combat( 396 449 "condition", 397 450 {"target": "Goblin", "condition": "Poisoned"}, 398 451 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], ··· 402 455 goblin = ctx.initiative._find("Goblin") 403 456 assert any(c.name == "Poisoned" for c in goblin.conditions) 404 457 405 - def test_condition_remove(self, ctx: ToolContext): 406 - ctx.initiative.begin([ 407 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 408 - ]) 458 + def test_condition_remove( 459 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 460 + ): 461 + ctx.initiative.begin( 462 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)] 463 + ) 409 464 ctx.initiative.add_condition(target="Goblin", condition="Stunned") 410 465 411 - async def _run() -> str: 412 - from fastmcp import Client 413 - server = await _compose_server("dm") 414 - _flip_into_combat() 415 - try: 416 - async with Client(server) as client: 417 - r = await client.call_tool("condition", { 418 - "target": "Goblin", 419 - "condition": "Stunned", 420 - "action": "remove", 421 - }) 422 - return r.data 423 - finally: 424 - _flip_out_of_combat() 425 - 426 - result = asyncio.run(_run()) 466 + result = mcp_call_in_combat( 467 + "condition", 468 + { 469 + "target": "Goblin", 470 + "condition": "Stunned", 471 + "action": "remove", 472 + }, 473 + ) 427 474 assert "removed" in result.lower() or "Stunned" in result 428 475 goblin = ctx.initiative._find("Goblin") 429 476 assert not any(c.name == "Stunned" for c in goblin.conditions) 430 477 431 - def test_end_initiative_clears_combat(self, ctx: ToolContext): 432 - ctx.initiative.begin([ 433 - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 434 - ]) 435 - 436 - async def _run() -> str: 437 - from fastmcp import Client 438 - server = await _compose_server("dm") 439 - _flip_into_combat() 440 - try: 441 - async with Client(server) as client: 442 - r = await client.call_tool("end_initiative", {}) 443 - return r.data 444 - finally: 445 - _flip_out_of_combat() 446 - 447 - result = asyncio.run(_run()) 478 + def test_end_initiative_clears_combat( 479 + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat 480 + ): 481 + result = mcp_call_in_combat( 482 + "end_initiative", 483 + {}, 484 + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], 485 + ) 448 486 assert "ended" in result.lower() 449 487 assert not ctx.initiative.active 450 488 ··· 453 491 454 492 455 493 @pytest.fixture 456 - def kira(ctx: ToolContext) -> ToolContext: 494 + def kira(ctx: ToolContext, mcp_call: McpCall) -> ToolContext: 457 495 """A minimum-viable character used by the wrapper-coverage tests.""" 458 - call("create_character", { 459 - "name": "Kira", "race": "Human", "char_class": "Fighter", 460 - "level": 1, "abilities": { 461 - "strength": 16, "dexterity": 12, "constitution": 14, 462 - "intelligence": 10, "wisdom": 13, "charisma": 8, 496 + mcp_call( 497 + "create_character", 498 + { 499 + "name": "Kira", 500 + "race": "Human", 501 + "char_class": "Fighter", 502 + "level": 1, 503 + "abilities": { 504 + "strength": 16, 505 + "dexterity": 12, 506 + "constitution": 14, 507 + "intelligence": 10, 508 + "wisdom": 13, 509 + "charisma": 8, 510 + }, 511 + "hp_max": 12, 512 + "ac": 16, 463 513 }, 464 - "hp_max": 12, "ac": 16, 465 - }) 514 + ) 466 515 return ctx 467 516 468 517 ··· 472 521 client so the wrapper bodies (and their Dependency-resolved arguments) 473 522 are actually executed.""" 474 523 475 - def test_damage_player_by_name(self, kira: ToolContext): 476 - result = call("damage", {"target": "Kira", "amount": 3}) 524 + def test_damage_player_by_name(self, kira: ToolContext, mcp_call: McpCall): 525 + result = mcp_call("damage", {"target": "Kira", "amount": 3}) 477 526 assert "3" in result 478 527 char = load_character("default") 479 528 assert char["state"]["hp"]["current"] == 9 480 529 481 - def test_damage_with_type(self, kira: ToolContext): 482 - result = call("damage", {"target": "Kira", "amount": 2, "type": "fire"}) 530 + def test_damage_with_type(self, kira: ToolContext, mcp_call: McpCall): 531 + result = mcp_call("damage", {"target": "Kira", "amount": 2, "type": "fire"}) 483 532 assert "2" in result 484 533 485 - def test_heal_player_by_name(self, kira: ToolContext): 486 - call("damage", {"target": "Kira", "amount": 5}) 487 - result = call("heal", {"target": "Kira", "amount": 3}) 534 + def test_heal_player_by_name(self, kira: ToolContext, mcp_call: McpCall): 535 + mcp_call("damage", {"target": "Kira", "amount": 5}) 536 + result = mcp_call("heal", {"target": "Kira", "amount": 3}) 488 537 assert result 489 538 char = load_character("default") 490 539 assert char["state"]["hp"]["current"] == 10 491 540 492 - def test_adjust_coins(self, kira: ToolContext): 493 - result = call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) 541 + def test_adjust_coins(self, kira: ToolContext, mcp_call: McpCall): 542 + result = mcp_call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) 494 543 assert "10" in result 495 544 char = load_character("default") 496 545 assert char["state"]["purse"]["gp"] == 10 497 546 assert char["state"]["purse"]["sp"] == 5 498 547 499 - def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext): 548 + def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext, mcp_call: McpCall): 500 549 # Spending only gp; the zero-delta filter exercises the comprehension branch 501 - call("adjust_coins", {"deltas": {"gp": 5}}) 502 - result = call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) 550 + mcp_call("adjust_coins", {"deltas": {"gp": 5}}) 551 + result = mcp_call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) 503 552 char = load_character("default") 504 553 assert char["state"]["purse"]["gp"] == 2 505 554 assert "silver" not in result.lower() 506 555 507 - def test_add_effect(self, kira: ToolContext): 508 - result = call("add_effect", { 509 - "source": "Bless", "description": "+1d4 attacks/saves", 510 - }) 556 + def test_add_effect(self, kira: ToolContext, mcp_call: McpCall): 557 + result = mcp_call( 558 + "add_effect", 559 + { 560 + "source": "Bless", 561 + "description": "+1d4 attacks/saves", 562 + }, 563 + ) 511 564 assert result 512 565 513 - def test_add_effect_with_expires(self, kira: ToolContext): 514 - result = call("add_effect", { 515 - "source": "Heroism", "description": "+10 temp HP", 516 - "expires": "d1-1430", 517 - }) 566 + def test_add_effect_with_expires(self, kira: ToolContext, mcp_call: McpCall): 567 + result = mcp_call( 568 + "add_effect", 569 + { 570 + "source": "Heroism", 571 + "description": "+10 temp HP", 572 + "expires": "d1-1430", 573 + }, 574 + ) 518 575 assert result 519 576 520 - def test_remove_effect(self, kira: ToolContext): 521 - call("add_effect", {"source": "Bless", "description": "+1d4"}) 522 - result = call("remove_effect", {"source": "Bless"}) 577 + def test_remove_effect(self, kira: ToolContext, mcp_call: McpCall): 578 + mcp_call("add_effect", {"source": "Bless", "description": "+1d4"}) 579 + result = mcp_call("remove_effect", {"source": "Bless"}) 523 580 assert result 524 581 525 - def test_add_and_remove_condition(self, kira: ToolContext): 526 - result = call("add_condition", {"name": "Poisoned"}) 582 + def test_add_and_remove_condition(self, kira: ToolContext, mcp_call: McpCall): 583 + result = mcp_call("add_condition", {"name": "Poisoned"}) 527 584 assert result 528 - result = call("remove_condition", {"name": "Poisoned"}) 585 + result = mcp_call("remove_condition", {"name": "Poisoned"}) 529 586 assert result 530 587 531 - def test_add_item_default_location(self, kira: ToolContext): 532 - result = call("add_item", {"item": "Lockpicks"}) 588 + def test_add_item_default_location(self, kira: ToolContext, mcp_call: McpCall): 589 + result = mcp_call("add_item", {"item": "Lockpicks"}) 533 590 assert result 534 591 535 - def test_add_item_with_location(self, kira: ToolContext): 536 - result = call("add_item", { 537 - "item": "Spare cloak", "location": "stashed_at_inn", 538 - }) 592 + def test_add_item_with_location(self, kira: ToolContext, mcp_call: McpCall): 593 + result = mcp_call( 594 + "add_item", 595 + { 596 + "item": "Spare cloak", 597 + "location": "stashed_at_inn", 598 + }, 599 + ) 539 600 assert result 540 601 541 - def test_remove_item(self, kira: ToolContext): 542 - call("add_item", {"item": "Boot knife"}) 543 - result = call("remove_item", {"item": "Boot knife"}) 602 + def test_remove_item(self, kira: ToolContext, mcp_call: McpCall): 603 + mcp_call("add_item", {"item": "Boot knife"}) 604 + result = mcp_call("remove_item", {"item": "Boot knife"}) 544 605 assert result 545 606 546 - def test_set_item_status(self, kira: ToolContext): 607 + def test_set_item_status(self, kira: ToolContext, mcp_call: McpCall): 547 608 # The item must already be a known magic item entity for status tracking 548 - call("establish", { 549 - "entity_type": "items", "name": "Bracer of Defense", 550 - "description": "A leather bracer with a faint silver sheen.", 551 - }) 552 - result = call("set_item_status", { 553 - "item": "Bracer of Defense", "status": "attuned", 554 - }) 609 + mcp_call( 610 + "establish", 611 + { 612 + "entity_type": "items", 613 + "name": "Bracer of Defense", 614 + "description": "A leather bracer with a faint silver sheen.", 615 + }, 616 + ) 617 + result = mcp_call( 618 + "set_item_status", 619 + { 620 + "item": "Bracer of Defense", 621 + "status": "attuned", 622 + }, 623 + ) 555 624 assert result 556 625 557 - def test_adjust_resource(self, kira: ToolContext): 626 + def test_adjust_resource(self, kira: ToolContext, mcp_call: McpCall): 558 627 # Add a resource via update_character first 559 - call("update_character", { 560 - "updates": { 561 - "resources.hit_dice_d10": { 562 - "current": 1, "max": 1, "refresh": "long_rest", 563 - "notes": "Hit Dice (d10)", 628 + mcp_call( 629 + "update_character", 630 + { 631 + "updates": { 632 + "resources.hit_dice_d10": { 633 + "current": 1, 634 + "max": 1, 635 + "refresh": "long_rest", 636 + "notes": "Hit Dice (d10)", 637 + }, 564 638 }, 565 639 }, 566 - }) 567 - result = call("adjust_resource", {"name": "hit_dice", "delta": -1}) 640 + ) 641 + result = mcp_call("adjust_resource", {"name": "hit_dice", "delta": -1}) 568 642 assert "Used" in result 569 - result = call("adjust_resource", {"name": "hit_dice", "delta": 1}) 643 + result = mcp_call("adjust_resource", {"name": "hit_dice", "delta": 1}) 570 644 assert "Restored" in result 571 645 572 - def test_rest_short(self, kira: ToolContext): 573 - result = call("rest", {"type": "short"}) 646 + def test_rest_short(self, kira: ToolContext, mcp_call: McpCall): 647 + result = mcp_call("rest", {"type": "short"}) 574 648 assert result 575 649 576 - def test_rest_long(self, kira: ToolContext): 577 - result = call("rest", {"type": "long"}) 650 + def test_rest_long(self, kira: ToolContext, mcp_call: McpCall): 651 + result = mcp_call("rest", {"type": "long"}) 578 652 assert result 579 653 580 - def test_add_note(self, kira: ToolContext): 581 - result = call("add_note", {"text": "Found a hidden passage"}) 654 + def test_add_note(self, kira: ToolContext, mcp_call: McpCall): 655 + result = mcp_call("add_note", {"text": "Found a hidden passage"}) 582 656 assert result 583 657 584 658 ··· 589 663 """Cover the scene.py wrapper bodies — set_scene's optional-field branches, 590 664 tune's file write, end_session's threads branch, notify_dm's append.""" 591 665 592 - def test_set_scene_event_only(self, ctx: ToolContext): 593 - result = call("set_scene", { 594 - "event": "Walked into the tavern", 595 - "duration": "5 min", 596 - }) 666 + def test_set_scene_event_only(self, ctx: ToolContext, mcp_call: McpCall): 667 + result = mcp_call( 668 + "set_scene", 669 + { 670 + "event": "Walked into the tavern", 671 + "duration": "5 min", 672 + }, 673 + ) 597 674 assert "Logged" in result 598 675 599 - def test_set_scene_with_situation_and_location(self, ctx: ToolContext): 600 - result = call("set_scene", { 601 - "event": "Arrived at the inn", 602 - "duration": "1 hour", 603 - "situation": "Resting by the fire", 604 - "location": "The Rusty Anchor", 605 - }) 676 + def test_set_scene_with_situation_and_location( 677 + self, ctx: ToolContext, mcp_call: McpCall 678 + ): 679 + result = mcp_call( 680 + "set_scene", 681 + { 682 + "event": "Arrived at the inn", 683 + "duration": "1 hour", 684 + "situation": "Resting by the fire", 685 + "location": "The Rusty Anchor", 686 + }, 687 + ) 606 688 assert "Logged" in result 607 689 assert "updated" in result.lower() 608 690 609 - def test_set_scene_with_present_auto_marks(self, ctx: ToolContext): 691 + def test_set_scene_with_present_auto_marks( 692 + self, ctx: ToolContext, mcp_call: McpCall 693 + ): 610 694 # Establish an entity first so auto-mark has something to find 611 - call("establish", { 612 - "entity_type": "npcs", "name": "Vera", 613 - "description": "Tavern owner.", 614 - }) 615 - result = call("set_scene", { 616 - "event": "Spoke with Vera", 617 - "duration": "10 min", 618 - "present": ["[[Vera]]"], 619 - }) 695 + mcp_call( 696 + "establish", 697 + { 698 + "entity_type": "npcs", 699 + "name": "Vera", 700 + "description": "Tavern owner.", 701 + }, 702 + ) 703 + result = mcp_call( 704 + "set_scene", 705 + { 706 + "event": "Spoke with Vera", 707 + "duration": "10 min", 708 + "present": ["[[Vera]]"], 709 + }, 710 + ) 620 711 assert "Auto-marked: Vera" in result 621 712 622 713 def test_auto_mark_cooldown_suppresses_near_repeats( 623 - self, ctx: ToolContext, tmp_path: Path, 714 + self, 715 + ctx: ToolContext, 716 + tmp_path: Path, 717 + mcp_call: McpCall, 624 718 ): 625 719 """A second set_scene with the same present entity within the 626 720 cooldown window should NOT append another Was entry.""" 627 - call("establish", { 628 - "entity_type": "npcs", "name": "Margit", 629 - "description": "Chandler.", 630 - }) 721 + mcp_call( 722 + "establish", 723 + { 724 + "entity_type": "npcs", 725 + "name": "Margit", 726 + "description": "Chandler.", 727 + }, 728 + ) 631 729 632 - call("set_scene", { 633 - "event": "Met Mira at the candle shop", 634 - "duration": "10 min", 635 - "present": ["[[Margit]]"], 636 - }) 637 - result2 = call("set_scene", { 638 - "event": "Walked together to dinner", 639 - "duration": "10 min", 640 - "present": ["[[Margit]]"], 641 - }) 730 + mcp_call( 731 + "set_scene", 732 + { 733 + "event": "Met Mira at the candle shop", 734 + "duration": "10 min", 735 + "present": ["[[Margit]]"], 736 + }, 737 + ) 738 + result2 = mcp_call( 739 + "set_scene", 740 + { 741 + "event": "Walked together to dinner", 742 + "duration": "10 min", 743 + "present": ["[[Margit]]"], 744 + }, 745 + ) 642 746 643 747 assert "Auto-marked" not in result2 644 748 content = ( ··· 647 751 assert content.count("Met Mira at the candle shop") == 1 648 752 assert "Walked together to dinner" not in content 649 753 650 - def test_auto_mark_cooldown_clears_after_window(self, ctx: ToolContext): 754 + def test_auto_mark_cooldown_clears_after_window( 755 + self, ctx: ToolContext, mcp_call: McpCall 756 + ): 651 757 """After more than cooldown minutes have passed in-game, the next 652 758 auto-mark for the same entity fires again.""" 653 - call("establish", { 654 - "entity_type": "npcs", "name": "Aldric", 655 - "description": "Bookseller.", 656 - }) 759 + mcp_call( 760 + "establish", 761 + { 762 + "entity_type": "npcs", 763 + "name": "Aldric", 764 + "description": "Bookseller.", 765 + }, 766 + ) 657 767 658 - call("set_scene", { 659 - "event": "Briefed Aldric on the investigation", 660 - "duration": "30 min", # advances the clock past the cooldown 661 - "present": ["[[Aldric]]"], 662 - }) 663 - call("set_scene", { 664 - "event": "Walked away to get lunch", 665 - "duration": "30 min", 666 - }) 667 - result3 = call("set_scene", { 668 - "event": "Returned and shared a new lead with Aldric", 669 - "duration": "20 min", 670 - "present": ["[[Aldric]]"], 671 - }) 768 + mcp_call( 769 + "set_scene", 770 + { 771 + "event": "Briefed Aldric on the investigation", 772 + "duration": "30 min", # advances the clock past the cooldown 773 + "present": ["[[Aldric]]"], 774 + }, 775 + ) 776 + mcp_call( 777 + "set_scene", 778 + { 779 + "event": "Walked away to get lunch", 780 + "duration": "30 min", 781 + }, 782 + ) 783 + result3 = mcp_call( 784 + "set_scene", 785 + { 786 + "event": "Returned and shared a new lead with Aldric", 787 + "duration": "20 min", 788 + "present": ["[[Aldric]]"], 789 + }, 790 + ) 672 791 673 792 assert "Auto-marked: Aldric" in result3 674 793 675 - def test_auto_mark_skips_operational_events(self, ctx: ToolContext, tmp_path: Path): 794 + def test_auto_mark_skips_operational_events( 795 + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall 796 + ): 676 797 """Session-lifecycle events (Session resumed, etc) must never land 677 798 in an entity's history.""" 678 - call("establish", { 679 - "entity_type": "npcs", "name": "Dortha", 680 - "description": "Tanner.", 681 - }) 682 - result = call("set_scene", { 683 - "event": "Session resumed. Mira at Dortha's shop.", 684 - "duration": "0 min", 685 - "present": ["[[Dortha]]"], 686 - }) 799 + mcp_call( 800 + "establish", 801 + { 802 + "entity_type": "npcs", 803 + "name": "Dortha", 804 + "description": "Tanner.", 805 + }, 806 + ) 807 + result = mcp_call( 808 + "set_scene", 809 + { 810 + "event": "Session resumed. Mira at Dortha's shop.", 811 + "duration": "0 min", 812 + "present": ["[[Dortha]]"], 813 + }, 814 + ) 687 815 688 816 assert "Auto-marked" not in result 689 817 content = ( ··· 691 819 ).read_text() 692 820 assert "Session resumed" not in content 693 821 694 - def test_set_scene_with_threads(self, ctx: ToolContext): 695 - result = call("set_scene", { 696 - "event": "Got a lead", 697 - "duration": "5 min", 698 - "threads": ["Find the missing merchant"], 699 - }) 822 + def test_set_scene_with_threads(self, ctx: ToolContext, mcp_call: McpCall): 823 + result = mcp_call( 824 + "set_scene", 825 + { 826 + "event": "Got a lead", 827 + "duration": "5 min", 828 + "threads": ["Find the missing merchant"], 829 + }, 830 + ) 700 831 assert result 701 832 702 - def test_set_scene_no_args_returns_no_updates(self, ctx: ToolContext): 833 + def test_set_scene_no_args_returns_no_updates( 834 + self, ctx: ToolContext, mcp_call: McpCall 835 + ): 703 836 # Both event and duration omitted, no other fields → "No updates" 704 - result = call("set_scene", {}) 837 + result = mcp_call("set_scene", {}) 705 838 assert result == "No updates" 706 839 707 - def test_tune_writes_style_file(self, ctx: ToolContext, tmp_path: Path): 708 - result = call("tune", {"tuning": "Lean into intrigue and slow pacing."}) 840 + def test_tune_writes_style_file( 841 + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall 842 + ): 843 + result = mcp_call("tune", {"tuning": "Lean into intrigue and slow pacing."}) 709 844 assert "updated" in result.lower() 710 845 style_path = tmp_path / "worlds" / ctx.world_id / "style.md" 711 846 assert style_path.exists() 712 847 assert "intrigue" in style_path.read_text() 713 848 714 - def test_end_session_no_threads(self, ctx: ToolContext): 715 - result = call("end_session", {"situation": "In the tavern"}) 849 + def test_end_session_no_threads(self, ctx: ToolContext, mcp_call: McpCall): 850 + result = mcp_call("end_session", {"situation": "In the tavern"}) 716 851 assert result == "SESSION_ENDED" 717 852 718 - def test_end_session_with_threads(self, ctx: ToolContext): 719 - result = call("end_session", { 720 - "situation": "In the tavern", 721 - "threads": ["Investigate the warehouse", "Find the merchant"], 722 - }) 853 + def test_end_session_with_threads(self, ctx: ToolContext, mcp_call: McpCall): 854 + result = mcp_call( 855 + "end_session", 856 + { 857 + "situation": "In the tavern", 858 + "threads": ["Investigate the warehouse", "Find the merchant"], 859 + }, 860 + ) 723 861 assert result == "SESSION_ENDED" 724 862 725 - def test_notify_dm_appends_to_queue(self, ctx: ToolContext, tmp_path: Path): 726 - result = call("notify_dm", {"message": "Background world has shifted"}) 863 + def test_notify_dm_appends_to_queue( 864 + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall 865 + ): 866 + result = mcp_call("notify_dm", {"message": "Background world has shifted"}) 727 867 assert "queued" in result.lower() 728 868 path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" 729 869 assert path.exists() ··· 734 874 735 875 736 876 class TestRunCodeWrapper: 737 - def test_run_code_simple(self, ctx: ToolContext): 738 - result = call("run_code", { 739 - "description": "Two plus two", 740 - "code": "2 + 2", 741 - }) 877 + def test_run_code_simple(self, ctx: ToolContext, mcp_call: McpCall): 878 + result = mcp_call( 879 + "run_code", 880 + { 881 + "description": "Two plus two", 882 + "code": "2 + 2", 883 + }, 884 + ) 742 885 assert "4" in result 743 886 744 - def test_run_code_with_print(self, ctx: ToolContext): 745 - result = call("run_code", { 746 - "description": "Print test", 747 - "code": 'print("hello sandbox")', 748 - }) 887 + def test_run_code_with_print(self, ctx: ToolContext, mcp_call: McpCall): 888 + result = mcp_call( 889 + "run_code", 890 + { 891 + "description": "Print test", 892 + "code": 'print("hello sandbox")', 893 + }, 894 + ) 749 895 assert "hello sandbox" in result
+129 -41
tests/test_initiative.py
··· 1 + # pyright: reportOptionalMemberAccess=false 2 + # Tests reach into combatant lookups via ctx.initiative._find(name) and 3 + # trust the result — the setup guarantees the named combatant exists. 1 4 """Tests for the initiative tracking state machine. 2 5 3 6 The FastMCP combat tool surface is tested separately in test_mcp_server.py ··· 10 13 from storied.initiative import ( 11 14 Combatant, 12 15 InitiativeTracker, 13 - TrackedCondition, 14 16 ) 15 17 16 18 ··· 33 35 def test_starts_inactive(self, tracker: InitiativeTracker): 34 36 assert not tracker.active 35 37 36 - def test_begin_activates(self, tracker: InitiativeTracker, combatants: list[Combatant]): 38 + def test_begin_activates( 39 + self, tracker: InitiativeTracker, combatants: list[Combatant] 40 + ): 37 41 tracker.begin(combatants) 38 42 39 43 assert tracker.active ··· 41 45 assert tracker.current_index == 0 42 46 43 47 def test_begin_preserves_list_order( 44 - self, tracker: InitiativeTracker, combatants: list[Combatant], 48 + self, 49 + tracker: InitiativeTracker, 50 + combatants: list[Combatant], 45 51 ): 46 52 tracker.begin(combatants) 47 53 48 54 assert [c.name for c in tracker.combatants] == ["Kira", "Goblin 1", "Goblin 2"] 49 55 50 - def test_end_deactivates(self, tracker: InitiativeTracker, combatants: list[Combatant]): 56 + def test_end_deactivates( 57 + self, tracker: InitiativeTracker, combatants: list[Combatant] 58 + ): 51 59 tracker.begin(combatants) 52 60 summary = tracker.end() 53 61 ··· 55 63 assert "1" in summary # round count 56 64 57 65 def test_end_reports_defeated( 58 - self, tracker: InitiativeTracker, combatants: list[Combatant], 66 + self, 67 + tracker: InitiativeTracker, 68 + combatants: list[Combatant], 59 69 ): 60 70 tracker.begin(combatants) 61 71 tracker.apply_damage("Goblin 1", 7) ··· 65 75 assert "defeated" in summary.lower() 66 76 67 77 def test_end_reports_duration( 68 - self, tracker: InitiativeTracker, combatants: list[Combatant], 78 + self, 79 + tracker: InitiativeTracker, 80 + combatants: list[Combatant], 69 81 ): 70 82 tracker.begin(combatants) 71 83 tracker.next_turn() ··· 78 90 79 91 class TestTurnAdvancement: 80 92 def test_next_turn_advances( 81 - self, tracker: InitiativeTracker, combatants: list[Combatant], 93 + self, 94 + tracker: InitiativeTracker, 95 + combatants: list[Combatant], 82 96 ): 83 97 tracker.begin(combatants) 84 98 assert tracker.current_combatant.name == "Kira" ··· 99 113 assert "Round 2" in result 100 114 101 115 def test_skips_defeated( 102 - self, tracker: InitiativeTracker, combatants: list[Combatant], 116 + self, 117 + tracker: InitiativeTracker, 118 + combatants: list[Combatant], 103 119 ): 104 120 tracker.begin(combatants) 105 121 tracker.apply_damage("Goblin 1", 7) # defeat Goblin 1 ··· 108 124 assert tracker.current_combatant.name == "Goblin 2" 109 125 110 126 def test_hints_one_side_remaining( 111 - self, tracker: InitiativeTracker, combatants: list[Combatant], 127 + self, 128 + tracker: InitiativeTracker, 129 + combatants: list[Combatant], 112 130 ): 113 131 tracker.begin(combatants) 114 132 tracker.apply_damage("Goblin 1", 7) ··· 120 138 121 139 class TestDamageAndHealing: 122 140 def test_damage_reduces_hp( 123 - self, tracker: InitiativeTracker, combatants: list[Combatant], 141 + self, 142 + tracker: InitiativeTracker, 143 + combatants: list[Combatant], 124 144 ): 125 145 tracker.begin(combatants) 126 146 result = tracker.apply_damage("Goblin 1", 3) ··· 130 150 assert "3" in result # damage amount 131 151 132 152 def test_damage_defeats_at_zero( 133 - self, tracker: InitiativeTracker, combatants: list[Combatant], 153 + self, 154 + tracker: InitiativeTracker, 155 + combatants: list[Combatant], 134 156 ): 135 157 tracker.begin(combatants) 136 158 result = tracker.apply_damage("Goblin 1", 7) ··· 141 163 assert "down" in result.lower() 142 164 143 165 def test_damage_clamps_to_zero( 144 - self, tracker: InitiativeTracker, combatants: list[Combatant], 166 + self, 167 + tracker: InitiativeTracker, 168 + combatants: list[Combatant], 145 169 ): 146 170 tracker.begin(combatants) 147 171 tracker.apply_damage("Goblin 1", 100) ··· 149 173 assert tracker._find("Goblin 1").hp == 0 150 174 151 175 def test_damage_reports_bloodied( 152 - self, tracker: InitiativeTracker, combatants: list[Combatant], 176 + self, 177 + tracker: InitiativeTracker, 178 + combatants: list[Combatant], 153 179 ): 154 180 tracker.begin(combatants) 155 181 result = tracker.apply_damage("Kira", 13) # 25 -> 12, half is 12 ··· 157 183 assert "bloodied" in result.lower() 158 184 159 185 def test_heal_increases_hp( 160 - self, tracker: InitiativeTracker, combatants: list[Combatant], 186 + self, 187 + tracker: InitiativeTracker, 188 + combatants: list[Combatant], 161 189 ): 162 190 tracker.begin(combatants) 163 191 tracker.apply_damage("Kira", 10) ··· 167 195 assert "5" in result 168 196 169 197 def test_heal_clamps_to_max( 170 - self, tracker: InitiativeTracker, combatants: list[Combatant], 198 + self, 199 + tracker: InitiativeTracker, 200 + combatants: list[Combatant], 171 201 ): 172 202 tracker.begin(combatants) 173 203 tracker.apply_damage("Kira", 5) ··· 176 206 assert tracker._find("Kira").hp == 25 177 207 178 208 def test_heal_revives_defeated( 179 - self, tracker: InitiativeTracker, combatants: list[Combatant], 209 + self, 210 + tracker: InitiativeTracker, 211 + combatants: list[Combatant], 180 212 ): 181 213 tracker.begin(combatants) 182 214 tracker.apply_damage("Goblin 1", 7) ··· 189 221 assert not goblin.defeated 190 222 191 223 def test_damage_unknown_target( 192 - self, tracker: InitiativeTracker, combatants: list[Combatant], 224 + self, 225 + tracker: InitiativeTracker, 226 + combatants: list[Combatant], 193 227 ): 194 228 tracker.begin(combatants) 195 229 result = tracker.apply_damage("Nobody", 5) ··· 197 231 assert "not found" in result.lower() 198 232 199 233 def test_heal_unknown_target( 200 - self, tracker: InitiativeTracker, combatants: list[Combatant], 234 + self, 235 + tracker: InitiativeTracker, 236 + combatants: list[Combatant], 201 237 ): 202 238 tracker.begin(combatants) 203 239 result = tracker.apply_heal("Nobody", 5) ··· 207 243 208 244 class TestConditions: 209 245 def test_add_condition( 210 - self, tracker: InitiativeTracker, combatants: list[Combatant], 246 + self, 247 + tracker: InitiativeTracker, 248 + combatants: list[Combatant], 211 249 ): 212 250 tracker.begin(combatants) 213 251 result = tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") ··· 218 256 assert "Prone" in result 219 257 220 258 def test_remove_condition( 221 - self, tracker: InitiativeTracker, combatants: list[Combatant], 259 + self, 260 + tracker: InitiativeTracker, 261 + combatants: list[Combatant], 222 262 ): 223 263 tracker.begin(combatants) 224 264 tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") ··· 229 269 assert "Prone" in result 230 270 231 271 def test_remove_nonexistent_condition( 232 - self, tracker: InitiativeTracker, combatants: list[Combatant], 272 + self, 273 + tracker: InitiativeTracker, 274 + combatants: list[Combatant], 233 275 ): 234 276 tracker.begin(combatants) 235 277 result = tracker.remove_condition("Goblin 1", "Invisible") ··· 237 279 assert "not found" in result.lower() or "no" in result.lower() 238 280 239 281 def test_condition_unknown_target( 240 - self, tracker: InitiativeTracker, combatants: list[Combatant], 282 + self, 283 + tracker: InitiativeTracker, 284 + combatants: list[Combatant], 241 285 ): 242 286 tracker.begin(combatants) 243 287 result = tracker.add_condition("Nobody", "Prone", duration=-1, source="Kira") ··· 245 289 assert "not found" in result.lower() 246 290 247 291 def test_effect_expires_end_of_source_turn( 248 - self, tracker: InitiativeTracker, combatants: list[Combatant], 292 + self, 293 + tracker: InitiativeTracker, 294 + combatants: list[Combatant], 249 295 ): 250 296 """Effect with ends_on='end' expires when leaving source's turn.""" 251 297 tracker.begin(combatants) 252 298 # Kira (idx 0) applies 1-round effect on Goblin 1, ends at end of Kira's turn 253 299 tracker.add_condition( 254 - "Goblin 1", "Stunned", duration=1, ends_on="end", source="Kira", 300 + "Goblin 1", 301 + "Stunned", 302 + duration=1, 303 + ends_on="end", 304 + source="Kira", 255 305 ) 256 306 257 307 # Advance from Kira's turn -> processes end-of-Kira effects ··· 261 311 assert not any(c.name == "Stunned" for c in goblin.conditions) 262 312 263 313 def test_effect_expires_start_of_source_turn( 264 - self, tracker: InitiativeTracker, combatants: list[Combatant], 314 + self, 315 + tracker: InitiativeTracker, 316 + combatants: list[Combatant], 265 317 ): 266 318 """Effect with ends_on='start' expires when arriving at source's turn.""" 267 319 tracker.begin(combatants) 268 320 # Kira applies 1-round effect, ends at start of Kira's next turn 269 321 tracker.add_condition( 270 - "Goblin 1", "Frightened", duration=1, ends_on="start", source="Kira", 322 + "Goblin 1", 323 + "Frightened", 324 + duration=1, 325 + ends_on="start", 326 + source="Kira", 271 327 ) 272 328 273 329 # Full round: Kira -> G1 -> G2 -> Kira (round 2, start of Kira's turn) ··· 279 335 assert not any(c.name == "Frightened" for c in goblin.conditions) 280 336 281 337 def test_indefinite_condition_persists( 282 - self, tracker: InitiativeTracker, combatants: list[Combatant], 338 + self, 339 + tracker: InitiativeTracker, 340 + combatants: list[Combatant], 283 341 ): 284 342 """Duration -1 never auto-expires.""" 285 343 tracker.begin(combatants) ··· 293 351 assert any(c.name == "Grappled" for c in goblin.conditions) 294 352 295 353 def test_multi_round_duration( 296 - self, tracker: InitiativeTracker, combatants: list[Combatant], 354 + self, 355 + tracker: InitiativeTracker, 356 + combatants: list[Combatant], 297 357 ): 298 358 """2-round effect lasts through 2 full rounds of the source's turns.""" 299 359 tracker.begin(combatants) 300 360 tracker.add_condition( 301 - "Goblin 1", "Held", duration=2, ends_on="end", source="Kira", 361 + "Goblin 1", 362 + "Held", 363 + duration=2, 364 + ends_on="end", 365 + source="Kira", 302 366 ) 303 367 304 368 # Round 1: Kira -> G1 -> G2 (end of Kira's turn, duration 2 -> 1) ··· 318 382 319 383 class TestAddRemoveCombatant: 320 384 def test_add_combatant( 321 - self, tracker: InitiativeTracker, combatants: list[Combatant], 385 + self, 386 + tracker: InitiativeTracker, 387 + combatants: list[Combatant], 322 388 ): 323 389 tracker.begin(combatants) 324 390 result = tracker.add_combatant( ··· 329 395 assert "Archer" in result 330 396 331 397 def test_add_inserts_by_initiative( 332 - self, tracker: InitiativeTracker, combatants: list[Combatant], 398 + self, 399 + tracker: InitiativeTracker, 400 + combatants: list[Combatant], 333 401 ): 334 402 tracker.begin(combatants) # Kira(18), G1(14), G2(10) 335 403 tracker.add_combatant( ··· 340 408 assert names == ["Kira", "Goblin 1", "Archer", "Goblin 2"] 341 409 342 410 def test_add_before_current_adjusts_index( 343 - self, tracker: InitiativeTracker, combatants: list[Combatant], 411 + self, 412 + tracker: InitiativeTracker, 413 + combatants: list[Combatant], 344 414 ): 345 415 tracker.begin(combatants) 346 416 tracker.next_turn() # -> Goblin 1 (index 1) ··· 354 424 assert tracker.current_combatant.name == "Goblin 1" 355 425 356 426 def test_remove_combatant( 357 - self, tracker: InitiativeTracker, combatants: list[Combatant], 427 + self, 428 + tracker: InitiativeTracker, 429 + combatants: list[Combatant], 358 430 ): 359 431 tracker.begin(combatants) 360 432 result = tracker.remove_combatant("Goblin 2") ··· 363 435 assert "Goblin 2" in result 364 436 365 437 def test_remove_current_advances( 366 - self, tracker: InitiativeTracker, combatants: list[Combatant], 438 + self, 439 + tracker: InitiativeTracker, 440 + combatants: list[Combatant], 367 441 ): 368 442 tracker.begin(combatants) 369 443 tracker.next_turn() # -> Goblin 1 ··· 373 447 assert tracker.current_combatant.name == "Goblin 2" 374 448 375 449 def test_remove_unknown( 376 - self, tracker: InitiativeTracker, combatants: list[Combatant], 450 + self, 451 + tracker: InitiativeTracker, 452 + combatants: list[Combatant], 377 453 ): 378 454 tracker.begin(combatants) 379 455 result = tracker.remove_combatant("Nobody") ··· 383 459 384 460 class TestFormatForContext: 385 461 def test_includes_table( 386 - self, tracker: InitiativeTracker, combatants: list[Combatant], 462 + self, 463 + tracker: InitiativeTracker, 464 + combatants: list[Combatant], 387 465 ): 388 466 tracker.begin(combatants) 389 467 context = tracker.format_for_context() ··· 393 471 assert "25/25" in context # HP display 394 472 395 473 def test_marks_current_turn( 396 - self, tracker: InitiativeTracker, combatants: list[Combatant], 474 + self, 475 + tracker: InitiativeTracker, 476 + combatants: list[Combatant], 397 477 ): 398 478 tracker.begin(combatants) 399 479 context = tracker.format_for_context() ··· 402 482 assert "Current turn" in context 403 483 404 484 def test_shows_defeated( 405 - self, tracker: InitiativeTracker, combatants: list[Combatant], 485 + self, 486 + tracker: InitiativeTracker, 487 + combatants: list[Combatant], 406 488 ): 407 489 tracker.begin(combatants) 408 490 tracker.apply_damage("Goblin 1", 7) ··· 411 493 assert "~~Goblin 1~~" in context or "Defeated" in context 412 494 413 495 def test_shows_conditions( 414 - self, tracker: InitiativeTracker, combatants: list[Combatant], 496 + self, 497 + tracker: InitiativeTracker, 498 + combatants: list[Combatant], 415 499 ): 416 500 tracker.begin(combatants) 417 501 tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") ··· 420 504 assert "Prone" in context 421 505 422 506 def test_shows_up_next( 423 - self, tracker: InitiativeTracker, combatants: list[Combatant], 507 + self, 508 + tracker: InitiativeTracker, 509 + combatants: list[Combatant], 424 510 ): 425 511 tracker.begin(combatants) 426 512 context = tracker.format_for_context() ··· 429 515 assert "Goblin 1" in context.split("Up next")[1] 430 516 431 517 def test_shows_round( 432 - self, tracker: InitiativeTracker, combatants: list[Combatant], 518 + self, 519 + tracker: InitiativeTracker, 520 + combatants: list[Combatant], 433 521 ): 434 522 tracker.begin(combatants) 435 523 context = tracker.format_for_context()
+20 -11
tests/test_log.py
··· 1 1 """Tests for campaign log functionality.""" 2 2 3 - import pytest 4 - 5 3 from pathlib import Path 4 + 5 + import pytest 6 6 7 7 from storied.log import ( 8 8 CampaignLog, ··· 41 41 assert t.minute == 30 42 42 43 43 def test_from_anchor_invalid(self): 44 - with pytest.raises(ValueError): 44 + with pytest.raises(ValueError, match="anchor"): 45 45 GameTime.from_anchor("invalid") 46 46 47 47 def test_add_duration_minutes(self): ··· 283 283 class TestTranscriptLog: 284 284 def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): 285 285 transcript.append_turn( 286 - "I look around", "The tavern is dimly lit.", 286 + "I look around", 287 + "The tavern is dimly lit.", 287 288 GameTime(day=1, hour=8, minute=0), 288 289 ) 289 290 path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" ··· 291 292 292 293 def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): 293 294 transcript.append_turn( 294 - "I look around", "The tavern is dimly lit.", 295 + "I look around", 296 + "The tavern is dimly lit.", 295 297 GameTime(day=1, hour=8, minute=0), 296 298 ) 297 - content = (tmp_path / "worlds" / "test" / "transcripts" / "day+001.md").read_text() 299 + content = ( 300 + tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" 301 + ).read_text() 298 302 assert "> I look around" in content 299 303 assert "The tavern is dimly lit." in content 300 304 assert "### Day 1, 08:00" in content ··· 310 314 311 315 def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): 312 316 transcript.append_turn( 313 - "[Session starting]", "Welcome back!", 317 + "[Session starting]", 318 + "Welcome back!", 314 319 GameTime(day=1, hour=8, minute=0), 315 320 ) 316 321 path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" ··· 319 324 def test_recent_turns_limit(self, transcript: TranscriptLog): 320 325 for i in range(15): 321 326 transcript.append_turn( 322 - f"Turn {i}", f"Response {i}.", 327 + f"Turn {i}", 328 + f"Response {i}.", 323 329 GameTime(day=1, hour=8, minute=i), 324 330 ) 325 331 context = transcript.recent_turns(1, n=5) ··· 332 338 333 339 def test_recent_turns_spans_days(self, transcript: TranscriptLog): 334 340 transcript.append_turn( 335 - "Yesterday", "Something happened.", 341 + "Yesterday", 342 + "Something happened.", 336 343 GameTime(day=1, hour=20, minute=0), 337 344 ) 338 345 transcript.append_turn( 339 - "Today", "Morning arrives.", 346 + "Today", 347 + "Morning arrives.", 340 348 GameTime(day=2, hour=8, minute=0), 341 349 ) 342 350 context = transcript.recent_turns(2) ··· 346 354 def test_includes_display_blocks(self, transcript: TranscriptLog): 347 355 dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." 348 356 transcript.append_turn( 349 - "Look around", dm_response, 357 + "Look around", 358 + dm_response, 350 359 GameTime(day=1, hour=8, minute=0), 351 360 ) 352 361 context = transcript.recent_turns(1)
+101 -27
tests/test_mcp_server.py
··· 21 21 async def _gather() -> set[str]: 22 22 server = await _compose_server(role) 23 23 return {t.name for t in await server.list_tools()} 24 + 24 25 return asyncio.run(_gather()) 25 26 26 27 ··· 40 41 def test_dm_includes_character_tools(self): 41 42 names = _names("dm") 42 43 for tool_name in ( 43 - "damage", "heal", "adjust_coins", "add_effect", "remove_effect", 44 - "add_condition", "remove_condition", "add_item", "remove_item", 45 - "set_item_status", "adjust_resource", "rest", 46 - "add_note", "update_character", "create_character", 44 + "damage", 45 + "heal", 46 + "adjust_coins", 47 + "add_effect", 48 + "remove_effect", 49 + "add_condition", 50 + "remove_condition", 51 + "add_item", 52 + "remove_item", 53 + "set_item_status", 54 + "adjust_resource", 55 + "rest", 56 + "add_note", 57 + "update_character", 58 + "create_character", 47 59 ): 48 60 assert tool_name in names, f"missing {tool_name}" 49 61 ··· 71 83 72 84 def test_planner_only_has_its_tools(self): 73 85 assert _names("planner") == { 74 - "establish", "mark", "amend_mark", "notify_dm", "recall", 75 - "forge_culture", "generate_names", 86 + "establish", 87 + "mark", 88 + "amend_mark", 89 + "notify_dm", 90 + "recall", 91 + "forge_culture", 92 + "generate_names", 76 93 } 77 94 78 95 def test_seeder_only_has_its_tools(self): 79 96 assert _names("seeder") == { 80 - "establish", "set_scene", "forge_culture", "generate_names", 97 + "establish", 98 + "set_scene", 99 + "forge_culture", 100 + "generate_names", 81 101 } 82 102 83 103 def test_advancement_only_has_its_tools(self): ··· 90 110 91 111 def test_arc_architect_in_all_roles(self): 92 112 from storied.mcp_server import ALL_ROLES 113 + 93 114 assert "arc_architect" in ALL_ROLES 94 115 95 116 ··· 107 128 if t.name == tool_name: 108 129 return t.parameters 109 130 raise AssertionError(f"tool {tool_name!r} not found") 131 + 110 132 return asyncio.run(_gather()) 111 133 112 134 def test_enter_initiative_documents_combatant_shape(self): ··· 122 144 def test_create_character_documents_ability_keys(self): 123 145 schema = self._schema("create_character") 124 146 ability_props = schema["properties"]["abilities"]["properties"] 125 - for ability in ("strength", "dexterity", "constitution", 126 - "intelligence", "wisdom", "charisma"): 147 + for ability in ( 148 + "strength", 149 + "dexterity", 150 + "constitution", 151 + "intelligence", 152 + "wisdom", 153 + "charisma", 154 + ): 127 155 assert ability in ability_props, ( 128 156 f"create_character must document the {ability} ability score" 129 157 ) ··· 147 175 assert denom in purse_props 148 176 149 177 @pytest.mark.parametrize( 150 - "tool_name,param,expected_values", 178 + ("tool_name", "param", "expected_values"), 151 179 [ 152 180 ("rest", "type", {"short", "long"}), 153 181 ("set_item_status", "status", {"attuned", "equipped", "carried"}), 154 182 ("recall", "scope", {"rules", "world", "all"}), 155 - ("establish", "entity_type", 156 - {"npcs", "locations", "items", "factions", "threads", "lore", 157 - "maps", "cultures"}), 158 - ("mark", "entity_type", 159 - {"npcs", "locations", "items", "factions", "threads", "maps", 160 - "cultures"}), 161 - ("note_discovery", "content_type", 162 - {"npcs", "locations", "factions", "lore", "cultures"}), 183 + ( 184 + "establish", 185 + "entity_type", 186 + { 187 + "npcs", 188 + "locations", 189 + "items", 190 + "factions", 191 + "threads", 192 + "lore", 193 + "maps", 194 + "cultures", 195 + }, 196 + ), 197 + ( 198 + "mark", 199 + "entity_type", 200 + { 201 + "npcs", 202 + "locations", 203 + "items", 204 + "factions", 205 + "threads", 206 + "maps", 207 + "cultures", 208 + }, 209 + ), 210 + ( 211 + "note_discovery", 212 + "content_type", 213 + {"npcs", "locations", "factions", "lore", "cultures"}, 214 + ), 163 215 ], 164 216 ) 165 217 def test_enum_parameters_expose_valid_values( 166 - self, tool_name: str, param: str, expected_values: set[str], 218 + self, 219 + tool_name: str, 220 + param: str, 221 + expected_values: set[str], 167 222 ): 168 223 """Each conceptually-enum parameter must surface as a JSON Schema 169 224 enum, not a free-form string. Guards against regression to bare `str`.""" ··· 228 283 def test_combat_control_stays_visible_through_cycle(self, ctx: ToolContext): 229 284 """enter_initiative / end_initiative are tagged combat_control and 230 285 must stay visible whether initiative is active or not.""" 286 + 231 287 async def _gather_combat_control() -> tuple[set[str], set[str], set[str]]: 232 288 server = await _compose_server("dm") 233 289 initial = {t.name for t in await server.list_tools()} ··· 248 304 249 305 def test_level_up_hidden_at_compose_time(self, ctx: ToolContext): 250 306 """Fresh compose should not expose level_up — nothing has granted it yet.""" 307 + 251 308 async def _gather() -> set[str]: 252 309 server = await _compose_server("dm") 253 310 return {t.name for t in await server.list_tools()} ··· 278 335 def test_level_up_not_in_planner_compose(self, ctx: ToolContext): 279 336 """Only the DM role cares about advancement visibility. Other roles 280 337 don't have level_up at all, so the flip is a no-op for them.""" 338 + 281 339 async def _run() -> set[str]: 282 340 server = await _compose_server("planner") 283 341 return {t.name for t in await server.list_tools()} ··· 288 346 def test_refresh_with_none_character_is_safe(self, ctx: ToolContext): 289 347 """A character sheet that doesn't exist yet (pre-creation) should 290 348 not crash the visibility flip.""" 349 + 291 350 async def _run() -> None: 292 351 await _compose_server("dm") 293 352 refresh_advancement_visibility(None) ··· 328 387 vi = MagicMock() 329 388 vi.has_source.return_value = False 330 389 _populate_index( 331 - world_dir, vi, srd_root=tmp_path / "srd-missing", 390 + world_dir, 391 + vi, 392 + srd_root=tmp_path / "srd-missing", 332 393 ) 333 394 vi.reindex_directory.assert_called_once_with( 334 - world_dir, source="world", 395 + world_dir, 396 + source="world", 335 397 skip_subdirs=frozenset({"transcripts"}), 336 398 ) 337 399 ··· 370 432 vi = MagicMock() 371 433 vi.has_source.return_value = False 372 434 _populate_index( 373 - world_dir, vi, srd_root=tmp_path / "srd-missing", 435 + world_dir, 436 + vi, 437 + srd_root=tmp_path / "srd-missing", 374 438 ) 375 439 # Should have indexed user and world, in that order 376 440 assert vi.reindex_directory.call_args_list == [ ··· 453 517 """ 454 518 455 519 def test_srd_stays_available_after_transcript_upsert_race( 456 - self, tmp_path, monkeypatch, 520 + self, 521 + tmp_path, 522 + monkeypatch, 457 523 ): 458 524 from storied import paths 459 525 from storied.mcp_server import _populate_index ··· 477 543 seed_index.close() 478 544 479 545 monkeypatch.setattr( 480 - paths, "shipped_rules_path", lambda: tmp_path / "shipped", 546 + paths, 547 + "shipped_rules_path", 548 + lambda: tmp_path / "shipped", 481 549 ) 482 550 483 551 world_dir = paths.world_path("default") ··· 515 583 assert any(h.source == "srd" for h in hits) 516 584 517 585 def test_populate_is_idempotent_on_repeated_start_server( 518 - self, tmp_path, monkeypatch, 586 + self, 587 + tmp_path, 588 + monkeypatch, 519 589 ): 520 590 """A second start_server (onboarding → play handoff) must not 521 591 wipe the world/transcript rows by re-copying the SRD seed.""" ··· 528 598 seed_db = srd_root / "search.db" 529 599 seed_index = VectorIndex(seed_db) 530 600 seed_index.upsert( 531 - "srd:x.md:0", "# X", {"source": "srd", "path": "x.md"}, 601 + "srd:x.md:0", 602 + "# X", 603 + {"source": "srd", "path": "x.md"}, 532 604 ) 533 605 seed_index.close() 534 606 535 607 monkeypatch.setattr( 536 - paths, "shipped_rules_path", lambda: tmp_path / "shipped", 608 + paths, 609 + "shipped_rules_path", 610 + lambda: tmp_path / "shipped", 537 611 ) 538 612 539 613 world_dir = paths.world_path("default")
+1 -3
tests/test_names_discipline.py
··· 118 118 assert self._violations("import storied.names.engine") == [] 119 119 120 120 def test_storied_names_from_import_passes(self): 121 - assert self._violations( 122 - "from storied.names.engine import generator" 123 - ) == [] 121 + assert self._violations("from storied.names.engine import generator") == [] 124 122 125 123 def test_relative_import_passes(self): 126 124 assert self._violations("from . import forge") == []
+20 -8
tests/test_names_engine.py
··· 68 68 69 69 def test_sample_returns_phoneme(self, inventory: PhonemeInventory): 70 70 import random 71 + 71 72 rng = random.Random(42) 72 73 result = inventory.sample("V", rng) 73 74 assert result in inventory.vowels 74 75 75 76 def test_sample_unknown_class_raises(self, inventory: PhonemeInventory): 76 77 import random 78 + 77 79 rng = random.Random(42) 78 - with pytest.raises(ValueError): 80 + with pytest.raises(ValueError, match="Z"): 79 81 inventory.sample("Z", rng) 80 82 81 83 def test_trim_reduces_consonants(self, inventory: PhonemeInventory): 82 84 import random 85 + 83 86 rng = random.Random(42) 84 87 trimmed = inventory.trim(rng, drop_fraction=0.3) 85 88 # 0.3 * 7 ≈ 2 dropped from 7 ··· 87 90 88 91 def test_trim_keeps_small_inventories(self): 89 92 small = PhonemeInventory( 90 - name="tiny", consonants=["k", "n"], vowels=["a", "i"], 93 + name="tiny", 94 + consonants=["k", "n"], 95 + vowels=["a", "i"], 91 96 ) 92 97 import random 98 + 93 99 rng = random.Random(42) 94 100 trimmed = small.trim(rng, drop_fraction=0.5) 95 101 assert trimmed.consonants == ["k", "n"] ··· 132 138 @pytest.fixture 133 139 def inventory(self) -> PhonemeInventory: 134 140 return PhonemeInventory( 135 - name="x", consonants=["k", "t"], vowels=["a", "i"], 141 + name="x", 142 + consonants=["k", "t"], 143 + vowels=["a", "i"], 136 144 ) 137 145 138 146 def test_returns_phoneme_list(self, inventory: PhonemeInventory): 139 147 import random 148 + 140 149 rng = random.Random(42) 141 150 slots = parse_template("CV") 142 151 result = sample_syllable(slots, inventory, rng) ··· 144 153 assert len(result) == 2 145 154 146 155 def test_optional_slot_sometimes_skipped( 147 - self, inventory: PhonemeInventory, 156 + self, 157 + inventory: PhonemeInventory, 148 158 ): 149 159 import random 160 + 150 161 rng = random.Random(42) 151 162 slots = parse_template("(C)V") 152 - results = [ 153 - sample_syllable(slots, inventory, rng) for _ in range(50) 154 - ] 163 + results = [sample_syllable(slots, inventory, rng) for _ in range(50)] 155 164 # Some should be 1-element (skipped optional), some 2 156 165 lengths = {len(r) for r in results} 157 166 assert 1 in lengths or 2 in lengths ··· 160 169 class TestSampleWord: 161 170 def test_produces_phonemes(self): 162 171 import random 172 + 163 173 rng = random.Random(42) 164 174 inv = PhonemeInventory( 165 - name="x", consonants=["k", "t", "n"], vowels=["a", "i", "o"], 175 + name="x", 176 + consonants=["k", "t", "n"], 177 + vowels=["a", "i", "o"], 166 178 ) 167 179 slots = [parse_template("CV")] 168 180 word = sample_word(slots, inv, rng, syllable_count=(2, 2))
+21 -7
tests/test_names_forge.py
··· 44 44 assert c1.name == c2.name 45 45 46 46 def test_forge_different_seeds_yield_different_cultures( 47 - self, world_dir: Path, 47 + self, 48 + world_dir: Path, 48 49 ): 49 50 forge = CultureForge(world_path=world_dir) 50 51 cultures = [forge.forge(seed=s) for s in range(10)] ··· 55 56 # Forge several coastal cultures and check they came from 56 57 # inventories tagged "coastal" in the data file. 57 58 coastal_inventories = { 58 - "welsh", "old-norse", "polynesian", "austronesian", 59 - "iberian", "japonic", "arabic-port", "yoruboid", 60 - "hellenic", "cornish", 59 + "welsh", 60 + "old-norse", 61 + "polynesian", 62 + "austronesian", 63 + "iberian", 64 + "japonic", 65 + "arabic-port", 66 + "yoruboid", 67 + "hellenic", 68 + "cornish", 61 69 } 62 70 for seed in range(5): 63 71 forge2 = CultureForge(world_path=world_dir.parent / f"w{seed}") ··· 134 142 gen = Generator(world_path=populated_world) 135 143 cultures = gen.list_cultures() 136 144 places = gen.sample( 137 - culture=cultures[0].name, count=3, kind="place", 145 + culture=cultures[0].name, 146 + count=3, 147 + kind="place", 138 148 ) 139 149 assert len(places) == 3 140 150 ··· 149 159 gen = Generator(world_path=populated_world) 150 160 cultures = gen.list_cultures() 151 161 names = gen.sample( 152 - culture=cultures[0].name, count=3, rarity="uncommon", 162 + culture=cultures[0].name, 163 + count=3, 164 + rarity="uncommon", 153 165 ) 154 166 assert len(names) == 3 155 167 ··· 157 169 gen = Generator(world_path=populated_world) 158 170 cultures = gen.list_cultures() 159 171 names = gen.sample( 160 - culture=cultures[0].name, count=3, rarity="archaic", 172 + culture=cultures[0].name, 173 + count=3, 174 + rarity="archaic", 161 175 ) 162 176 assert len(names) == 3
+30 -6
tests/test_names_morphology.py
··· 38 38 # bare_probability=1.0 → always skip 39 39 rng = random.Random(42) 40 40 result = apply_morphology( 41 - "kara", gendered, "female", rng, bare_probability=1.0, 41 + "kara", 42 + gendered, 43 + "female", 44 + rng, 45 + bare_probability=1.0, 42 46 ) 43 47 assert result == "kara" 44 48 45 49 def test_female_suffix_applied(self, gendered: Morphology): 46 50 rng = random.Random(42) 47 51 result = apply_morphology( 48 - "kar", gendered, "female", rng, bare_probability=0.0, 52 + "kar", 53 + gendered, 54 + "female", 55 + rng, 56 + bare_probability=0.0, 49 57 ) 50 58 assert result.startswith("kar") 51 59 assert any(result.endswith(s) for s in ["a", "ina"]) ··· 53 61 def test_male_suffix_applied(self, gendered: Morphology): 54 62 rng = random.Random(42) 55 63 result = apply_morphology( 56 - "kar", gendered, "male", rng, bare_probability=0.0, 64 + "kar", 65 + gendered, 66 + "male", 67 + rng, 68 + bare_probability=0.0, 57 69 ) 58 70 assert any(result.endswith(s) for s in ["us", "or"]) 59 71 60 72 def test_neutral_when_gender_is_none(self, gendered: Morphology): 61 73 rng = random.Random(42) 62 74 result = apply_morphology( 63 - "kar", gendered, None, rng, bare_probability=0.0, 75 + "kar", 76 + gendered, 77 + None, 78 + rng, 79 + bare_probability=0.0, 64 80 ) 65 81 assert result.endswith("en") 66 82 ··· 73 89 neutral_suffixes=[], 74 90 ) 75 91 result = apply_morphology( 76 - "kar", morph, None, rng, bare_probability=0.0, 92 + "kar", 93 + morph, 94 + None, 95 + rng, 96 + bare_probability=0.0, 77 97 ) 78 98 assert result == "kar" 79 99 ··· 86 106 neutral_suffixes=[""], 87 107 ) 88 108 result = apply_morphology( 89 - "kar", morph, "female", rng, bare_probability=0.0, 109 + "kar", 110 + morph, 111 + "female", 112 + rng, 113 + bare_probability=0.0, 90 114 ) 91 115 assert result == "kar" 92 116
+4 -3
tests/test_names_tool.py
··· 107 107 server = await _compose_server(role) 108 108 tools = await server.list_tools() 109 109 return { 110 - t.name for t in tools 111 - if t.name in ("forge_culture", "generate_names") 110 + t.name for t in tools if t.name in ("forge_culture", "generate_names") 112 111 } 112 + 113 113 return asyncio.run(_gather()) 114 114 115 115 @pytest.mark.parametrize("role", ["dm", "planner", "seeder"]) 116 116 def test_role_has_both_names_tools(self, role: str): 117 117 assert self._names_for_role(role) == { 118 - "forge_culture", "generate_names", 118 + "forge_culture", 119 + "generate_names", 119 120 } 120 121 121 122 def test_arc_architect_has_no_names_tools(self):
+39 -22
tests/test_notification_formatters.py
··· 16 16 _parse_tool_args, 17 17 ) 18 18 19 - 20 19 # --- Low-level helpers ------------------------------------------------------ 21 20 22 21 ··· 91 90 def test_unknown_denomination_falls_back_to_code(self): 92 91 # Defensive: unknown denom uses the raw key 93 92 from storied.notification_formatters import _format_coin_notification 93 + 94 94 result = _format_coin_notification('{"deltas": {"xx": 5}}') 95 95 # _DENOM_NAMES doesn't include 'xx', so it falls through the loop 96 96 # without matching any of the known denoms — output is generic ··· 99 99 100 100 class TestDamageNotification: 101 101 def test_with_target(self): 102 - assert _format("damage", '{"target": "Goblin", "amount": 7}') == "Goblin takes 7 damage" 102 + assert ( 103 + _format("damage", '{"target": "Goblin", "amount": 7}') 104 + == "Goblin takes 7 damage" 105 + ) 103 106 104 107 def test_with_type(self): 105 - assert _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage" 108 + assert ( 109 + _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage" 110 + ) 106 111 107 112 def test_plain(self): 108 113 assert _format("damage", '{"amount": 3}') == "Taking 3 damage" 109 114 110 115 def test_target_and_type_target_wins(self): 111 116 result = _format("damage", '{"target": "Mira", "amount": 5, "type": "cold"}') 112 - assert "Mira takes 5 damage" == result 117 + assert result == "Mira takes 5 damage" 113 118 114 119 def test_missing_amount_shows_question_mark(self): 115 120 assert _format("damage", "{}") == "Taking ? damage" ··· 117 122 118 123 class TestHealNotification: 119 124 def test_with_target(self): 120 - assert _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5" 125 + assert ( 126 + _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5" 127 + ) 121 128 122 129 def test_plain(self): 123 130 assert _format("heal", '{"amount": 8}') == "Healing 8 HP" ··· 131 138 def test_with_expires(self): 132 139 result = _format( 133 140 "add_effect", 134 - '{"source": "Heroism", "description": "+10 temp HP", "expires": "d28-1430"}', 141 + '{"source": "Heroism", "description": "+10 temp HP",' 142 + ' "expires": "d28-1430"}', 135 143 ) 136 144 assert "Heroism" in result 137 145 assert "until d28-1430" in result 138 146 139 147 def test_remove_effect(self): 140 - assert _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless" 148 + assert ( 149 + _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless" 150 + ) 141 151 142 152 143 153 class TestConditionNotification: ··· 145 155 assert _format("add_condition", '{"name": "Poisoned"}') == "Becoming Poisoned" 146 156 147 157 def test_remove_condition(self): 148 - assert _format("remove_condition", '{"name": "Frightened"}') == "Recovering from Frightened" 158 + assert ( 159 + _format("remove_condition", '{"name": "Frightened"}') 160 + == "Recovering from Frightened" 161 + ) 149 162 150 163 151 164 class TestItemNotification: ··· 157 170 assert result == "Adding 'Coin pouch' to on_person" 158 171 159 172 def test_remove_item(self): 160 - assert _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'" 173 + assert ( 174 + _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'" 175 + ) 161 176 162 177 @pytest.mark.parametrize( 163 - "status,verb", 178 + ("status", "verb"), 164 179 [ 165 180 ("attuned", "Attuning to"), 166 181 ("equipped", "Equipping"), ··· 178 193 179 194 class TestResourceNotification: 180 195 def test_use_one(self): 181 - assert _format( 182 - "adjust_resource", '{"name": "rage", "delta": -1}' 183 - ) == "Using rage" 196 + assert ( 197 + _format("adjust_resource", '{"name": "rage", "delta": -1}') == "Using rage" 198 + ) 184 199 185 200 def test_use_multiple(self): 186 - assert _format( 187 - "adjust_resource", '{"name": "ki", "delta": -3}' 188 - ) == "Using 3 of ki" 201 + assert ( 202 + _format("adjust_resource", '{"name": "ki", "delta": -3}') == "Using 3 of ki" 203 + ) 189 204 190 205 def test_restore(self): 191 - assert _format( 192 - "adjust_resource", '{"name": "ki", "delta": 2}' 193 - ) == "Restoring 2 of ki" 206 + assert ( 207 + _format("adjust_resource", '{"name": "ki", "delta": 2}') 208 + == "Restoring 2 of ki" 209 + ) 194 210 195 211 def test_zero_delta_fallback(self): 196 - assert _format( 197 - "adjust_resource", '{"name": "rage", "delta": 0}' 198 - ) == "Adjusting rage" 212 + assert ( 213 + _format("adjust_resource", '{"name": "rage", "delta": 0}') 214 + == "Adjusting rage" 215 + ) 199 216 200 217 201 218 class TestRestNotification:
+3 -1
tests/test_notifications.py
··· 54 54 assert messages == ["Hello"] 55 55 56 56 def test_drain_empty_existing_file_returns_empty( 57 - self, world: str, tmp_path: Path, 57 + self, 58 + world: str, 59 + tmp_path: Path, 58 60 ): 59 61 """An existing notifications file with only whitespace drains 60 62 as empty and is removed."""
+90 -45
tests/test_planner.py
··· 1 + # pyright: reportArgumentType=false 2 + # Tests pass session dicts with `object` values where helpers expect `str`; 3 + # the test harness shapes the dict correctly. 1 4 """Tests for the world planner — entity richness scoring, discovery, and context.""" 2 5 3 6 import json ··· 17 20 plan_world, 18 21 ) 19 22 from storied.session import save_session 23 + from storied.testing import call_tool 20 24 from storied.tools import ToolContext 21 25 from storied.tools.entities import establish, mark 22 - 23 - from storied.testing import call_tool 24 26 25 27 26 28 @pytest.fixture ··· 30 32 establish, 31 33 entity_type="locations", 32 34 name="Town Square", 33 - description="The center of [[Millford]]. A fountain stands here, surrounded by market stalls.", 35 + description=( 36 + "The center of [[Millford]]. A fountain stands here, " 37 + "surrounded by market stalls." 38 + ), 34 39 location="Central [[Millford]]", 35 40 knows=["The fountain was built by [[Old Gregor]]"], 36 41 wants=["To be a gathering place"], ··· 213 218 "default", 214 219 { 215 220 "location": "Town Square", 216 - "body": "## Situation\nThe player just arrived.\n\n## Open Threads\n- Find the missing cat", 221 + "body": ( 222 + "## Situation\nThe player just arrived.\n\n" 223 + "## Open Threads\n- Find the missing cat" 224 + ), 217 225 }, 218 226 ) 219 227 context = build_planning_context( ··· 225 233 assert "missing cat" in context 226 234 227 235 def test_includes_candidate_content( 228 - self, populated_world: ToolContext, tmp_path: Path, 236 + self, 237 + populated_world: ToolContext, 238 + tmp_path: Path, 229 239 ): 230 240 save_session( 231 241 "default", ··· 257 267 "default", 258 268 {"location": "Town Square", "body": ""}, 259 269 ) 260 - populated_world.campaign_log.append_entry("Fought three goblins in the clearing", "5 rounds") 261 - populated_world.campaign_log.append_entry("Spotted two lookouts near the old mill", "30 min") 270 + populated_world.campaign_log.append_entry( 271 + "Fought three goblins in the clearing", "5 rounds" 272 + ) 273 + populated_world.campaign_log.append_entry( 274 + "Spotted two lookouts near the old mill", "30 min" 275 + ) 262 276 263 277 context = build_planning_context( 264 278 world_id=populated_world.world_id, ··· 327 341 assert len(result.candidates) == 0 328 342 329 343 @patch("storied.claude.subprocess.Popen") 330 - def test_plan_world_calls_claude(self, mock_popen: MagicMock, populated_world: ToolContext): 344 + def test_plan_world_calls_claude( 345 + self, mock_popen: MagicMock, populated_world: ToolContext 346 + ): 331 347 save_session( 332 348 "default", 333 349 { ··· 337 353 ) 338 354 339 355 # Mock subprocess returning a result event 340 - result_line = json.dumps({ 341 - "type": "result", 342 - "session_id": "sess-1", 343 - "usage": {"input_tokens": 1000, "output_tokens": 200, "tool_calls": 0}, 344 - "duration_ms": 5000, 345 - }) 356 + result_line = json.dumps( 357 + { 358 + "type": "result", 359 + "session_id": "sess-1", 360 + "usage": {"input_tokens": 1000, "output_tokens": 200, "tool_calls": 0}, 361 + "duration_ms": 5000, 362 + } 363 + ) 346 364 mock_proc = MagicMock() 347 365 mock_proc.stdin = MagicMock() 348 366 mock_proc.stdout = iter([result_line.encode() + b"\n"]) ··· 354 372 result = plan_world( 355 373 world_id=populated_world.world_id, 356 374 player_id=populated_world.player_id, 357 - model="claude-opus-4-6", 375 + model="claude-opus-4-7", 358 376 ) 359 377 360 378 assert result.dry_run is False ··· 363 381 mock_popen.assert_called_once() 364 382 365 383 @patch("storied.claude.subprocess.Popen") 366 - def test_plan_world_counts_tool_calls(self, mock_popen: MagicMock, populated_world: ToolContext): 384 + def test_plan_world_counts_tool_calls( 385 + self, mock_popen: MagicMock, populated_world: ToolContext 386 + ): 367 387 save_session( 368 388 "default", 369 389 { ··· 374 394 375 395 # Stream with tool_use events followed by result 376 396 lines = [ 377 - json.dumps({ 378 - "type": "stream_event", 379 - "event": { 380 - "type": "content_block_start", 381 - "index": 1, 382 - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__establish"}, 383 - }, 384 - }), 385 - json.dumps({ 386 - "type": "result", 387 - "session_id": "sess-2", 388 - "usage": {"input_tokens": 800, "output_tokens": 100}, 397 + json.dumps( 398 + { 399 + "type": "stream_event", 400 + "event": { 401 + "type": "content_block_start", 402 + "index": 1, 403 + "content_block": { 404 + "type": "tool_use", 405 + "id": "t1", 406 + "name": "mcp__storied__establish", 407 + }, 408 + }, 409 + } 410 + ), 411 + json.dumps( 412 + { 413 + "type": "result", 414 + "session_id": "sess-2", 415 + "usage": {"input_tokens": 800, "output_tokens": 100}, 389 416 "duration_ms": 3000, 390 - }), 417 + } 418 + ), 391 419 ] 392 420 393 421 mock_proc = MagicMock() 394 422 mock_proc.stdin = MagicMock() 395 - mock_proc.stdout = iter([l.encode() + b"\n" for l in lines]) 423 + mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) 396 424 mock_proc.stderr = iter([]) 397 425 mock_proc.wait.return_value = 0 398 426 mock_proc.returncode = 0 ··· 401 429 result = plan_world( 402 430 world_id=populated_world.world_id, 403 431 player_id=populated_world.player_id, 404 - model="claude-opus-4-6", 432 + model="claude-opus-4-7", 405 433 ) 406 434 407 435 assert result.tool_calls == 1 ··· 438 466 assert "Idle" not in names 439 467 440 468 def test_skips_missing_type_directories( 441 - self, populated_world: ToolContext, 469 + self, 470 + populated_world: ToolContext, 442 471 ): 443 472 # The fixture creates npcs and locations but not items/factions/threads. 444 473 # The function should iterate without crashing. ··· 451 480 class TestBuildTickContext: 452 481 def test_includes_current_time(self, populated_world: ToolContext): 453 482 ctx_str = build_tick_context( 454 - populated_world.world_id, populated_world.player_id, entities=[], 483 + populated_world.world_id, 484 + populated_world.player_id, 485 + entities=[], 455 486 ) 456 487 assert "Current Game Time" in ctx_str 457 488 458 489 def test_includes_session_location(self, populated_world: ToolContext): 459 - save_session("default", { 460 - "location": "Town Square", 461 - "body": "## Present\n- [[Old Gregor]]", 462 - }) 490 + save_session( 491 + "default", 492 + { 493 + "location": "Town Square", 494 + "body": "## Present\n- [[Old Gregor]]", 495 + }, 496 + ) 463 497 464 498 ctx_str = build_tick_context( 465 - populated_world.world_id, populated_world.player_id, entities=[], 499 + populated_world.world_id, 500 + populated_world.player_id, 501 + entities=[], 466 502 ) 467 503 assert "Town Square" in ctx_str 468 504 assert "Old Gregor" in ctx_str 469 505 470 506 def test_includes_recent_events(self, populated_world: ToolContext): 471 507 populated_world.campaign_log.append_entry( 472 - "Met the merchant", "10 min", 508 + "Met the merchant", 509 + "10 min", 473 510 ) 474 511 populated_world.campaign_log.append_entry( 475 - "Found the secret door", "5 min", 512 + "Found the secret door", 513 + "5 min", 476 514 ) 477 515 ctx_str = build_tick_context( 478 - populated_world.world_id, populated_world.player_id, entities=[], 516 + populated_world.world_id, 517 + populated_world.player_id, 518 + entities=[], 479 519 ) 480 520 assert "Recent Events" in ctx_str 481 521 assert "Met the merchant" in ctx_str ··· 492 532 populated_world.world_id, 493 533 ) 494 534 ctx_str = build_tick_context( 495 - populated_world.world_id, populated_world.player_id, entities=triggers, 535 + populated_world.world_id, 536 + populated_world.player_id, 537 + entities=triggers, 496 538 ) 497 539 assert "Active Triggers" in ctx_str 498 540 assert "Lurker" in ctx_str ··· 509 551 510 552 def test_init_stores_state(self, tmp_path: Path): 511 553 ticker = BackgroundTicker( 512 - world_id="test", player_id="default", 554 + world_id="test", 555 + player_id="default", 513 556 ) 514 557 assert ticker._world_id == "test" 515 558 assert ticker._last_tick_day == 0 516 559 assert ticker._thread is None 517 560 518 561 def test_maybe_tick_skips_when_day_unchanged( 519 - self, populated_world: ToolContext, 562 + self, 563 + populated_world: ToolContext, 520 564 ): 521 565 ticker = BackgroundTicker( 522 566 world_id=populated_world.world_id, ··· 542 586 543 587 def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): 544 588 ticker = BackgroundTicker( 545 - world_id="test", player_id="default", 589 + world_id="test", 590 + player_id="default", 546 591 ) 547 592 assert ticker.pop_result() is None
+19 -4
tests/test_sandbox.py
··· 147 147 def test_includes_all_tools(self): 148 148 sigs = build_tool_signatures() 149 149 150 - for name in ["roll", "recall", "establish", "mark", "damage", "heal", 151 - "enter_initiative", "end_initiative", "next_turn"]: 150 + for name in [ 151 + "roll", 152 + "recall", 153 + "establish", 154 + "mark", 155 + "damage", 156 + "heal", 157 + "enter_initiative", 158 + "end_initiative", 159 + "next_turn", 160 + ]: 152 161 assert f"{name}(" in sigs 153 162 154 163 def test_excludes_dependency_params(self): ··· 157 166 sigs = build_tool_signatures() 158 167 159 168 # None of the Dependency-class instances should appear as defaults 160 - for marker in ("Combat()", "Lore()", "Player()", 161 - "Timekeeper()", "Entities()", "World()"): 169 + for marker in ( 170 + "Combat()", 171 + "Lore()", 172 + "Player()", 173 + "Timekeeper()", 174 + "Entities()", 175 + "World()", 176 + ): 162 177 assert marker not in sigs
+154 -69
tests/test_search.py
··· 3 3 from pathlib import Path 4 4 5 5 import pytest 6 - 7 6 from conftest import _fake_embed 7 + 8 8 from storied.search import SearchHit, VectorIndex, age_decay, chunk_document 9 9 10 10 ··· 108 108 109 109 def test_splits_on_bold_definitions(self): 110 110 content = "# Rules Glossary\n\n## Definitions\n\n" + "\n".join( 111 - f"**Term {i}**\nDefinition of term {i}. {'y ' * 400}\n" 112 - for i in range(5) 111 + f"**Term {i}**\nDefinition of term {i}. {'y ' * 400}\n" for i in range(5) 113 112 ) 114 113 chunks = chunk_document(Path("glossary.md"), content) 115 114 assert len(chunks) >= 5 ··· 125 124 assert any("Action Surge" in t for t in texts) 126 125 127 126 def test_oversized_with_no_headings_splits_on_paragraphs(self): 128 - content = "# Blob\n\n" + "\n\n".join( 129 - "word " * 200 for _ in range(5) 130 - ) 127 + content = "# Blob\n\n" + "\n\n".join("word " * 200 for _ in range(5)) 131 128 chunks = chunk_document(Path("blob.md"), content) 132 129 assert len(chunks) > 1 133 130 assert any("word" in text for _, text in chunks) ··· 173 170 index.upsert( 174 171 "srd:spells/fireball.md:0", 175 172 "Fireball: 8d6 fire damage in a 20-foot radius", 176 - {"source": "srd", "content_type": "spells", 177 - "path": "/tmp/fireball.md", "title": "Fireball"}, 173 + { 174 + "source": "srd", 175 + "content_type": "spells", 176 + "path": "/tmp/fireball.md", 177 + "title": "Fireball", 178 + }, 178 179 ) 179 180 results = index.search("fireball") 180 181 assert len(results) >= 1 ··· 182 183 183 184 def test_upsert_overwrites(self, index: VectorIndex): 184 185 doc_id = "world:npcs/vex.md:0" 185 - index.upsert(doc_id, "Captain Vex the pirate", 186 - {"source": "world", "content_type": "npcs", 187 - "path": "/tmp/vex.md", "title": "Captain Vex"}) 188 - index.upsert(doc_id, "Captain Vex the reformed merchant", 189 - {"source": "world", "content_type": "npcs", 190 - "path": "/tmp/vex.md", "title": "Captain Vex"}) 186 + index.upsert( 187 + doc_id, 188 + "Captain Vex the pirate", 189 + { 190 + "source": "world", 191 + "content_type": "npcs", 192 + "path": "/tmp/vex.md", 193 + "title": "Captain Vex", 194 + }, 195 + ) 196 + index.upsert( 197 + doc_id, 198 + "Captain Vex the reformed merchant", 199 + { 200 + "source": "world", 201 + "content_type": "npcs", 202 + "path": "/tmp/vex.md", 203 + "title": "Captain Vex", 204 + }, 205 + ) 191 206 stats = index.stats() 192 207 assert stats["total_documents"] == 1 193 208 194 209 def test_delete(self, index: VectorIndex): 195 210 doc_id = "srd:spells/fireball.md:0" 196 - index.upsert(doc_id, "Fireball spell", 197 - {"source": "srd", "content_type": "spells", 198 - "path": "/tmp/fireball.md", "title": "Fireball"}) 211 + index.upsert( 212 + doc_id, 213 + "Fireball spell", 214 + { 215 + "source": "srd", 216 + "content_type": "spells", 217 + "path": "/tmp/fireball.md", 218 + "title": "Fireball", 219 + }, 220 + ) 199 221 index.delete(doc_id) 200 222 stats = index.stats() 201 223 assert stats["total_documents"] == 0 ··· 211 233 @pytest.fixture(autouse=True) 212 234 def _populate(self, index: VectorIndex): 213 235 docs = [ 214 - ("srd:spells/fireball.md:0", 215 - "Fireball: 8d6 fire damage in a 20-foot radius sphere", 216 - {"source": "srd", "content_type": "spells", 217 - "path": "/tmp/spells/fireball.md", "title": "Fireball"}), 218 - ("srd:spells/cure-wounds.md:0", 219 - "Cure Wounds: restore hit points by touch", 220 - {"source": "srd", "content_type": "spells", 221 - "path": "/tmp/spells/cure-wounds.md", "title": "Cure Wounds"}), 222 - ("world:npcs/vex.md:0", 223 - "Captain Vex is a notorious pirate who sails the Shattered Coast", 224 - {"source": "world", "content_type": "npcs", 225 - "path": "/tmp/npcs/vex.md", "title": "Captain Vex"}), 226 - ("transcript:transcripts/day+001.md:0", 227 - "Player asked about the harbor. DM described ships at dock.", 228 - {"source": "transcript", "content_type": "transcripts", 229 - "path": "/tmp/transcripts/day+001.md", "title": "Day 1", 230 - "game_day": 1}), 236 + ( 237 + "srd:spells/fireball.md:0", 238 + "Fireball: 8d6 fire damage in a 20-foot radius sphere", 239 + { 240 + "source": "srd", 241 + "content_type": "spells", 242 + "path": "/tmp/spells/fireball.md", 243 + "title": "Fireball", 244 + }, 245 + ), 246 + ( 247 + "srd:spells/cure-wounds.md:0", 248 + "Cure Wounds: restore hit points by touch", 249 + { 250 + "source": "srd", 251 + "content_type": "spells", 252 + "path": "/tmp/spells/cure-wounds.md", 253 + "title": "Cure Wounds", 254 + }, 255 + ), 256 + ( 257 + "world:npcs/vex.md:0", 258 + "Captain Vex is a notorious pirate who sails the Shattered Coast", 259 + { 260 + "source": "world", 261 + "content_type": "npcs", 262 + "path": "/tmp/npcs/vex.md", 263 + "title": "Captain Vex", 264 + }, 265 + ), 266 + ( 267 + "transcript:transcripts/day+001.md:0", 268 + "Player asked about the harbor. DM described ships at dock.", 269 + { 270 + "source": "transcript", 271 + "content_type": "transcripts", 272 + "path": "/tmp/transcripts/day+001.md", 273 + "title": "Day 1", 274 + "game_day": 1, 275 + }, 276 + ), 231 277 ] 232 278 for doc_id, text, meta in docs: 233 279 index.upsert(doc_id, text, meta) ··· 267 313 # Search with decay_ref far from day 1 transcript 268 314 results_no_decay = index.search("harbor ships") 269 315 results_decayed = index.search("harbor ships", decay_ref=100) 270 - transcript_no_decay = [r for r in results_no_decay 271 - if r.source == "transcript"] 272 - transcript_decayed = [r for r in results_decayed 273 - if r.source == "transcript"] 316 + transcript_no_decay = [r for r in results_no_decay if r.source == "transcript"] 317 + transcript_decayed = [r for r in results_decayed if r.source == "transcript"] 274 318 if transcript_no_decay and transcript_decayed: 275 319 assert transcript_decayed[0].score < transcript_no_decay[0].score 276 320 ··· 294 338 stats = index.stats() 295 339 assert stats["total_documents"] == 5 296 340 297 - def test_reindex_updates_changed_files( 298 - self, index: VectorIndex, srd_tree: Path 299 - ): 341 + def test_reindex_updates_changed_files(self, index: VectorIndex, srd_tree: Path): 300 342 index.reindex_directory(srd_tree, source="srd") 301 343 # Modify a file 302 344 fireball = srd_tree / "spells" / "fireball.md" 303 345 fireball.write_text("# Fireball\n\nNow deals 10d6 fire damage!\n") 304 - count = index.reindex_directory(srd_tree, source="srd") 346 + index.reindex_directory(srd_tree, source="srd") 305 347 # Should still have 5 docs total (updated, not duplicated) 306 348 stats = index.stats() 307 349 assert stats["total_documents"] == 5 ··· 316 358 assert stats["total_documents"] == 0 317 359 318 360 def test_stats_by_source(self, index: VectorIndex): 319 - index.upsert("srd:a:0", "text a", 320 - {"source": "srd", "content_type": "spells", 321 - "path": "/a", "title": "A"}) 322 - index.upsert("world:b:0", "text b", 323 - {"source": "world", "content_type": "npcs", 324 - "path": "/b", "title": "B"}) 361 + index.upsert( 362 + "srd:a:0", 363 + "text a", 364 + {"source": "srd", "content_type": "spells", "path": "/a", "title": "A"}, 365 + ) 366 + index.upsert( 367 + "world:b:0", 368 + "text b", 369 + {"source": "world", "content_type": "npcs", "path": "/b", "title": "B"}, 370 + ) 325 371 stats = index.stats() 326 372 assert stats["total_documents"] == 2 327 373 assert stats["by_source"]["srd"] == 1 ··· 336 382 db_path = tmp_path / "nonexistent" / "test.db" 337 383 idx = VectorIndex(db_path) 338 384 idx._embed_fn = _fake_embed 339 - idx.upsert("test:a:0", "hello", 340 - {"source": "test", "content_type": "misc", 341 - "path": "/a", "title": "A"}) 385 + idx.upsert( 386 + "test:a:0", 387 + "hello", 388 + {"source": "test", "content_type": "misc", "path": "/a", "title": "A"}, 389 + ) 342 390 assert idx.stats()["total_documents"] == 1 343 391 344 392 def test_corrupt_db_recreates(self, tmp_path: Path): ··· 346 394 db_path.write_bytes(b"this is not a sqlite database") 347 395 idx = VectorIndex(db_path) 348 396 idx._embed_fn = _fake_embed 349 - idx.upsert("test:a:0", "hello", 350 - {"source": "test", "content_type": "misc", 351 - "path": "/a", "title": "A"}) 397 + idx.upsert( 398 + "test:a:0", 399 + "hello", 400 + {"source": "test", "content_type": "misc", "path": "/a", "title": "A"}, 401 + ) 352 402 assert idx.stats()["total_documents"] == 1 353 403 354 404 ··· 357 407 358 408 class TestSeedFrom: 359 409 def test_seed_copies_documents(self, index: VectorIndex, tmp_path: Path): 360 - index.upsert("srd:spells/fireball.md:0", "Fireball spell", 361 - {"source": "srd", "content_type": "spells", 362 - "path": "/tmp/fireball.md", "title": "Fireball"}) 363 - index.upsert("srd:monsters/goblin.md:0", "Goblin monster", 364 - {"source": "srd", "content_type": "monsters", 365 - "path": "/tmp/goblin.md", "title": "Goblin"}) 410 + index.upsert( 411 + "srd:spells/fireball.md:0", 412 + "Fireball spell", 413 + { 414 + "source": "srd", 415 + "content_type": "spells", 416 + "path": "/tmp/fireball.md", 417 + "title": "Fireball", 418 + }, 419 + ) 420 + index.upsert( 421 + "srd:monsters/goblin.md:0", 422 + "Goblin monster", 423 + { 424 + "source": "srd", 425 + "content_type": "monsters", 426 + "path": "/tmp/goblin.md", 427 + "title": "Goblin", 428 + }, 429 + ) 366 430 index.close() 367 431 368 432 dest = tmp_path / "world" / "search.db" ··· 372 436 seeded.close() 373 437 374 438 def test_seed_allows_additional_upserts(self, index: VectorIndex, tmp_path: Path): 375 - index.upsert("srd:spells/fireball.md:0", "Fireball spell", 376 - {"source": "srd", "content_type": "spells", 377 - "path": "/tmp/fireball.md", "title": "Fireball"}) 439 + index.upsert( 440 + "srd:spells/fireball.md:0", 441 + "Fireball spell", 442 + { 443 + "source": "srd", 444 + "content_type": "spells", 445 + "path": "/tmp/fireball.md", 446 + "title": "Fireball", 447 + }, 448 + ) 378 449 index.close() 379 450 380 451 dest = tmp_path / "world" / "search.db" 381 452 seeded = VectorIndex.seed_from(index._db_path, dest) 382 453 seeded._embed_fn = _fake_embed 383 - seeded.upsert("world:npcs/vex.md:0", "Captain Vex", 384 - {"source": "world", "content_type": "npcs", 385 - "path": "/tmp/vex.md", "title": "Vex"}) 454 + seeded.upsert( 455 + "world:npcs/vex.md:0", 456 + "Captain Vex", 457 + { 458 + "source": "world", 459 + "content_type": "npcs", 460 + "path": "/tmp/vex.md", 461 + "title": "Vex", 462 + }, 463 + ) 386 464 assert seeded.stats()["total_documents"] == 2 387 465 assert seeded.stats()["by_source"]["srd"] == 1 388 466 assert seeded.stats()["by_source"]["world"] == 1 ··· 393 471 """Tests for cross-thread access (MCP server runs on a background thread).""" 394 472 395 473 def test_search_from_different_thread(self, index: VectorIndex): 396 - index.upsert("world:npcs/vex.md:0", "Captain Vex, harbor master", 397 - {"source": "world", "content_type": "npcs", 398 - "path": "/tmp/vex.md", "title": "Captain Vex"}) 474 + index.upsert( 475 + "world:npcs/vex.md:0", 476 + "Captain Vex, harbor master", 477 + { 478 + "source": "world", 479 + "content_type": "npcs", 480 + "path": "/tmp/vex.md", 481 + "title": "Captain Vex", 482 + }, 483 + ) 399 484 400 485 import threading 401 486
+88 -59
tests/test_seeder.py
··· 19 19 async def _gather() -> set[str]: 20 20 server = await _compose_server("seeder") 21 21 return {t.name for t in await server.list_tools()} 22 + 22 23 return asyncio.run(_gather()) 23 24 24 25 def test_seeder_has_world_building_tools(self): 25 26 assert self._seeder_names() == { 26 - "establish", "set_scene", "forge_culture", "generate_names", 27 + "establish", 28 + "set_scene", 29 + "forge_culture", 30 + "generate_names", 27 31 } 28 32 29 33 def test_seeder_excludes_disallowed_tools(self): ··· 43 47 async def _gather() -> set[str]: 44 48 server = await _compose_server("arc_architect") 45 49 return {t.name for t in await server.list_tools()} 50 + 46 51 return asyncio.run(_gather()) 47 52 48 53 def test_architect_only_has_commit_arc_and_recall(self): ··· 51 56 def test_architect_excludes_world_building_tools(self): 52 57 names = self._architect_names() 53 58 for forbidden in ( 54 - "establish", "set_scene", "mark", "amend_mark", 55 - "note_discovery", "tune", "damage", "heal", 56 - "adjust_coins", "create_character", "end_session", 59 + "establish", 60 + "set_scene", 61 + "mark", 62 + "amend_mark", 63 + "note_discovery", 64 + "tune", 65 + "damage", 66 + "heal", 67 + "adjust_coins", 68 + "create_character", 69 + "end_session", 57 70 ): 58 71 assert forbidden not in names 59 72 ··· 92 105 def test_seed_world_calls_claude( 93 106 self, mock_popen: MagicMock, character_world: Path 94 107 ): 95 - result_line = json.dumps({ 96 - "type": "result", 97 - "session_id": "sess-1", 98 - "usage": {"input_tokens": 500, "output_tokens": 100}, 99 - "duration_ms": 3000, 100 - }) 108 + result_line = json.dumps( 109 + { 110 + "type": "result", 111 + "session_id": "sess-1", 112 + "usage": {"input_tokens": 500, "output_tokens": 100}, 113 + "duration_ms": 3000, 114 + } 115 + ) 101 116 mock_proc = MagicMock() 102 117 mock_proc.stdin = MagicMock() 103 118 mock_proc.stdout = iter([result_line.encode() + b"\n"]) ··· 123 138 self, mock_popen: MagicMock, character_world: Path 124 139 ): 125 140 lines = [ 126 - json.dumps({ 127 - "type": "stream_event", 128 - "event": { 129 - "type": "content_block_start", 130 - "index": 0, 131 - "content_block": { 132 - "type": "tool_use", 133 - "id": "t1", 134 - "name": "mcp__storied__establish", 141 + json.dumps( 142 + { 143 + "type": "stream_event", 144 + "event": { 145 + "type": "content_block_start", 146 + "index": 0, 147 + "content_block": { 148 + "type": "tool_use", 149 + "id": "t1", 150 + "name": "mcp__storied__establish", 151 + }, 135 152 }, 136 - }, 137 - }), 138 - json.dumps({ 139 - "type": "stream_event", 140 - "event": { 141 - "type": "content_block_start", 142 - "index": 1, 143 - "content_block": { 144 - "type": "tool_use", 145 - "id": "t2", 146 - "name": "mcp__storied__set_scene", 153 + } 154 + ), 155 + json.dumps( 156 + { 157 + "type": "stream_event", 158 + "event": { 159 + "type": "content_block_start", 160 + "index": 1, 161 + "content_block": { 162 + "type": "tool_use", 163 + "id": "t2", 164 + "name": "mcp__storied__set_scene", 165 + }, 147 166 }, 148 - }, 149 - }), 150 - json.dumps({ 151 - "type": "result", 152 - "session_id": "sess-2", 153 - "usage": {"input_tokens": 800, "output_tokens": 150}, 167 + } 168 + ), 169 + json.dumps( 170 + { 171 + "type": "result", 172 + "session_id": "sess-2", 173 + "usage": {"input_tokens": 800, "output_tokens": 150}, 154 174 "duration_ms": 5000, 155 - }), 175 + } 176 + ), 156 177 ] 157 178 158 179 mock_proc = MagicMock() ··· 174 195 def test_seed_world_returns_result( 175 196 self, mock_popen: MagicMock, character_world: Path 176 197 ): 177 - result_line = json.dumps({ 178 - "type": "result", 179 - "session_id": "sess-1", 180 - "usage": {"input_tokens": 500, "output_tokens": 100}, 181 - "duration_ms": 3000, 182 - }) 198 + result_line = json.dumps( 199 + { 200 + "type": "result", 201 + "session_id": "sess-1", 202 + "usage": {"input_tokens": 500, "output_tokens": 100}, 203 + "duration_ms": 3000, 204 + } 205 + ) 183 206 mock_proc = MagicMock() 184 207 mock_proc.stdin = MagicMock() 185 208 mock_proc.stdout = iter([result_line.encode() + b"\n"]) ··· 203 226 self, mock_popen: MagicMock, character_world: Path 204 227 ): 205 228 lines = [ 206 - json.dumps({ 207 - "type": "stream_event", 208 - "event": { 209 - "type": "content_block_start", 210 - "index": 0, 211 - "content_block": { 212 - "type": "tool_use", 213 - "id": "t1", 214 - "name": "mcp__storied__establish", 229 + json.dumps( 230 + { 231 + "type": "stream_event", 232 + "event": { 233 + "type": "content_block_start", 234 + "index": 0, 235 + "content_block": { 236 + "type": "tool_use", 237 + "id": "t1", 238 + "name": "mcp__storied__establish", 239 + }, 215 240 }, 216 - }, 217 - }), 218 - json.dumps({ 219 - "type": "result", 220 - "session_id": "sess-1", 221 - "usage": {"input_tokens": 800, "output_tokens": 100}, 241 + } 242 + ), 243 + json.dumps( 244 + { 245 + "type": "result", 246 + "session_id": "sess-1", 247 + "usage": {"input_tokens": 800, "output_tokens": 100}, 222 248 "duration_ms": 3000, 223 - }), 249 + } 250 + ), 224 251 ] 225 252 226 253 mock_proc = MagicMock() ··· 259 286 character_world: Path, 260 287 ): 261 288 from storied.paths import world_path 289 + 262 290 world_dir = world_path("default") 263 291 world_dir.mkdir(parents=True, exist_ok=True) 264 292 (world_dir / "style.md").write_text( ··· 300 328 character_world: Path, 301 329 ): 302 330 from storied.paths import world_path 331 + 303 332 world_dir = world_path("default") 304 333 world_dir.mkdir(parents=True, exist_ok=True) 305 334 (world_dir / "style.md").write_text(" \n\n \n")
+7 -1
tests/test_session.py
··· 1 + # pyright: reportOptionalSubscript=false, reportReturnType=false 2 + # pyright: reportOperatorIssue=false 3 + # Tests subscript load_session results without null-narrowing — setup 4 + # guarantees the file exists. 1 5 """Tests for session state management.""" 2 6 3 7 from pathlib import Path ··· 173 177 def test_finds_location(self, tmp_path: Path): 174 178 loc_dir = tmp_path / "worlds" / "default" / "locations" 175 179 loc_dir.mkdir(parents=True) 176 - (loc_dir / "The Rusty Anchor.md").write_text("---\nname: The Rusty Anchor\n---\n") 180 + (loc_dir / "The Rusty Anchor.md").write_text( 181 + "---\nname: The Rusty Anchor\n---\n" 182 + ) 177 183 result = resolve_wiki_link("The Rusty Anchor", "default") 178 184 assert result is not None 179 185
+21 -12
tests/test_srd_recall.py
··· 1 + # pyright: reportReturnType=false, reportInvalidTypeForm=false 2 + # pytest fixtures yielding generators trip pyright's return-type check. 1 3 """Empirical tests: can recall find key SRD content? 2 4 3 5 These test against the real SRD files to verify that chunking and search ··· 39 41 40 42 41 43 def _assert_any_hit_contains( 42 - index: VectorIndex, query: str, needle: str, top_n: int = 3, 44 + index: VectorIndex, 45 + query: str, 46 + needle: str, 47 + top_n: int = 3, 43 48 ): 44 49 """Assert that at least one of the top N results contains needle.""" 45 50 hits = index.search(query, limit=top_n) 46 51 snippets = [h.snippet for h in hits] 47 52 doc_ids = [h.doc_id for h in hits] 48 - assert any( 49 - needle.lower() in s.lower() for s in snippets 50 - ) or any( 53 + assert any(needle.lower() in s.lower() for s in snippets) or any( 51 54 needle.lower() in d.lower() for d in doc_ids 52 55 ), ( 53 56 f"'{needle}' not found in top {top_n} for query '{query}'.\n" 54 - f"Got: {list(zip(doc_ids, [s[:80] for s in snippets]))}" 57 + f"Got: {list(zip(doc_ids, [s[:80] for s in snippets], strict=False))}" 55 58 ) 56 59 57 60 58 61 # --- Class Features --- 59 62 60 - class TestClassFeatureRecall: 61 63 64 + class TestClassFeatureRecall: 62 65 def test_sneak_attack(self, srd_index: VectorIndex): 63 66 _assert_any_hit_contains(srd_index, "sneak attack", "Sneak Attack") 64 67 ··· 83 86 84 87 # --- Conditions --- 85 88 86 - class TestConditionRecall: 87 89 90 + class TestConditionRecall: 88 91 def test_grappled(self, srd_index: VectorIndex): 89 92 _assert_any_hit_contains(srd_index, "grappled condition", "Grappled") 90 93 ··· 100 103 101 104 # --- Core Mechanics --- 102 105 103 - class TestCoreMechanicsRecall: 104 106 107 + class TestCoreMechanicsRecall: 105 108 def test_opportunity_attack(self, srd_index: VectorIndex): 106 109 _assert_any_hit_contains( 107 - srd_index, "opportunity attack", "Opportunity Attack", 110 + srd_index, 111 + "opportunity attack", 112 + "Opportunity Attack", 108 113 ) 109 114 110 115 def test_death_saving_throw(self, srd_index: VectorIndex): 111 116 _assert_any_hit_contains( 112 - srd_index, "death saving throw", "Death", 117 + srd_index, 118 + "death saving throw", 119 + "Death", 113 120 ) 114 121 115 122 def test_short_rest(self, srd_index: VectorIndex): ··· 117 124 118 125 def test_concentration(self, srd_index: VectorIndex): 119 126 _assert_any_hit_contains( 120 - srd_index, "concentration spell", "Concentration", 127 + srd_index, 128 + "concentration spell", 129 + "Concentration", 121 130 ) 122 131 123 132 124 133 # --- Equipment --- 125 134 126 - class TestEquipmentRecall: 127 135 136 + class TestEquipmentRecall: 128 137 def test_rapier(self, srd_index: VectorIndex): 129 138 _assert_any_hit_contains(srd_index, "rapier", "Rapier") 130 139
+9 -3
tests/test_tune.py
··· 55 55 """Tests for the arc_architect's commit_arc tool.""" 56 56 57 57 def test_commit_arc_creates_arc_file( 58 - self, ctx: ToolContext, tmp_path: Path, 58 + self, 59 + ctx: ToolContext, 60 + tmp_path: Path, 59 61 ): 60 62 commit_arc("# Campaign Arc\n\n## Premise\nA quiet mystery.\n") 61 63 ··· 63 65 assert arc_path.exists() 64 66 65 67 def test_commit_arc_writes_content_verbatim( 66 - self, ctx: ToolContext, tmp_path: Path, 68 + self, 69 + ctx: ToolContext, 70 + tmp_path: Path, 67 71 ): 68 72 content = ( 69 73 "# Campaign Arc\n\n## Premise\nA wedding postponed twice.\n" ··· 75 79 assert arc_path.read_text() == content 76 80 77 81 def test_commit_arc_replaces_existing( 78 - self, ctx: ToolContext, tmp_path: Path, 82 + self, 83 + ctx: ToolContext, 84 + tmp_path: Path, 79 85 ): 80 86 commit_arc("# Campaign Arc\n\n## Premise\nFirst draft.\n") 81 87 commit_arc("# Campaign Arc\n\n## Premise\nSecond draft.\n")
+48
uv.lock
··· 474 474 ] 475 475 476 476 [[package]] 477 + name = "execnet" 478 + version = "2.1.2" 479 + source = { registry = "https://pypi.org/simple" } 480 + sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } 481 + wheels = [ 482 + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, 483 + ] 484 + 485 + [[package]] 477 486 name = "fastembed" 478 487 version = "0.8.0" 479 488 source = { registry = "https://pypi.org/simple" } ··· 1043 1052 ] 1044 1053 1045 1054 [[package]] 1055 + name = "nodeenv" 1056 + version = "1.10.0" 1057 + source = { registry = "https://pypi.org/simple" } 1058 + sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } 1059 + wheels = [ 1060 + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, 1061 + ] 1062 + 1063 + [[package]] 1046 1064 name = "numpy" 1047 1065 version = "2.4.3" 1048 1066 source = { registry = "https://pypi.org/simple" } ··· 1561 1579 ] 1562 1580 1563 1581 [[package]] 1582 + name = "pyright" 1583 + version = "1.1.408" 1584 + source = { registry = "https://pypi.org/simple" } 1585 + dependencies = [ 1586 + { name = "nodeenv" }, 1587 + { name = "typing-extensions" }, 1588 + ] 1589 + sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } 1590 + wheels = [ 1591 + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, 1592 + ] 1593 + 1594 + [[package]] 1564 1595 name = "pysqlite3-binary" 1565 1596 version = "0.5.4.post2" 1566 1597 source = { registry = "https://pypi.org/simple" } ··· 1598 1629 sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } 1599 1630 wheels = [ 1600 1631 { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, 1632 + ] 1633 + 1634 + [[package]] 1635 + name = "pytest-xdist" 1636 + version = "3.8.0" 1637 + source = { registry = "https://pypi.org/simple" } 1638 + dependencies = [ 1639 + { name = "execnet" }, 1640 + { name = "pytest" }, 1641 + ] 1642 + sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } 1643 + wheels = [ 1644 + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, 1601 1645 ] 1602 1646 1603 1647 [[package]] ··· 1941 1985 [package.dev-dependencies] 1942 1986 dev = [ 1943 1987 { name = "mypy" }, 1988 + { name = "pyright" }, 1944 1989 { name = "pytest" }, 1945 1990 { name = "pytest-cov" }, 1991 + { name = "pytest-xdist" }, 1946 1992 { name = "ruff" }, 1947 1993 ] 1948 1994 ··· 1970 2016 [package.metadata.requires-dev] 1971 2017 dev = [ 1972 2018 { name = "mypy", specifier = ">=1.19.1" }, 2019 + { name = "pyright", specifier = ">=1.1.408" }, 1973 2020 { name = "pytest", specifier = ">=9.0.2" }, 1974 2021 { name = "pytest-cov", specifier = ">=7.0.0" }, 2022 + { name = "pytest-xdist", specifier = ">=3.8.0" }, 1975 2023 { name = "ruff", specifier = ">=0.14.10" }, 1976 2024 ] 1977 2025