diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6ba1424 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.14 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix, --show-fixes] + - id: ruff-format + + - repo: local + hooks: + - id: loq + name: loq (file size limits) + entry: uv run loq check + language: system + pass_filenames: false + - id: pyright + name: pyright (source and tests) + entry: uv run pyright src/storied tests + language: system + types: [python] + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index 79a11f6..f29c034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,7 +144,16 @@ Separate from world state to support multiple characters and future multiplayer. - Python 3.12+, full type hints - TDD: write tests first -- 100% test coverage required +- Target 100% test coverage with two documented exclusions: + - `src/storied/cli.py` — thin argparse + dispatch only; the real + work belongs in domain modules that are tested directly (the + CLI is currently still doing too much; thinning is in flight) + - `src/storied/srd/*` — one-shot PDF pipeline run via `make srd` + + The pytest gate (`--cov-fail-under` in `pyproject.toml`) is set to + the current floor on the included code, not the target. Raise it as + coverage improves; never lower the floor without a specific reason + that's worth writing down. - Use `uv sync` to install dependencies **Run commands directly** - the `.envrc` activates the venv, so use `storied` not `.venv/bin/storied` or `uv run storied`. diff --git a/loq.toml b/loq.toml new file mode 100644 index 0000000..ed4db84 --- /dev/null +++ b/loq.toml @@ -0,0 +1,71 @@ +default_max_lines = 500 +respect_gitignore = true + +# Exclude generated content and curated data files where the line count +# is incidental: uv.lock, the SRD section markdown produced by `make srd`, +# the long-form DM system prompt, and the curated phoneme-inventory +# literals. Per-file Python violations are tracked via `loq baseline` +# rules below (regenerate with `uv run loq baseline`). +exclude = [ + "**/uv.lock", + "**/.git/**", + "**/rules/**", + "**/prompts/dm-system.md", + "**/src/storied/names/data/inventories.py", +] + +[[rules]] +path = "src/storied/engine.py" +max_lines = 636 + +[[rules]] +path = "src/storied/cli.py" +max_lines = 1265 + +[[rules]] +path = "tests/test_mcp_server.py" +max_lines = 631 + +[[rules]] +path = "tests/test_character.py" +max_lines = 1387 + +[[rules]] +path = "src/storied/tools/entities.py" +max_lines = 703 + +[[rules]] +path = "src/storied/log.py" +max_lines = 575 + +[[rules]] +path = "tests/test_search.py" +max_lines = 501 + +[[rules]] +path = "tests/test_initiative.py" +max_lines = 530 + +[[rules]] +path = "src/storied/tools/character.py" +max_lines = 611 + +[[rules]] +path = "tests/test_planner.py" +max_lines = 592 + +[[rules]] +path = "src/storied/character/operations.py" +max_lines = 518 + +[[rules]] +path = "tests/test_execute_tool.py" +max_lines = 895 + +[[rules]] +path = "src/storied/planner.py" +max_lines = 775 + +[[rules]] +path = "tests/test_entities.py" +max_lines = 786 diff --git a/plans/architecture-implementation.md b/plans/architecture-implementation.md deleted file mode 100644 index 3d47472..0000000 --- a/plans/architecture-implementation.md +++ /dev/null @@ -1,386 +0,0 @@ -# Storied: AI-Powered Text Adventure RPG - -## Concept -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. - -## Key Decisions -- **Stack**: Python (Textual for TUI, future web interface) -- **Players**: Single-player first, architected for multiplayer -- **Mechanics**: Adaptive/light - AI decides when dice matter, rolls shown contextually -- **Dice modes**: Implicit (AI rolls), or player brings IRL dice -- **Interface**: TUI first, API-based core enables future web - ---- - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────┐ -│ TUI Interface │ -│ ┌─────────────────┐ ┌──────────┐ ┌──────────────────┐ │ -│ │ Narrative Pane │ │ Dice │ │ Character Status │ │ -│ │ │ │ Display │ │ │ │ -│ └─────────────────┘ └──────────┘ └──────────────────┘ │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Input Area ││ -│ └─────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ DM Engine Core │ -│ • Interprets player actions │ -│ • Maintains narrative context │ -│ • Invokes rules when appropriate │ -│ • Reads/writes world files │ -│ • Generates tools on demand │ -└─────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ World State │ │ Rules Engine│ │ Tool System │ -│ (Markdown) │ │ (5e SRD) │ │ (Generated) │ -└─────────────┘ └─────────────┘ └─────────────┘ -``` - ---- - -## Component Details - -### 1. DM Engine Core -The heart of the system - an agentic LLM loop that: -- Receives player input -- Loads relevant world context (locations, NPCs, history) -- Decides on narrative response + any mechanical resolution -- Generates new content as needed (lazy world-building) -- Persists changes to world files -- Returns narrative + any dice results to display - -**Context Management**: Needs smart retrieval - can't load entire world into context. Options: -- Semantic search over world files -- Graph-based retrieval (connected locations/characters) -- Recency + relevance scoring - -### 2. World State Layer -Markdown files with YAML frontmatter for structured data: - -```markdown ---- -type: location -name: The Rusty Anchor -region: Portside District -connections: - - harbor_main - - fish_market - - back_alley -tags: [tavern, social, quest-hook] -first_visited: 2025-01-15 ---- - -# The Rusty Anchor - -A weathered dockside tavern where sailors swap stories... - -## Notable Features -- The bar is a repurposed ship's hull -- A mysterious map hangs behind the counter - -## NPCs Present -- [[Mara Saltwind]] - the owner, former pirate -- [[Old Tam]] - regular, knows everyone's business -``` - -**Directory Structure:** -``` -worlds/{world_name}/ -├── locations/ -├── characters/ -├── items/ -├── lore/ -├── factions/ -├── quests/ -├── tools/ # Generated procedural tools -├── sessions/ # Session logs -└── world.yaml # World config + meta -``` - -### 3. Player State (Separate from World) -``` -players/{player_id}/ -├── character.yaml # Stats, class, abilities -├── inventory.yaml # Items carried -├── journal.md # Personal notes, discoveries -├── relationships.yaml # NPC relationship tracking -└── session_log.md # Running narrative history -``` - -Separation enables: -- Multiple characters in same world -- Future multiplayer (each player has own state) -- Clean rollback/save points - -### 4. Rules Engine (5e SRD) -Pinned to 5e SRD - no pluggable abstraction needed. - -- Character creation/leveling -- Skill checks with modifiers -- Combat resolution -- Spell effects -- Condition tracking - -**Dice Display:** -- AI rolls implicitly, results shown in dice pane -- Narrative describes outcomes without explicit numbers -- (Future: optional manual dice entry mode) - -### 5. Tool Generation System -The meta-capability: AI generates utilities that become world content. - -**Example: Cave System Generator** -When story needs caves, AI writes: -```python -# worlds/myworld/tools/cave_generator.py -"""Generates connected cave networks for the Underdark regions.""" - -def generate_cave_system( - num_chambers: int, - connectivity: float = 0.3, - seed: str | None = None -) -> CaveSystem: - ... -``` - -Generated tools are: -- Stored in `worlds/{world}/tools/` -- Documented with their purpose -- Reusable for similar future needs -- Part of the world's "DNA" - -### 6. TUI Interface (Textual) -``` -┌─ Storied ──────────────────────────────────────────────┐ -│ ┌─ The Rusty Anchor ─────────────────────────────────┐ │ -│ │ You push through the heavy oak door. The smell of │ │ -│ │ salt and stale ale washes over you. A one-eyed │ │ -│ │ woman behind the bar looks up, her hand drifting │ │ -│ │ toward something beneath the counter. │ │ -│ │ │ │ -│ │ "We don't get many strangers here," she says. │ │ -│ │ "State your business." │ │ -│ └────────────────────────────────────────────────────┘ │ -│ ┌─ Dice ──────┐ ┌─ Status ────────────────────────────┐│ -│ │ ⚄ Insight │ │ Kira Stoneheart HP: 24/24 AC: 16 ││ -│ │ 14 + 3 = 17 │ │ Fighter 3 GP: 47 ││ -│ └─────────────┘ └─────────────────────────────────────┘│ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ > I approach the bar carefully, hands visible... │ │ -│ └────────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────┘ -``` - ---- - -## Lazy World Generation - -The world expands as explored: - -1. **Reference Phase**: Location mentioned in passing (name only) -2. **Sketch Phase**: Player asks about it, basic details generated -3. **Detail Phase**: Player visits, full description + NPCs + connections -4. **Living Phase**: Events occur, state changes, history accumulates - -Each phase writes more detail to the markdown file. Previously mentioned details become constraints for future generation. - ---- - -## Decided - -- **LLM Provider**: Anthropic API (Claude) -- **Context Strategy**: Hybrid - graph-based for structure + semantic search for lore -- **Tool Execution**: Sandboxed (restricted environment, output-only) - -## Open Questions - -1. **Session Continuity**: How much narrative history to maintain? -2. **Conflict Resolution**: When world files contradict, which wins? -3. **Embedding Model**: Which model for semantic search? (local vs API) - ---- - -## Implementation Plan: Vertical Slice First - -### Milestone 0: Project Foundation -Establish the repo and design documentation. - -**0.1 Repository Setup** -- Initialize git repo -- Create `.gitignore` (Python, venv, IDE files, etc.) -- Basic `pyproject.toml` with project metadata -- `README.md` with project overview -- `CLAUDE.md` - project context for Claude Code sessions: - - Project overview and philosophy - - Key architecture decisions - - File structure and conventions - - Pointers to design docs - -**0.2 Design Documentation** -- Create `design/` directory -- Move this architecture doc to `design/architecture.md` -- `design/worldfiles.md` - specification for world file format -- `design/dm-engine.md` - DM engine behavior and prompts - -**0.3 Project Structure** -``` -storied/ -├── design/ # Architecture and design docs -├── plans/ # Implementation plans (in-progress work) -├── src/storied/ # Python package (empty for now) -├── rules/ # SRD and game rules (processed) -├── worlds/ # World content (gitignored) -├── players/ # Player state (gitignored) -├── tests/ # Test directory -├── pyproject.toml -├── README.md -└── .gitignore -``` - ---- - -### Milestone 0.5: SRD Processing Pipeline -Build our own system to extract and structure the 5e SRD PDF. - -**0.5.1 Setup** -- Create `rules/` directory structure -- Download SRD 5.2.1 PDF to `rules/sources/SRD_CC_v5.2.1.pdf` -- Add PDF processing dependency (pymupdf or pdfplumber) - -**0.5.2 PDF Extraction** -- Extract text from PDF preserving structure -- Identify section headers, tables, stat blocks -- Handle multi-column layouts - -**0.5.3 Markdown Generation** -- Output structured markdown to `rules/srd-5.2.1/` -- Organize by topic: - ``` - rules/srd-5.2.1/ - ├── races/ - ├── classes/ - ├── backgrounds/ - ├── equipment/ - ├── spells/ - ├── monsters/ - ├── combat/ - ├── adventuring/ - └── index.yaml # Structured index for queries - ``` - -**0.5.4 Structured Data** -- Generate YAML/JSON indexes for programmatic access -- Spell lists with levels, schools, components -- Monster stat blocks as structured data -- Equipment tables - -**Milestone 0.5 Deliverable**: Markdown + structured data from SRD that the DM engine can reference. - ---- - -### Milestone 1: Playable Prototype -Get a working game loop as fast as possible. - -**1.1 Add Dependencies** -- Add to pyproject.toml: `textual`, `anthropic`, `pyyaml` -- Create initial module files: - - `src/storied/__init__.py` - - `src/storied/main.py` - entry point - - `src/storied/engine.py` - DM engine core - - `src/storied/world.py` - world file I/O - - `src/storied/tui.py` - Textual interface - -**1.2 Minimal TUI** -- Single screen: narrative pane + input box -- No dice display yet, no status bar yet -- Just get text flowing - -**1.3 Basic DM Engine** -- Simple prompt that establishes the DM role -- Receives player input, returns narrative -- No world persistence yet - just vibes - -**1.4 World Persistence** -- Create/read location markdown files -- DM engine loads current location context -- New locations written when visited -- Simple `current_location` tracking - -**Milestone 1 Deliverable**: You can start a game, explore, and locations persist between sessions. - ---- - -### Milestone 2: Character & Mechanics - -**2.1 Character Creation (AI-Assisted)** -Start simple - player describes what they want, AI generates the character: - -- "I want to play a grumpy dwarf fighter" → AI generates valid 5e stats -- Backstory emerges lazily during play (AI asks when relevant) -- Character file grows with narrative details over time - -*(Future: Add traditional wizard, quick templates as alternative paths)* - -**2.2 Character System** -- Character YAML file (name, race, class, stats, level, HP, inventory) -- Display character status in TUI -- Backstory section grows as story unfolds - -**2.3 Dice System** -- Dice roller utility -- AI rolls implicitly, results shown in dice pane -- Narrative focuses on outcomes, not numbers - -**2.4 Basic 5e Integration** -- Skill checks (ability + proficiency) -- Simple combat (attack rolls, damage) -- Death saves, resting - ---- - -### Milestone 3: Richer World - -**3.1 NPCs & Relationships** -- NPC markdown files -- Relationship tracking per player -- NPCs persist state/mood - -**3.2 Context Retrieval** -- Graph-based: load connected locations -- Semantic: embed world files, search for relevant lore -- Combine for DM context window - -**3.3 Session Management** -- Session logs -- Save/restore game state -- Multiple save slots - ---- - -### Milestone 4: Tool Generation - -**4.1 Tool Framework** -- Sandboxed execution environment -- Tool registry (discover generated tools) -- Tool invocation from DM engine - -**4.2 Example Tools** -- Procedural name generator -- Simple dungeon/cave generator -- Loot table roller - ---- - -### Future Considerations -- Web interface (API already supports it) -- Multiplayer / shared worlds -- Alternative rule systems -- Voice input/output -- Map visualization diff --git a/prompts/cold-draft.md b/prompts/arc-cold-draft.md similarity index 100% rename from prompts/cold-draft.md rename to prompts/arc-cold-draft.md diff --git a/prompts/dm-system.md b/prompts/dm-system.md index 2c4cffe..0c29e31 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -613,4 +613,3 @@ You can adjust your storytelling style with the `tune` tool. Call it when: - You notice the player consistently gravitating toward or away from certain kinds of play (e.g., skipping combat, seeking out NPCs) 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. - diff --git a/pyproject.toml b/pyproject.toml index c1786c9..2d2737f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,10 +46,15 @@ packages = ["src/storied"] testpaths = ["tests"] pythonpath = ["src"] addopts = [ + "-n", "4", "--cov=src/storied", "--cov=tests", "--cov-report=term-missing:skip-covered", "--cov-branch", + # Target: 100%. Floor: 96% (the current reality on included code). + # Raise the floor as coverage improves; never lower it without a + # specific reason that's worth writing down. + "--cov-fail-under=96", "-m", "not slow", ] markers = ["slow: slow tests that build real search indices"] @@ -63,25 +68,46 @@ target-version = "py312" line-length = 88 [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM"] +select = ["E", "F", "I", "UP", "B", "SIM", "PT", "RUF"] # B008 fires on the FastMCP + uncalled_for dependency-injection idiom # (`root: Path = StorageRoot()` etc). Those aren't mutable defaults — # they're DI markers resolved at call time. Suppressing project-wide # so real B008 regressions would still surface elsewhere. -ignore = ["B008"] +# +# RUF001/002/003 flag ambiguous Unicode (em dashes, curly quotes, ×). +# These are intentional throughout the project's prose, prompts, and +# comments — the house style uses em dashes. +ignore = ["B008", "RUF001", "RUF002", "RUF003"] + +[tool.codespell] +# Skip the SRD content (5e creature/feature names trip the spell-check) +# and uv.lock. Allow specific intentional terms elsewhere: phonological +# diphthongs, partial streaming-test fragments, and valid spelling +# variants. +skip = "rules/**,uv.lock" +ignore-words-list = "ue,ie,hel,wit,unparseable" + +[tool.pyright] +include = ["src", "tests"] +# Strict surfaces ~1300 errors here, mostly reportUnknownX from +# dict/yaml/dynamic-prompt code that pre-dates a strict typing pass. +# Basic catches the real bugs without requiring every locals to be +# explicitly typed. Tighten as the codebase becomes more annotated. +typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" [tool.coverage.run] source = ["src/storied"] branch = true omit = ["src/storied/cli.py", "src/storied/srd/*"] -[tool.coverage.report] -fail_under = 95 - [dependency-groups] dev = [ "mypy>=1.19.1", + "pyright>=1.1.408", "pytest>=9.0.2", "pytest-cov>=7.0.0", + "pytest-xdist>=3.8.0", "ruff>=0.14.10", ] diff --git a/src/storied/advancement.py b/src/storied/advancement.py index 47f040c..3f2ef05 100644 --- a/src/storied/advancement.py +++ b/src/storied/advancement.py @@ -3,7 +3,6 @@ import time from collections.abc import Callable from dataclasses import dataclass -from pathlib import Path from threading import Thread from storied import notifications @@ -80,7 +79,7 @@ def build_advancement_context( def evaluate_advancement( world_id: str = "default", player_id: str = "default", - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", on_progress: Callable[[str], None] | None = None, ) -> AdvancementResult: """Evaluate whether the character has earned a level-up.""" @@ -156,7 +155,7 @@ class BackgroundAdvancement: self, world_id: str, player_id: str, - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", interval: int = 5, ): self._world_id = world_id diff --git a/src/storied/character/__init__.py b/src/storied/character/__init__.py index 6b1da41..8d89bec 100644 --- a/src/storied/character/__init__.py +++ b/src/storied/character/__init__.py @@ -47,7 +47,7 @@ from storied.character.operations import ( set_item_status, ) -__all__ = [ +__all__ = [ # noqa: RUF022 — grouped by submodule, not alphabetized # Data "DEFAULT_SCHEMA", "create_character", diff --git a/src/storied/character/compute.py b/src/storied/character/compute.py index 8cae070..1d93cdf 100644 --- a/src/storied/character/compute.py +++ b/src/storied/character/compute.py @@ -8,7 +8,6 @@ charge of rules; these functions just do the baseline math so the DM doesn't have to. See `design/architecture.md` for the invariant. """ - # Maps each skill to its governing ability SKILL_TO_ABILITY: dict[str, str] = { "acrobatics": "dexterity", @@ -33,7 +32,14 @@ SKILL_TO_ABILITY: dict[str, str] = { ALL_SKILLS = list(SKILL_TO_ABILITY.keys()) -ABILITIES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] +ABILITIES = [ + "strength", + "dexterity", + "constitution", + "intelligence", + "wisdom", + "charisma", +] def ability_modifier(score: int) -> int: diff --git a/src/storied/character/data.py b/src/storied/character/data.py index 90521a3..3aaa5de 100644 --- a/src/storied/character/data.py +++ b/src/storied/character/data.py @@ -8,7 +8,6 @@ import yaml from storied.character.schema import coerce_character, validate_for_write from storied.paths import player_path - # Default character schema — used when creating a new character DEFAULT_SCHEMA: dict = { "identity": { @@ -97,9 +96,7 @@ def load_character_prose(player_id: str) -> str: return md_path.read_text() -def save_character( - player_id: str, data: dict -) -> None: +def save_character(player_id: str, data: dict) -> None: """Save character data back to character.yaml.""" yaml_path = _character_yaml_path(player_id) yaml_path.parent.mkdir(parents=True, exist_ok=True) @@ -108,9 +105,7 @@ def save_character( ) -def save_character_prose( - player_id: str, prose: str -) -> None: +def save_character_prose(player_id: str, prose: str) -> None: """Save the character's free-text prose to character.md.""" md_path = _character_md_path(player_id) md_path.parent.mkdir(parents=True, exist_ok=True) @@ -127,20 +122,13 @@ def _merge_defaults(data: dict) -> dict: def _deep_update(target: dict, source: dict) -> None: """Recursively update target with values from source.""" for key, value in source.items(): - if ( - key in target - and isinstance(target[key], dict) - and isinstance(value, dict) - ): + if key in target and isinstance(target[key], dict) and isinstance(value, dict): _deep_update(target[key], value) else: target[key] = value -def update_character( - player_id: str, - updates: dict -) -> str: +def update_character(player_id: str, updates: dict) -> str: """Update fields in character.yaml using dot notation. Example: {"state.hp.current": 5, "identity.classes.0.level": 4} @@ -178,17 +166,17 @@ def update_character( error = validate_for_write(data) if error: - return ( - f"Update rejected — {error}. " - f"Character on disk is unchanged." - ) + return f"Update rejected — {error}. Character on disk is unchanged." save_character(player_id, data) return "Character updated: " + ", ".join(changes) def _set_nested(data: dict, key: str, value) -> None: - """Set a nested value using dot notation. Supports list indices like 'classes.0.level'.""" + """Set a nested value using dot notation. + + Supports list indices like 'classes.0.level'. + """ parts = key.split(".") current = data @@ -225,7 +213,7 @@ def create_character( proficiencies: dict | None = None, features: list[dict] | None = None, equipment: dict | None = None, - backstory: str | None = None + backstory: str | None = None, ) -> str: """Create a new character with the new schema. diff --git a/src/storied/character/display.py b/src/storied/character/display.py index 02a3c56..499b0a7 100644 --- a/src/storied/character/display.py +++ b/src/storied/character/display.py @@ -181,7 +181,13 @@ def _vital_line(char: dict) -> str: speed = state.get("speed", 30) init = initiative_modifier(char) pb = proficiency_bonus(char) - parts = [f"HP {hp_str}", f"AC {ac}", f"Speed {speed}", f"Init {init:+d}", f"PB +{pb}"] + parts = [ + f"HP {hp_str}", + f"AC {ac}", + f"Speed {speed}", + f"Init {init:+d}", + f"PB +{pb}", + ] exhaustion = state.get("exhaustion", 0) if exhaustion: parts.append(f"Exhaustion {exhaustion}") @@ -252,7 +258,12 @@ def format_sheet(data: dict) -> str: lines.extend(def_lines) lines.append("") - for section_func in (_format_effects, _format_resources, _format_magic_items, _format_features): + for section_func in ( + _format_effects, + _format_resources, + _format_magic_items, + _format_features, + ): section = section_func(data) if section: lines.extend(section) diff --git a/src/storied/character/operations.py b/src/storied/character/operations.py index 46fd785..0c2c1d1 100644 --- a/src/storied/character/operations.py +++ b/src/storied/character/operations.py @@ -5,23 +5,16 @@ a result message describing what happened. These are the underlying implementations behind the DM's bookkeeping tools. """ -from pathlib import Path - from storied.character.data import ( load_character, save_character, ) from storied.paths import player_path - # --- HP operations --- -def damage( - player_id: str, - amount: int, - damage_type: str | None = None -) -> str: +def damage(player_id: str, amount: int, damage_type: str | None = None) -> str: """Apply raw damage to the character. Temp HP soaks first, then current HP. `damage_type` is metadata for @@ -61,10 +54,7 @@ def damage( return ". ".join(parts) -def heal( - player_id: str, - amount: int -) -> str: +def heal(player_id: str, amount: int) -> str: """Heal the character, clamped to max HP.""" data = load_character(player_id) if data is None: @@ -89,7 +79,7 @@ def add_effect( source: str, description: str, expires: str | None = None, - concentration: bool = False + concentration: bool = False, ) -> str: """Add a temporary effect to the character. @@ -121,10 +111,7 @@ def add_effect( return " ".join(parts) -def remove_effect( - player_id: str, - source: str -) -> str: +def remove_effect(player_id: str, source: str) -> str: """Remove an effect by source name (case-insensitive substring match).""" data = load_character(player_id) if data is None: @@ -145,10 +132,7 @@ def remove_effect( # --- Condition operations --- -def add_condition( - player_id: str, - name: str -) -> str: +def add_condition(player_id: str, name: str) -> str: """Add a condition to the character (no duplicates).""" data = load_character(player_id) if data is None: @@ -164,10 +148,7 @@ def add_condition( return f"Condition added: {name}" -def remove_condition( - player_id: str, - name: str -) -> str: +def remove_condition(player_id: str, name: str) -> str: """Remove a condition (case-insensitive).""" data = load_character(player_id) if data is None: @@ -187,11 +168,7 @@ def remove_condition( # --- Inventory operations --- -def add_item( - player_id: str, - item: str, - location: str | None = None -) -> str: +def add_item(player_id: str, item: str, location: str | None = None) -> str: """Add an item to a location in the equipment dict. Substring match on existing location keys. Creates the location if it @@ -228,10 +205,7 @@ def add_item( return f"Added '{item}' to {target_key}" -def remove_item( - player_id: str, - item: str -) -> str: +def remove_item(player_id: str, item: str) -> str: """Remove an item by case-insensitive substring match across all locations.""" data = load_character(player_id) if data is None: @@ -250,11 +224,7 @@ def remove_item( return f"No item matching '{item}' found" -def set_item_status( - player_id: str, - item: str, - status: str -) -> str: +def set_item_status(player_id: str, item: str, status: str) -> str: """Set a magic item's status (attuned, equipped, carried). The item is referenced by its world entity name. The function manages @@ -273,10 +243,7 @@ def set_item_status( ) # Normalize to wikilink format - if not item.startswith("[["): - wikilink = f"[[{item}]]" - else: - wikilink = item + wikilink = item if item.startswith("[[") else f"[[{item}]]" # Remove from all current statuses for s in valid_statuses: @@ -294,11 +261,7 @@ def set_item_status( # --- Resource operations --- -def adjust_resource( - player_id: str, - name: str, - delta: int -) -> str: +def adjust_resource(player_id: str, name: str, delta: int) -> str: """Adjust a resource pool by a delta. Negative = use (clamped to 0), positive = restore (clamped to max). @@ -342,10 +305,7 @@ def adjust_resource( return f"No change to {notes} ({new_current}/{maximum})" -def rest( - player_id: str, - rest_type: str -) -> str: +def rest(player_id: str, rest_type: str) -> str: """Take a short or long rest. Refreshes resources by refresh type. short rest: refreshes resources with refresh: short_rest @@ -401,10 +361,7 @@ def rest( # --- Coin and notes operations --- -def adjust_coins( - player_id: str, - deltas: dict[str, int] -) -> str: +def adjust_coins(player_id: str, deltas: dict[str, int]) -> str: """Apply relative coin changes (positive=gain, negative=spend). Atomic: if any denomination would go below zero, the entire call is @@ -430,9 +387,7 @@ def adjust_coins( old = purse.get(denom, 0) new = old + delta if new < 0: - shortfalls.append( - f"{denom}: have {old}, need {-delta} (short {-new})" - ) + shortfalls.append(f"{denom}: have {old}, need {-delta} (short {-new})") proposed[denom] = new if shortfalls: @@ -476,7 +431,7 @@ def level_up( new_level: int, hp_gain: int, features: list[dict] | None = None, - time_anchor: str | None = None + time_anchor: str | None = None, ) -> str: """Atomically level up the character. @@ -547,11 +502,7 @@ def level_up( return " ".join(parts) -def add_note( - player_id: str, - text: str, - time_anchor: str | None = None -) -> str: +def add_note(player_id: str, text: str, time_anchor: str | None = None) -> str: """Append a note to the player's notes.md file.""" notes_path = player_path(player_id) / "notes.md" notes_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/storied/character/schema.py b/src/storied/character/schema.py index 1e12063..2755614 100644 --- a/src/storied/character/schema.py +++ b/src/storied/character/schema.py @@ -13,7 +13,6 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator - # --- Sub-models ------------------------------------------------------------- @@ -170,7 +169,7 @@ def _format_error(exc: ValidationError) -> str: msg = err["msg"] # Pydantic prefixes "Value error, " on raised ValueErrors — strip it if msg.startswith("Value error, "): - msg = msg[len("Value error, "):] + msg = msg[len("Value error, ") :] parts.append(f"{loc}: {msg}") return "; ".join(parts) diff --git a/src/storied/claude.py b/src/storied/claude.py index 5a6eed0..f8527ef 100644 --- a/src/storied/claude.py +++ b/src/storied/claude.py @@ -96,17 +96,22 @@ def _build_tool_args( """Build args for a claude -p call with MCP tools and stream-json I/O.""" args = [ _find_claude(), - "--tools", "", + "--tools", + "", "-p", - "--input-format", "stream-json", - "--output-format", "stream-json", + "--input-format", + "stream-json", + "--output-format", + "stream-json", "--include-partial-messages", "--verbose", "--dangerously-skip-permissions", "--disable-slash-commands", "--strict-mcp-config", - "--mcp-config", mcp_config, - "--effort", effort, + "--mcp-config", + mcp_config, + "--effort", + effort, # Keep per-machine sections (cwd, env, memory paths, git # status) out of the system prompt. We control the entire # prompt explicitly via --system-prompt, and the dynamic @@ -201,7 +206,7 @@ def stream_with_tools( # pragma: no cover user_message: str, mcp_url: str, *, - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", effort: str = "medium", resume_session_id: str | None = None, cwd: Path | None = None, @@ -264,7 +269,7 @@ def run_with_tools( user_message: str, mcp_url: str, *, - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", effort: str = "high", on_tool_start: Callable[[str], None] | None = None, cwd: Path | None = None, @@ -358,9 +363,12 @@ def run_prompt( # pragma: no cover return None args = [ - claude_path, "-p", - "--model", model, - "--system-prompt", system_prompt, + claude_path, + "-p", + "--model", + model, + "--system-prompt", + system_prompt, "--no-session-persistence", "--dangerously-skip-permissions", "--exclude-dynamic-system-prompt-sections", diff --git a/src/storied/cli.py b/src/storied/cli.py index 055f935..9855b5b 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -25,11 +25,11 @@ SLASH_COMMANDS = { "/context": "Show token usage", "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", "/note": ( - "Add a note to your character sheet " - "(e.g. /note remember the prayer words)" + "Add a note to your character sheet (e.g. /note remember the prayer words)" ), } + def _format_character_display( player_id: str, full: bool, @@ -274,9 +274,7 @@ def _run_engine_loop( list(engine.stream_action(save_msg)) except Exception: pass - console.print( - "[yellow]Session saved. Farewell, adventurer![/yellow]" - ) + console.print("[yellow]Session saved. Farewell, adventurer![/yellow]") try: while True: @@ -316,8 +314,7 @@ def _run_engine_loop( else: context_k = stats["context_total"] / 1000 usage_str = ( - f"~{context_k:.1f}k/{limit_k:.0f}k " - "system tokens (estimated)" + f"~{context_k:.1f}k/{limit_k:.0f}k system tokens (estimated)" ) console.print( f"[bold]Context Usage[/bold] [dim]({game_time})[/dim]" @@ -352,9 +349,7 @@ def _run_engine_loop( if key.startswith("Entity:") or key.startswith("Linked:"): label = key.split(":", 1)[1] color = ( - "magenta" - if key.startswith("Entity:") - else "dark_magenta" + "magenta" if key.startswith("Entity:") else "dark_magenta" ) else: label = key @@ -402,13 +397,10 @@ def _run_engine_loop( for name, tokens, color in legend_items: tokens_str = ( - f"~{tokens:,}" - if tokens < 1_000 - else f"~{tokens / 1_000:.1f}k" + f"~{tokens:,}" if tokens < 1_000 else f"~{tokens / 1_000:.1f}k" ) console.print( - f" [{color}]█[/{color}] {name}: " - f"[dim]{tokens_str}[/dim]" + f" [{color}]█[/{color}] {name}: [dim]{tokens_str}[/dim]" ) if stats["total_input"] > 0: @@ -431,9 +423,7 @@ def _run_engine_loop( if cache_write: breakdown_parts.append(f"{cache_write:,} cache-write") breakdown = ( - f" ({' · '.join(breakdown_parts)})" - if breakdown_parts - else "" + f" ({' · '.join(breakdown_parts)})" if breakdown_parts else "" ) console.print( f" [dim]Last turn: {last_in} in" @@ -450,7 +440,8 @@ def _run_engine_loop( if action.strip().lower() in ("/status", "/me"): is_full = action.strip().lower() == "/me" formatted = _format_character_display( - player_id, full=is_full, + player_id, + full=is_full, ) if formatted: console.print() @@ -473,6 +464,7 @@ def _run_engine_loop( # Handle /save command if action.strip().lower() == "/save": from storied.session import load_session + console.print() game_time = engine.get_current_time() session = load_session(player_id) @@ -506,6 +498,7 @@ def _run_engine_loop( console.print("[dim]Usage: /note [/dim]") continue from storied.character import add_note as char_add_note + time_anchor = engine._campaign_log.get_current_time().to_anchor() char_add_note( player_id, @@ -513,9 +506,7 @@ def _run_engine_loop( time_anchor=time_anchor, ) console.print() - console.print( - f"[dim]Noted ({time_anchor}): {note_msg}[/dim]" - ) + console.print(f"[dim]Noted ({time_anchor}): {note_msg}[/dim]") continue try: @@ -594,9 +585,7 @@ def _run_engine_loop( if engine.session_ended: if is_onboarding: - console.print( - "[green]Onboarding complete.[/green]" - ) + console.print("[green]Onboarding complete.[/green]") else: console.print( "[yellow]Session saved. Farewell, adventurer![/yellow]" @@ -629,6 +618,7 @@ def cmd_play(args: argparse.Namespace) -> int: from storied.character import load_character from storied.engine import DMEngine + history_file = Path.home() / ".storied_history" with contextlib.suppress(FileNotFoundError): readline.read_history_file(history_file) @@ -668,14 +658,13 @@ def cmd_play(args: argparse.Namespace) -> int: user_rules_home=Path.home() / ".storied" / "rules", ) else: - configure( - data_home=resolve_data_home(getattr(args, "base_path", None)) - ) + configure(data_home=resolve_data_home(getattr(args, "base_path", None))) data_home().mkdir(parents=True, exist_ok=True) # Export STORIED_HOME so any subprocess that re-enters storied # (e.g. via run_code) sees the same data directory. import os + os.environ["STORIED_HOME"] = str(data_home()) from storied.paths import world_path @@ -694,13 +683,15 @@ def cmd_play(args: argparse.Namespace) -> int: # DM calls end_session, we fall through to the normal seed + # play path in the same invocation. if cold_start: - console.print(Panel.fit( - "[bold]Welcome to Storied![/bold]\n" - "Let's figure out the kind of adventure you want\n" - "and build your character.", - title="New Campaign", - border_style="yellow", - )) + console.print( + Panel.fit( + "[bold]Welcome to Storied![/bold]\n" + "Let's figure out the kind of adventure you want\n" + "and build your character.", + title="New Campaign", + border_style="yellow", + ) + ) console.print(f"[dim]World: {world_id}[/dim]") console.print() @@ -742,35 +733,41 @@ def cmd_play(args: argparse.Namespace) -> int: missing.append("world style") if missing: console.print() - console.print(Panel.fit( - f"[yellow]Onboarding incomplete — missing: " - f"{', '.join(missing)}.[/yellow]\n" - f"Run [cyan]storied play[/cyan] again to continue.", - border_style="yellow", - )) + console.print( + Panel.fit( + f"[yellow]Onboarding incomplete — missing: " + f"{', '.join(missing)}.[/yellow]\n" + f"Run [cyan]storied play[/cyan] again to continue.", + border_style="yellow", + ) + ) return 0 # Welcome panel for continuing play (skipped when we just # finished onboarding — the transition into seeding + play # speaks for itself). if sandbox: - console.print(Panel.fit( - "[bold]Storied Sandbox[/bold]\n" - "No character, no world — just you and the DM.\n" - "Type [cyan]Ctrl+D[/cyan] to quit.", - title="Sandbox", - border_style="cyan", - )) + console.print( + Panel.fit( + "[bold]Storied Sandbox[/bold]\n" + "No character, no world — just you and the DM.\n" + "Type [cyan]Ctrl+D[/cyan] to quit.", + title="Sandbox", + border_style="cyan", + ) + ) console.print(f"[dim]World: {world_id} (sandbox)[/dim]") console.print() elif not cold_start: - console.print(Panel.fit( - "[bold]Welcome to Storied![/bold]\n" - "Let the DM know when you're ready to quit.\n" - "Type [cyan]/context[/cyan] to see token usage.", - title="Storied", - border_style="green", - )) + console.print( + Panel.fit( + "[bold]Welcome to Storied![/bold]\n" + "Let the DM know when you're ready to quit.\n" + "Type [cyan]/context[/cyan] to see token usage.", + title="Storied", + border_style="green", + ) + ) console.print(f"[dim]World: {world_id}[/dim]") console.print() @@ -789,9 +786,7 @@ def cmd_play(args: argparse.Namespace) -> int: if character is not None and not arc_path.exists(): from storied.planner import plot_arc - console.print( - "[dim]Plotting the shape of your story...[/dim]" - ) + console.print("[dim]Plotting the shape of your story...[/dim]") def on_arc_progress(msg: str) -> None: console.print(f"[dim] {msg}[/dim]") @@ -888,7 +883,6 @@ def cmd_play(args: argparse.Namespace) -> int: return 0 - def cmd_index_srd(args: argparse.Namespace) -> int: """Build search index for SRD content.""" from storied.search import VectorIndex @@ -1057,11 +1051,13 @@ def build_parser() -> argparse.ArgumentParser: help="URL to download from", ) download_parser.add_argument( - "--output", "-o", + "--output", + "-o", help="Output path (default: rules/sources/SRD_CC_v5.2.1.pdf)", ) download_parser.add_argument( - "--force", "-f", + "--force", + "-f", action="store_true", help="Download even if file exists", ) @@ -1076,7 +1072,8 @@ def build_parser() -> argparse.ArgumentParser: help="Path to PDF (default: rules/sources/SRD_CC_v5.2.1.pdf)", ) convert_parser.add_argument( - "--output", "-o", + "--output", + "-o", help="Output path (default: rules/srd-5.2.1/srd.md)", ) convert_parser.set_defaults(func=cmd_srd_convert) @@ -1086,11 +1083,13 @@ def build_parser() -> argparse.ArgumentParser: "split", help="Split SRD markdown into sections" ) split_parser.add_argument( - "--input", "-i", + "--input", + "-i", help="Input markdown file (default: rules/srd-5.2.1/srd.md)", ) split_parser.add_argument( - "--output", "-o", + "--output", + "-o", help="Output directory (default: rules/srd-5.2.1/sections)", ) split_parser.set_defaults(func=cmd_srd_split) @@ -1100,7 +1099,8 @@ def build_parser() -> argparse.ArgumentParser: "clean", help="Clean up extracted markdown files" ) clean_parser.add_argument( - "--dir", "-d", + "--dir", + "-d", help="Sections directory (default: rules/srd-5.2.1/sections)", ) clean_parser.set_defaults(func=cmd_srd_clean) @@ -1108,20 +1108,24 @@ def build_parser() -> argparse.ArgumentParser: # play command play_parser = subparsers.add_parser("play", help="Start an interactive DM session") play_parser.add_argument( - "--world", "-w", + "--world", + "-w", help="World ID to use for world-specific content", ) play_parser.add_argument( - "--debug", "-d", + "--debug", + "-d", action="store_true", help="Show token usage after each response", ) play_parser.add_argument( - "--transcript", "-t", + "--transcript", + "-t", help="Path to write full debug transcript (JSONL format)", ) play_parser.add_argument( - "--sandbox", "-s", + "--sandbox", + "-s", action="store_true", help="Throwaway session — no character, no world state", ) @@ -1138,15 +1142,18 @@ def build_parser() -> argparse.ArgumentParser: # reset command reset_parser = subparsers.add_parser("reset", help="Reset player and world state") reset_parser.add_argument( - "--world", "-w", + "--world", + "-w", help="World ID to reset (default: default)", ) reset_parser.add_argument( - "--player", "-p", + "--player", + "-p", help="Player ID to reset (default: default)", ) reset_parser.add_argument( - "--force", "-f", + "--force", + "-f", action="store_true", help="Skip confirmation prompt", ) @@ -1154,8 +1161,7 @@ def build_parser() -> argparse.ArgumentParser: "--base-path", type=Path, help=( - "Where worlds and players live (defaults to $STORIED_HOME or " - "~/.storied/)" + "Where worlds and players live (defaults to $STORIED_HOME or ~/.storied/)" ), ) reset_parser.set_defaults(func=cmd_reset) @@ -1165,22 +1171,26 @@ def build_parser() -> argparse.ArgumentParser: "seed", help="Seed an empty world from a character sheet" ) seed_parser.add_argument( - "--world", "-w", + "--world", + "-w", default="default", help="World ID (default: default)", ) seed_parser.add_argument( - "--player", "-p", + "--player", + "-p", default="default", help="Player ID (default: default)", ) seed_parser.add_argument( - "--model", "-m", - default="claude-opus-4-6", - help="Model to use for seeding (default: claude-opus-4-6)", + "--model", + "-m", + default="claude-opus-4-7", + help="Model to use for seeding (default: claude-opus-4-7)", ) seed_parser.add_argument( - "--force", "-f", + "--force", + "-f", action="store_true", help="Re-seed even if a session already exists", ) @@ -1200,9 +1210,7 @@ def build_parser() -> argparse.ArgumentParser: index_world_parser = index_subparsers.add_parser( "world", help="Build search index for a world (includes SRD)" ) - index_world_parser.add_argument( - "--world", "-w", help="World ID (default: default)" - ) + index_world_parser.add_argument("--world", "-w", help="World ID (default: default)") index_world_parser.set_defaults(func=cmd_index_world) index_status_parser = index_subparsers.add_parser( @@ -1234,8 +1242,10 @@ def main(argv: list[str] | None = None) -> int: parser.print_help() return 0 - if args.command in ("srd", "index") and not getattr( - args, f"{args.command}_command", None + if ( + args.command in ("srd", "index") + and not getattr(args, f"{args.command}_command", None) + and parser._subparsers is not None ): for action in parser._subparsers._actions: if isinstance(action, argparse._SubParsersAction): diff --git a/src/storied/content.py b/src/storied/content.py index b7ed1c0..744505b 100644 --- a/src/storied/content.py +++ b/src/storied/content.py @@ -1,4 +1,7 @@ -"""Content layer resolution — finds and loads content across world / user / rules layers.""" +"""Content layer resolution. + +Finds and loads content across world / user / rules layers. +""" import re from pathlib import Path @@ -16,8 +19,9 @@ class ContentResolver: — campaign-specific overrides and narrative content. 2. **User layer** (flat): ``/{content_type}/*.md`` — your personal homebrew, applies across every campaign. - 3. **Shipped layer** (nested): ``/{rules_system}/sections/{content_type}/*.md`` - — the stock 5e SRD bundled with the repo. + 3. **Shipped layer** (nested): + ``/{rules_system}/sections/{content_type}/*.md`` — + the stock 5e SRD bundled with the repo. Narrative content (npcs, locations, factions, threads, lore, maps) only exists at the world layer; layers 2 and 3 miss and the lookup @@ -46,14 +50,10 @@ class ContentResolver: if self.world_id: roots.append(paths.world_path(self.world_id)) roots.append(paths.user_rules_path()) - roots.append( - paths.shipped_rules_path() / self.rules_system / "sections" - ) + roots.append(paths.shipped_rules_path() / self.rules_system / "sections") return roots - def _search_dirs( - self, content_type: str | None - ) -> list[tuple[Path, str]]: + def _search_dirs(self, content_type: str | None) -> list[tuple[Path, str]]: """Directories to search for content files, paired with their content type label. Walks the three layers in priority order. """ diff --git a/src/storied/display.py b/src/storied/display.py index d7dd424..2e0b0de 100644 --- a/src/storied/display.py +++ b/src/storied/display.py @@ -6,7 +6,6 @@ Rich Panels. No Rich Live context, no redraws, perfect scrollback. """ import re -from io import TextIOBase from rich import box as rich_box from rich.align import Align @@ -14,19 +13,29 @@ from rich.console import Console from rich.panel import Panel from rich.rule import Rule -BLOCK_TYPES = ("map", "aside", "item", "scroll", "letter", "sign", "lore", "verse", "dream") +BLOCK_TYPES = ( + "map", + "aside", + "item", + "scroll", + "letter", + "sign", + "lore", + "verse", + "dream", +) # (border_style, box, content_style) BLOCK_STYLES: dict[str, tuple[str, rich_box.Box, str]] = { - "map": ("green", rich_box.HEAVY, ""), - "aside": ("yellow", rich_box.ROUNDED, ""), - "item": ("magenta", rich_box.DOUBLE, ""), - "scroll": ("dark_goldenrod", rich_box.DOUBLE, "navajo_white1 on grey7"), - "letter": ("grey50", rich_box.ROUNDED, ""), - "sign": ("bright_white", rich_box.HEAVY, "bold"), - "lore": ("cornflower_blue", rich_box.ROUNDED, ""), - "verse": ("pale_turquoise1", rich_box.ROUNDED, "italic"), - "dream": ("medium_purple1", rich_box.ROUNDED, "dim italic"), + "map": ("green", rich_box.HEAVY, ""), + "aside": ("yellow", rich_box.ROUNDED, ""), + "item": ("magenta", rich_box.DOUBLE, ""), + "scroll": ("dark_goldenrod", rich_box.DOUBLE, "navajo_white1 on grey7"), + "letter": ("grey50", rich_box.ROUNDED, ""), + "sign": ("bright_white", rich_box.HEAVY, "bold"), + "lore": ("cornflower_blue", rich_box.ROUNDED, ""), + "verse": ("pale_turquoise1", rich_box.ROUNDED, "italic"), + "dream": ("medium_purple1", rich_box.ROUNDED, "dim italic"), } _FENCE_RE = re.compile(r"^```(" + "|".join(BLOCK_TYPES) + r")\s*(.*)") @@ -52,7 +61,8 @@ RESET = "\033[0m" def make_panel(kind: str, title: str, content: str) -> Panel: """Create a styled Panel for a display block.""" border_style, border_box, content_style = BLOCK_STYLES.get( - kind, ("white", rich_box.ROUNDED, ""), + kind, + ("white", rich_box.ROUNDED, ""), ) kwargs: dict = dict( title=title or None, @@ -82,7 +92,7 @@ class StreamRenderer: def __init__(self, console: Console) -> None: self._console = console - self._out: TextIOBase = console.file + self._out = console.file # Inline markdown state self._bold = False @@ -145,6 +155,7 @@ class StreamRenderer: # ── Block mode ─────────────────────────────────────────────────── def _feed_block(self, char: str) -> None: + assert self._block is not None # caller guarantees we're in block mode if char == "\n": if self._block_line.strip() == "```": self._close_block() @@ -155,6 +166,7 @@ class StreamRenderer: self._block_line += char def _close_block(self) -> None: + assert self._block is not None # caller guarantees we're in block mode kind = self._block["kind"] title = self._block["title"] content = "\n".join(self._block["lines"]) diff --git a/src/storied/engine.py b/src/storied/engine.py index b8b3c50..73a08a7 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -72,7 +72,7 @@ class DMEngine: self, world_id: str = "default", player_id: str = "default", - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", prompt_name: str = "dm-system", transcript_path: Path | None = None, ): @@ -208,6 +208,7 @@ class DMEngine: # compose time; the engine reveals it for the duration of any # turn where `advancement_ready` is set on the sheet. from storied.tools.character import refresh_advancement_visibility + refresh_advancement_visibility(character) # 2. Campaign log @@ -303,6 +304,7 @@ class DMEngine: # Terminal width so the DM can size display blocks import os + try: term_width = os.get_terminal_size().columns except OSError: @@ -316,9 +318,7 @@ class DMEngine: if not self.world_id: return None - knowledge_dir = ( - player_path(self.player_id) / "worlds" / self.world_id - ) + knowledge_dir = player_path(self.player_id) / "worlds" / self.world_id if not knowledge_dir.exists(): return None @@ -449,9 +449,7 @@ class DMEngine: context_breakdown[name] = self._estimate_tokens(content) context_total = ( - base_prompt_tokens - + tool_surface_tokens - + sum(context_breakdown.values()) + base_prompt_tokens + tool_surface_tokens + sum(context_breakdown.values()) ) # Usage from the last result event. The Anthropic API splits @@ -541,8 +539,7 @@ class DMEngine: self.combat_ended = True is_deferred = ( - short in ("roll", "run_code") - or short in DEFERRED_FORMATTERS + short in ("roll", "run_code") or short in DEFERRED_FORMATTERS ) if is_deferred and not self.debug: # Signal the CLI to flush the renderer before we @@ -601,10 +598,13 @@ class DMEngine: ) self._total_input_tokens += real_in self._total_output_tokens += r.usage.get("output_tokens", 0) - self._log_transcript("result", { - "session_id": r.session_id, - "usage": r.usage, - }) + self._log_transcript( + "result", + { + "session_id": r.session_id, + "usage": r.usage, + }, + ) # Write conversation turn to transcript dm_response = "".join(dm_text_parts) @@ -618,9 +618,13 @@ class DMEngine: self._mcp.ctx.vector_index.upsert( f"transcript:transcripts/day+{game_time.day:03d}.md:0", day_path.read_text(), - {"source": "transcript", "content_type": "transcripts", - "path": str(day_path), "title": f"Day {game_time.day}", - "game_day": game_time.day}, + { + "source": "transcript", + "content_type": "transcripts", + "path": str(day_path), + "title": f"Day {game_time.day}", + "game_day": game_time.day, + }, ) def reset(self) -> None: diff --git a/src/storied/initiative.py b/src/storied/initiative.py index a5d02d1..f38eefb 100644 --- a/src/storied/initiative.py +++ b/src/storied/initiative.py @@ -75,7 +75,7 @@ class InitiativeTracker: self.active = True first = self.combatants[0] - lines = [f"Initiative started — Round 1", ""] + lines = ["Initiative started — Round 1", ""] lines.append(self._format_order()) lines.append("") lines.append(f"**{first.name}** goes first.") @@ -175,7 +175,10 @@ class InitiativeTracker: return f"Combatant '{target}' not found." tc = TrackedCondition( - name=condition, source=source, duration=duration, ends_on=ends_on, + name=condition, + source=source, + duration=duration, + ends_on=ends_on, ) combatant.conditions.append(tc) @@ -212,8 +215,7 @@ class InitiativeTracker: self.current_index += 1 return ( - f"{combatant.name} joins initiative " - f"(initiative {combatant.initiative})" + f"{combatant.name} joins initiative (initiative {combatant.initiative})" ) def remove_combatant(self, name: str) -> str: @@ -233,16 +235,16 @@ class InitiativeTracker: if not self.combatants: self.active = False return ( - f"{removed.name} removed. No combatants remain — " - f"initiative ended." + f"{removed.name} removed. No combatants remain — initiative ended." ) if idx < self.current_index: self.current_index -= 1 - elif idx == self.current_index: - if self.current_index >= len(self.combatants): - self.current_index = 0 - self.round += 1 + elif idx == self.current_index and self.current_index >= len( + self.combatants + ): + self.current_index = 0 + self.round += 1 return f"{removed.name} removed from initiative." @@ -255,8 +257,7 @@ class InitiativeTracker: duration_sec = rounds * 6 lines = [ - f"Initiative ended after {rounds} rounds " - f"({duration_sec} seconds)." + f"Initiative ended after {rounds} rounds ({duration_sec} seconds)." ] if defeated: @@ -301,9 +302,7 @@ class InitiativeTracker: if c.defeated: name = f"~~{c.name}~~" hp_str = f"{c.hp}/{c.hp_max}" - conds = ", ".join( - self._format_condition(co) for co in c.conditions - ) + conds = ", ".join(self._format_condition(co) for co in c.conditions) if c.defeated and not conds: conds = "Defeated" lines.append( @@ -397,5 +396,7 @@ class InitiativeTracker: def _format_order(self) -> str: parts = [] for c in self.combatants: - parts.append(f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})") + parts.append( + f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})" + ) return "\n".join(parts) diff --git a/src/storied/log.py b/src/storied/log.py index 596857c..c9f1794 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -460,7 +460,7 @@ class CampaignLog: if last_idx == -1: return all_entries - return all_entries[last_idx + 1:] + return all_entries[last_idx + 1 :] def find_tag_entries(self, tag: str) -> list[LogEntry]: """Find all entries with a given tag, across all days.""" @@ -524,7 +524,10 @@ class TranscriptLog: return self.transcript_dir / f"day{day:04d}.md" def append_turn( - self, player_input: str, dm_response: str, game_time: "GameTime", + self, + player_input: str, + dm_response: str, + game_time: GameTime, ) -> None: """Append a turn to the current day's transcript.""" # Skip system messages (session starting, save requests, etc.) @@ -537,17 +540,15 @@ class TranscriptLog: # Blockquote the player input quoted = "\n".join(f"> {line}" for line in player_input.splitlines()) - turn = ( - f"\n### {game_time}\n\n" - f"{quoted}\n\n" - f"{dm_response.strip()}\n" - ) + turn = f"\n### {game_time}\n\n{quoted}\n\n{dm_response.strip()}\n" with path.open("a") as f: f.write(turn) def recent_turns( - self, current_day: int, n: int = 10, + self, + current_day: int, + n: int = 10, ) -> str: """Load the last N turns across recent days for context injection.""" turns: list[str] = [] diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py index fd2cbe0..081b2df 100644 --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -120,7 +120,9 @@ def _populate_index( # by the engine after each turn with source="transcript". if world_dir.exists(): vi.reindex_directory( - world_dir, source="world", skip_subdirs=frozenset({"transcripts"}), + world_dir, + source="world", + skip_subdirs=frozenset({"transcripts"}), ) @@ -169,6 +171,7 @@ async def _compose_server(role: str) -> FastMCP: global _tool_signatures if _tool_signatures is None: from storied.sandbox import build_tool_signatures + _tool_signatures = build_tool_signatures() for tool in await server.list_tools(): if tool.description and "{tool_signatures}" in tool.description: diff --git a/src/storied/names/data/inventories.py b/src/storied/names/data/inventories.py index 5b7cbe0..4779cdf 100644 --- a/src/storied/names/data/inventories.py +++ b/src/storied/names/data/inventories.py @@ -32,8 +32,25 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="welsh", consonants=[ - "n", "r", "l", "d", "s", "h", "g", "m", "k", - "th", "dh", "rh", "ll", "f", "p", "t", "b", "ch", "w", + "n", + "r", + "l", + "d", + "s", + "h", + "g", + "m", + "k", + "th", + "dh", + "rh", + "ll", + "f", + "p", + "t", + "b", + "ch", + "w", ], vowels=["a", "e", "i", "o", "u", "y", "ae", "wy", "aw"], liquids=["l", "r", "ll", "rh"], @@ -46,8 +63,23 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="gaelic", consonants=[ - "n", "r", "l", "s", "d", "t", "g", "b", "m", "k", - "ch", "mh", "bh", "f", "p", "h", "th", + "n", + "r", + "l", + "s", + "d", + "t", + "g", + "b", + "m", + "k", + "ch", + "mh", + "bh", + "f", + "p", + "h", + "th", ], vowels=["a", "i", "e", "o", "u", "ai", "ei", "io", "ua"], liquids=["l", "r"], @@ -60,8 +92,22 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="cornish", consonants=[ - "n", "r", "l", "d", "s", "t", "k", "g", "m", "p", - "h", "w", "th", "gh", "f", "v", + "n", + "r", + "l", + "d", + "s", + "t", + "k", + "g", + "m", + "p", + "h", + "w", + "th", + "gh", + "f", + "v", ], vowels=["a", "e", "i", "o", "u", "y", "oe", "eu"], liquids=["l", "r"], @@ -70,14 +116,27 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"coastal", "pastoral", "ancient"}, ), - # ----- Germanic family ---------------------------------------------- ( PhonemeInventory( name="old-norse", consonants=[ - "r", "n", "s", "t", "k", "l", "d", "g", "m", "h", - "th", "v", "f", "p", "b", "j", + "r", + "n", + "s", + "t", + "k", + "l", + "d", + "g", + "m", + "h", + "th", + "v", + "f", + "p", + "b", + "j", ], vowels=["a", "i", "e", "u", "o", "ø", "y", "ei", "au"], liquids=["r", "l"], @@ -90,8 +149,22 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="old-english", consonants=[ - "n", "r", "s", "t", "l", "d", "h", "w", "k", "g", - "m", "th", "f", "b", "p", "sh", + "n", + "r", + "s", + "t", + "l", + "d", + "h", + "w", + "k", + "g", + "m", + "th", + "f", + "b", + "p", + "sh", ], vowels=["a", "e", "i", "o", "u", "y", "ae", "ea", "eo"], liquids=["r", "l"], @@ -104,8 +177,22 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="germanic-soot", consonants=[ - "r", "n", "s", "t", "k", "l", "h", "g", "d", "m", - "f", "p", "b", "ch", "z", "sh", + "r", + "n", + "s", + "t", + "k", + "l", + "h", + "g", + "d", + "m", + "f", + "p", + "b", + "ch", + "z", + "sh", ], vowels=["a", "e", "i", "o", "u", "ä", "ö", "ü"], liquids=["r", "l"], @@ -114,14 +201,26 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"industrial", "highborn"}, ), - # ----- Romance / Latin-derived -------------------------------------- ( PhonemeInventory( name="latin-clerical", consonants=[ - "s", "n", "r", "t", "l", "i", "k", "m", "d", "p", - "g", "b", "f", "v", "h", + "s", + "n", + "r", + "t", + "l", + "i", + "k", + "m", + "d", + "p", + "g", + "b", + "f", + "v", + "h", ], vowels=["a", "e", "i", "o", "u", "ae", "oe", "au"], liquids=["l", "r"], @@ -134,8 +233,22 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="iberian", consonants=[ - "r", "n", "s", "l", "t", "d", "k", "m", "g", "p", - "ch", "ñ", "ll", "j", "f", "rr", + "r", + "n", + "s", + "l", + "t", + "d", + "k", + "m", + "g", + "p", + "ch", + "ñ", + "ll", + "j", + "f", + "rr", ], vowels=["a", "e", "i", "o", "u"], liquids=["l", "r", "ll", "rr"], @@ -144,14 +257,27 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"coastal", "pastoral", "highborn"}, ), - # ----- Slavic family ------------------------------------------------ ( PhonemeInventory( name="slavic-east", consonants=[ - "n", "r", "s", "t", "l", "k", "d", "v", "m", "p", - "z", "g", "ch", "sh", "zh", "h", + "n", + "r", + "s", + "t", + "l", + "k", + "d", + "v", + "m", + "p", + "z", + "g", + "ch", + "sh", + "zh", + "h", ], vowels=["a", "o", "e", "i", "u", "y", "ya", "yu"], liquids=["l", "r"], @@ -160,14 +286,29 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"highland", "pastoral", "ancient"}, ), - # ----- Semitic / Levantine ------------------------------------------ ( PhonemeInventory( name="aramaic", consonants=[ - "l", "n", "r", "m", "s", "t", "k", "h", "d", "b", - "sh", "kh", "th", "q", "ts", "p", "z", "gh", + "l", + "n", + "r", + "m", + "s", + "t", + "k", + "h", + "d", + "b", + "sh", + "kh", + "th", + "q", + "ts", + "p", + "z", + "gh", ], vowels=["a", "i", "u", "e", "o"], liquids=["l", "r"], @@ -180,8 +321,24 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="arabic-port", consonants=[ - "l", "r", "n", "m", "s", "t", "k", "h", "d", "b", - "sh", "kh", "q", "j", "z", "f", "gh", "ts", + "l", + "r", + "n", + "m", + "s", + "t", + "k", + "h", + "d", + "b", + "sh", + "kh", + "q", + "j", + "z", + "f", + "gh", + "ts", ], vowels=["a", "i", "u", "aa", "ii", "uu"], liquids=["l", "r"], @@ -190,14 +347,27 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"coastal", "desert", "ancient"}, ), - # ----- Hellenic ----------------------------------------------------- ( PhonemeInventory( name="hellenic", consonants=[ - "s", "n", "r", "t", "l", "k", "p", "m", "d", "th", - "ph", "ch", "h", "g", "x", "ps", + "s", + "n", + "r", + "t", + "l", + "k", + "p", + "m", + "d", + "th", + "ph", + "ch", + "h", + "g", + "x", + "ps", ], vowels=["a", "o", "e", "i", "u", "ai", "oi", "eu"], liquids=["l", "r"], @@ -206,14 +376,24 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"coastal", "highborn", "liturgical", "ancient"}, ), - # ----- Uralic / Finnic ---------------------------------------------- ( PhonemeInventory( name="finnic", consonants=[ - "n", "l", "t", "s", "k", "r", "i", "m", "h", "p", - "v", "j", "y", + "n", + "l", + "t", + "s", + "k", + "r", + "i", + "m", + "h", + "p", + "v", + "j", + "y", ], vowels=["a", "i", "e", "o", "u", "ä", "ö", "y", "ai", "äi"], liquids=["l", "r"], @@ -222,14 +402,26 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"forest", "pastoral", "ancient"}, ), - # ----- Turkic / Steppe ---------------------------------------------- ( PhonemeInventory( name="turkic", consonants=[ - "n", "r", "l", "t", "k", "s", "m", "d", "y", "b", - "g", "sh", "ch", "z", "h", + "n", + "r", + "l", + "t", + "k", + "s", + "m", + "d", + "y", + "b", + "g", + "sh", + "ch", + "z", + "h", ], vowels=["a", "e", "i", "o", "u", "ı", "ö", "ü"], liquids=["l", "r"], @@ -242,8 +434,21 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="mongolic", consonants=[ - "n", "r", "l", "g", "t", "k", "s", "d", "m", "b", - "y", "kh", "ch", "j", "h", + "n", + "r", + "l", + "g", + "t", + "k", + "s", + "d", + "m", + "b", + "y", + "kh", + "ch", + "j", + "h", ], vowels=["a", "o", "u", "e", "i"], liquids=["l", "r"], @@ -252,14 +457,29 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"steppe", "ancient", "pastoral"}, ), - # ----- Caucasian --------------------------------------------------- ( PhonemeInventory( name="kartvelian", consonants=[ - "r", "n", "l", "t", "k", "s", "m", "d", "g", "b", - "ts", "ch", "kh", "ph", "q", "z", "v", "sh", + "r", + "n", + "l", + "t", + "k", + "s", + "m", + "d", + "g", + "b", + "ts", + "ch", + "kh", + "ph", + "q", + "z", + "v", + "sh", ], vowels=["a", "e", "i", "o", "u"], liquids=["l", "r"], @@ -268,7 +488,6 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"highland", "ancient", "highborn"}, ), - # ----- Pacific ----------------------------------------------------- ( PhonemeInventory( @@ -285,8 +504,21 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="austronesian", consonants=[ - "n", "t", "k", "l", "s", "m", "r", "p", "d", "g", - "ng", "h", "w", "y", "b", + "n", + "t", + "k", + "l", + "s", + "m", + "r", + "p", + "d", + "g", + "ng", + "h", + "w", + "y", + "b", ], vowels=["a", "i", "u", "e", "o"], liquids=["l", "r"], @@ -295,14 +527,27 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"coastal", "forest", "pastoral"}, ), - # ----- East Asian -------------------------------------------------- ( PhonemeInventory( name="japonic", consonants=[ - "n", "k", "t", "s", "r", "m", "h", "g", "d", "b", - "y", "w", "z", "p", "sh", "ch", + "n", + "k", + "t", + "s", + "r", + "m", + "h", + "g", + "d", + "b", + "y", + "w", + "z", + "p", + "sh", + "ch", ], vowels=["a", "i", "u", "e", "o"], liquids=["r"], @@ -311,14 +556,27 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"coastal", "highborn", "ancient"}, ), - # ----- South Asian ------------------------------------------------- ( PhonemeInventory( name="dravidian", consonants=[ - "n", "r", "t", "k", "l", "m", "p", "v", "y", "ch", - "th", "d", "g", "ng", "ny", "zh", + "n", + "r", + "t", + "k", + "l", + "m", + "p", + "v", + "y", + "ch", + "th", + "d", + "g", + "ng", + "ny", + "zh", ], vowels=["a", "i", "u", "e", "o", "aa", "ii", "uu"], liquids=["l", "r", "zh"], @@ -327,14 +585,27 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"forest", "ancient", "highborn"}, ), - # ----- Andean ------------------------------------------------------ ( PhonemeInventory( name="quechuan", consonants=[ - "n", "k", "t", "p", "s", "r", "m", "ch", "y", "w", - "l", "h", "ll", "q", "kh", "ph", + "n", + "k", + "t", + "p", + "s", + "r", + "m", + "ch", + "y", + "w", + "l", + "h", + "ll", + "q", + "kh", + "ph", ], vowels=["a", "i", "u"], liquids=["l", "r", "ll"], @@ -343,14 +614,26 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"highland", "ancient", "pastoral"}, ), - # ----- Mesoamerican ------------------------------------------------ ( PhonemeInventory( name="nahuatl", consonants=[ - "n", "t", "k", "l", "s", "ch", "m", "y", "w", "p", - "tz", "x", "h", "ts", "tl", + "n", + "t", + "k", + "l", + "s", + "ch", + "m", + "y", + "w", + "p", + "tz", + "x", + "h", + "ts", + "tl", ], vowels=["a", "i", "e", "o", "u"], liquids=["l"], @@ -359,14 +642,26 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ ), {"forest", "highland", "ancient"}, ), - # ----- West African ------------------------------------------------ ( PhonemeInventory( name="yoruboid", consonants=[ - "n", "l", "r", "k", "b", "t", "s", "m", "y", "g", - "p", "f", "j", "w", "sh", + "n", + "l", + "r", + "k", + "b", + "t", + "s", + "m", + "y", + "g", + "p", + "f", + "j", + "w", + "sh", ], vowels=["a", "e", "i", "o", "u", "ẹ", "ọ"], liquids=["l", "r"], @@ -379,8 +674,22 @@ INVENTORIES: list[tuple[PhonemeInventory, set[str]]] = [ PhonemeInventory( name="bantoid", consonants=[ - "n", "m", "l", "k", "t", "b", "s", "g", "p", "d", - "w", "y", "z", "v", "ng", "ny", + "n", + "m", + "l", + "k", + "t", + "b", + "s", + "g", + "p", + "d", + "w", + "y", + "z", + "v", + "ng", + "ny", ], vowels=["a", "i", "u", "e", "o"], liquids=["l"], diff --git a/src/storied/names/engine/clusters.py b/src/storied/names/engine/clusters.py index d6eeac3..a3d7cf2 100644 --- a/src/storied/names/engine/clusters.py +++ b/src/storied/names/engine/clusters.py @@ -37,12 +37,57 @@ def has_forbidden_cluster(word: str, forbidden: list[str]) -> bool: # enough to ensure each culture rejects a few combinations and # they vary across cultures. FORBIDDEN_BANK: list[str] = [ - "#sr", "#tl", "#sl", "#dl", "#tn", "#mn", "#km", "#nl", "#nr", - "#ml", "#mr", "#mlr", "#lr", "#rl", "#nz", "#kn", "#gn", - "tl#", "rl#", "lr#", "nm#", "mn#", "ml#", "mr#", "nl#", - "sshs", "tttt", "kkk", "rrrr", "llll", "ghgh", "khkh", - "tlt", "rlr", "lrl", "nmn", "mnm", "rnr", "lnl", - "qq", "xx", "zz", "ngng", "ththth", - "ghgh", "ghgh", "mlm", "mrm", "rlr", - "mlm", "mrm", "rlw", "rly", + "#sr", + "#tl", + "#sl", + "#dl", + "#tn", + "#mn", + "#km", + "#nl", + "#nr", + "#ml", + "#mr", + "#mlr", + "#lr", + "#rl", + "#nz", + "#kn", + "#gn", + "tl#", + "rl#", + "lr#", + "nm#", + "mn#", + "ml#", + "mr#", + "nl#", + "sshs", + "tttt", + "kkk", + "rrrr", + "llll", + "ghgh", + "khkh", + "tlt", + "rlr", + "lrl", + "nmn", + "mnm", + "rnr", + "lnl", + "qq", + "xx", + "zz", + "ngng", + "ththth", + "ghgh", + "ghgh", + "mlm", + "mrm", + "rlr", + "mlm", + "mrm", + "rlw", + "rly", ] diff --git a/src/storied/names/engine/generator.py b/src/storied/names/engine/generator.py index 4fdcb3b..26dc9a0 100644 --- a/src/storied/names/engine/generator.py +++ b/src/storied/names/engine/generator.py @@ -52,7 +52,10 @@ class Engine: """Sample a phoneme sequence that passes all the engine's checks.""" for _ in range(max_attempts): phonemes = sample_word( - self._parsed, self.inventory, rng, self.syllable_count, + self._parsed, + self.inventory, + rng, + self.syllable_count, ) if not phonemes: continue @@ -68,7 +71,10 @@ class Engine: return phonemes # Fallback: relax constraints rather than loop forever return sample_word( - self._parsed, self.inventory, rng, self.syllable_count, + self._parsed, + self.inventory, + rng, + self.syllable_count, ) def name(self, seed: int | None = None) -> str: diff --git a/src/storied/names/engine/inventory.py b/src/storied/names/engine/inventory.py index 5f73f46..f9ecaf1 100644 --- a/src/storied/names/engine/inventory.py +++ b/src/storied/names/engine/inventory.py @@ -80,10 +80,11 @@ class PhonemeInventory: from its source inventory — a Welsh-derived culture is not Welsh, it's a phonotactic cousin. """ + def _trim(items: list[str]) -> list[str]: if len(items) <= 3: return list(items) - drop_n = max(0, int(round(len(items) * drop_fraction))) + drop_n = max(0, round(len(items) * drop_fraction)) keep = list(items) for _ in range(drop_n): idx = rng.randrange(len(keep)) @@ -103,8 +104,24 @@ class PhonemeInventory: _LIQUID_HEURISTIC = {"l", "r", "ll", "rr", "rh", "lh", "ɾ", "ʎ", "ʟ"} _NASAL_HEURISTIC = {"m", "n", "ng", "ñ", "ŋ", "nh", "mh"} _FRICATIVE_HEURISTIC = { - "s", "z", "f", "v", "sh", "zh", "th", "dh", "h", "x", "ch", - "ʃ", "ʒ", "θ", "ð", "χ", "ɣ", "ħ", + "s", + "z", + "f", + "v", + "sh", + "zh", + "th", + "dh", + "h", + "x", + "ch", + "ʃ", + "ʒ", + "θ", + "ð", + "χ", + "ɣ", + "ħ", } diff --git a/src/storied/names/forge.py b/src/storied/names/forge.py index 62091e2..a297ce9 100644 --- a/src/storied/names/forge.py +++ b/src/storied/names/forge.py @@ -214,9 +214,7 @@ class CultureForge: rng = random.Random(seed) # Pick an inventory (biased by feel if given) - candidates = ( - inventories_for_feel(feel) if feel else all_inventories() - ) + candidates = inventories_for_feel(feel) if feel else all_inventories() source = rng.choice(candidates) # Trim it for distinctiveness, but only if it's big enough @@ -238,7 +236,8 @@ class CultureForge: # Pick 3-5 forbidden clusters from the bank n_forbidden = rng.randint(3, 5) forbidden = rng.sample( - FORBIDDEN_BANK, k=min(n_forbidden, len(FORBIDDEN_BANK)), + FORBIDDEN_BANK, + k=min(n_forbidden, len(FORBIDDEN_BANK)), ) # Pick 0-2 rewrite rules @@ -304,7 +303,9 @@ class CultureForge: return _ascii_slug(sub_engine.name(seed=seed)) def _generate_morphology( - self, engine: Engine, rng: random.Random, + self, + engine: Engine, + rng: random.Random, ) -> Morphology: """Generate gendered suffixes from the engine, or none.""" if rng.random() < 0.4: @@ -322,7 +323,10 @@ class CultureForge: ) def _suffixes( - self, engine: Engine, rng: random.Random, count: int, + self, + engine: Engine, + rng: random.Random, + count: int, ) -> list[str]: """Generate `count` short suffixes from the engine. @@ -349,7 +353,9 @@ class CultureForge: return sorted(out) def _generate_place_suffixes( - self, engine: Engine, rng: random.Random, + self, + engine: Engine, + rng: random.Random, ) -> list[str]: """Generate place-name suffixes from the engine.""" return self._suffixes(engine, rng, count=5) diff --git a/src/storied/names/generator.py b/src/storied/names/generator.py index adf2684..619949f 100644 --- a/src/storied/names/generator.py +++ b/src/storied/names/generator.py @@ -33,7 +33,8 @@ class Generator: def _forge(self) -> CultureForge: return CultureForge( - world_path=self.world_path, cultures_subdir=self.cultures_subdir, + world_path=self.world_path, + cultures_subdir=self.cultures_subdir, ) def load_culture(self, name: str) -> ForgedCulture | None: diff --git a/src/storied/notifications.py b/src/storied/notifications.py index 1fa4375..9ebeba2 100644 --- a/src/storied/notifications.py +++ b/src/storied/notifications.py @@ -12,7 +12,6 @@ import threading from storied.paths import world_path - _lock = threading.Lock() @@ -43,8 +42,4 @@ def drain(world_id: str) -> list[str]: # Clear the file path.unlink(missing_ok=True) - return [ - line.lstrip("- ").strip() - for line in content.splitlines() - if line.strip() - ] + return [line.lstrip("- ").strip() for line in content.splitlines() if line.strip()] diff --git a/src/storied/paths.py b/src/storied/paths.py index 186031f..383018a 100644 --- a/src/storied/paths.py +++ b/src/storied/paths.py @@ -32,10 +32,9 @@ startup so child processes resolve the same path. from __future__ import annotations import os +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Iterator - _DEFAULT_DATA_HOME = Path.home() / ".storied" diff --git a/src/storied/planner.py b/src/storied/planner.py index 64eb47b..9594f21 100644 --- a/src/storied/planner.py +++ b/src/storied/planner.py @@ -12,7 +12,6 @@ from storied.character import format_character_context, load_character from storied.claude import run_prompt, run_with_tools from storied.engine import load_prompt from storied.log import CampaignLog -from storied.tools._context import get_or_create_ctx from storied.mcp_server import start_server as start_mcp_server from storied.paths import data_home, world_path from storied.session import ( @@ -21,6 +20,7 @@ from storied.session import ( resolve_wiki_link, ) from storied.tools import EntityIndex, _load_entity +from storied.tools._context import get_or_create_ctx # Shared empty index for read-only entity parsing (no cache needed) _EMPTY_INDEX = EntityIndex() @@ -88,13 +88,14 @@ def find_nearby_entities( # Seed from current location location = session.get("location") - if location: + if isinstance(location, str): add_entity(location) # Seed from present entities in session body body = session.get("body", "") - for name in extract_wiki_links(body): - add_entity(name) + if isinstance(body, str): + for name in extract_wiki_links(body): + add_entity(name) # Follow links from all discovered entities (breadth-first, one pass) i = 0 @@ -234,8 +235,7 @@ def build_planning_context( "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " "Apply them as you decide what to add to each thin entity — " "they're lenses for finding less-obvious choices, not " - "things you must incorporate:\n\n" - + "\n".join(f"- {s}" for s in obliques) + "things you must incorporate:\n\n" + "\n".join(f"- {s}" for s in obliques) ) parts.append(oblique_text) @@ -292,7 +292,7 @@ class PlanResult: def plan_world( world_id: str = "default", player_id: str = "default", - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", threshold: float = 0.7, max_entities: int = 8, dry_run: bool = False, @@ -385,7 +385,7 @@ class SeedResult: def seed_world( world_id: str = "default", player_id: str = "default", - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", on_progress: Callable[[str], None] | None = None, ) -> SeedResult: """Build the initial world from a character sheet.""" @@ -416,9 +416,7 @@ def seed_world( "Weave at least three of these into the entities you build. " "They should appear as concrete details — an item an NPC " "carries, a feature of a location, the texture of a thread:" - "\n\n" - + "\n".join(f"- {c}" for c in concepts) - + "\n\n---\n\n" + "\n\n" + "\n".join(f"- {c}" for c in concepts) + "\n\n---\n\n" ) obliques = _draw_oblique_strategies(count=3) @@ -427,9 +425,7 @@ def seed_world( "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " "Apply them as you decide what to establish — they're lenses " "for finding less-obvious choices about what each entity is " - "and what it wants:\n\n" - + "\n".join(f"- {s}" for s in obliques) - + "\n\n---\n\n" + "and what it wants:\n\n" + "\n".join(f"- {s}" for s in obliques) + "\n\n---\n\n" ) context = ( @@ -471,7 +467,7 @@ def seed_world( def plot_arc( world_id: str = "default", player_id: str = "default", - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", on_progress: Callable[[str], None] | None = None, ) -> SeedResult: """Two-pass arc planning: cold draft → architect commit. @@ -506,7 +502,7 @@ def plot_arc( # ---- Pass A: cold draft (no tools, no anti-rut help) ---- progress("Drafting the default treatment (Pass A)...") cold_draft = run_prompt( - system_prompt=load_prompt("cold-draft"), + system_prompt=load_prompt("arc-cold-draft"), user_message=style_block + char_block, model=model, effort="max", @@ -550,11 +546,7 @@ def plot_arc( ) context = ( - style_block - + cold_draft_block - + concept_block - + oblique_block - + char_block + style_block + cold_draft_block + concept_block + oblique_block + char_block ) mcp = start_mcp_server(world_id, player_id, "arc_architect") @@ -668,7 +660,7 @@ def build_tick_context( def tick_world( # pragma: no cover world_id: str = "default", player_id: str = "default", - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", on_progress: Callable[[str], None] | None = None, ) -> TickResult: """Advance the world by evaluating Will triggers and adding small changes. @@ -738,7 +730,7 @@ class BackgroundTicker: self, world_id: str, player_id: str, - model: str = "claude-opus-4-6", + model: str = "claude-opus-4-7", ): self._world_id = world_id self._player_id = player_id diff --git a/src/storied/sandbox.py b/src/storied/sandbox.py index 4d9772e..35b570e 100644 --- a/src/storied/sandbox.py +++ b/src/storied/sandbox.py @@ -14,7 +14,7 @@ from collections.abc import Callable from typing import Any import pydantic_monty -from uncalled_for import Dependency, get_dependency_parameters, resolved_dependencies +from uncalled_for import get_dependency_parameters, resolved_dependencies from storied.dice import roll as dice_roll from storied.tools import character, combat, entities, mechanics, scene @@ -46,11 +46,14 @@ def _adapt(fn: Callable[..., Any]) -> Callable[..., Any]: resolved values back into the call. The sandbox passes its own kwargs for the LLM-visible params. """ + def wrapper(**kwargs: Any) -> Any: async def _run() -> Any: async with resolved_dependencies(fn, kwargs) as deps: return fn(**{**kwargs, **deps}) + return asyncio.run(_run()) + wrapper.__name__ = fn.__name__ wrapper.__doc__ = fn.__doc__ return wrapper @@ -73,7 +76,7 @@ def _enumerate_tools() -> list[tuple[str, Callable[..., Any]]]: if not hasattr(tool, "fn"): continue # skip non-function components (resources, prompts) seen.add(name) - pairs.append((name, tool.fn)) + pairs.append((name, tool.fn)) # pyright: ignore[reportAttributeAccessIssue] return pairs diff --git a/src/storied/search.py b/src/storied/search.py index d0e10d9..9bb1c87 100644 --- a/src/storied/search.py +++ b/src/storied/search.py @@ -12,7 +12,7 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -import pysqlite3 as sqlite3 +import pysqlite3 as sqlite3 # pyright: ignore[reportMissingTypeStubs] import sqlite_vec from fastembed import TextEmbedding @@ -22,11 +22,11 @@ CHUNK_CHAR_THRESHOLD = 1_500 # Splitting patterns, tried in order from coarsest to finest. _SPLIT_PATTERNS = [ - r"\n(?=## )", # ## headings - r"\n(?=### )", # ### headings - r"\n(?=#### )", # #### headings (class features, combat sub-topics) - r"\n(?=\*\*[A-Z])", # bold definitions (glossary entries, level features) - r"\n\n", # paragraph breaks (table rows, final resort) + r"\n(?=## )", # ## headings + r"\n(?=### )", # ### headings + r"\n(?=#### )", # #### headings (class features, combat sub-topics) + r"\n(?=\*\*[A-Z])", # bold definitions (glossary entries, level features) + r"\n\n", # paragraph breaks (table rows, final resort) ] @@ -53,7 +53,9 @@ def age_decay(current_day: int, doc_day: int, half_life: int = 3) -> float: def _split_oversized( - sections: list[str], threshold: int, patterns: list[str], + sections: list[str], + threshold: int, + patterns: list[str], ) -> list[str]: """Recursively split sections that exceed threshold using finer patterns.""" if not patterns: @@ -73,7 +75,9 @@ def _split_oversized( else: result.extend( _split_oversized( - [p for p in parts if p.strip()], threshold, remaining, + [p for p in parts if p.strip()], + threshold, + remaining, ) ) @@ -99,7 +103,9 @@ def chunk_document(path: Path, content: str) -> list[tuple[int, str]]: return [(0, content)] raw_sections = _split_oversized( - [content], CHUNK_CHAR_THRESHOLD, _SPLIT_PATTERNS, + [content], + CHUNK_CHAR_THRESHOLD, + _SPLIT_PATTERNS, ) chunks: list[tuple[int, str]] = [] @@ -130,9 +136,9 @@ def _default_embed(texts: list[str]) -> list[list[float]]: return [vec.tolist() for vec in _default_embed._model.embed(texts)] # type: ignore[attr-defined] -def _connect(db_path: str) -> sqlite3.Connection: +def _connect(db_path: str) -> sqlite3.Connection: # pyright: ignore[reportAttributeAccessIssue] """Open a SQLite connection with sqlite-vec loaded.""" - conn = sqlite3.connect(db_path, check_same_thread=False) + conn = sqlite3.connect(db_path, check_same_thread=False) # pyright: ignore[reportAttributeAccessIssue] conn.enable_load_extension(True) sqlite_vec.load(conn) conn.enable_load_extension(False) @@ -195,7 +201,7 @@ class VectorIndex: with self._lock: self._conn.close() - def _open_or_recreate(self) -> sqlite3.Connection: + def _open_or_recreate(self) -> sqlite3.Connection: # pyright: ignore[reportAttributeAccessIssue] """Open the database, recreating if corrupt.""" self._db_path.parent.mkdir(parents=True, exist_ok=True) try: @@ -207,7 +213,7 @@ class VectorIndex: self._db_path.unlink() return self._create_fresh() - def _create_fresh(self) -> sqlite3.Connection: + def _create_fresh(self) -> sqlite3.Connection: # pyright: ignore[reportAttributeAccessIssue] """Create a new database with the required schema.""" self._db_path.parent.mkdir(parents=True, exist_ok=True) conn = _connect(str(self._db_path)) @@ -244,12 +250,8 @@ class VectorIndex: preview = text[:200].strip() with self._lock: - self._conn.execute( - "DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,) - ) - self._conn.execute( - "DELETE FROM documents WHERE doc_id = ?", (doc_id,) - ) + self._conn.execute("DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,)) + self._conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,)) self._conn.execute( """INSERT INTO documents @@ -277,12 +279,8 @@ class VectorIndex: def delete(self, doc_id: str) -> None: """Remove a document and its embedding.""" with self._lock: - self._conn.execute( - "DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,) - ) - self._conn.execute( - "DELETE FROM documents WHERE doc_id = ?", (doc_id,) - ) + self._conn.execute("DELETE FROM vec_documents WHERE doc_id = ?", (doc_id,)) + self._conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,)) self._conn.commit() def search( @@ -350,14 +348,16 @@ class VectorIndex: ): score *= age_decay(decay_ref, game_day) - hits.append(SearchHit( - doc_id=doc_id, - path=Path(path), - score=score, - snippet=preview or "", - source=source, - content_type=ctype or "", - )) + hits.append( + SearchHit( + doc_id=doc_id, + path=Path(path), + score=score, + snippet=preview or "", + source=source, + content_type=ctype or "", + ) + ) hits.sort(key=lambda h: h.score, reverse=True) return hits[:limit] @@ -409,9 +409,7 @@ class VectorIndex: title_match = re.match(r"^#\s+(.+)", content) title = ( - title_match.group(1).strip() - if title_match - else md_file.stem + title_match.group(1).strip() if title_match else md_file.stem ) game_day = None @@ -419,15 +417,19 @@ class VectorIndex: if day_match: game_day = int(day_match.group(1)) - self.upsert(doc_id, chunk_text, { - "source": source, - "content_type": content_type, - "path": str(md_file), - "title": title, - "chunk_index": chunk_idx, - "game_day": game_day, - "updated_at": mtime, - }) + self.upsert( + doc_id, + chunk_text, + { + "source": source, + "content_type": content_type, + "path": str(md_file), + "title": title, + "chunk_index": chunk_idx, + "game_day": game_day, + "updated_at": mtime, + }, + ) count += 1 stale = set(existing.keys()) - seen_doc_ids @@ -439,9 +441,7 @@ class VectorIndex: def stats(self) -> dict: """Return index statistics.""" with self._lock: - total = self._conn.execute( - "SELECT count(*) FROM documents" - ).fetchone()[0] + total = self._conn.execute("SELECT count(*) FROM documents").fetchone()[0] by_source: dict[str, int] = {} for source, cnt in self._conn.execute( diff --git a/src/storied/session.py b/src/storied/session.py index ad6da99..c40c88e 100644 --- a/src/storied/session.py +++ b/src/storied/session.py @@ -75,10 +75,7 @@ def save_session(player_id: str, data: dict) -> None: data["body"] = body -def update_session( - player_id: str, - updates: dict -) -> str: +def update_session(player_id: str, updates: dict) -> str: """Update specific fields in the session state. Args: @@ -163,15 +160,18 @@ def extract_wiki_links(text: str) -> list[str]: # Priority order for wikilink resolution ENTITY_TYPES = [ - "npcs", "locations", "items", "factions", "threads", "lore", "maps", + "npcs", + "locations", + "items", + "factions", + "threads", + "lore", + "maps", "cultures", ] -def resolve_wiki_link( - name: str, - world_id: str -) -> Path | None: +def resolve_wiki_link(name: str, world_id: str) -> Path | None: """Resolve a wikilink name to a file path. Searches entity directories in priority order and returns the first match. @@ -195,10 +195,7 @@ def resolve_wiki_link( return None -def load_entity_content( - name: str, - world_id: str -) -> dict | None: +def load_entity_content(name: str, world_id: str) -> dict | None: """Load an entity's content by resolving its wikilink. Args: diff --git a/src/storied/srd/extract.py b/src/storied/srd/extract.py index 58578ed..adfd167 100644 --- a/src/storied/srd/extract.py +++ b/src/storied/srd/extract.py @@ -24,7 +24,9 @@ def extract_to_markdown(pdf_path: Path, pages: list[int] | None = None) -> str: Returns: Markdown text of the document """ - return pymupdf4llm.to_markdown(str(pdf_path), pages=pages) + result = pymupdf4llm.to_markdown(str(pdf_path), pages=pages) + assert isinstance(result, str) + return result def extract_pages(pdf_path: Path) -> list[PageContent]: diff --git a/src/storied/srd/split.py b/src/storied/srd/split.py index 2ef5e72..f3ef49a 100644 --- a/src/storied/srd/split.py +++ b/src/storied/srd/split.py @@ -139,7 +139,8 @@ def split_srd(input_path: Path, output_dir: Path) -> list[Path]: elif section.slug == "magic-items": written.extend(split_magic_items(section, section_dir)) else: - # Other item sections (classes, feats, etc.) - write subsections as files + # Other item sections (classes, feats, etc.): + # write subsections as files for subsection in section.subsections: file_path = section_dir / f"{subsection.slug}.md" content = format_section(subsection) @@ -197,10 +198,7 @@ def split_spells(section: Section, output_dir: Path) -> list[Path]: spell_content = section.content # Split on spell headers: **Spell Name** followed by newline and italicized level - spell_pattern = re.compile( - r"^\*\*([A-Z][^*]+)\*\*\s*\n_([^_]+)_", - re.MULTILINE - ) + spell_pattern = re.compile(r"^\*\*([A-Z][^*]+)\*\*\s*\n_([^_]+)_", re.MULTILINE) # Find all spell starts matches = list(spell_pattern.finditer(spell_content)) @@ -214,10 +212,10 @@ def split_spells(section: Section, output_dir: Path) -> list[Path]: slug = slugify(spell_name) file_path = output_dir / f"{slug}.md" - # Format with proper header - formatted = f"# {spell_name}\n\n{spell_text[len(match.group(0)):].strip()}\n" - # Add the level line back - formatted = f"# {spell_name}\n\n_{match.group(2).strip()}_\n\n{spell_text[len(match.group(0)):].strip()}\n" + # Format with proper header and level line + body = spell_text[len(match.group(0)) :].strip() + level_line = match.group(2).strip() + formatted = f"# {spell_name}\n\n_{level_line}_\n\n{body}\n" file_path.write_text(formatted) written.append(file_path) diff --git a/src/storied/testing.py b/src/storied/testing.py index 5ffa78f..9f6ab40 100644 --- a/src/storied/testing.py +++ b/src/storied/testing.py @@ -27,7 +27,9 @@ def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: direct calls leave the Dependency instances as parameter defaults rather than resolving them. """ + async def _run() -> Any: async with resolved_dependencies(fn, kwargs) as deps: return fn(**{**kwargs, **deps}) + return asyncio.run(_run()) diff --git a/src/storied/tools/_context.py b/src/storied/tools/_context.py index 195bfd0..9b7abea 100644 --- a/src/storied/tools/_context.py +++ b/src/storied/tools/_context.py @@ -11,7 +11,6 @@ from storied.initiative import InitiativeTracker from storied.log import CampaignLog from storied.search import VectorIndex - # Per-file locks for thread-safe entity writes (establish, mark) _file_locks: dict[Path, threading.Lock] = {} _file_locks_lock = threading.Lock() @@ -191,6 +190,7 @@ def _require() -> ToolContext: class World(Dependency[str]): """The current world ID.""" + single = True async def __aenter__(self) -> str: @@ -199,6 +199,7 @@ class World(Dependency[str]): class Player(Dependency[str]): """The current player ID.""" + single = True async def __aenter__(self) -> str: @@ -207,6 +208,7 @@ class Player(Dependency[str]): class Timekeeper(Dependency[CampaignLog]): """The campaign log: game time, world events, recent history.""" + single = True async def __aenter__(self) -> CampaignLog: @@ -215,6 +217,7 @@ class Timekeeper(Dependency[CampaignLog]): class Entities(Dependency[EntityIndex]): """The entity name→path index with parsed-entity cache.""" + single = True async def __aenter__(self) -> EntityIndex: @@ -223,6 +226,7 @@ class Entities(Dependency[EntityIndex]): class Lore(Dependency[VectorIndex]): """The semantic search index over rules + world content.""" + single = True async def __aenter__(self) -> VectorIndex: @@ -231,6 +235,7 @@ class Lore(Dependency[VectorIndex]): class Combat(Dependency[InitiativeTracker]): """The active initiative tracker.""" + single = True async def __aenter__(self) -> InitiativeTracker: diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index 6d4b8f9..886f3a2 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -1,14 +1,15 @@ +# pyright: reportArgumentType=false +# DI markers like `world: str = World()` look like type errors to pyright +# but FastMCP resolves them to the typed value at call time. """Character management tools — bookkeeping primitives for the DM.""" from __future__ import annotations -from pathlib import Path from typing import Literal from fastmcp import FastMCP from pydantic import JsonValue -from storied.character.schema import Abilities, CoinDelta, Purse from storied.character import ( add_condition as char_add_condition, ) @@ -60,6 +61,7 @@ from storied.character import ( from storied.character import ( update_character as char_update, ) +from storied.character.schema import Abilities, CoinDelta, Purse from storied.initiative import InitiativeTracker from storied.log import CampaignLog from storied.tools._context import ( @@ -206,7 +208,7 @@ def create_character( purse=purse.model_dump() if purse else None, subclass=subclass, backstory=backstory, - ) + ) # --- HP operations (unified — work in or out of combat) --- @@ -354,7 +356,7 @@ def add_effect( old one first. Args: - source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") + source: Where the effect comes from (e.g., "Potion of Heroism"). description: What the effect does in narrative + mechanical terms expires: Optional game time anchor when the effect ends (e.g., "d28-1430") concentration: Metadata flag for concentration-bound effects. Defaults to False. @@ -363,8 +365,11 @@ def add_effect( Confirmation """ return char_add_effect( - player, source, description, - expires=expires, concentration=concentration, + player, + source, + description, + expires=expires, + concentration=concentration, ) @@ -579,7 +584,7 @@ def level_up( hp_gain=hp_gain, features=features, time_anchor=time_anchor, - ) + ) # --- Notes --- diff --git a/src/storied/tools/combat.py b/src/storied/tools/combat.py index 7c5d753..5d0e799 100644 --- a/src/storied/tools/combat.py +++ b/src/storied/tools/combat.py @@ -1,3 +1,7 @@ +# pyright: reportArgumentType=false +# DI markers like `combat: InitiativeTracker = Combat()` look like type +# errors to pyright but FastMCP resolves them to the typed value at call +# time. """FastMCP combat tool surface — initiative tools that flip the visibility of `combat`-tagged tools on the composed top-level server. @@ -35,6 +39,7 @@ class CombatantInput(BaseModel): description="True for the player character; false for NPCs and monsters", ) + # The composed top-level server and the set of combat tool keys to hide # when leaving combat — both registered at start_server() time so the # enter/end_initiative tools can flip combat tag visibility on the parent. @@ -109,7 +114,10 @@ def enter_initiative( @mcp.tool(tags={"dm", "combat"}) def next_turn(combat: InitiativeTracker = Combat()) -> str: - """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" + """Advance to the next combatant's turn. + + Skips defeated. Call after resolving actions. + """ return combat.next_turn() @@ -125,8 +133,12 @@ def add_combatant( ) -> str: """Add a combatant (reinforcements, surprised creatures waking up).""" c = Combatant( - name=name, initiative=initiative, hp=hp, - hp_max=hp_max, ac=ac, is_player=is_player, + name=name, + initiative=initiative, + hp=hp, + hp_max=hp_max, + ac=ac, + is_player=is_player, ) return combat.add_combatant(c) @@ -165,14 +177,20 @@ def condition( if action == "remove": return combat.remove_condition(target, condition) return combat.add_condition( - target=target, condition=condition, - duration=duration, ends_on=ends_on, source=source, + target=target, + condition=condition, + duration=duration, + ends_on=ends_on, + source=source, ) @mcp.tool(tags={"dm", "combat", "combat_control"}) def end_initiative(combat: InitiativeTracker = Combat()) -> str: - """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" + """End initiative and return to narrative. + + Returns a summary with rounds, defeated, and survivor HP. + """ result = combat.end() _flip_out_of_combat() return result diff --git a/src/storied/tools/entities.py b/src/storied/tools/entities.py index cf73412..ebec2df 100644 --- a/src/storied/tools/entities.py +++ b/src/storied/tools/entities.py @@ -1,3 +1,6 @@ +# pyright: reportArgumentType=false +# DI markers like `world: str = World()` look like type errors to pyright +# but FastMCP resolves them to the typed value at call time. """World entity tools — establish, mark, note_discovery.""" import re @@ -25,11 +28,23 @@ from storied.tools._context import ( # Entity-type enums exposed to the LLM via JSON Schema. Each tool's set is # slightly different — only the kinds that make sense for that operation. EstablishType = Literal[ - "npcs", "locations", "items", "factions", "threads", "lore", "maps", + "npcs", + "locations", + "items", + "factions", + "threads", + "lore", + "maps", "cultures", ] MarkType = Literal[ - "npcs", "locations", "items", "factions", "threads", "maps", "cultures", + "npcs", + "locations", + "items", + "factions", + "threads", + "maps", + "cultures", ] DiscoveryType = Literal["npcs", "locations", "factions", "lore", "cultures"] @@ -68,19 +83,25 @@ def _load_entity(file_path: Path, entity_index: EntityIndex) -> dict: section_re = r"### {label}\n\n?(.*?)(?=\n### |\n## |\Z)" knows_match = re.search( - section_re.format(label="Knows"), is_content, re.DOTALL, + section_re.format(label="Knows"), + is_content, + re.DOTALL, ) if knows_match: result["knows"] = _parse_list_items(knows_match.group(1)) wants_match = re.search( - section_re.format(label="Wants"), is_content, re.DOTALL, + section_re.format(label="Wants"), + is_content, + re.DOTALL, ) if wants_match: result["wants"] = _parse_list_items(wants_match.group(1)) will_match = re.search( - section_re.format(label="Will"), is_content, re.DOTALL, + section_re.format(label="Will"), + is_content, + re.DOTALL, ) if will_match: result["will"] = _parse_list_items(will_match.group(1)) @@ -164,8 +185,13 @@ def _write_entity( ) -> None: """Write an entity to disk and update all indexes.""" file_content = _format_entity( - name, data["description"], data["location"], - data["knows"], data["wants"], data["will"], data["was"], + name, + data["description"], + data["location"], + data["knows"], + data["wants"], + data["will"], + data["was"], ) file_path.write_text(file_content) entity_index.register(name, file_path) @@ -173,8 +199,12 @@ def _write_entity( lore.upsert( f"world:{entity_type}/{name}.md:0", file_content, - {"source": "world", "content_type": entity_type, - "path": str(file_path), "title": name}, + { + "source": "world", + "content_type": entity_type, + "path": str(file_path), + "title": name, + }, ) @@ -213,8 +243,12 @@ def _do_establish( was = existing.get("was", []) data = { - "description": description, "location": location, - "knows": knows, "wants": wants, "will": will, "was": was, + "description": description, + "location": location, + "knows": knows, + "wants": wants, + "will": will, + "was": was, } _write_entity(file_path, name, entity_type, data, entity_index, lore) @@ -231,6 +265,7 @@ def _normalize_anchor(when: str) -> str: dropping it. """ from storied.log import GameTime + try: return GameTime.from_anchor(when).to_anchor() except ValueError: @@ -329,7 +364,8 @@ def _last_was_anchor(entity: dict) -> str | None: def _minutes_since( - anchor: str, now_hhmm_days: tuple[int, int, int], + anchor: str, + now_hhmm_days: tuple[int, int, int], ) -> int | None: """Minutes from `anchor` to the given (day, hour, minute) tuple. @@ -338,6 +374,7 @@ def _minutes_since( want the cooldown to fire). """ from storied.log import GameTime + try: then = GameTime.from_anchor(anchor) except ValueError: @@ -404,8 +441,14 @@ def _auto_mark_present( entity_type = file_path.parent.name _do_mark( - entity_type, name, event, None, - world_id, entity_index, lore, timekeeper, + entity_type, + name, + event, + None, + world_id, + entity_index, + lore, + timekeeper, ) marked.append(name) @@ -470,8 +513,16 @@ def establish( ) return _do_establish( - entity_type, name, description, location, knows, wants, will, - world, entities, lore, + entity_type, + name, + description, + location, + knows, + wants, + will, + world, + entities, + lore, ) @@ -512,8 +563,15 @@ def mark( Confirmation message """ return _do_mark( - entity_type, name, event, resolves, - world, entities, lore, timekeeper, when=when, + entity_type, + name, + event, + resolves, + world, + entities, + lore, + timekeeper, + when=when, ) @@ -634,8 +692,12 @@ def note_discovery( lore.upsert( f"player:{content_type}/{slug}.md:0", file_content, - {"source": "player", "content_type": content_type, - "path": str(file_path), "title": entity}, + { + "source": "player", + "content_type": content_type, + "path": str(file_path), + "title": entity, + }, ) return f"Noted: player learned about '{entity}'" diff --git a/src/storied/tools/mechanics.py b/src/storied/tools/mechanics.py index 956a965..e4ec0ef 100644 --- a/src/storied/tools/mechanics.py +++ b/src/storied/tools/mechanics.py @@ -1,3 +1,6 @@ +# pyright: reportArgumentType=false +# DI markers like `world: str = World()` look like type errors to pyright +# but FastMCP resolves them to the typed value at call time. """Dice and rules-lookup tools.""" from pathlib import Path @@ -84,7 +87,9 @@ def recall( current_day = timekeeper.get_current_time().day hits = lore.search( - query, limit=5, source_filter=source_filter, + query, + limit=5, + source_filter=source_filter, decay_ref=current_day, ) if hits: diff --git a/src/storied/tools/names.py b/src/storied/tools/names.py index e2eee0d..fdf034b 100644 --- a/src/storied/tools/names.py +++ b/src/storied/tools/names.py @@ -1,3 +1,6 @@ +# pyright: reportArgumentType=false +# DI markers like `world: str = World()` look like type errors to pyright +# but FastMCP resolves them to the typed value at call time. """MCP tool adapter for storied.names — fantasy name generation. This is the ONLY file allowed to import from `storied.names`. It @@ -88,7 +91,7 @@ def forge_culture( description = ( f"A freshly-forged culture awaiting placement in the world" f"{feel_text}. Sample names: {sample_str}. Use " - f"`generate_names(culture=\"{culture.name}\", ...)` to draw more " + f'`generate_names(culture="{culture.name}", ...)` to draw more ' f"names. Flesh out this entity with `establish` to give the " f"culture its place in the story." ) diff --git a/src/storied/tools/run_code.py b/src/storied/tools/run_code.py index 556224e..ebb855d 100644 --- a/src/storied/tools/run_code.py +++ b/src/storied/tools/run_code.py @@ -31,4 +31,5 @@ def run_code(description: str, code: str) -> str: code: Python code to execute """ from storied.sandbox import execute as sandbox_execute + return sandbox_execute(code) diff --git a/src/storied/tools/scene.py b/src/storied/tools/scene.py index 0cc9bb7..7151b5a 100644 --- a/src/storied/tools/scene.py +++ b/src/storied/tools/scene.py @@ -1,3 +1,6 @@ +# pyright: reportArgumentType=false +# DI markers like `world: str = World()` look like type errors to pyright +# but FastMCP resolves them to the typed value at call time. """Scene management, session, style tuning, and DM notification tools.""" from fastmcp import FastMCP @@ -82,7 +85,12 @@ def set_scene( if event and present: marked = _auto_mark_present( - present, event, world, entities, lore, timekeeper, + present, + event, + world, + entities, + lore, + timekeeper, ) if marked: parts.append(f"Auto-marked: {', '.join(marked)}") diff --git a/tests/conftest.py b/tests/conftest.py index e5125a2..e0d9c57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,21 +21,28 @@ storied code touches paths, there's no window where a stale default matters. """ +import asyncio import hashlib -from collections.abc import Iterator +from collections.abc import Callable, Iterator from pathlib import Path +from typing import Any import pytest +from fastmcp import Client from storied import paths +from storied.initiative import Combatant from storied.log import CampaignLog +from storied.mcp_server import _compose_server from storied.search import VectorIndex from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx +from storied.tools.combat import _flip_into_combat, _flip_out_of_combat @pytest.fixture(autouse=True) def _isolate_storied_paths( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> Iterator[Path]: """Point storied's data home and user rules at ``tmp_path``. @@ -101,3 +108,62 @@ def ctx(tmp_path: Path) -> Iterator[ToolContext]: yield context finally: reset_ctx() + + +# --- MCP in-memory client helpers ------------------------------------------- +# +# Tests that exercise the FastMCP tool surface drive the same call path the +# production server uses (claude → MCP → tool function), but in-process via +# fastmcp.Client. These fixtures hide the asyncio.run + Client(server) +# scaffolding so individual tests stay flat. + + +McpCall = Callable[[str, dict[str, Any] | None], str] +McpCallInCombat = Callable[[str, dict[str, Any], list[Combatant] | None], str] + + +@pytest.fixture +def mcp_call() -> McpCall: + """Sync helper: call a tool on the composed dm server, return its text.""" + + def _call(tool_name: str, args: dict[str, Any] | None = None) -> str: + async def _run() -> str: + server = await _compose_server("dm") + async with Client(server) as client: + result = await client.call_tool(tool_name, args or {}) + return result.data if result.data is not None else "" + + return asyncio.run(_run()) + + return _call + + +@pytest.fixture +def mcp_call_in_combat(ctx: ToolContext) -> McpCallInCombat: + """Sync helper: begin initiative, flip combat tools visible, call a tool. + + Pass ``combatants`` to start a fresh initiative; pass ``None`` when the + test has already begun initiative (or wired up state) on its own. + """ + + def _call( + tool_name: str, + args: dict[str, Any], + combatants: list[Combatant] | None = None, + ) -> str: + if combatants is not None: + ctx.initiative.begin(combatants) + + async def _run() -> str: + server = await _compose_server("dm") + _flip_into_combat() + try: + async with Client(server) as client: + result = await client.call_tool(tool_name, args) + return result.data if result.data is not None else "" + finally: + _flip_out_of_combat() + + return asyncio.run(_run()) + + return _call diff --git a/tests/test_advancement.py b/tests/test_advancement.py index 5c639ab..5d88d2d 100644 --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -6,20 +6,19 @@ from unittest.mock import MagicMock, patch import pytest -from storied.character import load_character, save_character -from storied.log import CampaignLog from storied.advancement import ( AdvancementResult, BackgroundAdvancement, build_advancement_context, evaluate_advancement, ) +from storied.character import save_character +from storied.log import CampaignLog from storied.session import save_session +from storied.testing import call_tool from storied.tools import ToolContext from storied.tools.scene import notify_dm as _notify_dm -from storied.testing import call_tool - def notify_dm(message: str, ctx: ToolContext) -> str: """Test shim: drop legacy `ctx` arg and resolve Dependency params.""" @@ -102,17 +101,11 @@ class TestNotifyDM: class TestBuildAdvancementContext: def test_returns_none_without_character(self, ctx: ToolContext, tmp_path: Path): - result = build_advancement_context( - ctx.world_id, ctx.player_id - ) + result = build_advancement_context(ctx.world_id, ctx.player_id) assert result is None - def test_includes_character_info( - self, ctx: ToolContext, character: dict - ): - context = build_advancement_context( - ctx.world_id, ctx.player_id - ) + def test_includes_character_info(self, ctx: ToolContext, character: dict): + context = build_advancement_context(ctx.world_id, ctx.player_id) assert context is not None assert "Kira" in context assert "Rogue" in context @@ -124,50 +117,38 @@ class TestBuildAdvancementContext: character: dict, campaign_with_events: CampaignLog, ): - context = build_advancement_context( - ctx.world_id, ctx.player_id - ) + context = build_advancement_context(ctx.world_id, ctx.player_id) assert context is not None assert "warehouse" in context assert "smuggling ring" in context - def test_includes_entries_since_level_tag( - self, ctx: ToolContext, character: dict - ): + def test_includes_entries_since_level_tag(self, ctx: ToolContext, character: dict): log = ctx.campaign_log log.append_entry("Old event before level-up", "1 hour") log.append_entry("Leveled up to 3", "5 min", tags=["level"]) log.append_entry("New adventure begins", "30 min") log.append_entry("Fought a dragon", "5 rounds", tags=["combat"]) - context = build_advancement_context( - ctx.world_id, ctx.player_id - ) + context = build_advancement_context(ctx.world_id, ctx.player_id) assert context is not None assert "Old event before level-up" not in context assert "New adventure begins" in context assert "Fought a dragon" in context - def test_includes_advancement_history( - self, ctx: ToolContext, character: dict - ): + def test_includes_advancement_history(self, ctx: ToolContext, character: dict): log = ctx.campaign_log log.append_entry("Reached level 2", "5 min", tags=["level"]) log.append_entry("Adventured more", "2 hours") log.append_entry("Reached level 3", "5 min", tags=["level"]) log.append_entry("Recent events", "1 hour") - context = build_advancement_context( - ctx.world_id, ctx.player_id - ) + context = build_advancement_context(ctx.world_id, ctx.player_id) assert context is not None assert "Advancement History" in context assert "Reached level 2" in context assert "Reached level 3" in context - def test_includes_session_state( - self, ctx: ToolContext, character: dict - ): + def test_includes_session_state(self, ctx: ToolContext, character: dict): save_session( "default", { @@ -176,9 +157,7 @@ class TestBuildAdvancementContext: }, ) - context = build_advancement_context( - ctx.world_id, ctx.player_id - ) + context = build_advancement_context(ctx.world_id, ctx.player_id) assert context is not None assert "missing merchant" in context @@ -187,9 +166,7 @@ class TestBuildAdvancementContext: class TestLogTagMethods: - def test_get_entries_since_tag_returns_all_when_no_tag( - self, ctx: ToolContext - ): + def test_get_entries_since_tag_returns_all_when_no_tag(self, ctx: ToolContext): log = ctx.campaign_log log.append_entry("Event one", "10 min") log.append_entry("Event two", "10 min") @@ -197,9 +174,7 @@ class TestLogTagMethods: entries = log.get_entries_since_tag("level") assert len(entries) == 2 - def test_get_entries_since_tag_returns_after_last_tag( - self, ctx: ToolContext - ): + def test_get_entries_since_tag_returns_after_last_tag(self, ctx: ToolContext): log = ctx.campaign_log log.append_entry("Before level", "10 min") log.append_entry("Level up!", "5 min", tags=["level"]) @@ -209,9 +184,7 @@ class TestLogTagMethods: assert len(entries) == 1 assert entries[0].event == "After level" - def test_get_entries_since_tag_uses_last_occurrence( - self, ctx: ToolContext - ): + def test_get_entries_since_tag_uses_last_occurrence(self, ctx: ToolContext): log = ctx.campaign_log log.append_entry("First level", "5 min", tags=["level"]) log.append_entry("Between levels", "1 hour") @@ -265,7 +238,10 @@ class TestEvaluateAdvancement: assert result.evaluated is False def test_posts_reminder_when_advancement_pending( - self, ctx: ToolContext, character: dict, tmp_path: Path, + self, + ctx: ToolContext, + character: dict, + tmp_path: Path, ): character["advancement_ready"] = 4 save_character("default", character) @@ -276,9 +252,7 @@ class TestEvaluateAdvancement: ) assert result.evaluated is False - path = ( - tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" - ) + path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" assert path.exists() contents = path.read_text() assert "Kira" in contents @@ -292,12 +266,14 @@ class TestEvaluateAdvancement: character: dict, campaign_with_events: CampaignLog, ): - result_line = json.dumps({ - "type": "result", - "session_id": "sess-adv", - "usage": {"input_tokens": 500, "output_tokens": 100}, - "duration_ms": 2000, - }) + result_line = json.dumps( + { + "type": "result", + "session_id": "sess-adv", + "usage": {"input_tokens": 500, "output_tokens": 100}, + "duration_ms": 2000, + } + ) mock_proc = MagicMock() mock_proc.stdin = MagicMock() mock_proc.stdout = iter([result_line.encode() + b"\n"]) @@ -364,7 +340,8 @@ class TestBackgroundAdvancement: def test_pop_result_returns_none_when_no_thread(self): adv = BackgroundAdvancement( - world_id="test", player_id="default", + world_id="test", + player_id="default", ) assert adv.pop_result() is None @@ -388,10 +365,12 @@ class TestBackgroundAdvancement: def test_maybe_evaluate_skips_when_already_running(self): adv = BackgroundAdvancement( - world_id="test", player_id="default", + world_id="test", + player_id="default", ) # Stub a fake "still running" thread on the instance from unittest.mock import MagicMock + fake_thread = MagicMock() fake_thread.is_alive.return_value = True adv._thread = fake_thread diff --git a/tests/test_arc_planner.py b/tests/test_arc_planner.py index 1b3442d..5cb2092 100644 --- a/tests/test_arc_planner.py +++ b/tests/test_arc_planner.py @@ -37,14 +37,21 @@ class TestLoadConceptPools: assert pools == {"Custom": ["alpha", "beta", "gamma"]} def test_returns_empty_when_neither_exists( - self, tmp_path: Path, monkeypatch, + self, + tmp_path: Path, + monkeypatch, ): monkeypatch.setattr( - paths, "user_rules_path", lambda: tmp_path / "missing-user", + paths, + "user_rules_path", + lambda: tmp_path / "missing-user", ) from storied import planner + monkeypatch.setattr( - planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", + planner, + "_REPO_PROMPTS", + tmp_path / "missing-shipped", ) pools = _load_concept_pools() @@ -99,20 +106,23 @@ class TestPickRandomConcepts: def test_count_exceeding_categories_returns_all(self, tmp_path: Path): user_dir = paths.user_rules_path() user_dir.mkdir(parents=True, exist_ok=True) - (user_dir / "concept_pools.md").write_text( - "## A\n- a1\n\n## B\n- b1\n" - ) + (user_dir / "concept_pools.md").write_text("## A\n- a1\n\n## B\n- b1\n") picks = _pick_random_concepts(count=10) assert len(picks) == 2 def test_empty_pools_returns_empty(self, tmp_path: Path, monkeypatch): monkeypatch.setattr( - paths, "user_rules_path", lambda: tmp_path / "missing-user", + paths, + "user_rules_path", + lambda: tmp_path / "missing-user", ) from storied import planner + monkeypatch.setattr( - planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", + planner, + "_REPO_PROMPTS", + tmp_path / "missing-shipped", ) assert _pick_random_concepts(count=8) == [] @@ -146,20 +156,23 @@ class TestDrawObliqueStrategies: def test_count_exceeding_deck_returns_all(self, tmp_path: Path): user_dir = paths.user_rules_path() user_dir.mkdir(parents=True, exist_ok=True) - (user_dir / "oblique_strategies.md").write_text( - "- only\n- two\n" - ) + (user_dir / "oblique_strategies.md").write_text("- only\n- two\n") drawn = _draw_oblique_strategies(count=10) assert len(drawn) == 2 def test_empty_deck_returns_empty(self, tmp_path: Path, monkeypatch): monkeypatch.setattr( - paths, "user_rules_path", lambda: tmp_path / "missing-user", + paths, + "user_rules_path", + lambda: tmp_path / "missing-user", ) from storied import planner + monkeypatch.setattr( - planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", + planner, + "_REPO_PROMPTS", + tmp_path / "missing-shipped", ) assert _draw_oblique_strategies(count=4) == [] @@ -224,8 +237,12 @@ def character_world(tmp_path: Path) -> Path: char_class="Ranger", level=1, abilities={ - "strength": 10, "dexterity": 16, "constitution": 12, - "intelligence": 14, "wisdom": 13, "charisma": 14, + "strength": 10, + "dexterity": 16, + "constitution": 12, + "intelligence": 14, + "wisdom": 13, + "charisma": 14, }, hp_max=11, ac=14, @@ -260,7 +277,7 @@ class TestPlotArc: # Pass A: cold draft via run_prompt mock_run_prompt.assert_called_once() assert mock_run_prompt.call_args.kwargs["effort"] == "max" - assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-6" + assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-7" # Pass B: architect via run_with_tools mock_run_with_tools.assert_called_once() @@ -341,7 +358,9 @@ class TestPlotArc: @patch("storied.planner.run_prompt") def test_no_character_returns_empty_result( - self, mock_run_prompt: MagicMock, tmp_path: Path, + self, + mock_run_prompt: MagicMock, + tmp_path: Path, ): result = plot_arc(world_id="default", player_id="default") assert result.tool_calls == 0 @@ -412,16 +431,20 @@ class TestPlannerInspirationContext: """ def test_planner_context_includes_obliques( - self, character_world: Path, + self, + character_world: Path, ): from storied.planner import build_planning_context from storied.session import save_session - save_session("default", { - "location": "Tavern", - "world": "default", - "body": "## Situation\nSeren is at the bar.", - }) + save_session( + "default", + { + "location": "Tavern", + "world": "default", + "body": "## Situation\nSeren is at the bar.", + }, + ) context = build_planning_context( world_id="default", @@ -433,16 +456,20 @@ class TestPlannerInspirationContext: assert "Oblique Strategies" in context def test_planner_context_does_not_include_concept_seeds( - self, character_world: Path, + self, + character_world: Path, ): from storied.planner import build_planning_context from storied.session import save_session - save_session("default", { - "location": "Tavern", - "world": "default", - "body": "## Situation\nSeren is at the bar.", - }) + save_session( + "default", + { + "location": "Tavern", + "world": "default", + "body": "## Situation\nSeren is at the bar.", + }, + ) context = build_planning_context( world_id="default", diff --git a/tests/test_character.py b/tests/test_character.py index e505708..2c2b8d8 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -1,3 +1,7 @@ +# pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false +# pyright: reportArgumentType=false, reportOperatorIssue=false, reportReturnType=false +# Tests immediately subscript dicts returned by load_character() without +# the assert-not-None dance — the test setup guarantees the file exists. """Tests for the character system: data, computation, display, and operations.""" from pathlib import Path @@ -40,7 +44,6 @@ from storied.character import ( update_character, ) - # --- Fixtures --- @@ -90,7 +93,9 @@ def mira(player_dir: Path) -> dict: "test-player", { "resources.hit_dice_d8": { - "current": 3, "max": 3, "refresh": "long_rest", + "current": 3, + "max": 3, + "refresh": "long_rest", "notes": "Hit Dice (d8)", }, }, @@ -112,8 +117,14 @@ class TestDataLayer: race="Human", char_class="Barbarian", level=1, - abilities={"strength": 18, "dexterity": 14, "constitution": 16, - "intelligence": 8, "wisdom": 10, "charisma": 12}, + abilities={ + "strength": 18, + "dexterity": 14, + "constitution": 16, + "intelligence": 8, + "wisdom": 10, + "charisma": 12, + }, hp_max=15, ac=14, ) @@ -140,8 +151,14 @@ class TestDataLayer: race="Half-Elf", char_class="Bard", level=1, - abilities={"strength": 8, "dexterity": 14, "constitution": 12, - "intelligence": 13, "wisdom": 10, "charisma": 16}, + abilities={ + "strength": 8, + "dexterity": 14, + "constitution": 12, + "intelligence": 13, + "wisdom": 10, + "charisma": 16, + }, hp_max=9, ac=12, backstory="A wandering minstrel with secrets.", @@ -157,30 +174,22 @@ class TestUpdateCharacter: assert data["state"]["ac"] == 17 def test_update_nested_via_dot(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.hp.max": 30} - ) + update_character("test-player", {"state.hp.max": 30}) data = load_character("test-player") assert data["state"]["hp"]["max"] == 30 def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.hp.current": -5} - ) + update_character("test-player", {"state.hp.current": -5}) data = load_character("test-player") assert data["state"]["hp"]["current"] == 0 def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.hp.current": 100} - ) + update_character("test-player", {"state.hp.current": 100}) data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_negative_coins_clamped(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.purse.sp": -20} - ) + update_character("test-player", {"state.purse.sp": -20}) data = load_character("test-player") assert data["state"]["purse"]["sp"] == 0 @@ -218,7 +227,7 @@ class TestSchemaValidation: result = update_character( "test-player", {"state.hp": 24}, # missing required fields - ) + ) assert "rejected" in result.lower() # Original HP block is preserved data = load_character("test-player") @@ -228,10 +237,14 @@ class TestSchemaValidation: def test_valid_resources_update_succeeds(self, mira: dict, player_dir: Path): result = update_character( "test-player", - {"resources.channel_divinity": { - "current": 1, "max": 1, "refresh": "short_rest", - "notes": "Channel Divinity", - }}, + { + "resources.channel_divinity": { + "current": 1, + "max": 1, + "refresh": "short_rest", + "notes": "Channel Divinity", + } + }, ) assert "rejected" not in result.lower() data = load_character("test-player") @@ -253,20 +266,42 @@ class TestSchemaCoercion: def test_load_coerces_resources_list_to_dict(self, player_dir: Path): import yaml + # Hand-write a character with resources as a list (the bad shape) path = player_dir / "players" / "test-player" / "character.yaml" - path.write_text(yaml.dump({ - "identity": {"name": "Damaged", "classes": [{"class": "Cleric", "level": 3}]}, - "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, - "intelligence": 10, "wisdom": 14, "charisma": 10}, - "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, - "resources": [ - {"name": "Channel Divinity", "current": 1, "max": 1, - "refresh": "short_rest"}, - {"name": "Lay on Hands", "current": 15, "max": 15, - "refresh": "long_rest"}, - ], - })) + path.write_text( + yaml.dump( + { + "identity": { + "name": "Damaged", + "classes": [{"class": "Cleric", "level": 3}], + }, + "abilities": { + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 14, + "charisma": 10, + }, + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, + "resources": [ + { + "name": "Channel Divinity", + "current": 1, + "max": 1, + "refresh": "short_rest", + }, + { + "name": "Lay on Hands", + "current": 15, + "max": 15, + "refresh": "long_rest", + }, + ], + } + ) + ) data = load_character("test-player") assert isinstance(data["resources"], dict) assert "channel_divinity" in data["resources"] @@ -277,32 +312,57 @@ class TestSchemaCoercion: """End-to-end: a character with bad-shape resources on disk should be usable via adjust_resource after load coercion.""" import yaml + path = player_dir / "players" / "test-player" / "character.yaml" - path.write_text(yaml.dump({ - "identity": {"name": "Damaged"}, - "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, - "intelligence": 10, "wisdom": 10, "charisma": 10}, - "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, - "resources": [ - {"name": "Channel Divinity", "current": 1, "max": 1, - "refresh": "short_rest", "notes": "Channel Divinity"}, - ], - })) - result = adjust_resource( - "test-player", "channel", -1 + path.write_text( + yaml.dump( + { + "identity": {"name": "Damaged"}, + "abilities": { + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 10, + "charisma": 10, + }, + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, + "resources": [ + { + "name": "Channel Divinity", + "current": 1, + "max": 1, + "refresh": "short_rest", + "notes": "Channel Divinity", + }, + ], + } + ) ) + result = adjust_resource("test-player", "channel", -1) assert "Used 1" in result def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): import yaml + path = player_dir / "players" / "test-player" / "character.yaml" - path.write_text(yaml.dump({ - "identity": {"name": "Damaged"}, - "abilities": {"strength": 10, "dexterity": 10, "constitution": 10, - "intelligence": 10, "wisdom": 10, "charisma": 10}, - "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, - "equipment": ["Longsword", "Shield"], - })) + path.write_text( + yaml.dump( + { + "identity": {"name": "Damaged"}, + "abilities": { + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 10, + "charisma": 10, + }, + "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, + "equipment": ["Longsword", "Shield"], + } + ) + ) data = load_character("test-player") assert isinstance(data["equipment"], dict) assert data["equipment"]["on_person"] == ["Longsword", "Shield"] @@ -326,19 +386,35 @@ class TestComputation: def test_total_level_multiclass(self, player_dir: Path): save_character( "test-player", - {"identity": {"classes": [ - {"class": "Fighter", "level": 3}, - {"class": "Wizard", "level": 2}, - ]}}) + { + "identity": { + "classes": [ + {"class": "Fighter", "level": 3}, + {"class": "Wizard", "level": 2}, + ] + } + }, + ) data = load_character("test-player") assert total_level(data) == 5 def test_proficiency_bonus_scaling(self, player_dir: Path): - for level, expected in [(1, 2), (4, 2), (5, 3), (8, 3), (9, 4), - (12, 4), (13, 5), (16, 5), (17, 6), (20, 6)]: + for level, expected in [ + (1, 2), + (4, 2), + (5, 3), + (8, 3), + (9, 4), + (12, 4), + (13, 5), + (16, 5), + (17, 6), + (20, 6), + ]: save_character( "test-player", - {"identity": {"classes": [{"class": "Fighter", "level": level}]}}) + {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, + ) data = load_character("test-player") assert proficiency_bonus(data) == expected, f"level {level}" @@ -398,8 +474,8 @@ class TestComputation: def test_effective_hp_with_temp(self, player_dir: Path): save_character( - "test-player", - {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}) + "test-player", {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}} + ) data = load_character("test-player") hp = effective_hp(data) assert hp["effective"] == 25 @@ -463,9 +539,7 @@ class TestDisplay: def test_format_character_context_includes_advancement( self, mira: dict, player_dir: Path ): - update_character( - "test-player", {"advancement_ready": 4} - ) + update_character("test-player", {"advancement_ready": 4}) data = load_character("test-player") result = format_character_context(data) assert "Advancement Ready" in result @@ -502,8 +576,11 @@ class TestDisplay: def test_format_sheet_renders_resources_with_die(self, mira: dict): mira["resources"] = { "bardic_inspiration": { - "current": 3, "max": 3, "refresh": "long_rest", - "notes": "Bardic Inspiration", "die": "d8", + "current": 3, + "max": 3, + "refresh": "long_rest", + "notes": "Bardic Inspiration", + "die": "d8", }, } result = format_sheet(mira) @@ -567,10 +644,12 @@ class TestDisplay: def test_format_sheet_shows_inspiration_when_available(self, mira: dict): mira["state"]["inspiration"] = True sheet = format_sheet(mira) - assert "Inspiration" in sheet and "available" in sheet + assert "Inspiration" in sheet + assert "available" in sheet def test_format_sheet_shows_exhaustion_reminder_when_nonzero( - self, mira: dict, + self, + mira: dict, ): """When exhaustion is set, the sheet shows a reminder that the DM applies the effect — it does NOT fold a numeric penalty into the @@ -594,8 +673,14 @@ class TestDisplay: race="Human", char_class="Fighter", level=1, - abilities={"strength": 10, "dexterity": 10, "constitution": 10, - "intelligence": 10, "wisdom": 10, "charisma": 10}, + abilities={ + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 10, + "charisma": 10, + }, hp_max=10, ac=10, ) @@ -604,20 +689,25 @@ class TestDisplay: assert "Purse" not in result def test_format_status_truncates_equipment_over_eight_items( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): # Add lots of items so the truncation branch fires for i in range(12): update_character( "test-player", - {f"equipment.on_person": [f"item_{i}" for i in range(12)]}, + {"equipment.on_person": [f"item_{i}" for i in range(12)]}, ) data = load_character("test-player") result = format_status(data) assert "and 4 more" in result # 12 items - 8 shown = 4 more def test_format_character_display_respects_data_home( - self, mira: dict, player_dir: Path, tmp_path: Path, + self, + mira: dict, + player_dir: Path, + tmp_path: Path, ): """The /me slash command resolves the character via the ``storied.paths`` module globals — sandbox sessions get the @@ -658,18 +748,14 @@ class TestDamageHeal: assert data["state"]["hp"]["current"] == 19 def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.hp.temp": 5} - ) + update_character("test-player", {"state.hp.temp": 5}) damage("test-player", 3) data = load_character("test-player") assert data["state"]["hp"]["temp"] == 2 assert data["state"]["hp"]["current"] == 24 def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.hp.temp": 5} - ) + update_character("test-player", {"state.hp.temp": 5}) damage("test-player", 8) data = load_character("test-player") assert data["state"]["hp"]["temp"] == 0 @@ -680,9 +766,7 @@ class TestDamageHeal: data = load_character("test-player") assert data["state"]["hp"]["current"] == 0 - def test_damage_at_zero_mentions_death_saves( - self, mira: dict, player_dir: Path - ): + def test_damage_at_zero_mentions_death_saves(self, mira: dict, player_dir: Path): result = damage("test-player", 100) assert "death save" in result.lower() @@ -719,7 +803,9 @@ class TestDamageHeal: {"defenses.vulnerabilities": [{"damage": "radiant"}]}, ) damage( - "test-player", 5, damage_type="radiant", + "test-player", + 5, + damage_type="radiant", ) data = load_character("test-player") # Raw 5, not doubled @@ -731,7 +817,9 @@ class TestDamageHeal: {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, ) damage( - "test-player", 12, damage_type="poison", + "test-player", + 12, + damage_type="poison", ) data = load_character("test-player") # Raw 12, not zeroed @@ -740,7 +828,9 @@ class TestDamageHeal: class TestLevelUp: def test_level_up_increments_class_level( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): result = level_up( "test-player", @@ -753,26 +843,34 @@ class TestLevelUp: assert "3 → 4" in result def test_level_up_adds_hp_to_max_and_current( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): # mira starts with 24/24 level_up( - "test-player", "Rogue", - new_level=4, hp_gain=6, + "test-player", + "Rogue", + new_level=4, + hp_gain=6, ) data = load_character("test-player") assert data["state"]["hp"]["max"] == 30 assert data["state"]["hp"]["current"] == 30 def test_level_up_preserves_wounded_current_relative( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): # Wound the character first damage("test-player", 10) # HP is now 14/24 level_up( - "test-player", "Rogue", - new_level=4, hp_gain=6, + "test-player", + "Rogue", + new_level=4, + hp_gain=6, ) data = load_character("test-player") # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) @@ -780,40 +878,52 @@ class TestLevelUp: assert data["state"]["hp"]["current"] == 20 def test_level_up_sets_level_since( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): level_up( - "test-player", "Rogue", - new_level=4, hp_gain=6, + "test-player", + "Rogue", + new_level=4, + hp_gain=6, time_anchor="#d12-1500", ) data = load_character("test-player") assert data["level_since"] == "#d12-1500" def test_level_up_clears_advancement_ready( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): update_character( "test-player", {"advancement_ready": 4}, ) level_up( - "test-player", "Rogue", - new_level=4, hp_gain=6, + "test-player", + "Rogue", + new_level=4, + hp_gain=6, ) data = load_character("test-player") assert data.get("advancement_ready") is None def test_level_up_replaces_features_when_provided( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): new_features = [ {"name": "Sneak Attack", "text": "2d6"}, {"name": "Uncanny Dodge", "text": "Reaction for half damage"}, ] level_up( - "test-player", "Rogue", - new_level=4, hp_gain=6, + "test-player", + "Rogue", + new_level=4, + hp_gain=6, features=new_features, ) data = load_character("test-player") @@ -821,55 +931,73 @@ class TestLevelUp: assert data["features"][1]["name"] == "Uncanny Dodge" def test_level_up_preserves_features_when_omitted( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): update_character( "test-player", {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, ) level_up( - "test-player", "Rogue", - new_level=4, hp_gain=6, + "test-player", + "Rogue", + new_level=4, + hp_gain=6, ) data = load_character("test-player") assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] def test_level_up_rejects_downgrade( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): result = level_up( - "test-player", "Rogue", - new_level=2, hp_gain=0, + "test-player", + "Rogue", + new_level=2, + hp_gain=0, ) assert "Refusing" in result data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 # unchanged def test_level_up_rejects_unknown_class( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): result = level_up( - "test-player", "Wizard", - new_level=4, hp_gain=4, + "test-player", + "Wizard", + new_level=4, + hp_gain=4, ) assert "No class matching" in result data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 def test_level_up_multiclass_finds_correct_class( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): # Add a Fighter level to make Mira multiclass update_character( "test-player", - {"identity.classes": [ - {"class": "Rogue", "subclass": "Thief", "level": 3}, - {"class": "Fighter", "subclass": None, "level": 1}, - ]}, + { + "identity.classes": [ + {"class": "Rogue", "subclass": "Thief", "level": 3}, + {"class": "Fighter", "subclass": None, "level": 1}, + ] + }, ) level_up( - "test-player", "Fighter", - new_level=2, hp_gain=7, + "test-player", + "Fighter", + new_level=2, + hp_gain=7, ) data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged @@ -883,10 +1011,14 @@ class TestConcentration: The DM decides when to drop a concentration effect.""" def test_add_concentration_effect_flags_it( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): result = add_effect( - "test-player", "Bless", "+1d4 to attacks", + "test-player", + "Bless", + "+1d4 to attacks", concentration=True, ) assert "[Concentration]" in result @@ -894,15 +1026,21 @@ class TestConcentration: assert data["effects"][0]["concentration"] is True def test_multiple_concentration_effects_allowed( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): """No enforcement — the DM can flag two effects concentration.""" add_effect( - "test-player", "Bless", "+1d4", + "test-player", + "Bless", + "+1d4", concentration=True, ) add_effect( - "test-player", "Hold Person", "paralyzed", + "test-player", + "Hold Person", + "paralyzed", concentration=True, ) data = load_character("test-player") @@ -911,11 +1049,15 @@ class TestConcentration: assert "Hold Person" in sources def test_damage_does_not_emit_concentration_save_hint( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): """The DM issues concentration saves manually per the rules.""" add_effect( - "test-player", "Bless", "+1d4", + "test-player", + "Bless", + "+1d4", concentration=True, ) result = damage("test-player", 6) @@ -931,7 +1073,9 @@ class TestEffects: def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): add_effect( - "test-player", "Potion", "+10 temp HP", + "test-player", + "Potion", + "+10 temp HP", expires="d1-1430", ) data = load_character("test-player") @@ -987,7 +1131,9 @@ class TestInventory: def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): add_item( - "test-player", "Rope (50ft)", location="backpack", + "test-player", + "Rope (50ft)", + location="backpack", ) data = load_character("test-player") assert "Rope (50ft)" in data["equipment"]["backpack"] @@ -1013,26 +1159,22 @@ class TestInventory: class TestMagicItems: def test_set_item_status_attuned(self, mira: dict, player_dir: Path): set_item_status( - "test-player", "Bracer of the Unseen Step", "attuned", + "test-player", + "Bracer of the Unseen Step", + "attuned", ) data = load_character("test-player") assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): - set_item_status( - "test-player", "Cloak", "carried" - ) - set_item_status( - "test-player", "Cloak", "equipped" - ) + set_item_status("test-player", "Cloak", "carried") + set_item_status("test-player", "Cloak", "equipped") data = load_character("test-player") assert "[[Cloak]]" not in data["magic_items"]["carried"] assert "[[Cloak]]" in data["magic_items"]["equipped"] def test_set_item_status_invalid(self, mira: dict, player_dir: Path): - result = set_item_status( - "test-player", "Cloak", "invalid" - ) + result = set_item_status("test-player", "Cloak", "invalid") assert "invalid status" in result.lower() @@ -1043,51 +1185,53 @@ class TestResources: assert data["resources"]["hit_dice_d8"]["current"] == 2 def test_adjust_resource_spend_multiple( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): adjust_resource("test-player", "hit_dice", -2) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 1 def test_adjust_resource_clamped_to_zero( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): - result = adjust_resource( - "test-player", "hit_dice", -10 - ) + result = adjust_resource("test-player", "hit_dice", -10) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 0 assert "short" in result.lower() def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): - result = adjust_resource( - "test-player", "nonexistent", -1 - ) + result = adjust_resource("test-player", "nonexistent", -1) assert "no resource matching" in result.lower() def test_adjust_resource_restore_clamped_to_max( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): adjust_resource("test-player", "hit_dice", -2) - adjust_resource( - "test-player", "hit_dice", 100 - ) + adjust_resource("test-player", "hit_dice", 100) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 def test_adjust_resource_zero_delta_is_noop( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): - result = adjust_resource( - "test-player", "hit_dice", 0 - ) + result = adjust_resource("test-player", "hit_dice", 0) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 assert "no change" in result.lower() class TestRest: - def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): + def test_long_rest_refreshes_long_rest_resources( + self, mira: dict, player_dir: Path + ): adjust_resource("test-player", "hit_dice", -3) rest("test-player", "long") data = load_character("test-player") @@ -1110,9 +1254,7 @@ class TestRest: assert data["state"]["death_saves"]["failures"] == 0 def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): - update_character( - "test-player", {"state.exhaustion": 3} - ) + update_character("test-player", {"state.exhaustion": 3}) rest("test-player", "long") data = load_character("test-player") assert data["state"]["exhaustion"] == 2 @@ -1145,9 +1287,7 @@ class TestCoins: def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path): before = load_character("test-player")["state"]["purse"]["gp"] - result = adjust_coins( - "test-player", {"gp": -100} - ) + result = adjust_coins("test-player", {"gp": -100}) data = load_character("test-player") # Rejected — purse is unchanged. assert data["state"]["purse"]["gp"] == before @@ -1155,14 +1295,14 @@ class TestCoins: assert "insufficient" in result.lower() def test_adjust_coins_rejects_partial_underflow( - self, mira: dict, player_dir: Path, + self, + mira: dict, + player_dir: Path, ): # Mira has gp but not enough cp. The mixed delta should be # rejected as a whole — neither denomination should change. before = load_character("test-player")["state"]["purse"] - result = adjust_coins( - "test-player", {"gp": -1, "cp": -100} - ) + result = adjust_coins("test-player", {"gp": -1, "cp": -100}) data = load_character("test-player") assert data["state"]["purse"]["gp"] == before["gp"] assert data["state"]["purse"]["cp"] == before["cp"] @@ -1170,6 +1310,7 @@ class TestCoins: def test_adjust_coins_making_change(self, player_dir: Path): from storied.character.data import create_character + create_character( player_id="test-player", name="Coin Test", @@ -1177,8 +1318,12 @@ class TestCoins: char_class="Fighter", level=1, abilities={ - "strength": 10, "dexterity": 10, "constitution": 10, - "intelligence": 10, "wisdom": 10, "charisma": 10, + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 10, + "charisma": 10, }, hp_max=10, ac=10, @@ -1210,7 +1355,8 @@ class TestNotes: def test_add_note_with_anchor(self, mira: dict, player_dir: Path): add_note( - "test-player", "Witnessed the heist", + "test-player", + "Witnessed the heist", time_anchor="d28-1330", ) notes_path = player_dir / "players" / "test-player" / "notes.md" diff --git a/tests/test_claude.py b/tests/test_claude.py index 930a59f..5ab2bc0 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -25,9 +25,7 @@ class TestBuildMcpConfig: assert "storied" in parsed["mcpServers"] def test_url_set(self): - config = json.loads( - build_mcp_config("http://127.0.0.1:8080/sse") - ) + config = json.loads(build_mcp_config("http://127.0.0.1:8080/sse")) server = config["mcpServers"]["storied"] assert server["url"] == "http://127.0.0.1:8080/sse" assert server["type"] == "sse" @@ -45,7 +43,9 @@ class TestBuildClaudeArgs: def test_resume_session_includes_system_prompt(self, monkeypatch): monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") args = build_claude_args( - "sonnet", "You are a DM", "{}", + "sonnet", + "You are a DM", + "{}", resume_session_id="abc-123", ) assert "--resume" in args @@ -95,59 +95,76 @@ class TestFormatUserMessage: class TestParseEvent: def test_text_delta(self): - line = json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "text_delta", "text": "Hello"}, - }, - }) + line = json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello"}, + }, + } + ) event = parse_event(line) assert isinstance(event, TextDelta) assert event.text == "Hello" def test_tool_start(self): - line = json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 1, - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__roll"}, - }, - }) + line = json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "t1", + "name": "mcp__storied__roll", + }, + }, + } + ) event = parse_event(line) assert isinstance(event, ToolStart) assert event.name == "mcp__storied__roll" def test_tool_input_delta(self): - line = json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 1, - "delta": {"type": "input_json_delta", "partial_json": '{"notation"'}, - }, - }) + line = json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": '{"notation"', + }, + }, + } + ) event = parse_event(line) assert isinstance(event, ToolInputDelta) assert event.json_fragment == '{"notation"' def test_tool_stop(self): - line = json.dumps({ - "type": "stream_event", - "event": {"type": "content_block_stop", "index": 1}, - }) + line = json.dumps( + { + "type": "stream_event", + "event": {"type": "content_block_stop", "index": 1}, + } + ) event = parse_event(line) assert isinstance(event, ToolStop) def test_result(self): - line = json.dumps({ - "type": "result", - "session_id": "sess-1", - "usage": {"input_tokens": 100, "output_tokens": 50}, - "duration_ms": 1234, - }) + line = json.dumps( + { + "type": "result", + "session_id": "sess-1", + "usage": {"input_tokens": 100, "output_tokens": 50}, + "duration_ms": 1234, + } + ) event = parse_event(line) assert isinstance(event, Result) assert event.session_id == "sess-1" @@ -164,12 +181,14 @@ class TestParseEvent: assert parse_event(json.dumps({"type": "unknown"})) is None def test_text_block_start_ignored(self): - line = json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "text"}, - }, - }) + line = json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text"}, + }, + } + ) assert parse_event(line) is None diff --git a/tests/test_cli.py b/tests/test_cli.py index 125547b..289be4f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,29 +41,20 @@ class TestColdStartDetection: def test_cold_start_when_nothing_exists(self): assert _is_cold_start("default", "default") is True - def test_cold_start_when_only_character_exists( - self, - _minimal_character: None, - ): + @pytest.mark.usefixtures("_minimal_character") + def test_cold_start_when_only_character_exists(self): assert _is_cold_start("default", "default") is True - def test_cold_start_when_only_style_exists( - self, - _world_style: None, - ): + @pytest.mark.usefixtures("_world_style") + def test_cold_start_when_only_style_exists(self): assert _is_cold_start("default", "default") is True - def test_not_cold_start_when_both_exist( - self, - _minimal_character: None, - _world_style: None, - ): + @pytest.mark.usefixtures("_minimal_character", "_world_style") + def test_not_cold_start_when_both_exist(self): assert _is_cold_start("default", "default") is False - def test_not_cold_start_ignores_other_worlds( - self, - _minimal_character: None, - ): + @pytest.mark.usefixtures("_minimal_character") + def test_not_cold_start_ignores_other_worlds(self): # Style lives in a different world — doesn't count for this one. world_dir = world_path("other") world_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_content.py b/tests/test_content.py index 5341cd4..ed1ca01 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1,3 +1,5 @@ +# pyright: reportOptionalMemberAccess=false +# Tests reach into ContentResolver.load() results without null-narrowing. """Tests for three-layer content resolution (world > user > shipped). The autouse ``_isolate_storied_paths`` fixture in ``conftest.py`` already @@ -13,7 +15,6 @@ import pytest from storied import paths from storied.content import ContentResolver - # --------------------------------------------------------------------------- # Fixtures — build a fake three-layer setup under tmp_path. # --------------------------------------------------------------------------- @@ -90,7 +91,9 @@ class TestLayerPriority: assert hit == shipped_goblin def test_user_overrides_shipped( - self, shipped_goblin: Path, user_goblin: Path, + self, + shipped_goblin: Path, + user_goblin: Path, ): resolver = ContentResolver(world_id="test-world") hit = resolver.find("goblin", content_type="monsters") @@ -98,7 +101,10 @@ class TestLayerPriority: assert "Homebrew" in hit.read_text() def test_world_overrides_user( - self, shipped_goblin: Path, user_goblin: Path, world_goblin: Path, + self, + shipped_goblin: Path, + user_goblin: Path, + world_goblin: Path, ): resolver = ContentResolver(world_id="test-world") hit = resolver.find("goblin", content_type="monsters") @@ -106,14 +112,18 @@ class TestLayerPriority: assert "Island Goblin" in hit.read_text() def test_world_overrides_shipped_without_user( - self, shipped_goblin: Path, world_goblin: Path, + self, + shipped_goblin: Path, + world_goblin: Path, ): resolver = ContentResolver(world_id="test-world") hit = resolver.find("goblin", content_type="monsters") assert hit == world_goblin def test_user_overrides_shipped_without_world( - self, shipped_goblin: Path, user_goblin: Path, + self, + shipped_goblin: Path, + user_goblin: Path, ): resolver = ContentResolver(world_id="test-world") hit = resolver.find("goblin", content_type="monsters") @@ -140,7 +150,8 @@ class TestNarrativeContent: assert hit is None def test_no_world_id_still_searches_rule_layers( - self, shipped_fireball: Path, + self, + shipped_fireball: Path, ): """A resolver with no world still finds rule content at the user and shipped layers.""" @@ -165,7 +176,8 @@ class TestLoadContent: monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" monsters.mkdir(parents=True) (monsters / "orc.md").write_text( - "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n# Orc\n\nBig and mean.\n" + "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n" + "# Orc\n\nBig and mean.\n" ) resolver = ContentResolver(world_id="test-world") @@ -195,7 +207,9 @@ class TestUntypedSearch: assert hit == shipped_goblin def test_world_wins_in_untyped_search( - self, shipped_goblin: Path, world_goblin: Path, + self, + shipped_goblin: Path, + world_goblin: Path, ): resolver = ContentResolver(world_id="test-world") hit = resolver.find("goblin") diff --git a/tests/test_dice.py b/tests/test_dice.py index 28020dd..f69df97 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -2,7 +2,7 @@ import pytest -from storied.dice import DiceRoll, RollResult, parse_notation, roll +from storied.dice import RollResult, parse_notation, roll class TestParseNotation: diff --git a/tests/test_display.py b/tests/test_display.py index 0ca3003..0c61102 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -37,7 +37,6 @@ def rendered(renderer: StreamRenderer, out: io.StringIO) -> str: class TestBold: - def test_bold_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("**hello**\n") text = rendered(renderer, out) @@ -61,7 +60,6 @@ class TestBold: class TestItalic: - def test_italic_wraps_text(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("*hello*\n") text = rendered(renderer, out) @@ -78,7 +76,6 @@ class TestItalic: class TestBoldItalic: - def test_bold_italic(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("***both***\n") text = rendered(renderer, out) @@ -96,7 +93,6 @@ class TestBoldItalic: class TestCode: - def test_inline_code(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("`code`\n") text = rendered(renderer, out) @@ -106,8 +102,9 @@ class TestCode: class TestPlainText: - - def test_plain_text_passes_through(self, renderer: StreamRenderer, out: io.StringIO): + def test_plain_text_passes_through( + self, renderer: StreamRenderer, out: io.StringIO + ): renderer.feed("hello world\n") text = rendered(renderer, out) assert "hello world" in text @@ -128,7 +125,6 @@ class TestPlainText: class TestHorizontalRule: - def test_rule(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("---\n") text = rendered(renderer, out) @@ -137,7 +133,6 @@ class TestHorizontalRule: class TestHeadings: - def test_h1(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("# Title\n") text = rendered(renderer, out) @@ -164,7 +159,6 @@ class TestHeadings: class TestBulletList: - def test_dash_bullet(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("- first item\n") text = rendered(renderer, out) @@ -179,7 +173,6 @@ class TestBulletList: class TestBlockquote: - def test_blockquote(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("> quoted text\n") text = rendered(renderer, out) @@ -193,7 +186,6 @@ class TestBlockquote: class TestNumberedList: - def test_numbered(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("1. first\n") text = rendered(renderer, out) @@ -205,7 +197,6 @@ class TestNumberedList: class TestBlocks: - def test_map_block_renders_panel(self, renderer: StreamRenderer, out: io.StringIO): renderer.feed("```map Tavern\n+-+\n|X|\n+-+\n```\n") text = rendered(renderer, out) @@ -225,7 +216,9 @@ class TestBlocks: assert "+-+" in text assert "After" in text - def test_regular_code_block_passes_through(self, renderer: StreamRenderer, out: io.StringIO): + def test_regular_code_block_passes_through( + self, renderer: StreamRenderer, out: io.StringIO + ): renderer.feed("```python\nprint('hi')\n```\n") text = rendered(renderer, out) assert "python" in text @@ -244,14 +237,17 @@ class TestBlocks: class TestSOLClassification: - - def test_text_starting_with_hash_no_space(self, renderer: StreamRenderer, out: io.StringIO): + def test_text_starting_with_hash_no_space( + self, renderer: StreamRenderer, out: io.StringIO + ): renderer.feed("#hashtag\n") text = rendered(renderer, out) assert "#hashtag" in text assert BOLD_ON not in text - def test_text_starting_with_dash_no_space(self, renderer: StreamRenderer, out: io.StringIO): + def test_text_starting_with_dash_no_space( + self, renderer: StreamRenderer, out: io.StringIO + ): renderer.feed("-not a bullet\n") text = rendered(renderer, out) assert "-not a bullet" in text @@ -261,7 +257,9 @@ class TestSOLClassification: text = rendered(renderer, out) assert "--not a rule" in text - def test_line_starting_with_letter(self, renderer: StreamRenderer, out: io.StringIO): + def test_line_starting_with_letter( + self, renderer: StreamRenderer, out: io.StringIO + ): renderer.feed("The quick brown fox\n") text = rendered(renderer, out) assert "The quick brown fox" in text @@ -271,8 +269,9 @@ class TestSOLClassification: class TestFlush: - - def test_flush_emits_buffered_star_as_italic(self, renderer: StreamRenderer, out: io.StringIO): + def test_flush_emits_buffered_star_as_italic( + self, renderer: StreamRenderer, out: io.StringIO + ): renderer.feed("trailing*") text = rendered(renderer, out) assert "trailing" in text @@ -300,14 +299,17 @@ def narrow_renderer(out: io.StringIO) -> StreamRenderer: class TestWordWrap: - - def test_short_line_no_wrap(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_short_line_no_wrap( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): narrow_renderer.feed("hello world\n") text = rendered(narrow_renderer, out) assert "hello world" in text assert text.count("\n") == 1 # just the trailing newline - def test_wraps_at_word_boundary(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_wraps_at_word_boundary( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): # "one two three four" = 18 chars, fits. Add "five" and it wraps. narrow_renderer.feed("one two three four five\n") text = rendered(narrow_renderer, out) @@ -324,14 +326,18 @@ class TestWordWrap: for line in text.split("\n"): assert "ccccccc" not in line or "ccccccccccccc" in line - def test_word_longer_than_width_overflows(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_word_longer_than_width_overflows( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): # A single word longer than 20 chars just overflows (no crash) narrow_renderer.feed("superlongwordthatexceedstwentycharacters end\n") text = rendered(narrow_renderer, out) assert "superlongwordthatexceedstwentycharacters" in text assert "end" in text - def test_wrap_preserves_bold(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_wrap_preserves_bold( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): narrow_renderer.feed("aaa bbb **ccc ddd eee fff** ggg\n") text = rendered(narrow_renderer, out) assert BOLD_ON in text @@ -340,13 +346,17 @@ class TestWordWrap: for word in ("aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg"): assert word in text - def test_wrap_preserves_italic(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_wrap_preserves_italic( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): narrow_renderer.feed("aaa bbb *ccc ddd eee fff* ggg\n") text = rendered(narrow_renderer, out) assert ITALIC_ON in text assert ITALIC_OFF in text - def test_bullet_wrap_accounts_for_prefix(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_bullet_wrap_accounts_for_prefix( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): # " • " = 4 chars, so only 16 chars of content before wrap narrow_renderer.feed("- aaa bbb ccc ddd eee\n") text = rendered(narrow_renderer, out) @@ -354,7 +364,9 @@ class TestWordWrap: lines = text.strip().split("\n") assert len(lines) >= 2 # should wrap - def test_streaming_chunks_wrap_correctly(self, narrow_renderer: StreamRenderer, out: io.StringIO): + def test_streaming_chunks_wrap_correctly( + self, narrow_renderer: StreamRenderer, out: io.StringIO + ): # Words arrive across multiple chunks narrow_renderer.feed("one two thr") narrow_renderer.feed("ee four five ") diff --git a/tests/test_engine.py b/tests/test_engine.py index 98f2341..d1e73f1 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,3 +1,5 @@ +# pyright: reportArgumentType=false +# Tests pass dict literals where typed inputs are stricter than runtime needs. """Tests for engine helper functions and context building.""" from pathlib import Path @@ -13,7 +15,6 @@ from storied.engine import ( class TestLoadPrompt: - def test_loads_dm_system(self): result = load_prompt("dm-system") assert "Dungeon Master" in result @@ -30,7 +31,6 @@ class TestLoadPrompt: class TestExtractRollReason: - def test_extracts_reason(self): assert _extract_roll_reason('{"reason": "Athletics"}') == "Athletics" @@ -45,7 +45,6 @@ class TestExtractRollReason: class TestToolNotification: - def test_known_tool(self): assert "Rolling" in _tool_notification("roll") @@ -79,15 +78,23 @@ class TestDMEngineContext: from storied.log import CampaignLog from storied.tools import EntityIndex - mock_mcp.return_value = type("Handle", (), { - "url": "http://localhost:0/sse", - "ctx": type("Ctx", (), { - "campaign_log": CampaignLog("test"), - "entity_index": EntityIndex(world_dir), - "vector_index": None, - "initiative": InitiativeTracker(), - })(), - })() + mock_mcp.return_value = type( + "Handle", + (), + { + "url": "http://localhost:0/sse", + "ctx": type( + "Ctx", + (), + { + "campaign_log": CampaignLog("test"), + "entity_index": EntityIndex(world_dir), + "vector_index": None, + "initiative": InitiativeTracker(), + }, + )(), + }, + )() return DMEngine( world_id="test", player_id="default", @@ -126,7 +133,9 @@ class TestDMEngineContext: assert "cosmic-prisoner" in engine._context_parts["Arc"] def test_arc_appears_after_style_in_context_order( - self, engine, tmp_path: Path, + self, + engine, + tmp_path: Path, ): style_path = tmp_path / "worlds" / "test" / "style.md" style_path.write_text("# Style\n\nDark tone.\n") @@ -165,12 +174,17 @@ class TestDMEngineContext: def test_estimate_tokens(self): from storied.engine import DMEngine + assert DMEngine._estimate_tokens("a" * 400) == 100 def test_format_entity(self, engine): - result = engine._format_entity("Npc", { - "name": "Vera", "body": "Tavern owner.", - }) + result = engine._format_entity( + "Npc", + { + "name": "Vera", + "body": "Tavern owner.", + }, + ) assert "## Npc: Vera" in result assert "Tavern owner." in result @@ -188,7 +202,9 @@ class TestDMEngineContext: assert result["body"] == "Tavern owner." def test_parse_knowledge_file_malformed_frontmatter( - self, engine, tmp_path: Path, + self, + engine, + tmp_path: Path, ): f = tmp_path / "broken.md" f.write_text("---\nnot: [valid yaml\n---\n\nBody.") @@ -218,15 +234,23 @@ class TestDMEngineContext: with patch("storied.engine.start_mcp_server") as mock_mcp: from storied.log import CampaignLog - mock_mcp.return_value = type("Handle", (), { - "url": "http://localhost:0/sse", - "ctx": type("Ctx", (), { - "campaign_log": CampaignLog("test"), - "entity_index": EntityIndex(tmp_path / "worlds" / "test"), - "vector_index": None, - "initiative": InitiativeTracker(), - })(), - })() + mock_mcp.return_value = type( + "Handle", + (), + { + "url": "http://localhost:0/sse", + "ctx": type( + "Ctx", + (), + { + "campaign_log": CampaignLog("test"), + "entity_index": EntityIndex(tmp_path / "worlds" / "test"), + "vector_index": None, + "initiative": InitiativeTracker(), + }, + )(), + }, + )() engine = DMEngine( world_id="test", player_id="default", @@ -267,6 +291,7 @@ class TestDMEngineContext: def test_build_context_with_character(self, engine, tmp_path: Path): # Drop a character file in place; _build_context should pick it up from storied.character import create_character + (tmp_path / "players" / "default").mkdir(parents=True) create_character( player_id="default", @@ -274,8 +299,14 @@ class TestDMEngineContext: race="Human", char_class="Rogue", level=3, - abilities={"strength": 10, "dexterity": 18, "constitution": 14, - "intelligence": 14, "wisdom": 12, "charisma": 16}, + abilities={ + "strength": 10, + "dexterity": 18, + "constitution": 14, + "intelligence": 14, + "wisdom": 12, + "charisma": 16, + }, hp_max=24, ac=16, ) @@ -285,12 +316,16 @@ class TestDMEngineContext: def test_build_context_with_session(self, engine, tmp_path: Path): from storied.session import save_session + (tmp_path / "players" / "default").mkdir(parents=True) - save_session("default", { - "location": "The Tavern", - "body": "## Present\n- [[Vera]]", - "situation": "Resting", - }) + save_session( + "default", + { + "location": "The Tavern", + "body": "## Present\n- [[Vera]]", + "situation": "Resting", + }, + ) engine._build_context() assert "Session" in engine._context_parts @@ -302,16 +337,24 @@ class TestDMEngineContext: # Establish an NPC and put them in the session's present list _do_establish( - "npcs", "Vera", "Tavern owner.", "[[The Tavern]]", - None, None, None, + "npcs", + "Vera", + "Tavern owner.", + "[[The Tavern]]", + None, + None, + None, "test", engine._mcp.ctx.entity_index, type("FakeIdx", (), {"upsert": lambda *a, **k: None})(), ) - save_session("default", { - "location": "The Tavern", - "body": "## Present\n- [[Vera]]", - }) + save_session( + "default", + { + "location": "The Tavern", + "body": "## Present\n- [[Vera]]", + }, + ) engine._build_context() # The Vera entity should have been loaded into the DM context @@ -323,9 +366,7 @@ class TestDMEngineContext: assert result is None def test_load_player_knowledge_aggregates_files(self, engine, tmp_path: Path): - knowledge = ( - tmp_path / "players" / "default" / "worlds" / "test" / "npcs" - ) + knowledge = tmp_path / "players" / "default" / "worlds" / "test" / "npcs" knowledge.mkdir(parents=True) (knowledge / "vera.md").write_text( "---\nname: Vera Blackwater\n---\n\nA tavern owner." @@ -352,7 +393,9 @@ class TestDMEngineContext: assert engine._find_entity("Nobody") is None def test_build_context_loads_location_and_one_hop_linked( - self, engine, tmp_path: Path, + self, + engine, + tmp_path: Path, ): """When the session points at a location, _build_context should load the location, then one-hop into entities the location wikilinks.""" @@ -363,9 +406,7 @@ class TestDMEngineContext: # Location wikilinks to a related NPC loc_path = tmp_path / "worlds" / "test" / "locations" / "Tavern.md" loc_path.parent.mkdir(parents=True, exist_ok=True) - loc_path.write_text( - "# Tavern\n\nA cozy spot where [[Vera]] holds court." - ) + loc_path.write_text("# Tavern\n\nA cozy spot where [[Vera]] holds court.") engine._mcp.ctx.entity_index.register("Tavern", loc_path) # The linked NPC @@ -374,25 +415,27 @@ class TestDMEngineContext: npc_path.write_text("# Vera\n\nTavern owner.") engine._mcp.ctx.entity_index.register("Vera", npc_path) - save_session("default", { - "location": "Tavern", - "body": "Player just walked in.", - }) + save_session( + "default", + { + "location": "Tavern", + "body": "Player just walked in.", + }, + ) engine._build_context() # Location should be loaded assert "Location" in engine._context_parts # One-hop linked entity should be picked up via Linked: prefix - linked_keys = [ - k for k in engine._context_parts if k.startswith("Linked:") - ] + linked_keys = [k for k in engine._context_parts if k.startswith("Linked:")] assert any("Vera" in k for k in linked_keys) def test_build_context_includes_notifications(self, engine): from storied import notifications notifications.append( - engine.world_id, "World tick: Vera left the tavern", + engine.world_id, + "World tick: Vera left the tavern", ) engine._build_context() assert "Notifications" in engine._context_parts @@ -401,9 +444,11 @@ class TestDMEngineContext: def test_build_context_injects_initiative_when_active(self, engine): from storied.initiative import Combatant - engine._mcp.ctx.initiative.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) + engine._mcp.ctx.initiative.begin( + [ + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), + ] + ) engine._build_context() assert "Initiative" in engine._context_parts assert "Goblin" in engine._context_parts["Initiative"] diff --git a/tests/test_entities.py b/tests/test_entities.py index d3537bf..4a8f09a 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,3 +1,5 @@ +# pyright: reportArgumentType=false, reportOptionalSubscript=false +# Tests use loose dict types and trust load results without null-narrowing. """Tests for the entity model - establish, mark, and wikilink resolution.""" from pathlib import Path @@ -9,13 +11,12 @@ from storied.session import ( load_entity_content, resolve_wiki_link, ) +from storied.testing import call_tool from storied.tools import EntityIndex, ToolContext from storied.tools.entities import amend_mark as _amend_mark from storied.tools.entities import establish as _establish from storied.tools.entities import mark as _mark -from storied.testing import call_tool - def establish(**kwargs): """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" @@ -182,11 +183,15 @@ class TestEstablish: knows=["Where the keys are kept"], ) - content = (tmp_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() + content = ( + tmp_path / "worlds/test-world/npcs/Garrick the Jailer.md" + ).read_text() assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content assert "Heavyset man in his fifties." in content - def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext, tmp_path: Path): + def test_establish_refuses_player_character_as_npc( + self, ctx: ToolContext, tmp_path: Path + ): from storied.character import create_character create_character( @@ -195,9 +200,16 @@ class TestEstablish: race="Human", char_class="Rogue", level=3, - abilities={"strength": 10, "dexterity": 16, "constitution": 12, - "intelligence": 12, "wisdom": 12, "charisma": 14}, - hp_max=24, ac=16, + abilities={ + "strength": 10, + "dexterity": 16, + "constitution": 12, + "intelligence": 12, + "wisdom": 12, + "charisma": 14, + }, + hp_max=24, + ac=16, ) result = establish( @@ -211,7 +223,9 @@ class TestEstablish: assert "player character" in result assert not (tmp_path / "worlds/test-world/npcs/Mira.md").exists() - def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext, tmp_path: Path): + def test_establish_allows_player_name_for_non_npc( + self, ctx: ToolContext, tmp_path: Path + ): """The guard is NPC-scoped — an NPC can't share the PC's name, but a location or thread happening to be named 'Mira' is fine.""" from storied.character import create_character @@ -222,9 +236,16 @@ class TestEstablish: race="Human", char_class="Rogue", level=1, - abilities={"strength": 10, "dexterity": 16, "constitution": 12, - "intelligence": 12, "wisdom": 12, "charisma": 14}, - hp_max=8, ac=14, + abilities={ + "strength": 10, + "dexterity": 16, + "constitution": 12, + "intelligence": 12, + "wisdom": 12, + "charisma": 14, + }, + hp_max=8, + ac=14, ) result = establish( @@ -238,7 +259,9 @@ class TestEstablish: assert (tmp_path / "worlds/test-world/locations/Mira.md").exists() def test_establish_allows_npc_matching_pc_name_with_no_character( - self, ctx: ToolContext, tmp_path: Path, + self, + ctx: ToolContext, + tmp_path: Path, ): """Without a character sheet on disk, the guard should not fire.""" result = establish( @@ -251,7 +274,9 @@ class TestEstablish: assert "Established" in result assert (tmp_path / "worlds/test-world/npcs/Mira.md").exists() - def test_establish_location_preserved_on_update(self, ctx: ToolContext, tmp_path: Path): + def test_establish_location_preserved_on_update( + self, ctx: ToolContext, tmp_path: Path + ): # Create with location establish( entity_type="npcs", @@ -457,7 +482,8 @@ class TestMark: assert "#d2-1430" in content def test_mark_with_invalid_when_falls_back_gracefully( - self, ctx: ToolContext, + self, + ctx: ToolContext, ): # A garbage `when` value should not crash — the worst case is the # literal text landing in the Was prefix. Callers can tell they @@ -479,12 +505,15 @@ class TestAmendMark: def test_amend_replaces_most_recent_entry(self, ctx: ToolContext, tmp_path: Path): establish(entity_type="npcs", name="Vera", ctx=ctx, description="x") mark( - entity_type="npcs", name="Vera", - event="Told Mira a half-truth", ctx=ctx, + entity_type="npcs", + name="Vera", + event="Told Mira a half-truth", + ctx=ctx, ) result = amend_mark( - entity_type="npcs", name="Vera", + entity_type="npcs", + name="Vera", event="Told Mira the full truth about the smuggling ring", ctx=ctx, ) @@ -497,27 +526,36 @@ class TestAmendMark: def test_amend_preserves_anchor(self, ctx: ToolContext, tmp_path: Path): establish(entity_type="npcs", name="Tam", ctx=ctx, description="x") mark( - entity_type="npcs", name="Tam", - event="Original beat", when="d5-1200", ctx=ctx, + entity_type="npcs", + name="Tam", + event="Original beat", + when="d5-1200", + ctx=ctx, ) amend_mark( - entity_type="npcs", name="Tam", - event="Corrected beat", ctx=ctx, + entity_type="npcs", + name="Tam", + event="Corrected beat", + ctx=ctx, ) content = (tmp_path / "worlds/test-world/npcs/Tam.md").read_text() assert "#d5-1200 | Corrected beat" in content - def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext, tmp_path: Path): + def test_amend_leaves_older_entries_untouched( + self, ctx: ToolContext, tmp_path: Path + ): establish(entity_type="npcs", name="Oben", ctx=ctx, description="x") mark(entity_type="npcs", name="Oben", event="First beat", ctx=ctx) ctx.campaign_log.append_entry("advance", "1 hour") mark(entity_type="npcs", name="Oben", event="Second beat", ctx=ctx) amend_mark( - entity_type="npcs", name="Oben", - event="Second beat, corrected", ctx=ctx, + entity_type="npcs", + name="Oben", + event="Second beat, corrected", + ctx=ctx, ) content = (tmp_path / "worlds/test-world/npcs/Oben.md").read_text() @@ -526,19 +564,24 @@ class TestAmendMark: assert "- Second beat\n" not in content # old unamended line gone def test_amend_on_entity_with_no_history_returns_error( - self, ctx: ToolContext, + self, + ctx: ToolContext, ): establish(entity_type="npcs", name="Fresh", ctx=ctx, description="x") result = amend_mark( - entity_type="npcs", name="Fresh", - event="Something", ctx=ctx, + entity_type="npcs", + name="Fresh", + event="Something", + ctx=ctx, ) assert "no history" in result.lower() def test_amend_on_nonexistent_entity_returns_error(self, ctx: ToolContext): result = amend_mark( - entity_type="npcs", name="Ghost", - event="Something", ctx=ctx, + entity_type="npcs", + name="Ghost", + event="Something", + ctx=ctx, ) assert "not found" in result.lower() @@ -634,7 +677,8 @@ class TestLoadEntityContent: @pytest.fixture def indexed_world( - ctx: ToolContext, tmp_path: Path, + ctx: ToolContext, + tmp_path: Path, ) -> tuple[Path, EntityIndex]: """Create a world with entities and build an index.""" world_dir = tmp_path / "worlds" / "test-world" diff --git a/tests/test_execute_tool.py b/tests/test_execute_tool.py index 2ec8803..8d4c2e9 100644 --- a/tests/test_execute_tool.py +++ b/tests/test_execute_tool.py @@ -1,3 +1,7 @@ +# pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false +# pyright: reportCallIssue=false +# Tests subscript load_character / ctx.initiative._find results without +# null-narrowing — setup guarantees the values exist. """Tests for tool dispatch via the FastMCP in-memory client. These tests exercise the same call path the production server uses @@ -6,123 +10,98 @@ These tests exercise the same call path the production server uses from pathlib import Path -import asyncio -from typing import Any - import pytest -from fastmcp import Client +from conftest import McpCall, McpCallInCombat from storied.character import load_character from storied.initiative import Combatant -from storied.mcp_server import _compose_server +from storied.testing import call_tool from storied.tools import ToolContext -from storied.tools.combat import _flip_into_combat, _flip_out_of_combat from storied.tools.entities import note_discovery as _note_discovery from storied.tools.scene import end_session as _end_session -from storied.testing import call_tool - - -# --- Helpers ---------------------------------------------------------------- - - -def call(tool_name: str, args: dict[str, Any] | None = None) -> str: - """Compose a DM server, call a tool through the in-memory client, return text.""" - async def _run() -> str: - server = await _compose_server("dm") - async with Client(server) as client: - result = await client.call_tool(tool_name, args or {}) - return result.data if result.data is not None else "" - - return asyncio.run(_run()) - - -def call_in_combat( - tool_name: str, - args: dict[str, Any], - combatants: list[Combatant], -) -> str: - """Variant that begins initiative on the process-global tracker first. - - The composed server starts with combat tools hidden; we flip them on so - in-combat tools (next_turn, condition, etc.) become callable. - """ - from storied.tools._context import _require - _require().initiative.begin(combatants) - - async def _run() -> str: - server = await _compose_server("dm") - _flip_into_combat() - try: - async with Client(server) as client: - result = await client.call_tool(tool_name, args) - return result.data if result.data is not None else "" - finally: - _flip_out_of_combat() - - return asyncio.run(_run()) - - # --- Dispatch through the in-memory client ---------------------------------- class TestToolDispatch: - def test_roll_with_modifier(self, ctx: ToolContext): - result = call("roll", {"notation": "1d20+5", "reason": "attack"}) + def test_roll_with_modifier(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("roll", {"notation": "1d20+5", "reason": "attack"}) assert "Rolled" in result assert "1d20+5" in result - def test_roll_no_modifier(self, ctx: ToolContext): - result = call("roll", {"notation": "1d6", "reason": "damage"}) + def test_roll_no_modifier(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("roll", {"notation": "1d6", "reason": "damage"}) assert "Rolled" in result - def test_recall_finds_nothing(self, ctx: ToolContext): - result = call("recall", {"query": "nonexistent"}) + def test_recall_finds_nothing(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("recall", {"query": "nonexistent"}) assert "Nothing found" in result - def test_set_scene(self, ctx: ToolContext): - result = call("set_scene", {"event": "Spoke with guards", "duration": "10 min"}) + def test_set_scene(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "set_scene", {"event": "Spoke with guards", "duration": "10 min"} + ) assert result - def test_establish(self, ctx: ToolContext): - result = call("establish", {"entity_type": "npcs", "name": "Test NPC"}) + def test_establish(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("establish", {"entity_type": "npcs", "name": "Test NPC"}) assert "Established" in result - def test_mark(self, ctx: ToolContext): - call("establish", {"entity_type": "npcs", "name": "Vera"}) - result = call("mark", { - "entity_type": "npcs", "name": "Vera", - "event": "Revealed her secret", - }) + def test_mark(self, ctx: ToolContext, mcp_call: McpCall): + mcp_call("establish", {"entity_type": "npcs", "name": "Vera"}) + result = mcp_call( + "mark", + { + "entity_type": "npcs", + "name": "Vera", + "event": "Revealed her secret", + }, + ) assert "Marked" in result - def test_note_discovery(self, ctx: ToolContext): - result = call("note_discovery", { - "entity": "Vera Blackwater", - "content": "She used to be a smuggler", - }) + def test_note_discovery(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "note_discovery", + { + "entity": "Vera Blackwater", + "content": "She used to be a smuggler", + }, + ) assert "Noted" in result - def test_end_session(self, ctx: ToolContext): - result = call("end_session", {"situation": "In the tavern"}) + def test_end_session(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("end_session", {"situation": "In the tavern"}) assert result == "SESSION_ENDED" - def test_unknown_tool_raises(self, ctx: ToolContext): - with pytest.raises(Exception): - call("nonexistent", {}) + def test_unknown_tool_raises(self, ctx: ToolContext, mcp_call: McpCall): + with pytest.raises(Exception, match="nonexistent"): + mcp_call("nonexistent", {}) class TestUpdateCharacter: - def test_landed_in_state_hp_current(self, ctx: ToolContext, tmp_path: Path): - call("create_character", { - "name": "Test", "race": "Human", "char_class": "Fighter", - "level": 1, "abilities": { - "strength": 16, "dexterity": 12, "constitution": 14, - "intelligence": 10, "wisdom": 13, "charisma": 8, - }, - "hp_max": 12, "ac": 16, - }) - result = call("update_character", {"updates": {"state.hp.current": 8}}) + def test_landed_in_state_hp_current( + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall + ): + mcp_call( + "create_character", + { + "name": "Test", + "race": "Human", + "char_class": "Fighter", + "level": 1, + "abilities": { + "strength": 16, + "dexterity": 12, + "constitution": 14, + "intelligence": 10, + "wisdom": 13, + "charisma": 8, + }, + "hp_max": 12, + "ac": 16, + }, + ) + result = mcp_call("update_character", {"updates": {"state.hp.current": 8}}) assert "updated" in result.lower() data = load_character(ctx.player_id) @@ -139,29 +118,34 @@ class TestNoteDiscoveryDirect: """Direct (non-MCP) calls to note_discovery exercise the wrapper itself.""" def test_creates_knowledge_file(self, ctx: ToolContext, tmp_path: Path): - call_tool(_note_discovery, entity="The Rusty Anchor", - content="A seedy tavern on the docks") + call_tool( + _note_discovery, + entity="The Rusty Anchor", + content="A seedy tavern on the docks", + ) knowledge_dir = ( - tmp_path / "players" / ctx.player_id / "worlds" - / ctx.world_id / "lore" + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" ) assert any(knowledge_dir.iterdir()) def test_with_content_type(self, ctx: ToolContext, tmp_path: Path): - call_tool(_note_discovery, entity="Vera", content="Tavern owner", - content_type="npcs") + call_tool( + _note_discovery, entity="Vera", content="Tavern owner", content_type="npcs" + ) knowledge_dir = ( - tmp_path / "players" / ctx.player_id / "worlds" - / ctx.world_id / "npcs" + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "npcs" ) assert any(knowledge_dir.iterdir()) def test_with_tags(self, ctx: ToolContext, tmp_path: Path): - call_tool(_note_discovery, entity="Old Map", - content="Shows a hidden passage", tags=["quest"]) + call_tool( + _note_discovery, + entity="Old Map", + content="Shows a hidden passage", + tags=["quest"], + ) knowledge_dir = ( - tmp_path / "players" / ctx.player_id / "worlds" - / ctx.world_id / "lore" + tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore" ) content = next(knowledge_dir.iterdir()).read_text() assert "quest" in content @@ -184,8 +168,28 @@ class TestEndSessionDirect: # --- Recall with indexed content -------------------------------------------- +@pytest.fixture +def three_indexed_npcs(ctx: ToolContext) -> list[str]: + """Index 3 dock-loitering NPCs and return their names.""" + names = [f"NPC {i}" for i in range(3)] + for i, name in enumerate(names): + ctx.vector_index.upsert( + f"world:npcs/npc{i}.md:0", + f"NPC number {i} who hangs around the docks", + { + "source": "world", + "content_type": "npcs", + "path": f"/fake/npc{i}.md", + "title": name, + }, + ) + return names + + class TestRecall: - def test_recall_finds_indexed_entity(self, ctx: ToolContext, tmp_path: Path): + def test_recall_finds_indexed_entity( + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall + ): entity_dir = tmp_path / "worlds" / ctx.world_id / "npcs" entity_dir.mkdir(parents=True, exist_ok=True) entity_file = entity_dir / "Vera Blackwater.md" @@ -194,31 +198,28 @@ class TestRecall: ctx.vector_index.upsert( "world:npcs/Vera Blackwater.md:0", "Vera Blackwater. Tavern owner, former smuggler.", - {"source": "world", "content_type": "npcs", - "path": str(entity_file), "title": "Vera Blackwater"}, + { + "source": "world", + "content_type": "npcs", + "path": str(entity_file), + "title": "Vera Blackwater", + }, ) - result = call("recall", {"query": "Vera Blackwater"}) + result = mcp_call("recall", {"query": "Vera Blackwater"}) assert "Vera Blackwater" in result - def test_recall_rules_scope(self, ctx: ToolContext): - result = call("recall", {"query": "fireball", "scope": "rules"}) + def test_recall_rules_scope(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("recall", {"query": "fireball", "scope": "rules"}) assert "Nothing found" in result - def test_recall_world_scope(self, ctx: ToolContext): - result = call("recall", {"query": "something", "scope": "world"}) + def test_recall_world_scope(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("recall", {"query": "something", "scope": "world"}) assert "Nothing found" in result - def test_recall_multiple_hits(self, ctx: ToolContext): - for i in range(3): - ctx.vector_index.upsert( - f"world:npcs/npc{i}.md:0", - f"NPC number {i} who hangs around the docks", - {"source": "world", "content_type": "npcs", - "path": f"/fake/npc{i}.md", "title": f"NPC {i}"}, - ) - - result = call("recall", {"query": "docks NPC"}) + @pytest.mark.usefixtures("three_indexed_npcs") + def test_recall_multiple_hits(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("recall", {"query": "docks NPC"}) assert "Found" in result or "Nothing found" in result @@ -226,8 +227,10 @@ class TestRecall: class TestDamageHealCombat: - def test_damage_combatant_in_initiative(self, ctx: ToolContext): - result = call_in_combat( + def test_damage_combatant_in_initiative( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + result = mcp_call_in_combat( "damage", {"target": "Goblin", "amount": 3}, [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], @@ -235,21 +238,41 @@ class TestDamageHealCombat: assert "3" in result assert ctx.initiative._find("Goblin").hp == 4 - def test_damage_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): - call("create_character", { - "name": "Kira", "race": "Human", "char_class": "Fighter", - "level": 1, "abilities": { - "strength": 16, "dexterity": 12, "constitution": 14, - "intelligence": 10, "wisdom": 13, "charisma": 8, + def test_damage_syncs_player_hp( + self, + ctx: ToolContext, + tmp_path: Path, + mcp_call: McpCall, + mcp_call_in_combat: McpCallInCombat, + ): + mcp_call( + "create_character", + { + "name": "Kira", + "race": "Human", + "char_class": "Fighter", + "level": 1, + "abilities": { + "strength": 16, + "dexterity": 12, + "constitution": 14, + "intelligence": 10, + "wisdom": 13, + "charisma": 8, + }, + "hp_max": 25, + "ac": 16, }, - "hp_max": 25, "ac": 16, - }) + ) - result = call_in_combat( + result = mcp_call_in_combat( "damage", {"target": "Kira", "amount": 7}, - [Combatant(name="Kira", initiative=18, hp=25, hp_max=25, - ac=16, is_player=True)], + [ + Combatant( + name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True + ) + ], ) assert "synced" in result @@ -258,22 +281,42 @@ class TestDamageHealCombat: "_sync_player_hp must write to state.hp.current with the new schema" ) - def test_heal_syncs_player_hp(self, ctx: ToolContext, tmp_path: Path): - call("create_character", { - "name": "Kira", "race": "Human", "char_class": "Fighter", - "level": 1, "abilities": { - "strength": 16, "dexterity": 12, "constitution": 14, - "intelligence": 10, "wisdom": 13, "charisma": 8, + def test_heal_syncs_player_hp( + self, + ctx: ToolContext, + tmp_path: Path, + mcp_call: McpCall, + mcp_call_in_combat: McpCallInCombat, + ): + mcp_call( + "create_character", + { + "name": "Kira", + "race": "Human", + "char_class": "Fighter", + "level": 1, + "abilities": { + "strength": 16, + "dexterity": 12, + "constitution": 14, + "intelligence": 10, + "wisdom": 13, + "charisma": 8, + }, + "hp_max": 25, + "ac": 16, }, - "hp_max": 25, "ac": 16, - }) - call("update_character", {"updates": {"state.hp.current": 20}}) + ) + mcp_call("update_character", {"updates": {"state.hp.current": 20}}) - result = call_in_combat( + result = mcp_call_in_combat( "heal", {"target": "Kira", "amount": 3}, - [Combatant(name="Kira", initiative=18, hp=20, hp_max=25, - ac=16, is_player=True)], + [ + Combatant( + name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True + ) + ], ) assert "synced" in result @@ -282,16 +325,18 @@ class TestDamageHealCombat: "_sync_player_hp must write to state.hp.current with the new schema" ) - def test_damage_no_sync_for_non_player(self, ctx: ToolContext): - result = call_in_combat( + def test_damage_no_sync_for_non_player( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + result = mcp_call_in_combat( "damage", {"target": "Goblin", "amount": 3}, [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], ) assert "synced" not in result - def test_unknown_target_returns_error(self, ctx: ToolContext): - result = call("damage", {"target": "Nobody", "amount": 5}) + def test_unknown_target_returns_error(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("damage", {"target": "Nobody", "amount": 5}) assert "No such target" in result @@ -306,13 +351,16 @@ class TestCombatTools: so the wrapper bodies (not just the underlying tracker) get exercised. """ - def test_enter_initiative_starts_combat(self, ctx: ToolContext): - result = call_in_combat( + def test_enter_initiative_starts_combat( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + result = mcp_call_in_combat( "next_turn", {}, [ - Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, - is_player=True), + Combatant( + name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True + ), Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), ], ) @@ -320,67 +368,72 @@ class TestCombatTools: # next_turn from Kira should advance to Goblin assert "Goblin" in result - def test_enter_initiative_via_client(self, ctx: ToolContext): + def test_enter_initiative_via_client( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): """Drive enter_initiative through the in-memory client so the parsing + tracker.begin + flip path runs end-to-end.""" - async def _run() -> str: - from fastmcp import Client - server = await _compose_server("dm") - try: - async with Client(server) as client: - r = await client.call_tool("enter_initiative", { - "combatants": [ - {"name": "Kira", "initiative": 18, "hp": 25, - "hp_max": 25, "ac": 16, "is_player": True}, - {"name": "Goblin", "initiative": 10, "hp": 7, - "hp_max": 7, "ac": 15}, - ], - }) - return r.data - finally: - _flip_out_of_combat() - - result = asyncio.run(_run()) + result = mcp_call_in_combat( + "enter_initiative", + { + "combatants": [ + { + "name": "Kira", + "initiative": 18, + "hp": 25, + "hp_max": 25, + "ac": 16, + "is_player": True, + }, + { + "name": "Goblin", + "initiative": 10, + "hp": 7, + "hp_max": 7, + "ac": 15, + }, + ], + }, + ) assert "Initiative started" in result or "Round 1" in result assert ctx.initiative.active - def test_enter_initiative_when_already_active_errors(self, ctx: ToolContext): + def test_enter_initiative_when_already_active_errors( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): # Start combat manually so the wrapper hits the early-return guard - ctx.initiative.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) - - async def _run() -> str: - from fastmcp import Client - server = await _compose_server("dm") - _flip_into_combat() - try: - async with Client(server) as client: - r = await client.call_tool("enter_initiative", { - "combatants": [{ - "name": "X", "initiative": 1, "hp": 1, "hp_max": 1, - "ac": 10, - }], - }) - return r.data - finally: - _flip_out_of_combat() - - result = asyncio.run(_run()) + result = mcp_call_in_combat( + "enter_initiative", + { + "combatants": [ + { + "name": "X", + "initiative": 1, + "hp": 1, + "hp_max": 1, + "ac": 10, + } + ], + }, + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], + ) assert "already active" in result.lower() - def test_add_combatant_inserts_into_initiative(self, ctx: ToolContext): - result = call_in_combat( + def test_add_combatant_inserts_into_initiative( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + result = mcp_call_in_combat( "add_combatant", - {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, - "ac": 14}, + {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, "ac": 14}, [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], ) assert "Reinforcement" in result assert ctx.initiative._find("Reinforcement") is not None - def test_remove_combatant_removes_from_initiative(self, ctx: ToolContext): - result = call_in_combat( + def test_remove_combatant_removes_from_initiative( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + result = mcp_call_in_combat( "remove_combatant", {"name": "Goblin"}, [ @@ -391,8 +444,8 @@ class TestCombatTools: assert "removed" in result.lower() assert ctx.initiative._find("Goblin") is None - def test_condition_add(self, ctx: ToolContext): - result = call_in_combat( + def test_condition_add(self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat): + result = mcp_call_in_combat( "condition", {"target": "Goblin", "condition": "Poisoned"}, [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], @@ -402,49 +455,34 @@ class TestCombatTools: goblin = ctx.initiative._find("Goblin") assert any(c.name == "Poisoned" for c in goblin.conditions) - def test_condition_remove(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) + def test_condition_remove( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + ctx.initiative.begin( + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)] + ) ctx.initiative.add_condition(target="Goblin", condition="Stunned") - async def _run() -> str: - from fastmcp import Client - server = await _compose_server("dm") - _flip_into_combat() - try: - async with Client(server) as client: - r = await client.call_tool("condition", { - "target": "Goblin", - "condition": "Stunned", - "action": "remove", - }) - return r.data - finally: - _flip_out_of_combat() - - result = asyncio.run(_run()) + result = mcp_call_in_combat( + "condition", + { + "target": "Goblin", + "condition": "Stunned", + "action": "remove", + }, + ) assert "removed" in result.lower() or "Stunned" in result goblin = ctx.initiative._find("Goblin") assert not any(c.name == "Stunned" for c in goblin.conditions) - def test_end_initiative_clears_combat(self, ctx: ToolContext): - ctx.initiative.begin([ - Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), - ]) - - async def _run() -> str: - from fastmcp import Client - server = await _compose_server("dm") - _flip_into_combat() - try: - async with Client(server) as client: - r = await client.call_tool("end_initiative", {}) - return r.data - finally: - _flip_out_of_combat() - - result = asyncio.run(_run()) + def test_end_initiative_clears_combat( + self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat + ): + result = mcp_call_in_combat( + "end_initiative", + {}, + [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)], + ) assert "ended" in result.lower() assert not ctx.initiative.active @@ -453,16 +491,27 @@ class TestCombatTools: @pytest.fixture -def kira(ctx: ToolContext) -> ToolContext: +def kira(ctx: ToolContext, mcp_call: McpCall) -> ToolContext: """A minimum-viable character used by the wrapper-coverage tests.""" - call("create_character", { - "name": "Kira", "race": "Human", "char_class": "Fighter", - "level": 1, "abilities": { - "strength": 16, "dexterity": 12, "constitution": 14, - "intelligence": 10, "wisdom": 13, "charisma": 8, + mcp_call( + "create_character", + { + "name": "Kira", + "race": "Human", + "char_class": "Fighter", + "level": 1, + "abilities": { + "strength": 16, + "dexterity": 12, + "constitution": 14, + "intelligence": 10, + "wisdom": 13, + "charisma": 8, + }, + "hp_max": 12, + "ac": 16, }, - "hp_max": 12, "ac": 16, - }) + ) return ctx @@ -472,113 +521,138 @@ class TestCharacterToolWrappers: client so the wrapper bodies (and their Dependency-resolved arguments) are actually executed.""" - def test_damage_player_by_name(self, kira: ToolContext): - result = call("damage", {"target": "Kira", "amount": 3}) + def test_damage_player_by_name(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("damage", {"target": "Kira", "amount": 3}) assert "3" in result char = load_character("default") assert char["state"]["hp"]["current"] == 9 - def test_damage_with_type(self, kira: ToolContext): - result = call("damage", {"target": "Kira", "amount": 2, "type": "fire"}) + def test_damage_with_type(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("damage", {"target": "Kira", "amount": 2, "type": "fire"}) assert "2" in result - def test_heal_player_by_name(self, kira: ToolContext): - call("damage", {"target": "Kira", "amount": 5}) - result = call("heal", {"target": "Kira", "amount": 3}) + def test_heal_player_by_name(self, kira: ToolContext, mcp_call: McpCall): + mcp_call("damage", {"target": "Kira", "amount": 5}) + result = mcp_call("heal", {"target": "Kira", "amount": 3}) assert result char = load_character("default") assert char["state"]["hp"]["current"] == 10 - def test_adjust_coins(self, kira: ToolContext): - result = call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) + def test_adjust_coins(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}}) assert "10" in result char = load_character("default") assert char["state"]["purse"]["gp"] == 10 assert char["state"]["purse"]["sp"] == 5 - def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext): + def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext, mcp_call: McpCall): # Spending only gp; the zero-delta filter exercises the comprehension branch - call("adjust_coins", {"deltas": {"gp": 5}}) - result = call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) + mcp_call("adjust_coins", {"deltas": {"gp": 5}}) + result = mcp_call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}}) char = load_character("default") assert char["state"]["purse"]["gp"] == 2 assert "silver" not in result.lower() - def test_add_effect(self, kira: ToolContext): - result = call("add_effect", { - "source": "Bless", "description": "+1d4 attacks/saves", - }) + def test_add_effect(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call( + "add_effect", + { + "source": "Bless", + "description": "+1d4 attacks/saves", + }, + ) assert result - def test_add_effect_with_expires(self, kira: ToolContext): - result = call("add_effect", { - "source": "Heroism", "description": "+10 temp HP", - "expires": "d1-1430", - }) + def test_add_effect_with_expires(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call( + "add_effect", + { + "source": "Heroism", + "description": "+10 temp HP", + "expires": "d1-1430", + }, + ) assert result - def test_remove_effect(self, kira: ToolContext): - call("add_effect", {"source": "Bless", "description": "+1d4"}) - result = call("remove_effect", {"source": "Bless"}) + def test_remove_effect(self, kira: ToolContext, mcp_call: McpCall): + mcp_call("add_effect", {"source": "Bless", "description": "+1d4"}) + result = mcp_call("remove_effect", {"source": "Bless"}) assert result - def test_add_and_remove_condition(self, kira: ToolContext): - result = call("add_condition", {"name": "Poisoned"}) + def test_add_and_remove_condition(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("add_condition", {"name": "Poisoned"}) assert result - result = call("remove_condition", {"name": "Poisoned"}) + result = mcp_call("remove_condition", {"name": "Poisoned"}) assert result - def test_add_item_default_location(self, kira: ToolContext): - result = call("add_item", {"item": "Lockpicks"}) + def test_add_item_default_location(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("add_item", {"item": "Lockpicks"}) assert result - def test_add_item_with_location(self, kira: ToolContext): - result = call("add_item", { - "item": "Spare cloak", "location": "stashed_at_inn", - }) + def test_add_item_with_location(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call( + "add_item", + { + "item": "Spare cloak", + "location": "stashed_at_inn", + }, + ) assert result - def test_remove_item(self, kira: ToolContext): - call("add_item", {"item": "Boot knife"}) - result = call("remove_item", {"item": "Boot knife"}) + def test_remove_item(self, kira: ToolContext, mcp_call: McpCall): + mcp_call("add_item", {"item": "Boot knife"}) + result = mcp_call("remove_item", {"item": "Boot knife"}) assert result - def test_set_item_status(self, kira: ToolContext): + def test_set_item_status(self, kira: ToolContext, mcp_call: McpCall): # The item must already be a known magic item entity for status tracking - call("establish", { - "entity_type": "items", "name": "Bracer of Defense", - "description": "A leather bracer with a faint silver sheen.", - }) - result = call("set_item_status", { - "item": "Bracer of Defense", "status": "attuned", - }) + mcp_call( + "establish", + { + "entity_type": "items", + "name": "Bracer of Defense", + "description": "A leather bracer with a faint silver sheen.", + }, + ) + result = mcp_call( + "set_item_status", + { + "item": "Bracer of Defense", + "status": "attuned", + }, + ) assert result - def test_adjust_resource(self, kira: ToolContext): + def test_adjust_resource(self, kira: ToolContext, mcp_call: McpCall): # Add a resource via update_character first - call("update_character", { - "updates": { - "resources.hit_dice_d10": { - "current": 1, "max": 1, "refresh": "long_rest", - "notes": "Hit Dice (d10)", + mcp_call( + "update_character", + { + "updates": { + "resources.hit_dice_d10": { + "current": 1, + "max": 1, + "refresh": "long_rest", + "notes": "Hit Dice (d10)", + }, }, }, - }) - result = call("adjust_resource", {"name": "hit_dice", "delta": -1}) + ) + result = mcp_call("adjust_resource", {"name": "hit_dice", "delta": -1}) assert "Used" in result - result = call("adjust_resource", {"name": "hit_dice", "delta": 1}) + result = mcp_call("adjust_resource", {"name": "hit_dice", "delta": 1}) assert "Restored" in result - def test_rest_short(self, kira: ToolContext): - result = call("rest", {"type": "short"}) + def test_rest_short(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("rest", {"type": "short"}) assert result - def test_rest_long(self, kira: ToolContext): - result = call("rest", {"type": "long"}) + def test_rest_long(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("rest", {"type": "long"}) assert result - def test_add_note(self, kira: ToolContext): - result = call("add_note", {"text": "Found a hidden passage"}) + def test_add_note(self, kira: ToolContext, mcp_call: McpCall): + result = mcp_call("add_note", {"text": "Found a hidden passage"}) assert result @@ -589,56 +663,86 @@ class TestSceneToolWrappers: """Cover the scene.py wrapper bodies — set_scene's optional-field branches, tune's file write, end_session's threads branch, notify_dm's append.""" - def test_set_scene_event_only(self, ctx: ToolContext): - result = call("set_scene", { - "event": "Walked into the tavern", - "duration": "5 min", - }) + def test_set_scene_event_only(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "set_scene", + { + "event": "Walked into the tavern", + "duration": "5 min", + }, + ) assert "Logged" in result - def test_set_scene_with_situation_and_location(self, ctx: ToolContext): - result = call("set_scene", { - "event": "Arrived at the inn", - "duration": "1 hour", - "situation": "Resting by the fire", - "location": "The Rusty Anchor", - }) + def test_set_scene_with_situation_and_location( + self, ctx: ToolContext, mcp_call: McpCall + ): + result = mcp_call( + "set_scene", + { + "event": "Arrived at the inn", + "duration": "1 hour", + "situation": "Resting by the fire", + "location": "The Rusty Anchor", + }, + ) assert "Logged" in result assert "updated" in result.lower() - def test_set_scene_with_present_auto_marks(self, ctx: ToolContext): + def test_set_scene_with_present_auto_marks( + self, ctx: ToolContext, mcp_call: McpCall + ): # Establish an entity first so auto-mark has something to find - call("establish", { - "entity_type": "npcs", "name": "Vera", - "description": "Tavern owner.", - }) - result = call("set_scene", { - "event": "Spoke with Vera", - "duration": "10 min", - "present": ["[[Vera]]"], - }) + mcp_call( + "establish", + { + "entity_type": "npcs", + "name": "Vera", + "description": "Tavern owner.", + }, + ) + result = mcp_call( + "set_scene", + { + "event": "Spoke with Vera", + "duration": "10 min", + "present": ["[[Vera]]"], + }, + ) assert "Auto-marked: Vera" in result def test_auto_mark_cooldown_suppresses_near_repeats( - self, ctx: ToolContext, tmp_path: Path, + self, + ctx: ToolContext, + tmp_path: Path, + mcp_call: McpCall, ): """A second set_scene with the same present entity within the cooldown window should NOT append another Was entry.""" - call("establish", { - "entity_type": "npcs", "name": "Margit", - "description": "Chandler.", - }) - - call("set_scene", { - "event": "Met Mira at the candle shop", - "duration": "10 min", - "present": ["[[Margit]]"], - }) - result2 = call("set_scene", { - "event": "Walked together to dinner", - "duration": "10 min", - "present": ["[[Margit]]"], - }) + mcp_call( + "establish", + { + "entity_type": "npcs", + "name": "Margit", + "description": "Chandler.", + }, + ) + + mcp_call( + "set_scene", + { + "event": "Met Mira at the candle shop", + "duration": "10 min", + "present": ["[[Margit]]"], + }, + ) + result2 = mcp_call( + "set_scene", + { + "event": "Walked together to dinner", + "duration": "10 min", + "present": ["[[Margit]]"], + }, + ) assert "Auto-marked" not in result2 content = ( @@ -647,43 +751,67 @@ class TestSceneToolWrappers: assert content.count("Met Mira at the candle shop") == 1 assert "Walked together to dinner" not in content - def test_auto_mark_cooldown_clears_after_window(self, ctx: ToolContext): + def test_auto_mark_cooldown_clears_after_window( + self, ctx: ToolContext, mcp_call: McpCall + ): """After more than cooldown minutes have passed in-game, the next auto-mark for the same entity fires again.""" - call("establish", { - "entity_type": "npcs", "name": "Aldric", - "description": "Bookseller.", - }) - - call("set_scene", { - "event": "Briefed Aldric on the investigation", - "duration": "30 min", # advances the clock past the cooldown - "present": ["[[Aldric]]"], - }) - call("set_scene", { - "event": "Walked away to get lunch", - "duration": "30 min", - }) - result3 = call("set_scene", { - "event": "Returned and shared a new lead with Aldric", - "duration": "20 min", - "present": ["[[Aldric]]"], - }) + mcp_call( + "establish", + { + "entity_type": "npcs", + "name": "Aldric", + "description": "Bookseller.", + }, + ) + + mcp_call( + "set_scene", + { + "event": "Briefed Aldric on the investigation", + "duration": "30 min", # advances the clock past the cooldown + "present": ["[[Aldric]]"], + }, + ) + mcp_call( + "set_scene", + { + "event": "Walked away to get lunch", + "duration": "30 min", + }, + ) + result3 = mcp_call( + "set_scene", + { + "event": "Returned and shared a new lead with Aldric", + "duration": "20 min", + "present": ["[[Aldric]]"], + }, + ) assert "Auto-marked: Aldric" in result3 - def test_auto_mark_skips_operational_events(self, ctx: ToolContext, tmp_path: Path): + def test_auto_mark_skips_operational_events( + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall + ): """Session-lifecycle events (Session resumed, etc) must never land in an entity's history.""" - call("establish", { - "entity_type": "npcs", "name": "Dortha", - "description": "Tanner.", - }) - result = call("set_scene", { - "event": "Session resumed. Mira at Dortha's shop.", - "duration": "0 min", - "present": ["[[Dortha]]"], - }) + mcp_call( + "establish", + { + "entity_type": "npcs", + "name": "Dortha", + "description": "Tanner.", + }, + ) + result = mcp_call( + "set_scene", + { + "event": "Session resumed. Mira at Dortha's shop.", + "duration": "0 min", + "present": ["[[Dortha]]"], + }, + ) assert "Auto-marked" not in result content = ( @@ -691,39 +819,51 @@ class TestSceneToolWrappers: ).read_text() assert "Session resumed" not in content - def test_set_scene_with_threads(self, ctx: ToolContext): - result = call("set_scene", { - "event": "Got a lead", - "duration": "5 min", - "threads": ["Find the missing merchant"], - }) + def test_set_scene_with_threads(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "set_scene", + { + "event": "Got a lead", + "duration": "5 min", + "threads": ["Find the missing merchant"], + }, + ) assert result - def test_set_scene_no_args_returns_no_updates(self, ctx: ToolContext): + def test_set_scene_no_args_returns_no_updates( + self, ctx: ToolContext, mcp_call: McpCall + ): # Both event and duration omitted, no other fields → "No updates" - result = call("set_scene", {}) + result = mcp_call("set_scene", {}) assert result == "No updates" - def test_tune_writes_style_file(self, ctx: ToolContext, tmp_path: Path): - result = call("tune", {"tuning": "Lean into intrigue and slow pacing."}) + def test_tune_writes_style_file( + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall + ): + result = mcp_call("tune", {"tuning": "Lean into intrigue and slow pacing."}) assert "updated" in result.lower() style_path = tmp_path / "worlds" / ctx.world_id / "style.md" assert style_path.exists() assert "intrigue" in style_path.read_text() - def test_end_session_no_threads(self, ctx: ToolContext): - result = call("end_session", {"situation": "In the tavern"}) + def test_end_session_no_threads(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call("end_session", {"situation": "In the tavern"}) assert result == "SESSION_ENDED" - def test_end_session_with_threads(self, ctx: ToolContext): - result = call("end_session", { - "situation": "In the tavern", - "threads": ["Investigate the warehouse", "Find the merchant"], - }) + def test_end_session_with_threads(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "end_session", + { + "situation": "In the tavern", + "threads": ["Investigate the warehouse", "Find the merchant"], + }, + ) assert result == "SESSION_ENDED" - def test_notify_dm_appends_to_queue(self, ctx: ToolContext, tmp_path: Path): - result = call("notify_dm", {"message": "Background world has shifted"}) + def test_notify_dm_appends_to_queue( + self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall + ): + result = mcp_call("notify_dm", {"message": "Background world has shifted"}) assert "queued" in result.lower() path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md" assert path.exists() @@ -734,16 +874,22 @@ class TestSceneToolWrappers: class TestRunCodeWrapper: - def test_run_code_simple(self, ctx: ToolContext): - result = call("run_code", { - "description": "Two plus two", - "code": "2 + 2", - }) + def test_run_code_simple(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "run_code", + { + "description": "Two plus two", + "code": "2 + 2", + }, + ) assert "4" in result - def test_run_code_with_print(self, ctx: ToolContext): - result = call("run_code", { - "description": "Print test", - "code": 'print("hello sandbox")', - }) + def test_run_code_with_print(self, ctx: ToolContext, mcp_call: McpCall): + result = mcp_call( + "run_code", + { + "description": "Print test", + "code": 'print("hello sandbox")', + }, + ) assert "hello sandbox" in result diff --git a/tests/test_initiative.py b/tests/test_initiative.py index 76576e2..4d30839 100644 --- a/tests/test_initiative.py +++ b/tests/test_initiative.py @@ -1,3 +1,6 @@ +# pyright: reportOptionalMemberAccess=false +# Tests reach into combatant lookups via ctx.initiative._find(name) and +# trust the result — the setup guarantees the named combatant exists. """Tests for the initiative tracking state machine. The FastMCP combat tool surface is tested separately in test_mcp_server.py @@ -10,7 +13,6 @@ import pytest from storied.initiative import ( Combatant, InitiativeTracker, - TrackedCondition, ) @@ -33,7 +35,9 @@ class TestTrackerLifecycle: def test_starts_inactive(self, tracker: InitiativeTracker): assert not tracker.active - def test_begin_activates(self, tracker: InitiativeTracker, combatants: list[Combatant]): + def test_begin_activates( + self, tracker: InitiativeTracker, combatants: list[Combatant] + ): tracker.begin(combatants) assert tracker.active @@ -41,13 +45,17 @@ class TestTrackerLifecycle: assert tracker.current_index == 0 def test_begin_preserves_list_order( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) assert [c.name for c in tracker.combatants] == ["Kira", "Goblin 1", "Goblin 2"] - def test_end_deactivates(self, tracker: InitiativeTracker, combatants: list[Combatant]): + def test_end_deactivates( + self, tracker: InitiativeTracker, combatants: list[Combatant] + ): tracker.begin(combatants) summary = tracker.end() @@ -55,7 +63,9 @@ class TestTrackerLifecycle: assert "1" in summary # round count def test_end_reports_defeated( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Goblin 1", 7) @@ -65,7 +75,9 @@ class TestTrackerLifecycle: assert "defeated" in summary.lower() def test_end_reports_duration( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.next_turn() @@ -78,7 +90,9 @@ class TestTrackerLifecycle: class TestTurnAdvancement: def test_next_turn_advances( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) assert tracker.current_combatant.name == "Kira" @@ -99,7 +113,9 @@ class TestTurnAdvancement: assert "Round 2" in result def test_skips_defeated( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Goblin 1", 7) # defeat Goblin 1 @@ -108,7 +124,9 @@ class TestTurnAdvancement: assert tracker.current_combatant.name == "Goblin 2" def test_hints_one_side_remaining( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Goblin 1", 7) @@ -120,7 +138,9 @@ class TestTurnAdvancement: class TestDamageAndHealing: def test_damage_reduces_hp( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.apply_damage("Goblin 1", 3) @@ -130,7 +150,9 @@ class TestDamageAndHealing: assert "3" in result # damage amount def test_damage_defeats_at_zero( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.apply_damage("Goblin 1", 7) @@ -141,7 +163,9 @@ class TestDamageAndHealing: assert "down" in result.lower() def test_damage_clamps_to_zero( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Goblin 1", 100) @@ -149,7 +173,9 @@ class TestDamageAndHealing: assert tracker._find("Goblin 1").hp == 0 def test_damage_reports_bloodied( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.apply_damage("Kira", 13) # 25 -> 12, half is 12 @@ -157,7 +183,9 @@ class TestDamageAndHealing: assert "bloodied" in result.lower() def test_heal_increases_hp( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Kira", 10) @@ -167,7 +195,9 @@ class TestDamageAndHealing: assert "5" in result def test_heal_clamps_to_max( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Kira", 5) @@ -176,7 +206,9 @@ class TestDamageAndHealing: assert tracker._find("Kira").hp == 25 def test_heal_revives_defeated( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Goblin 1", 7) @@ -189,7 +221,9 @@ class TestDamageAndHealing: assert not goblin.defeated def test_damage_unknown_target( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.apply_damage("Nobody", 5) @@ -197,7 +231,9 @@ class TestDamageAndHealing: assert "not found" in result.lower() def test_heal_unknown_target( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.apply_heal("Nobody", 5) @@ -207,7 +243,9 @@ class TestDamageAndHealing: class TestConditions: def test_add_condition( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") @@ -218,7 +256,9 @@ class TestConditions: assert "Prone" in result def test_remove_condition( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") @@ -229,7 +269,9 @@ class TestConditions: assert "Prone" in result def test_remove_nonexistent_condition( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.remove_condition("Goblin 1", "Invisible") @@ -237,7 +279,9 @@ class TestConditions: assert "not found" in result.lower() or "no" in result.lower() def test_condition_unknown_target( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.add_condition("Nobody", "Prone", duration=-1, source="Kira") @@ -245,13 +289,19 @@ class TestConditions: assert "not found" in result.lower() def test_effect_expires_end_of_source_turn( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): """Effect with ends_on='end' expires when leaving source's turn.""" tracker.begin(combatants) # Kira (idx 0) applies 1-round effect on Goblin 1, ends at end of Kira's turn tracker.add_condition( - "Goblin 1", "Stunned", duration=1, ends_on="end", source="Kira", + "Goblin 1", + "Stunned", + duration=1, + ends_on="end", + source="Kira", ) # Advance from Kira's turn -> processes end-of-Kira effects @@ -261,13 +311,19 @@ class TestConditions: assert not any(c.name == "Stunned" for c in goblin.conditions) def test_effect_expires_start_of_source_turn( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): """Effect with ends_on='start' expires when arriving at source's turn.""" tracker.begin(combatants) # Kira applies 1-round effect, ends at start of Kira's next turn tracker.add_condition( - "Goblin 1", "Frightened", duration=1, ends_on="start", source="Kira", + "Goblin 1", + "Frightened", + duration=1, + ends_on="start", + source="Kira", ) # Full round: Kira -> G1 -> G2 -> Kira (round 2, start of Kira's turn) @@ -279,7 +335,9 @@ class TestConditions: assert not any(c.name == "Frightened" for c in goblin.conditions) def test_indefinite_condition_persists( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): """Duration -1 never auto-expires.""" tracker.begin(combatants) @@ -293,12 +351,18 @@ class TestConditions: assert any(c.name == "Grappled" for c in goblin.conditions) def test_multi_round_duration( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): """2-round effect lasts through 2 full rounds of the source's turns.""" tracker.begin(combatants) tracker.add_condition( - "Goblin 1", "Held", duration=2, ends_on="end", source="Kira", + "Goblin 1", + "Held", + duration=2, + ends_on="end", + source="Kira", ) # Round 1: Kira -> G1 -> G2 (end of Kira's turn, duration 2 -> 1) @@ -318,7 +382,9 @@ class TestConditions: class TestAddRemoveCombatant: def test_add_combatant( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.add_combatant( @@ -329,7 +395,9 @@ class TestAddRemoveCombatant: assert "Archer" in result def test_add_inserts_by_initiative( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) # Kira(18), G1(14), G2(10) tracker.add_combatant( @@ -340,7 +408,9 @@ class TestAddRemoveCombatant: assert names == ["Kira", "Goblin 1", "Archer", "Goblin 2"] def test_add_before_current_adjusts_index( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.next_turn() # -> Goblin 1 (index 1) @@ -354,7 +424,9 @@ class TestAddRemoveCombatant: assert tracker.current_combatant.name == "Goblin 1" def test_remove_combatant( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.remove_combatant("Goblin 2") @@ -363,7 +435,9 @@ class TestAddRemoveCombatant: assert "Goblin 2" in result def test_remove_current_advances( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.next_turn() # -> Goblin 1 @@ -373,7 +447,9 @@ class TestAddRemoveCombatant: assert tracker.current_combatant.name == "Goblin 2" def test_remove_unknown( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) result = tracker.remove_combatant("Nobody") @@ -383,7 +459,9 @@ class TestAddRemoveCombatant: class TestFormatForContext: def test_includes_table( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) context = tracker.format_for_context() @@ -393,7 +471,9 @@ class TestFormatForContext: assert "25/25" in context # HP display def test_marks_current_turn( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) context = tracker.format_for_context() @@ -402,7 +482,9 @@ class TestFormatForContext: assert "Current turn" in context def test_shows_defeated( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.apply_damage("Goblin 1", 7) @@ -411,7 +493,9 @@ class TestFormatForContext: assert "~~Goblin 1~~" in context or "Defeated" in context def test_shows_conditions( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") @@ -420,7 +504,9 @@ class TestFormatForContext: assert "Prone" in context def test_shows_up_next( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) context = tracker.format_for_context() @@ -429,7 +515,9 @@ class TestFormatForContext: assert "Goblin 1" in context.split("Up next")[1] def test_shows_round( - self, tracker: InitiativeTracker, combatants: list[Combatant], + self, + tracker: InitiativeTracker, + combatants: list[Combatant], ): tracker.begin(combatants) context = tracker.format_for_context() diff --git a/tests/test_log.py b/tests/test_log.py index 6e3e047..e24ca0b 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,9 +1,9 @@ """Tests for campaign log functionality.""" -import pytest - from pathlib import Path +import pytest + from storied.log import ( CampaignLog, Duration, @@ -41,7 +41,7 @@ class TestGameTime: assert t.minute == 30 def test_from_anchor_invalid(self): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="anchor"): GameTime.from_anchor("invalid") def test_add_duration_minutes(self): @@ -283,7 +283,8 @@ def transcript(tmp_path: Path) -> TranscriptLog: class TestTranscriptLog: def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( - "I look around", "The tavern is dimly lit.", + "I look around", + "The tavern is dimly lit.", GameTime(day=1, hour=8, minute=0), ) path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" @@ -291,10 +292,13 @@ class TestTranscriptLog: def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( - "I look around", "The tavern is dimly lit.", + "I look around", + "The tavern is dimly lit.", GameTime(day=1, hour=8, minute=0), ) - content = (tmp_path / "worlds" / "test" / "transcripts" / "day+001.md").read_text() + content = ( + tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" + ).read_text() assert "> I look around" in content assert "The tavern is dimly lit." in content assert "### Day 1, 08:00" in content @@ -310,7 +314,8 @@ class TestTranscriptLog: def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( - "[Session starting]", "Welcome back!", + "[Session starting]", + "Welcome back!", GameTime(day=1, hour=8, minute=0), ) path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" @@ -319,7 +324,8 @@ class TestTranscriptLog: def test_recent_turns_limit(self, transcript: TranscriptLog): for i in range(15): transcript.append_turn( - f"Turn {i}", f"Response {i}.", + f"Turn {i}", + f"Response {i}.", GameTime(day=1, hour=8, minute=i), ) context = transcript.recent_turns(1, n=5) @@ -332,11 +338,13 @@ class TestTranscriptLog: def test_recent_turns_spans_days(self, transcript: TranscriptLog): transcript.append_turn( - "Yesterday", "Something happened.", + "Yesterday", + "Something happened.", GameTime(day=1, hour=20, minute=0), ) transcript.append_turn( - "Today", "Morning arrives.", + "Today", + "Morning arrives.", GameTime(day=2, hour=8, minute=0), ) context = transcript.recent_turns(2) @@ -346,7 +354,8 @@ class TestTranscriptLog: def test_includes_display_blocks(self, transcript: TranscriptLog): dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." transcript.append_turn( - "Look around", dm_response, + "Look around", + dm_response, GameTime(day=1, hour=8, minute=0), ) context = transcript.recent_turns(1) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index fdd3ef7..eeeae7b 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -21,6 +21,7 @@ def _names(role: str) -> set[str]: async def _gather() -> set[str]: server = await _compose_server(role) return {t.name for t in await server.list_tools()} + return asyncio.run(_gather()) @@ -40,10 +41,21 @@ class TestPerRoleComposition: def test_dm_includes_character_tools(self): names = _names("dm") for tool_name in ( - "damage", "heal", "adjust_coins", "add_effect", "remove_effect", - "add_condition", "remove_condition", "add_item", "remove_item", - "set_item_status", "adjust_resource", "rest", - "add_note", "update_character", "create_character", + "damage", + "heal", + "adjust_coins", + "add_effect", + "remove_effect", + "add_condition", + "remove_condition", + "add_item", + "remove_item", + "set_item_status", + "adjust_resource", + "rest", + "add_note", + "update_character", + "create_character", ): assert tool_name in names, f"missing {tool_name}" @@ -71,13 +83,21 @@ class TestPerRoleComposition: def test_planner_only_has_its_tools(self): assert _names("planner") == { - "establish", "mark", "amend_mark", "notify_dm", "recall", - "forge_culture", "generate_names", + "establish", + "mark", + "amend_mark", + "notify_dm", + "recall", + "forge_culture", + "generate_names", } def test_seeder_only_has_its_tools(self): assert _names("seeder") == { - "establish", "set_scene", "forge_culture", "generate_names", + "establish", + "set_scene", + "forge_culture", + "generate_names", } def test_advancement_only_has_its_tools(self): @@ -90,6 +110,7 @@ class TestPerRoleComposition: def test_arc_architect_in_all_roles(self): from storied.mcp_server import ALL_ROLES + assert "arc_architect" in ALL_ROLES @@ -107,6 +128,7 @@ class TestToolSchemas: if t.name == tool_name: return t.parameters raise AssertionError(f"tool {tool_name!r} not found") + return asyncio.run(_gather()) def test_enter_initiative_documents_combatant_shape(self): @@ -122,8 +144,14 @@ class TestToolSchemas: def test_create_character_documents_ability_keys(self): schema = self._schema("create_character") ability_props = schema["properties"]["abilities"]["properties"] - for ability in ("strength", "dexterity", "constitution", - "intelligence", "wisdom", "charisma"): + for ability in ( + "strength", + "dexterity", + "constitution", + "intelligence", + "wisdom", + "charisma", + ): assert ability in ability_props, ( f"create_character must document the {ability} ability score" ) @@ -147,23 +175,50 @@ class TestToolSchemas: assert denom in purse_props @pytest.mark.parametrize( - "tool_name,param,expected_values", + ("tool_name", "param", "expected_values"), [ ("rest", "type", {"short", "long"}), ("set_item_status", "status", {"attuned", "equipped", "carried"}), ("recall", "scope", {"rules", "world", "all"}), - ("establish", "entity_type", - {"npcs", "locations", "items", "factions", "threads", "lore", - "maps", "cultures"}), - ("mark", "entity_type", - {"npcs", "locations", "items", "factions", "threads", "maps", - "cultures"}), - ("note_discovery", "content_type", - {"npcs", "locations", "factions", "lore", "cultures"}), + ( + "establish", + "entity_type", + { + "npcs", + "locations", + "items", + "factions", + "threads", + "lore", + "maps", + "cultures", + }, + ), + ( + "mark", + "entity_type", + { + "npcs", + "locations", + "items", + "factions", + "threads", + "maps", + "cultures", + }, + ), + ( + "note_discovery", + "content_type", + {"npcs", "locations", "factions", "lore", "cultures"}, + ), ], ) def test_enum_parameters_expose_valid_values( - self, tool_name: str, param: str, expected_values: set[str], + self, + tool_name: str, + param: str, + expected_values: set[str], ): """Each conceptually-enum parameter must surface as a JSON Schema enum, not a free-form string. Guards against regression to bare `str`.""" @@ -228,6 +283,7 @@ class TestCombatTagFlip: def test_combat_control_stays_visible_through_cycle(self, ctx: ToolContext): """enter_initiative / end_initiative are tagged combat_control and must stay visible whether initiative is active or not.""" + async def _gather_combat_control() -> tuple[set[str], set[str], set[str]]: server = await _compose_server("dm") initial = {t.name for t in await server.list_tools()} @@ -248,6 +304,7 @@ class TestAdvancementVisibility: def test_level_up_hidden_at_compose_time(self, ctx: ToolContext): """Fresh compose should not expose level_up — nothing has granted it yet.""" + async def _gather() -> set[str]: server = await _compose_server("dm") return {t.name for t in await server.list_tools()} @@ -278,6 +335,7 @@ class TestAdvancementVisibility: def test_level_up_not_in_planner_compose(self, ctx: ToolContext): """Only the DM role cares about advancement visibility. Other roles don't have level_up at all, so the flip is a no-op for them.""" + async def _run() -> set[str]: server = await _compose_server("planner") return {t.name for t in await server.list_tools()} @@ -288,6 +346,7 @@ class TestAdvancementVisibility: def test_refresh_with_none_character_is_safe(self, ctx: ToolContext): """A character sheet that doesn't exist yet (pre-creation) should not crash the visibility flip.""" + async def _run() -> None: await _compose_server("dm") refresh_advancement_visibility(None) @@ -328,10 +387,13 @@ class TestPopulateIndex: vi = MagicMock() vi.has_source.return_value = False _populate_index( - world_dir, vi, srd_root=tmp_path / "srd-missing", + world_dir, + vi, + srd_root=tmp_path / "srd-missing", ) vi.reindex_directory.assert_called_once_with( - world_dir, source="world", + world_dir, + source="world", skip_subdirs=frozenset({"transcripts"}), ) @@ -370,7 +432,9 @@ class TestPopulateIndex: vi = MagicMock() vi.has_source.return_value = False _populate_index( - world_dir, vi, srd_root=tmp_path / "srd-missing", + world_dir, + vi, + srd_root=tmp_path / "srd-missing", ) # Should have indexed user and world, in that order assert vi.reindex_directory.call_args_list == [ @@ -453,7 +517,9 @@ class TestRulesLookupRace: """ def test_srd_stays_available_after_transcript_upsert_race( - self, tmp_path, monkeypatch, + self, + tmp_path, + monkeypatch, ): from storied import paths from storied.mcp_server import _populate_index @@ -477,7 +543,9 @@ class TestRulesLookupRace: seed_index.close() monkeypatch.setattr( - paths, "shipped_rules_path", lambda: tmp_path / "shipped", + paths, + "shipped_rules_path", + lambda: tmp_path / "shipped", ) world_dir = paths.world_path("default") @@ -515,7 +583,9 @@ class TestRulesLookupRace: assert any(h.source == "srd" for h in hits) def test_populate_is_idempotent_on_repeated_start_server( - self, tmp_path, monkeypatch, + self, + tmp_path, + monkeypatch, ): """A second start_server (onboarding → play handoff) must not wipe the world/transcript rows by re-copying the SRD seed.""" @@ -528,12 +598,16 @@ class TestRulesLookupRace: seed_db = srd_root / "search.db" seed_index = VectorIndex(seed_db) seed_index.upsert( - "srd:x.md:0", "# X", {"source": "srd", "path": "x.md"}, + "srd:x.md:0", + "# X", + {"source": "srd", "path": "x.md"}, ) seed_index.close() monkeypatch.setattr( - paths, "shipped_rules_path", lambda: tmp_path / "shipped", + paths, + "shipped_rules_path", + lambda: tmp_path / "shipped", ) world_dir = paths.world_path("default") diff --git a/tests/test_names_discipline.py b/tests/test_names_discipline.py index 6062aac..63f0f74 100644 --- a/tests/test_names_discipline.py +++ b/tests/test_names_discipline.py @@ -118,9 +118,7 @@ class TestForbiddenImportVisitor: assert self._violations("import storied.names.engine") == [] def test_storied_names_from_import_passes(self): - assert self._violations( - "from storied.names.engine import generator" - ) == [] + assert self._violations("from storied.names.engine import generator") == [] def test_relative_import_passes(self): assert self._violations("from . import forge") == [] diff --git a/tests/test_names_engine.py b/tests/test_names_engine.py index 43c743f..e8404bb 100644 --- a/tests/test_names_engine.py +++ b/tests/test_names_engine.py @@ -68,18 +68,21 @@ class TestPhonemeInventory: def test_sample_returns_phoneme(self, inventory: PhonemeInventory): import random + rng = random.Random(42) result = inventory.sample("V", rng) assert result in inventory.vowels def test_sample_unknown_class_raises(self, inventory: PhonemeInventory): import random + rng = random.Random(42) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Z"): inventory.sample("Z", rng) def test_trim_reduces_consonants(self, inventory: PhonemeInventory): import random + rng = random.Random(42) trimmed = inventory.trim(rng, drop_fraction=0.3) # 0.3 * 7 ≈ 2 dropped from 7 @@ -87,9 +90,12 @@ class TestPhonemeInventory: def test_trim_keeps_small_inventories(self): small = PhonemeInventory( - name="tiny", consonants=["k", "n"], vowels=["a", "i"], + name="tiny", + consonants=["k", "n"], + vowels=["a", "i"], ) import random + rng = random.Random(42) trimmed = small.trim(rng, drop_fraction=0.5) assert trimmed.consonants == ["k", "n"] @@ -132,11 +138,14 @@ class TestSampleSyllable: @pytest.fixture def inventory(self) -> PhonemeInventory: return PhonemeInventory( - name="x", consonants=["k", "t"], vowels=["a", "i"], + name="x", + consonants=["k", "t"], + vowels=["a", "i"], ) def test_returns_phoneme_list(self, inventory: PhonemeInventory): import random + rng = random.Random(42) slots = parse_template("CV") result = sample_syllable(slots, inventory, rng) @@ -144,14 +153,14 @@ class TestSampleSyllable: assert len(result) == 2 def test_optional_slot_sometimes_skipped( - self, inventory: PhonemeInventory, + self, + inventory: PhonemeInventory, ): import random + rng = random.Random(42) slots = parse_template("(C)V") - results = [ - sample_syllable(slots, inventory, rng) for _ in range(50) - ] + results = [sample_syllable(slots, inventory, rng) for _ in range(50)] # Some should be 1-element (skipped optional), some 2 lengths = {len(r) for r in results} assert 1 in lengths or 2 in lengths @@ -160,9 +169,12 @@ class TestSampleSyllable: class TestSampleWord: def test_produces_phonemes(self): import random + rng = random.Random(42) inv = PhonemeInventory( - name="x", consonants=["k", "t", "n"], vowels=["a", "i", "o"], + name="x", + consonants=["k", "t", "n"], + vowels=["a", "i", "o"], ) slots = [parse_template("CV")] word = sample_word(slots, inv, rng, syllable_count=(2, 2)) diff --git a/tests/test_names_forge.py b/tests/test_names_forge.py index ff8b45c..ab4555d 100644 --- a/tests/test_names_forge.py +++ b/tests/test_names_forge.py @@ -44,7 +44,8 @@ class TestCultureForge: assert c1.name == c2.name def test_forge_different_seeds_yield_different_cultures( - self, world_dir: Path, + self, + world_dir: Path, ): forge = CultureForge(world_path=world_dir) cultures = [forge.forge(seed=s) for s in range(10)] @@ -55,9 +56,16 @@ class TestCultureForge: # Forge several coastal cultures and check they came from # inventories tagged "coastal" in the data file. coastal_inventories = { - "welsh", "old-norse", "polynesian", "austronesian", - "iberian", "japonic", "arabic-port", "yoruboid", - "hellenic", "cornish", + "welsh", + "old-norse", + "polynesian", + "austronesian", + "iberian", + "japonic", + "arabic-port", + "yoruboid", + "hellenic", + "cornish", } for seed in range(5): forge2 = CultureForge(world_path=world_dir.parent / f"w{seed}") @@ -134,7 +142,9 @@ class TestGenerator: gen = Generator(world_path=populated_world) cultures = gen.list_cultures() places = gen.sample( - culture=cultures[0].name, count=3, kind="place", + culture=cultures[0].name, + count=3, + kind="place", ) assert len(places) == 3 @@ -149,7 +159,9 @@ class TestGenerator: gen = Generator(world_path=populated_world) cultures = gen.list_cultures() names = gen.sample( - culture=cultures[0].name, count=3, rarity="uncommon", + culture=cultures[0].name, + count=3, + rarity="uncommon", ) assert len(names) == 3 @@ -157,6 +169,8 @@ class TestGenerator: gen = Generator(world_path=populated_world) cultures = gen.list_cultures() names = gen.sample( - culture=cultures[0].name, count=3, rarity="archaic", + culture=cultures[0].name, + count=3, + rarity="archaic", ) assert len(names) == 3 diff --git a/tests/test_names_morphology.py b/tests/test_names_morphology.py index 480ce08..51b1747 100644 --- a/tests/test_names_morphology.py +++ b/tests/test_names_morphology.py @@ -38,14 +38,22 @@ class TestApplyMorphology: # bare_probability=1.0 → always skip rng = random.Random(42) result = apply_morphology( - "kara", gendered, "female", rng, bare_probability=1.0, + "kara", + gendered, + "female", + rng, + bare_probability=1.0, ) assert result == "kara" def test_female_suffix_applied(self, gendered: Morphology): rng = random.Random(42) result = apply_morphology( - "kar", gendered, "female", rng, bare_probability=0.0, + "kar", + gendered, + "female", + rng, + bare_probability=0.0, ) assert result.startswith("kar") assert any(result.endswith(s) for s in ["a", "ina"]) @@ -53,14 +61,22 @@ class TestApplyMorphology: def test_male_suffix_applied(self, gendered: Morphology): rng = random.Random(42) result = apply_morphology( - "kar", gendered, "male", rng, bare_probability=0.0, + "kar", + gendered, + "male", + rng, + bare_probability=0.0, ) assert any(result.endswith(s) for s in ["us", "or"]) def test_neutral_when_gender_is_none(self, gendered: Morphology): rng = random.Random(42) result = apply_morphology( - "kar", gendered, None, rng, bare_probability=0.0, + "kar", + gendered, + None, + rng, + bare_probability=0.0, ) assert result.endswith("en") @@ -73,7 +89,11 @@ class TestApplyMorphology: neutral_suffixes=[], ) result = apply_morphology( - "kar", morph, None, rng, bare_probability=0.0, + "kar", + morph, + None, + rng, + bare_probability=0.0, ) assert result == "kar" @@ -86,7 +106,11 @@ class TestApplyMorphology: neutral_suffixes=[""], ) result = apply_morphology( - "kar", morph, "female", rng, bare_probability=0.0, + "kar", + morph, + "female", + rng, + bare_probability=0.0, ) assert result == "kar" diff --git a/tests/test_names_tool.py b/tests/test_names_tool.py index 88d57d6..88c3640 100644 --- a/tests/test_names_tool.py +++ b/tests/test_names_tool.py @@ -107,15 +107,16 @@ class TestNamesRoleSurface: server = await _compose_server(role) tools = await server.list_tools() return { - t.name for t in tools - if t.name in ("forge_culture", "generate_names") + t.name for t in tools if t.name in ("forge_culture", "generate_names") } + return asyncio.run(_gather()) @pytest.mark.parametrize("role", ["dm", "planner", "seeder"]) def test_role_has_both_names_tools(self, role: str): assert self._names_for_role(role) == { - "forge_culture", "generate_names", + "forge_culture", + "generate_names", } def test_arc_architect_has_no_names_tools(self): diff --git a/tests/test_notification_formatters.py b/tests/test_notification_formatters.py index ea13e85..11e6082 100644 --- a/tests/test_notification_formatters.py +++ b/tests/test_notification_formatters.py @@ -16,7 +16,6 @@ from storied.notification_formatters import ( _parse_tool_args, ) - # --- Low-level helpers ------------------------------------------------------ @@ -91,6 +90,7 @@ class TestCoinNotification: def test_unknown_denomination_falls_back_to_code(self): # Defensive: unknown denom uses the raw key from storied.notification_formatters import _format_coin_notification + result = _format_coin_notification('{"deltas": {"xx": 5}}') # _DENOM_NAMES doesn't include 'xx', so it falls through the loop # without matching any of the known denoms — output is generic @@ -99,17 +99,22 @@ class TestCoinNotification: class TestDamageNotification: def test_with_target(self): - assert _format("damage", '{"target": "Goblin", "amount": 7}') == "Goblin takes 7 damage" + assert ( + _format("damage", '{"target": "Goblin", "amount": 7}') + == "Goblin takes 7 damage" + ) def test_with_type(self): - assert _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage" + assert ( + _format("damage", '{"amount": 5, "type": "fire"}') == "Taking 5 fire damage" + ) def test_plain(self): assert _format("damage", '{"amount": 3}') == "Taking 3 damage" def test_target_and_type_target_wins(self): result = _format("damage", '{"target": "Mira", "amount": 5, "type": "cold"}') - assert "Mira takes 5 damage" == result + assert result == "Mira takes 5 damage" def test_missing_amount_shows_question_mark(self): assert _format("damage", "{}") == "Taking ? damage" @@ -117,7 +122,9 @@ class TestDamageNotification: class TestHealNotification: def test_with_target(self): - assert _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5" + assert ( + _format("heal", '{"target": "Mira", "amount": 5}') == "Healing Mira for 5" + ) def test_plain(self): assert _format("heal", '{"amount": 8}') == "Healing 8 HP" @@ -131,13 +138,16 @@ class TestEffectNotification: def test_with_expires(self): result = _format( "add_effect", - '{"source": "Heroism", "description": "+10 temp HP", "expires": "d28-1430"}', + '{"source": "Heroism", "description": "+10 temp HP",' + ' "expires": "d28-1430"}', ) assert "Heroism" in result assert "until d28-1430" in result def test_remove_effect(self): - assert _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless" + assert ( + _format("remove_effect", '{"source": "Bless"}') == "Removing effect: Bless" + ) class TestConditionNotification: @@ -145,7 +155,10 @@ class TestConditionNotification: assert _format("add_condition", '{"name": "Poisoned"}') == "Becoming Poisoned" def test_remove_condition(self): - assert _format("remove_condition", '{"name": "Frightened"}') == "Recovering from Frightened" + assert ( + _format("remove_condition", '{"name": "Frightened"}') + == "Recovering from Frightened" + ) class TestItemNotification: @@ -157,10 +170,12 @@ class TestItemNotification: assert result == "Adding 'Coin pouch' to on_person" def test_remove_item(self): - assert _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'" + assert ( + _format("remove_item", '{"item": "Boot knife"}') == "Removing 'Boot knife'" + ) @pytest.mark.parametrize( - "status,verb", + ("status", "verb"), [ ("attuned", "Attuning to"), ("equipped", "Equipping"), @@ -178,24 +193,26 @@ class TestItemNotification: class TestResourceNotification: def test_use_one(self): - assert _format( - "adjust_resource", '{"name": "rage", "delta": -1}' - ) == "Using rage" + assert ( + _format("adjust_resource", '{"name": "rage", "delta": -1}') == "Using rage" + ) def test_use_multiple(self): - assert _format( - "adjust_resource", '{"name": "ki", "delta": -3}' - ) == "Using 3 of ki" + assert ( + _format("adjust_resource", '{"name": "ki", "delta": -3}') == "Using 3 of ki" + ) def test_restore(self): - assert _format( - "adjust_resource", '{"name": "ki", "delta": 2}' - ) == "Restoring 2 of ki" + assert ( + _format("adjust_resource", '{"name": "ki", "delta": 2}') + == "Restoring 2 of ki" + ) def test_zero_delta_fallback(self): - assert _format( - "adjust_resource", '{"name": "rage", "delta": 0}' - ) == "Adjusting rage" + assert ( + _format("adjust_resource", '{"name": "rage", "delta": 0}') + == "Adjusting rage" + ) class TestRestNotification: diff --git a/tests/test_notifications.py b/tests/test_notifications.py index ffc43a0..eebffa8 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -54,7 +54,9 @@ class TestAppendAndDrain: assert messages == ["Hello"] def test_drain_empty_existing_file_returns_empty( - self, world: str, tmp_path: Path, + self, + world: str, + tmp_path: Path, ): """An existing notifications file with only whitespace drains as empty and is removed.""" diff --git a/tests/test_planner.py b/tests/test_planner.py index 1d68d94..3ee7ca9 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -1,3 +1,6 @@ +# pyright: reportArgumentType=false +# Tests pass session dicts with `object` values where helpers expect `str`; +# the test harness shapes the dict correctly. """Tests for the world planner — entity richness scoring, discovery, and context.""" import json @@ -17,11 +20,10 @@ from storied.planner import ( plan_world, ) from storied.session import save_session +from storied.testing import call_tool from storied.tools import ToolContext from storied.tools.entities import establish, mark -from storied.testing import call_tool - @pytest.fixture def populated_world(ctx: ToolContext) -> ToolContext: @@ -30,7 +32,10 @@ def populated_world(ctx: ToolContext) -> ToolContext: establish, entity_type="locations", name="Town Square", - description="The center of [[Millford]]. A fountain stands here, surrounded by market stalls.", + description=( + "The center of [[Millford]]. A fountain stands here, " + "surrounded by market stalls." + ), location="Central [[Millford]]", knows=["The fountain was built by [[Old Gregor]]"], wants=["To be a gathering place"], @@ -213,7 +218,10 @@ class TestBuildPlanningContext: "default", { "location": "Town Square", - "body": "## Situation\nThe player just arrived.\n\n## Open Threads\n- Find the missing cat", + "body": ( + "## Situation\nThe player just arrived.\n\n" + "## Open Threads\n- Find the missing cat" + ), }, ) context = build_planning_context( @@ -225,7 +233,9 @@ class TestBuildPlanningContext: assert "missing cat" in context def test_includes_candidate_content( - self, populated_world: ToolContext, tmp_path: Path, + self, + populated_world: ToolContext, + tmp_path: Path, ): save_session( "default", @@ -257,8 +267,12 @@ class TestBuildPlanningContext: "default", {"location": "Town Square", "body": ""}, ) - populated_world.campaign_log.append_entry("Fought three goblins in the clearing", "5 rounds") - populated_world.campaign_log.append_entry("Spotted two lookouts near the old mill", "30 min") + populated_world.campaign_log.append_entry( + "Fought three goblins in the clearing", "5 rounds" + ) + populated_world.campaign_log.append_entry( + "Spotted two lookouts near the old mill", "30 min" + ) context = build_planning_context( world_id=populated_world.world_id, @@ -327,7 +341,9 @@ class TestPlanWorld: assert len(result.candidates) == 0 @patch("storied.claude.subprocess.Popen") - def test_plan_world_calls_claude(self, mock_popen: MagicMock, populated_world: ToolContext): + def test_plan_world_calls_claude( + self, mock_popen: MagicMock, populated_world: ToolContext + ): save_session( "default", { @@ -337,12 +353,14 @@ class TestPlanWorld: ) # Mock subprocess returning a result event - result_line = json.dumps({ - "type": "result", - "session_id": "sess-1", - "usage": {"input_tokens": 1000, "output_tokens": 200, "tool_calls": 0}, - "duration_ms": 5000, - }) + result_line = json.dumps( + { + "type": "result", + "session_id": "sess-1", + "usage": {"input_tokens": 1000, "output_tokens": 200, "tool_calls": 0}, + "duration_ms": 5000, + } + ) mock_proc = MagicMock() mock_proc.stdin = MagicMock() mock_proc.stdout = iter([result_line.encode() + b"\n"]) @@ -354,7 +372,7 @@ class TestPlanWorld: result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - model="claude-opus-4-6", + model="claude-opus-4-7", ) assert result.dry_run is False @@ -363,7 +381,9 @@ class TestPlanWorld: mock_popen.assert_called_once() @patch("storied.claude.subprocess.Popen") - def test_plan_world_counts_tool_calls(self, mock_popen: MagicMock, populated_world: ToolContext): + def test_plan_world_counts_tool_calls( + self, mock_popen: MagicMock, populated_world: ToolContext + ): save_session( "default", { @@ -374,25 +394,33 @@ class TestPlanWorld: # Stream with tool_use events followed by result lines = [ - json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 1, - "content_block": {"type": "tool_use", "id": "t1", "name": "mcp__storied__establish"}, - }, - }), - json.dumps({ - "type": "result", - "session_id": "sess-2", - "usage": {"input_tokens": 800, "output_tokens": 100}, + json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "t1", + "name": "mcp__storied__establish", + }, + }, + } + ), + json.dumps( + { + "type": "result", + "session_id": "sess-2", + "usage": {"input_tokens": 800, "output_tokens": 100}, "duration_ms": 3000, - }), + } + ), ] mock_proc = MagicMock() mock_proc.stdin = MagicMock() - mock_proc.stdout = iter([l.encode() + b"\n" for l in lines]) + mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 mock_proc.returncode = 0 @@ -401,7 +429,7 @@ class TestPlanWorld: result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, - model="claude-opus-4-6", + model="claude-opus-4-7", ) assert result.tool_calls == 1 @@ -438,7 +466,8 @@ class TestFindEntitiesWithWill: assert "Idle" not in names def test_skips_missing_type_directories( - self, populated_world: ToolContext, + self, + populated_world: ToolContext, ): # The fixture creates npcs and locations but not items/factions/threads. # The function should iterate without crashing. @@ -451,31 +480,42 @@ class TestFindEntitiesWithWill: class TestBuildTickContext: def test_includes_current_time(self, populated_world: ToolContext): ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, entities=[], + populated_world.world_id, + populated_world.player_id, + entities=[], ) assert "Current Game Time" in ctx_str def test_includes_session_location(self, populated_world: ToolContext): - save_session("default", { - "location": "Town Square", - "body": "## Present\n- [[Old Gregor]]", - }) + save_session( + "default", + { + "location": "Town Square", + "body": "## Present\n- [[Old Gregor]]", + }, + ) ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, entities=[], + populated_world.world_id, + populated_world.player_id, + entities=[], ) assert "Town Square" in ctx_str assert "Old Gregor" in ctx_str def test_includes_recent_events(self, populated_world: ToolContext): populated_world.campaign_log.append_entry( - "Met the merchant", "10 min", + "Met the merchant", + "10 min", ) populated_world.campaign_log.append_entry( - "Found the secret door", "5 min", + "Found the secret door", + "5 min", ) ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, entities=[], + populated_world.world_id, + populated_world.player_id, + entities=[], ) assert "Recent Events" in ctx_str assert "Met the merchant" in ctx_str @@ -492,7 +532,9 @@ class TestBuildTickContext: populated_world.world_id, ) ctx_str = build_tick_context( - populated_world.world_id, populated_world.player_id, entities=triggers, + populated_world.world_id, + populated_world.player_id, + entities=triggers, ) assert "Active Triggers" in ctx_str assert "Lurker" in ctx_str @@ -509,14 +551,16 @@ class TestBackgroundTicker: def test_init_stores_state(self, tmp_path: Path): ticker = BackgroundTicker( - world_id="test", player_id="default", + world_id="test", + player_id="default", ) assert ticker._world_id == "test" assert ticker._last_tick_day == 0 assert ticker._thread is None def test_maybe_tick_skips_when_day_unchanged( - self, populated_world: ToolContext, + self, + populated_world: ToolContext, ): ticker = BackgroundTicker( world_id=populated_world.world_id, @@ -542,6 +586,7 @@ class TestBackgroundTicker: def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): ticker = BackgroundTicker( - world_id="test", player_id="default", + world_id="test", + player_id="default", ) assert ticker.pop_result() is None diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index f79045a..c3a2c88 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -147,8 +147,17 @@ class TestToolSignatures: def test_includes_all_tools(self): sigs = build_tool_signatures() - for name in ["roll", "recall", "establish", "mark", "damage", "heal", - "enter_initiative", "end_initiative", "next_turn"]: + for name in [ + "roll", + "recall", + "establish", + "mark", + "damage", + "heal", + "enter_initiative", + "end_initiative", + "next_turn", + ]: assert f"{name}(" in sigs def test_excludes_dependency_params(self): @@ -157,6 +166,12 @@ class TestToolSignatures: sigs = build_tool_signatures() # None of the Dependency-class instances should appear as defaults - for marker in ("Combat()", "Lore()", "Player()", - "Timekeeper()", "Entities()", "World()"): + for marker in ( + "Combat()", + "Lore()", + "Player()", + "Timekeeper()", + "Entities()", + "World()", + ): assert marker not in sigs diff --git a/tests/test_search.py b/tests/test_search.py index 2233d02..d89f9ff 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -3,8 +3,8 @@ from pathlib import Path import pytest - from conftest import _fake_embed + from storied.search import SearchHit, VectorIndex, age_decay, chunk_document @@ -108,8 +108,7 @@ class TestChunkDocument: def test_splits_on_bold_definitions(self): content = "# Rules Glossary\n\n## Definitions\n\n" + "\n".join( - f"**Term {i}**\nDefinition of term {i}. {'y ' * 400}\n" - for i in range(5) + f"**Term {i}**\nDefinition of term {i}. {'y ' * 400}\n" for i in range(5) ) chunks = chunk_document(Path("glossary.md"), content) assert len(chunks) >= 5 @@ -125,9 +124,7 @@ class TestChunkDocument: assert any("Action Surge" in t for t in texts) def test_oversized_with_no_headings_splits_on_paragraphs(self): - content = "# Blob\n\n" + "\n\n".join( - "word " * 200 for _ in range(5) - ) + content = "# Blob\n\n" + "\n\n".join("word " * 200 for _ in range(5)) chunks = chunk_document(Path("blob.md"), content) assert len(chunks) > 1 assert any("word" in text for _, text in chunks) @@ -173,8 +170,12 @@ class TestVectorIndexUpsert: index.upsert( "srd:spells/fireball.md:0", "Fireball: 8d6 fire damage in a 20-foot radius", - {"source": "srd", "content_type": "spells", - "path": "/tmp/fireball.md", "title": "Fireball"}, + { + "source": "srd", + "content_type": "spells", + "path": "/tmp/fireball.md", + "title": "Fireball", + }, ) results = index.search("fireball") assert len(results) >= 1 @@ -182,20 +183,41 @@ class TestVectorIndexUpsert: def test_upsert_overwrites(self, index: VectorIndex): doc_id = "world:npcs/vex.md:0" - index.upsert(doc_id, "Captain Vex the pirate", - {"source": "world", "content_type": "npcs", - "path": "/tmp/vex.md", "title": "Captain Vex"}) - index.upsert(doc_id, "Captain Vex the reformed merchant", - {"source": "world", "content_type": "npcs", - "path": "/tmp/vex.md", "title": "Captain Vex"}) + index.upsert( + doc_id, + "Captain Vex the pirate", + { + "source": "world", + "content_type": "npcs", + "path": "/tmp/vex.md", + "title": "Captain Vex", + }, + ) + index.upsert( + doc_id, + "Captain Vex the reformed merchant", + { + "source": "world", + "content_type": "npcs", + "path": "/tmp/vex.md", + "title": "Captain Vex", + }, + ) stats = index.stats() assert stats["total_documents"] == 1 def test_delete(self, index: VectorIndex): doc_id = "srd:spells/fireball.md:0" - index.upsert(doc_id, "Fireball spell", - {"source": "srd", "content_type": "spells", - "path": "/tmp/fireball.md", "title": "Fireball"}) + index.upsert( + doc_id, + "Fireball spell", + { + "source": "srd", + "content_type": "spells", + "path": "/tmp/fireball.md", + "title": "Fireball", + }, + ) index.delete(doc_id) stats = index.stats() assert stats["total_documents"] == 0 @@ -211,23 +233,47 @@ class TestVectorIndexSearch: @pytest.fixture(autouse=True) def _populate(self, index: VectorIndex): docs = [ - ("srd:spells/fireball.md:0", - "Fireball: 8d6 fire damage in a 20-foot radius sphere", - {"source": "srd", "content_type": "spells", - "path": "/tmp/spells/fireball.md", "title": "Fireball"}), - ("srd:spells/cure-wounds.md:0", - "Cure Wounds: restore hit points by touch", - {"source": "srd", "content_type": "spells", - "path": "/tmp/spells/cure-wounds.md", "title": "Cure Wounds"}), - ("world:npcs/vex.md:0", - "Captain Vex is a notorious pirate who sails the Shattered Coast", - {"source": "world", "content_type": "npcs", - "path": "/tmp/npcs/vex.md", "title": "Captain Vex"}), - ("transcript:transcripts/day+001.md:0", - "Player asked about the harbor. DM described ships at dock.", - {"source": "transcript", "content_type": "transcripts", - "path": "/tmp/transcripts/day+001.md", "title": "Day 1", - "game_day": 1}), + ( + "srd:spells/fireball.md:0", + "Fireball: 8d6 fire damage in a 20-foot radius sphere", + { + "source": "srd", + "content_type": "spells", + "path": "/tmp/spells/fireball.md", + "title": "Fireball", + }, + ), + ( + "srd:spells/cure-wounds.md:0", + "Cure Wounds: restore hit points by touch", + { + "source": "srd", + "content_type": "spells", + "path": "/tmp/spells/cure-wounds.md", + "title": "Cure Wounds", + }, + ), + ( + "world:npcs/vex.md:0", + "Captain Vex is a notorious pirate who sails the Shattered Coast", + { + "source": "world", + "content_type": "npcs", + "path": "/tmp/npcs/vex.md", + "title": "Captain Vex", + }, + ), + ( + "transcript:transcripts/day+001.md:0", + "Player asked about the harbor. DM described ships at dock.", + { + "source": "transcript", + "content_type": "transcripts", + "path": "/tmp/transcripts/day+001.md", + "title": "Day 1", + "game_day": 1, + }, + ), ] for doc_id, text, meta in docs: index.upsert(doc_id, text, meta) @@ -267,10 +313,8 @@ class TestVectorIndexSearch: # Search with decay_ref far from day 1 transcript results_no_decay = index.search("harbor ships") results_decayed = index.search("harbor ships", decay_ref=100) - transcript_no_decay = [r for r in results_no_decay - if r.source == "transcript"] - transcript_decayed = [r for r in results_decayed - if r.source == "transcript"] + transcript_no_decay = [r for r in results_no_decay if r.source == "transcript"] + transcript_decayed = [r for r in results_decayed if r.source == "transcript"] if transcript_no_decay and transcript_decayed: assert transcript_decayed[0].score < transcript_no_decay[0].score @@ -294,14 +338,12 @@ class TestReindexDirectory: stats = index.stats() assert stats["total_documents"] == 5 - def test_reindex_updates_changed_files( - self, index: VectorIndex, srd_tree: Path - ): + def test_reindex_updates_changed_files(self, index: VectorIndex, srd_tree: Path): index.reindex_directory(srd_tree, source="srd") # Modify a file fireball = srd_tree / "spells" / "fireball.md" fireball.write_text("# Fireball\n\nNow deals 10d6 fire damage!\n") - count = index.reindex_directory(srd_tree, source="srd") + index.reindex_directory(srd_tree, source="srd") # Should still have 5 docs total (updated, not duplicated) stats = index.stats() assert stats["total_documents"] == 5 @@ -316,12 +358,16 @@ class TestStats: assert stats["total_documents"] == 0 def test_stats_by_source(self, index: VectorIndex): - index.upsert("srd:a:0", "text a", - {"source": "srd", "content_type": "spells", - "path": "/a", "title": "A"}) - index.upsert("world:b:0", "text b", - {"source": "world", "content_type": "npcs", - "path": "/b", "title": "B"}) + index.upsert( + "srd:a:0", + "text a", + {"source": "srd", "content_type": "spells", "path": "/a", "title": "A"}, + ) + index.upsert( + "world:b:0", + "text b", + {"source": "world", "content_type": "npcs", "path": "/b", "title": "B"}, + ) stats = index.stats() assert stats["total_documents"] == 2 assert stats["by_source"]["srd"] == 1 @@ -336,9 +382,11 @@ class TestReindexOnCorrupt: db_path = tmp_path / "nonexistent" / "test.db" idx = VectorIndex(db_path) idx._embed_fn = _fake_embed - idx.upsert("test:a:0", "hello", - {"source": "test", "content_type": "misc", - "path": "/a", "title": "A"}) + idx.upsert( + "test:a:0", + "hello", + {"source": "test", "content_type": "misc", "path": "/a", "title": "A"}, + ) assert idx.stats()["total_documents"] == 1 def test_corrupt_db_recreates(self, tmp_path: Path): @@ -346,9 +394,11 @@ class TestReindexOnCorrupt: db_path.write_bytes(b"this is not a sqlite database") idx = VectorIndex(db_path) idx._embed_fn = _fake_embed - idx.upsert("test:a:0", "hello", - {"source": "test", "content_type": "misc", - "path": "/a", "title": "A"}) + idx.upsert( + "test:a:0", + "hello", + {"source": "test", "content_type": "misc", "path": "/a", "title": "A"}, + ) assert idx.stats()["total_documents"] == 1 @@ -357,12 +407,26 @@ class TestReindexOnCorrupt: class TestSeedFrom: def test_seed_copies_documents(self, index: VectorIndex, tmp_path: Path): - index.upsert("srd:spells/fireball.md:0", "Fireball spell", - {"source": "srd", "content_type": "spells", - "path": "/tmp/fireball.md", "title": "Fireball"}) - index.upsert("srd:monsters/goblin.md:0", "Goblin monster", - {"source": "srd", "content_type": "monsters", - "path": "/tmp/goblin.md", "title": "Goblin"}) + index.upsert( + "srd:spells/fireball.md:0", + "Fireball spell", + { + "source": "srd", + "content_type": "spells", + "path": "/tmp/fireball.md", + "title": "Fireball", + }, + ) + index.upsert( + "srd:monsters/goblin.md:0", + "Goblin monster", + { + "source": "srd", + "content_type": "monsters", + "path": "/tmp/goblin.md", + "title": "Goblin", + }, + ) index.close() dest = tmp_path / "world" / "search.db" @@ -372,17 +436,31 @@ class TestSeedFrom: seeded.close() def test_seed_allows_additional_upserts(self, index: VectorIndex, tmp_path: Path): - index.upsert("srd:spells/fireball.md:0", "Fireball spell", - {"source": "srd", "content_type": "spells", - "path": "/tmp/fireball.md", "title": "Fireball"}) + index.upsert( + "srd:spells/fireball.md:0", + "Fireball spell", + { + "source": "srd", + "content_type": "spells", + "path": "/tmp/fireball.md", + "title": "Fireball", + }, + ) index.close() dest = tmp_path / "world" / "search.db" seeded = VectorIndex.seed_from(index._db_path, dest) seeded._embed_fn = _fake_embed - seeded.upsert("world:npcs/vex.md:0", "Captain Vex", - {"source": "world", "content_type": "npcs", - "path": "/tmp/vex.md", "title": "Vex"}) + seeded.upsert( + "world:npcs/vex.md:0", + "Captain Vex", + { + "source": "world", + "content_type": "npcs", + "path": "/tmp/vex.md", + "title": "Vex", + }, + ) assert seeded.stats()["total_documents"] == 2 assert seeded.stats()["by_source"]["srd"] == 1 assert seeded.stats()["by_source"]["world"] == 1 @@ -393,9 +471,16 @@ class TestThreadSafety: """Tests for cross-thread access (MCP server runs on a background thread).""" def test_search_from_different_thread(self, index: VectorIndex): - index.upsert("world:npcs/vex.md:0", "Captain Vex, harbor master", - {"source": "world", "content_type": "npcs", - "path": "/tmp/vex.md", "title": "Captain Vex"}) + index.upsert( + "world:npcs/vex.md:0", + "Captain Vex, harbor master", + { + "source": "world", + "content_type": "npcs", + "path": "/tmp/vex.md", + "title": "Captain Vex", + }, + ) import threading diff --git a/tests/test_seeder.py b/tests/test_seeder.py index c4287c8..a722ce2 100644 --- a/tests/test_seeder.py +++ b/tests/test_seeder.py @@ -19,11 +19,15 @@ class TestSeederTools: async def _gather() -> set[str]: server = await _compose_server("seeder") return {t.name for t in await server.list_tools()} + return asyncio.run(_gather()) def test_seeder_has_world_building_tools(self): assert self._seeder_names() == { - "establish", "set_scene", "forge_culture", "generate_names", + "establish", + "set_scene", + "forge_culture", + "generate_names", } def test_seeder_excludes_disallowed_tools(self): @@ -43,6 +47,7 @@ class TestArcArchitectTools: async def _gather() -> set[str]: server = await _compose_server("arc_architect") return {t.name for t in await server.list_tools()} + return asyncio.run(_gather()) def test_architect_only_has_commit_arc_and_recall(self): @@ -51,9 +56,17 @@ class TestArcArchitectTools: def test_architect_excludes_world_building_tools(self): names = self._architect_names() for forbidden in ( - "establish", "set_scene", "mark", "amend_mark", - "note_discovery", "tune", "damage", "heal", - "adjust_coins", "create_character", "end_session", + "establish", + "set_scene", + "mark", + "amend_mark", + "note_discovery", + "tune", + "damage", + "heal", + "adjust_coins", + "create_character", + "end_session", ): assert forbidden not in names @@ -92,12 +105,14 @@ class TestSeedWorld: def test_seed_world_calls_claude( self, mock_popen: MagicMock, character_world: Path ): - result_line = json.dumps({ - "type": "result", - "session_id": "sess-1", - "usage": {"input_tokens": 500, "output_tokens": 100}, - "duration_ms": 3000, - }) + result_line = json.dumps( + { + "type": "result", + "session_id": "sess-1", + "usage": {"input_tokens": 500, "output_tokens": 100}, + "duration_ms": 3000, + } + ) mock_proc = MagicMock() mock_proc.stdin = MagicMock() mock_proc.stdout = iter([result_line.encode() + b"\n"]) @@ -123,36 +138,42 @@ class TestSeedWorld: self, mock_popen: MagicMock, character_world: Path ): lines = [ - json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "t1", - "name": "mcp__storied__establish", + json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "t1", + "name": "mcp__storied__establish", + }, }, - }, - }), - json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_use", - "id": "t2", - "name": "mcp__storied__set_scene", + } + ), + json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "t2", + "name": "mcp__storied__set_scene", + }, }, - }, - }), - json.dumps({ - "type": "result", - "session_id": "sess-2", - "usage": {"input_tokens": 800, "output_tokens": 150}, + } + ), + json.dumps( + { + "type": "result", + "session_id": "sess-2", + "usage": {"input_tokens": 800, "output_tokens": 150}, "duration_ms": 5000, - }), + } + ), ] mock_proc = MagicMock() @@ -174,12 +195,14 @@ class TestSeedWorld: def test_seed_world_returns_result( self, mock_popen: MagicMock, character_world: Path ): - result_line = json.dumps({ - "type": "result", - "session_id": "sess-1", - "usage": {"input_tokens": 500, "output_tokens": 100}, - "duration_ms": 3000, - }) + result_line = json.dumps( + { + "type": "result", + "session_id": "sess-1", + "usage": {"input_tokens": 500, "output_tokens": 100}, + "duration_ms": 3000, + } + ) mock_proc = MagicMock() mock_proc.stdin = MagicMock() mock_proc.stdout = iter([result_line.encode() + b"\n"]) @@ -203,24 +226,28 @@ class TestSeedWorld: self, mock_popen: MagicMock, character_world: Path ): lines = [ - json.dumps({ - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "t1", - "name": "mcp__storied__establish", + json.dumps( + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "t1", + "name": "mcp__storied__establish", + }, }, - }, - }), - json.dumps({ - "type": "result", - "session_id": "sess-1", - "usage": {"input_tokens": 800, "output_tokens": 100}, + } + ), + json.dumps( + { + "type": "result", + "session_id": "sess-1", + "usage": {"input_tokens": 800, "output_tokens": 100}, "duration_ms": 3000, - }), + } + ), ] mock_proc = MagicMock() @@ -259,6 +286,7 @@ class TestSeedWorldStyleContext: character_world: Path, ): from storied.paths import world_path + world_dir = world_path("default") world_dir.mkdir(parents=True, exist_ok=True) (world_dir / "style.md").write_text( @@ -300,6 +328,7 @@ class TestSeedWorldStyleContext: character_world: Path, ): from storied.paths import world_path + world_dir = world_path("default") world_dir.mkdir(parents=True, exist_ok=True) (world_dir / "style.md").write_text(" \n\n \n") diff --git a/tests/test_session.py b/tests/test_session.py index 9e58d2b..c2b0dee 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,3 +1,7 @@ +# pyright: reportOptionalSubscript=false, reportReturnType=false +# pyright: reportOperatorIssue=false +# Tests subscript load_session results without null-narrowing — setup +# guarantees the file exists. """Tests for session state management.""" from pathlib import Path @@ -173,7 +177,9 @@ class TestResolveWikiLink: def test_finds_location(self, tmp_path: Path): loc_dir = tmp_path / "worlds" / "default" / "locations" loc_dir.mkdir(parents=True) - (loc_dir / "The Rusty Anchor.md").write_text("---\nname: The Rusty Anchor\n---\n") + (loc_dir / "The Rusty Anchor.md").write_text( + "---\nname: The Rusty Anchor\n---\n" + ) result = resolve_wiki_link("The Rusty Anchor", "default") assert result is not None diff --git a/tests/test_srd_recall.py b/tests/test_srd_recall.py index 249a862..ae04940 100644 --- a/tests/test_srd_recall.py +++ b/tests/test_srd_recall.py @@ -1,3 +1,5 @@ +# pyright: reportReturnType=false, reportInvalidTypeForm=false +# pytest fixtures yielding generators trip pyright's return-type check. """Empirical tests: can recall find key SRD content? These test against the real SRD files to verify that chunking and search @@ -39,26 +41,27 @@ def _top_titles(index: VectorIndex, query: str, n: int = 5) -> list[str]: def _assert_any_hit_contains( - index: VectorIndex, query: str, needle: str, top_n: int = 3, + index: VectorIndex, + query: str, + needle: str, + top_n: int = 3, ): """Assert that at least one of the top N results contains needle.""" hits = index.search(query, limit=top_n) snippets = [h.snippet for h in hits] doc_ids = [h.doc_id for h in hits] - assert any( - needle.lower() in s.lower() for s in snippets - ) or any( + assert any(needle.lower() in s.lower() for s in snippets) or any( needle.lower() in d.lower() for d in doc_ids ), ( f"'{needle}' not found in top {top_n} for query '{query}'.\n" - f"Got: {list(zip(doc_ids, [s[:80] for s in snippets]))}" + f"Got: {list(zip(doc_ids, [s[:80] for s in snippets], strict=False))}" ) # --- Class Features --- -class TestClassFeatureRecall: +class TestClassFeatureRecall: def test_sneak_attack(self, srd_index: VectorIndex): _assert_any_hit_contains(srd_index, "sneak attack", "Sneak Attack") @@ -83,8 +86,8 @@ class TestClassFeatureRecall: # --- Conditions --- -class TestConditionRecall: +class TestConditionRecall: def test_grappled(self, srd_index: VectorIndex): _assert_any_hit_contains(srd_index, "grappled condition", "Grappled") @@ -100,16 +103,20 @@ class TestConditionRecall: # --- Core Mechanics --- -class TestCoreMechanicsRecall: +class TestCoreMechanicsRecall: def test_opportunity_attack(self, srd_index: VectorIndex): _assert_any_hit_contains( - srd_index, "opportunity attack", "Opportunity Attack", + srd_index, + "opportunity attack", + "Opportunity Attack", ) def test_death_saving_throw(self, srd_index: VectorIndex): _assert_any_hit_contains( - srd_index, "death saving throw", "Death", + srd_index, + "death saving throw", + "Death", ) def test_short_rest(self, srd_index: VectorIndex): @@ -117,14 +124,16 @@ class TestCoreMechanicsRecall: def test_concentration(self, srd_index: VectorIndex): _assert_any_hit_contains( - srd_index, "concentration spell", "Concentration", + srd_index, + "concentration spell", + "Concentration", ) # --- Equipment --- -class TestEquipmentRecall: +class TestEquipmentRecall: def test_rapier(self, srd_index: VectorIndex): _assert_any_hit_contains(srd_index, "rapier", "Rapier") diff --git a/tests/test_tune.py b/tests/test_tune.py index cb24cd1..fdb2b70 100644 --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -55,7 +55,9 @@ class TestCommitArc: """Tests for the arc_architect's commit_arc tool.""" def test_commit_arc_creates_arc_file( - self, ctx: ToolContext, tmp_path: Path, + self, + ctx: ToolContext, + tmp_path: Path, ): commit_arc("# Campaign Arc\n\n## Premise\nA quiet mystery.\n") @@ -63,7 +65,9 @@ class TestCommitArc: assert arc_path.exists() def test_commit_arc_writes_content_verbatim( - self, ctx: ToolContext, tmp_path: Path, + self, + ctx: ToolContext, + tmp_path: Path, ): content = ( "# Campaign Arc\n\n## Premise\nA wedding postponed twice.\n" @@ -75,7 +79,9 @@ class TestCommitArc: assert arc_path.read_text() == content def test_commit_arc_replaces_existing( - self, ctx: ToolContext, tmp_path: Path, + self, + ctx: ToolContext, + tmp_path: Path, ): commit_arc("# Campaign Arc\n\n## Premise\nFirst draft.\n") commit_arc("# Campaign Arc\n\n## Premise\nSecond draft.\n") diff --git a/uv.lock b/uv.lock index 31f65dd..9e39e79 100644 --- a/uv.lock +++ b/uv.lock @@ -473,6 +473,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + [[package]] name = "fastembed" version = "0.8.0" @@ -1042,6 +1051,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + [[package]] name = "numpy" version = "2.4.3" @@ -1560,6 +1578,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +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" } +wheels = [ + { 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" }, +] + [[package]] name = "pysqlite3-binary" version = "0.5.4.post2" @@ -1600,6 +1631,19 @@ wheels = [ { 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" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +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" } +wheels = [ + { 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" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1941,8 +1985,10 @@ dev = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -1970,8 +2016,10 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.19.1" }, + { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.14.10" }, ]