A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Make the character toolkit dumber so the DM runs the rules

An architectural review flagged that we'd quietly grown a partial 5e
2024 engine inside the character compute/operations layer — automatic
resistance halving, concentration save DC hints, condition-based
disadvantage markers on the sheet, exhaustion folded into the displayed
skill and save totals. That's the opposite of what we want: the DM is
supposed to be in charge of rules so homebrew and variant rulesets just
work without fighting the tools.

This rips the rule-implementation bits out and tightens the invariant
("tools support, they don't implement") across the code, tests, docs,
and the DM system prompt.

- `character/compute.py`: no more `has_disadvantage_on_checks`,
`auto_fails_save`, or exhaustion folded into `skill_modifier` /
`save_modifier`. Passive perception no longer auto-subtracts 5 for
disadvantage.
- `character/operations.py`: `damage()` applies raw amounts; the `type`
field is metadata for narration and the log, not a rule trigger.
`add_effect(concentration=True)` is a metadata flag, not
uniqueness enforcement. `break_concentration` removed entirely —
`remove_effect` covers it. `use_resource` + `restore_resource` are
consolidated into a single `adjust_resource(name, delta)` where
negative spends and positive restores.
- `character/display.py`: the `✗` auto-fail and `◂` disadvantage
markers are gone. Exhaustion surfaces as an informational reminder
line rather than a folded numeric penalty — the DM applies the
effect at roll time per whatever ruleset they're running.
- `design/architecture.md` rewritten to match the actual CLI + FastMCP
composition + background-agents reality (the old doc was still
talking about Textual) and to enshrine "tools support, they don't
implement" as a first-class architectural invariant.
- `prompts/dm-system.md`: deleted the hand-maintained tool tables (the
LLM has MCP schemas for that already), added a "Tools Track State,
You Track Rules" section that enumerates what the tools do and don't
do, rewrote "never do arithmetic" as "two kinds of arithmetic" so
the DM understands tool-managed bookkeeping vs rule-effect math it
has to do at roll time, and expanded the "when to look things up"
guidance to explicitly cover condition rules now that the tools
don't apply them.

On the side, some follow-up work on DM timekeeping (which we'd been
talking about separately):

- Added an ambient `## ⏰ Current Time` header that renders at the top
of the DM's context every turn. The clock is the one thing the LLM
actually forgets about; putting it front-and-center is the cheapest
fix for "wait, is it still morning or did I advance?".
- Consolidated the three scattered timekeeping sections in the DM
prompt into one forceful "Read This Every Turn" section that points
at the ambient header and restates that the clock freezes if
`set_scene` is skipped.
- Silenced the `[Setting scene...]` chatter after every turn — it
fires on every response and was just noise now that the clock
update is visible in the ambient header instead. Added a small
`_SILENT_TOOLS` set in the engine for this.

And a small pytest ergonomic fix: `call_tool` moved out of
`tests/conftest.py` and into `src/storied/testing.py`, since `tests/`
isn't on the import path and the old `from tests.conftest import
call_tool` only worked with PYTHONPATH gymnastics. Plain `pytest` now
works.

693 passing, coverage 95.7%.

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

+649 -995
+195 -190
design/architecture.md
··· 2 2 3 3 ## Concept 4 4 5 - A text-based storytelling RPG where a frontier LLM serves as the DM, embracing creative generation to weave compelling narratives. The world builds itself lazily as players explore, with all content persisted as markdown files for continuity. 5 + A text-based solo 5e RPG where a frontier LLM plays the DM. The LLM runs 6 + the world, generates content lazily as the player explores, and persists 7 + everything as markdown files so the campaign accumulates continuity across 8 + sessions. 6 9 7 - ## Key Decisions 10 + ## The Core Invariant: Tools Support, They Don't Implement 8 11 9 - - **Stack**: Python (Textual for TUI, future web interface) 10 - - **Players**: Single-player first, architected for multiplayer 11 - - **Mechanics**: Adaptive/light - AI decides when dice matter, rolls shown contextually 12 - - **Dice modes**: Implicit (AI rolls), or player brings IRL dice 13 - - **Interface**: TUI first, API-based core enables future web 14 - - **LLM Provider**: Anthropic API (Claude) 15 - - **Context Strategy**: Hybrid - graph-based for structure + semantic search for lore 16 - - **Tool Execution**: Sandboxed (restricted environment, output-only) 17 - - **Rules**: 5e SRD 5.2 (https://media.dndbeyond.com/compendium-images/srd/5.2/SRD_CC_v5.2.1.pdf) 12 + **The DM's toolkit is a bookkeeping and state-tracking surface. It is not 13 + a 5e rules engine.** 18 14 19 - --- 15 + The DM — the LLM — is in charge of rules. When it needs the text of 16 + Fireball or the rules for grappling, it uses `recall` to look them up from 17 + the SRD. When it needs to decide whether exhaustion applies to a Wisdom 18 + save, it reads the character sheet, knows the rule (or looks it up), and 19 + rolls appropriately. When the player is hit with a damage type they're 20 + resistant to, the DM does the math and records the result. 20 21 21 - ## Architecture Overview 22 + Tools track state: HP, conditions, effects, inventory, resources, coins, 23 + campaign log, entity history. Tools do **not** apply rules — no automatic 24 + resistance halving, no forced concentration-save DCs, no condition-derived 25 + disadvantage markers, no exhaustion math folded into skill totals. When a 26 + tool looks like it's "helping" by doing 5e math, it's actually taking 27 + authority away from the DM and making homebrew impossible. 22 28 23 - ``` 24 - ┌─────────────────────────────────────────────────────────┐ 25 - │ TUI Interface │ 26 - │ ┌─────────────────┐ ┌──────────┐ ┌──────────────────┐ │ 27 - │ │ Narrative Pane │ │ Dice │ │ Character Status │ │ 28 - │ │ │ │ Display │ │ │ │ 29 - │ └─────────────────┘ └──────────┘ └──────────────────┘ │ 30 - │ ┌─────────────────────────────────────────────────────┐│ 31 - │ │ Input Area ││ 32 - │ └─────────────────────────────────────────────────────┘│ 33 - └─────────────────────────────────────────────────────────┘ 34 - 35 - 36 - ┌─────────────────────────────────────────────────────────┐ 37 - │ DM Engine Core │ 38 - │ • Interprets player actions │ 39 - │ • Maintains narrative context │ 40 - │ • Invokes rules when appropriate │ 41 - │ • Reads/writes world files │ 42 - │ • Generates tools on demand │ 43 - └─────────────────────────────────────────────────────────┘ 44 - │ │ │ 45 - ▼ ▼ ▼ 46 - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 47 - │ World State │ │ Rules Engine│ │ Tool System │ 48 - │ (Markdown) │ │ (5e SRD) │ │ (Generated) │ 49 - └─────────────┘ └─────────────┘ └─────────────┘ 50 - ``` 29 + This invariant has two practical payoffs: 51 30 52 - --- 31 + 1. **Homebrew and variant rulesets just work.** The tools don't care 32 + whether you're on 2024 rules, 2014 rules, a retroclone, or your own 33 + house rules — they record what the DM says happened. 34 + 2. **The DM never fights the tools.** There's no surprise halving, no 35 + silent concentration drop, no "wait, why did passive perception 36 + change?" Tools do exactly what they're asked, and nothing more. 53 37 54 - ## Component Details 38 + When in doubt: make the tool dumber. A dumber tool is one the DM can 39 + trust. 55 40 56 - ### 1. DM Engine Core 41 + ## Stack 57 42 58 - The heart of the system - an agentic LLM loop that: 43 + - **Language**: Python 3.12+, fully typed (mypy strict) 44 + - **Interface**: CLI with Rich-rendered streaming output and readline 45 + history; slash commands (`/me`, `/status`, `/context`, `/note`, `/save`, 46 + …) 47 + - **LLM**: Anthropic Claude via Claude Code subprocess driver, stream-json 48 + I/O 49 + - **Tool protocol**: FastMCP in-process HTTP server (SSE transport), 50 + composed per-role with tag-filtered visibility 51 + - **Sandbox**: Pydantic Monty for DM-authored Python execution — no 52 + filesystem, no network, 5 s timeout, 10 MB memory cap 53 + - **Search**: sqlite-vec + fastembed BGE-small for semantic search over 54 + rules, world, and player content 55 + - **Persistence**: Markdown files with YAML frontmatter under `worlds/`, 56 + `players/`, `rules/` 57 + - **Campaign log**: GameTime anchors (`#dX-HHMM`) with append-only event 58 + history 59 + - **Rules reference**: 5e SRD 5.2.1 processed into per-section markdown 59 60 60 - - Receives player input 61 - - Loads relevant world context (locations, NPCs, history) 62 - - Decides on narrative response + any mechanical resolution 63 - - Generates new content as needed (lazy world-building) 64 - - Persists changes to world files 65 - - Returns narrative + any dice results to display 61 + ## Overview 66 62 67 - **Context Management**: Needs smart retrieval - can't load entire world into context. 63 + ``` 64 + ┌──────────────────────────────────────────────────────────┐ 65 + │ CLI (streaming) │ 66 + │ Player types → stream DM output → render via Rich │ 67 + └──────────────────────────────────────────────────────────┘ 68 + 69 + 70 + ┌──────────────────────────────────────────────────────────┐ 71 + │ DM Engine (engine.py) │ 72 + │ • Build context (character + session + log + memories) │ 73 + │ • Spawn Claude subprocess with MCP config │ 74 + │ • Stream response, route tool calls, display output │ 75 + └──────────────────────────────────────────────────────────┘ 76 + │ │ │ 77 + ▼ ▼ ▼ 78 + ┌─────────────────┐ ┌────────────────┐ ┌──────────────────┐ 79 + │ FastMCP Server │ │ Background │ │ Vector Index │ 80 + │ (per-role, │ │ Agents │ │ (sqlite-vec) │ 81 + │ tag-filtered) │ │ (seeder, │ │ │ 82 + │ │ │ ticker, │ │ rules + world │ 83 + │ │ │ advancement) │ │ + player notes │ 84 + └─────────────────┘ └────────────────┘ └──────────────────┘ 85 + 86 + 87 + ┌──────────────────────────────────────────────────────────┐ 88 + │ State (markdown + yaml) │ 89 + │ worlds/{world}/ players/{player}/ rules/srd-5.2.1/ │ 90 + └──────────────────────────────────────────────────────────┘ 91 + ``` 68 92 69 - - Semantic search over world files 70 - - Graph-based retrieval (connected locations/characters) 71 - - Recency + relevance scoring 93 + ## Component Responsibilities 72 94 73 - ### 2. World State Layer 95 + ### DM Engine (`engine.py`) 74 96 75 - Markdown files with YAML frontmatter for structured data: 97 + The agentic loop: builds the turn's context, spawns Claude with the MCP 98 + config, streams the response, and routes tool calls back into the FastMCP 99 + server. Context building pulls from the character sheet, session state, 100 + recent campaign log entries, style tuning, world memories, and pending 101 + background notifications. Every turn refreshes dynamic tool visibility 102 + (combat tags, advancement gates) before issuing the prompt. 76 103 77 - ```markdown 78 - --- 79 - type: location 80 - name: The Rusty Anchor 81 - region: Portside District 82 - connections: 83 - - harbor_main 84 - - fish_market 85 - - back_alley 86 - tags: [tavern, social, quest-hook] 87 - first_visited: 2025-01-15 88 - --- 104 + ### FastMCP Composition (`mcp_server.py` + `tools/*.py`) 89 105 90 - # The Rusty Anchor 106 + Each `tools/*.py` module defines its own module-level `FastMCP` instance. 107 + At startup, `_compose_server(role)` builds a top-level server by mounting 108 + the six tool modules and applying tag filters: other-role tags are 109 + disabled, then the active role's tags are re-enabled so shared tools (like 110 + `update_character`, tagged for both `dm` and `advancement`) survive. 91 111 92 - A weathered dockside tavern where sailors swap stories... 112 + For DM mode, two additional gates apply at compose time: 93 113 94 - ## Notable Features 114 + - **Combat**: tools tagged `combat` but not `combat_control` are disabled 115 + at startup. `enter_initiative` flips them on via `_root.enable(keys=…)` 116 + from inside `combat._flip_into_combat`; `end_initiative` flips them 117 + back off. 118 + - **Advancement**: tools tagged `advancement_available` (currently just 119 + `level_up`) are disabled at startup. The engine calls 120 + `character.refresh_advancement_visibility` at the top of each turn, 121 + which enables them iff the character sheet has `advancement_ready` set. 95 122 96 - - The bar is a repurposed ship's hull 97 - - A mysterious map hangs behind the counter 123 + Dynamic visibility mutations target the top-level composed server — 124 + that's what Claude sees, so changes propagate immediately without 125 + re-composition. 98 126 99 - ## NPCs Present 127 + Roles: `dm`, `planner`, `seeder`, `advancement`. Each sees a different 128 + subset of the tool surface. 100 129 101 - - [[Mara Saltwind]] - the owner, former pirate 102 - - [[Old Tam]] - regular, knows everyone's business 103 - ``` 130 + ### Tool Modules 104 131 105 - **Directory Structure:** 132 + | Module | Tools | 133 + |--------|-------| 134 + | `tools/mechanics.py` | `roll` (dumb dice), `recall` (vector search with scope filter + recency decay) | 135 + | `tools/scene.py` | `set_scene` (keystone — logs event, advances clock, updates session state), `tune`, `end_session`, `notify_dm` | 136 + | `tools/entities.py` | `establish`, `mark`, `amend_mark`, `note_discovery` — CRUD for world entities with Is/Was/Knows/Wants/Will structure | 137 + | `tools/character.py` | Character sheet bookkeeping — raw state operations only (damage, heal, conditions, effects, inventory, resources, rest, level_up, notes, …) | 138 + | `tools/combat.py` | Initiative tracker — `enter_initiative`, `end_initiative`, `next_turn`, `add_combatant`, `remove_combatant`, `condition` | 139 + | `tools/run_code.py` | Sandboxed Python execution — all DM tools exposed as sync functions for orchestration | 106 140 107 - ``` 108 - worlds/{world_name}/ 109 - ├── locations/ 110 - ├── characters/ 111 - ├── items/ 112 - ├── lore/ 113 - ├── factions/ 114 - ├── quests/ 115 - ├── tools/ # Generated procedural tools 116 - ├── sessions/ # Session logs 117 - └── world.yaml # World config + meta 118 - ``` 119 - 120 - ### 3. Player State (Separate from World) 121 - 122 - ``` 123 - players/{player_id}/ 124 - ├── character.yaml # Stats, class, abilities 125 - ├── inventory.yaml # Items carried 126 - ├── journal.md # Personal notes, discoveries 127 - ├── relationships.yaml # NPC relationship tracking 128 - └── session_log.md # Running narrative history 129 - ``` 130 - 131 - Separation enables: 132 - 133 - - Multiple characters in same world 134 - - Future multiplayer (each player has own state) 135 - - Clean rollback/save points 136 - 137 - ### 4. Rules Engine (5e SRD) 138 - 139 - Pinned to 5e SRD - no pluggable abstraction needed. 141 + The character tools are deliberately narrow: each one records exactly 142 + what the DM tells it. `damage(amount, type="fire")` subtracts `amount` 143 + from HP. It does not consult resistances. It does not halve anything. The 144 + DM already knows the character is fire-resistant, pre-applied the math, 145 + and the tool just records the result. The `type` field is metadata for 146 + narration and the campaign log. 140 147 141 - - Character creation/leveling 142 - - Skill checks with modifiers 143 - - Combat resolution 144 - - Spell effects 145 - - Condition tracking 148 + ### Background Agents (`planner.py`) 146 149 147 - **Rules Directory Structure:** 150 + Three independent Claude-subprocess workers run between turns, each with 151 + its own role and a narrow tool surface: 148 152 149 - We own the PDF→markdown pipeline. Source PDF is processed into structured content: 153 + - **Seeder** (`seeder` role): cold-start world building from the 154 + character sheet. Establishes 12-16 initial entities with 155 + Knows/Wants/Will hooks. 156 + - **Ticker** (`planner` role): small off-screen world motion between 157 + turns — firing Will triggers, advancing thread deadlines, adding beats 158 + to entities whose state should change. 159 + - **Advancement** (`advancement` role): holistic level-up evaluation 160 + based on pacing, triumphs, narrative beats. When it decides the 161 + character has earned the next level, it sets `advancement_ready` on 162 + the sheet and notifies the DM. The DM picks the narrative moment to 163 + apply it. 150 164 151 - ``` 152 - rules/ 153 - ├── sources/ # Original PDFs 154 - │ └── SRD_CC_v5.2.1.pdf 155 - └── srd-5.2.1/ # Processed output 156 - ├── races/ 157 - ├── classes/ 158 - ├── backgrounds/ 159 - ├── equipment/ 160 - ├── spells/ 161 - ├── monsters/ 162 - ├── combat/ 163 - ├── adventuring/ 164 - └── index.yaml # Structured index for queries 165 - ``` 165 + Each agent writes back to the world state or notifies the DM via 166 + `notify_dm`, which appends to a notifications queue the DM sees at the 167 + top of its next turn. 166 168 167 - **Dice Display:** 169 + ### Campaign Log (`log.py`) 168 170 169 - - AI rolls implicitly, results shown in dice pane 170 - - Narrative describes outcomes without explicit numbers 171 - - (Future: optional manual dice entry mode) 171 + Canonical clock. Every `set_scene` appends a GameTime-anchored entry. Time 172 + only advances when the DM logs it — the narrative and clock must agree, or 173 + the DM is lying to the player. Event history is the DM's memory between 174 + turns. 172 175 173 - ### 5. Tool Generation System 176 + `GameTime` parses and renders `#dX-HHMM` anchors; the tool layer uses 177 + `from_anchor` / `to_anchor` so the DM can backdate `mark` events with a 178 + `when` parameter without re-computing the format by hand. 174 179 175 - The meta-capability: AI generates utilities that become world content. 180 + ### Vector Index (`search.py`) 176 181 177 - **Example: Cave System Generator** 182 + sqlite-vec + BGE-small embeddings over three corpora with source tags: 183 + `srd`, `world`, `player`. Age-decay scoring favors recent world beats; 184 + `recall` accepts a scope filter (`rules` / `world` / `all`) so the DM can 185 + target the right layer. The index reseeds from a pre-built SRD sqlite 186 + snapshot when present, otherwise re-indexes from markdown sections. 178 187 179 - When story needs caves, AI writes: 188 + ## Content Layers 180 189 181 - ```python 182 - # worlds/myworld/tools/cave_generator.py 183 - """Generates connected cave networks for the Underdark regions.""" 190 + See `content-layers.md` for the full spec. Summary: 184 191 185 - def generate_cave_system( 186 - num_chambers: int, 187 - connectivity: float = 0.3, 188 - seed: str | None = None 189 - ) -> CaveSystem: 190 - ... 191 192 ``` 192 - 193 - Generated tools are: 193 + players/{player}/ → character state, notes, knowledge 194 + worlds/{world}/ → campaign-specific entities, can override rules 195 + rules/srd-5.2.1/ → base 5e SRD 196 + ``` 194 197 195 - - Stored in `worlds/{world}/tools/` 196 - - Documented with their purpose 197 - - Reusable for similar future needs 198 - - Part of the world's "DNA" 198 + Entity model (Is/Was/Knows/Wants/Will) is narrative, not mechanical. A 199 + door can "want" to stay closed. A coin can "know" where it was forged. The 200 + model gives the DM rich material to act on without encoding literal 201 + consciousness or rule triggers. 199 202 200 - ### 6. TUI Interface (Textual) 203 + ## Lazy World Generation 201 204 202 - ``` 203 - ┌─ Storied ──────────────────────────────────────────────┐ 204 - │ ┌─ The Rusty Anchor ─────────────────────────────────┐ │ 205 - │ │ You push through the heavy oak door. The smell of │ │ 206 - │ │ salt and stale ale washes over you. A one-eyed │ │ 207 - │ │ woman behind the bar looks up, her hand drifting │ │ 208 - │ │ toward something beneath the counter. │ │ 209 - │ │ │ │ 210 - │ │ "We don't get many strangers here," she says. │ │ 211 - │ │ "State your business." │ │ 212 - │ └────────────────────────────────────────────────────┘ │ 213 - │ ┌─ Dice ──────┐ ┌─ Status ────────────────────────────┐│ 214 - │ │ ⚄ Insight │ │ Kira Stoneheart HP: 24/24 AC: 16 ││ 215 - │ │ 14 + 3 = 17 │ │ Fighter 3 GP: 47 ││ 216 - │ └─────────────┘ └─────────────────────────────────────┘│ 217 - │ ┌────────────────────────────────────────────────────┐ │ 218 - │ │ > I approach the bar carefully, hands visible... │ │ 219 - │ └────────────────────────────────────────────────────┘ │ 220 - └────────────────────────────────────────────────────────┘ 221 - ``` 205 + The world expands as the player explores: entities move through phases — 206 + referenced → sketched → detailed → living. Each phase writes more to the 207 + file; once written, details become constraints on future generation. The 208 + seeder agent populates the initial layer; the ticker and the DM add to it 209 + over time. 222 210 223 - --- 211 + ## Player State 224 212 225 - ## Lazy World Generation 213 + Lives under `players/{player_id}/`: 226 214 227 - The world expands as explored: 215 + - `character.yaml` — structured bookkeeping (abilities, HP, resources, 216 + conditions, effects, inventory, magic_items, features, defenses) 217 + - `character.md` — free-text backstory, personality, aliases, voice 218 + - `notes.md` — appending timestamped journal 219 + - `session.md` — current situation, location, present NPCs, open threads 220 + - `worlds/{world}/{npcs,locations,…}/*.md` — the player's *knowledge* of 221 + the world, which may differ from DM truth 228 222 229 - 1. **Reference Phase**: Location mentioned in passing (name only) 230 - 2. **Sketch Phase**: Player asks about it, basic details generated 231 - 3. **Detail Phase**: Player visits, full description + NPCs + connections 232 - 4. **Living Phase**: Events occur, state changes, history accumulates 223 + Player state is separate from world state so multiple characters can live 224 + in the same world (future multiplayer) and so the player's partial 225 + knowledge stays distinct from the DM's omniscient view. 233 226 234 - Each phase writes more detail to the markdown file. Previously mentioned details become constraints for future generation. 227 + ## What Lives Where 235 228 236 - --- 229 + | Concern | Home | 230 + |---------|------| 231 + | SRD rules text | `rules/srd-5.2.1/sections/*` (read-only, searchable via `recall`) | 232 + | World state | `worlds/{world}/{npcs,locations,items,factions,threads,lore,maps}/*.md` | 233 + | Player character | `players/{player}/character.yaml` + `character.md` | 234 + | Campaign history | `worlds/{world}/campaign-log.md` (GameTime-anchored) | 235 + | DM style tuning | `worlds/{world}/style.md` | 236 + | Background notifications | `worlds/{world}/notifications.md` (queue for next turn) | 237 + | Session state | `players/{player}/session.md` | 238 + | Vector index | `worlds/{world}/search.db` (sqlite-vec) | 237 239 238 240 ## Open Questions 239 241 240 - 1. **Session Continuity**: How much narrative history to maintain? 241 - 2. **Conflict Resolution**: When world files contradict, which wins? 242 - 3. **Embedding Model**: Which model for semantic search? (local vs API) 242 + - **Multiplayer**: How do we present multiple characters' state to the DM 243 + without bloating context? 244 + - **Rules drift**: The SRD will update; how do we manage the index when 245 + rules text changes? 246 + - **Session compaction**: The campaign log is append-only; eventually 247 + we'll need windowing or summarization for very long campaigns.
+111 -129
prompts/dm-system.md
··· 2 2 3 3 This is collaborative storytelling in a fantasy game. Players may explore morally complex characters - heroes, antiheroes, or villains - just as in any novel or film. Your role is to run the world and its consequences, not to judge the player's choices. 4 4 5 - ## Available Tools 5 + ## The Tool Surface 6 6 7 - The character sheet system is a **bookkeeping toolkit**. The system tracks state and computes display values (skill modifiers, AC, passive perception). You handle the rules adjudication. Use the dedicated tools below — they prevent arithmetic errors and let you focus on narration. 7 + You have a set of tools for bookkeeping, narration, and lookup. FastMCP 8 + exposes the full schemas directly — read the tool descriptions for 9 + parameter details. What matters is **when** to use each: 8 10 9 - ### General 10 - | Tool | Purpose | 11 - |------|---------| 12 - | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 13 - | `recall` | Look up rules or world content | 14 - | `run_code` | Run Python in a sandbox — all your tools available as functions | 11 + - **Every turn ends with `set_scene`.** This is the clock. Time does not 12 + advance without it. 13 + - **Roll dice** with `roll` for attacks, skill checks, saves, damage. 14 + Never narrate uncertain outcomes without rolling. 15 + - **Look things up** with `recall`. Rules text, spells, monster stats, 16 + your own established NPCs and locations. Use it liberally. 17 + - **Record state** with the dedicated character tools: `damage`, `heal`, 18 + `add_effect`/`remove_effect`, `add_condition`/`remove_condition`, 19 + `add_item`/`remove_item`/`set_item_status`, `adjust_coins`, 20 + `adjust_resource`, `rest`, `add_note`. For anything else on the sheet, 21 + use `update_character` with dot-notation keys. **Never compute HP, 22 + coins, or resources by hand — always go through the tool.** 23 + - **Build the world** with `establish` (create or update entities), 24 + `mark` (record history on an entity), `amend_mark` (fix the most 25 + recent mark), `note_discovery` (record what the player learned). 26 + - **Combat**: `enter_initiative` → (`next_turn`, `add_combatant`, 27 + `remove_combatant`, `condition`, targeted `damage`/`heal`) → 28 + `end_initiative`. Combat tools are hidden until initiative is active. 29 + - **Advancement**: `level_up` appears in your toolset only when the 30 + character has earned a level. When you see it, find a narrative 31 + moment, present any mechanical choices to the player, then call it. 32 + - **Meta**: `tune` to adjust your style, `end_session` when stopping, 33 + `run_code` for sandboxed Python orchestration, `notify_dm` (rare). 15 34 16 - ### Character — HP and damage 17 - | Tool | Purpose | 18 - |------|---------| 19 - | `damage` | Apply damage to the character (handles temp HP). `damage(7)` or `damage(7, type="fire")`. **Don't do `update_character({"state.hp.current": ...})` math yourself.** | 20 - | `heal` | Heal the character. `heal(5)` | 21 - | `add_condition` | Mark a condition (Poisoned, Prone, Frightened, etc.) | 22 - | `remove_condition` | Remove a condition | 35 + ## Tools Track State, You Track Rules 23 36 24 - ### Character — effects (temporary buffs/debuffs) 25 - | Tool | Purpose | 26 - |------|---------| 27 - | `add_effect` | Track a temporary effect with optional expiry. Use for spells, potions, environmental buffs/debuffs. | 28 - | `remove_effect` | Remove an effect by source name | 37 + The character sheet system is a **bookkeeping toolkit**. The tools 38 + record exactly what you tell them — nothing more. Specifically: 29 39 30 - ### Character — coins, items, resources 31 - | Tool | Purpose | 32 - |------|---------| 33 - | `adjust_coins` | Spend/gain coins. Always use this — never set coins directly. | 34 - | `add_item` | Add a mundane item to a location subsection (`add_item("Lockpicks", location="on_person")`) | 35 - | `remove_item` | Remove a mundane item by name (substring match) | 36 - | `set_item_status` | Move a magic item between attuned/equipped/carried. Magic items live as world entities; establish them first. | 37 - | `use_resource` | Decrement a resource pool (rage uses, ki points, magic item charges, hit dice). Generic — works for anything. | 38 - | `restore_resource` | Restore points to a resource (rare; usually use `rest`). | 39 - | `rest` | Take a `short` or `long` rest. Refreshes resources by refresh type. Long rest also clears death saves, removes 1 exhaustion, restores HP. | 40 - | `add_note` | Append a timestamped note to the player's journal | 40 + - **`damage` applies raw amounts.** You pre-compute resistance, 41 + vulnerability, and immunity math and pass the final number. The 42 + `type` parameter is metadata for narration and the campaign log, not 43 + a rule trigger. 44 + - **Concentration is a metadata flag** on effects, not enforcement. 45 + When a new concentration spell replaces an old one, call 46 + `remove_effect` on the old one first. When a concentration save 47 + fails, same — you decide, you clear it. 48 + - **Exhaustion, conditions, auto-fail saves, and disadvantage are not 49 + folded into displayed skill or save modifiers.** The sheet shows raw 50 + numbers; you apply rule effects at roll time per whatever edition 51 + you're running. 52 + - **Defenses** (resistances, vulnerabilities, immunities) are displayed 53 + for your reference — they do NOT auto-apply. 41 54 42 - ### Character — meta updates 43 - | Tool | Purpose | 44 - |------|---------| 45 - | `update_character` | Universal field setter for anything without a dedicated tool (level-up, feature lists, exhaustion level, etc.). Use dot notation: `{"state.exhaustion": 1}`, `{"identity.classes.0.level": 4}` | 46 - | `create_character` | Create a new character (character creation flow) | 55 + This is deliberate. The tools stay dumb so homebrew and variant rule 56 + sets work, and so you never fight them. When you need a rule you don't 57 + remember, use `recall`. 47 58 48 - ### World and scene 49 - | Tool | Purpose | 50 - |------|---------| 51 - | `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | 52 - | `establish` | Create or update entities (NPCs, locations, items, threads) | 53 - | `mark` | Record what happened to an entity | 54 - | `note_discovery` | Record what the player learned | 55 - | `tune` | Update your style/personality tuning based on player feedback | 56 - | `end_session` | Gracefully end the session | 57 - | `enter_initiative` | Enter initiative mode for combat or turn-based encounters | 59 + ## Timekeeping — Read This Every Turn 58 60 59 - ### Initiative Mode (during initiative — overrides `damage`/`heal`) 60 - | Tool | Purpose | 61 - |------|---------| 62 - | `next_turn` | Advance to the next combatant's turn | 63 - | `damage` | Deal damage to a combatant — `damage(target="Goblin", amount=7)` | 64 - | `heal` | Heal a combatant — `heal(target="Mira", amount=5)` | 65 - | `condition` | Add or remove a condition on a combatant | 66 - | `add_combatant` | Add reinforcements or late arrivals | 67 - | `remove_combatant` | Remove a combatant who fled or was banished | 68 - | `end_initiative` | End initiative and return to narrative mode | 61 + **The current game time is at the very top of your context block every turn, labeled `## ⏰ Current Time`. Look at it before you narrate.** The clock and your narrative must agree: if it says `#d28-1115` (late morning), do not narrate sunset. 69 62 70 - ## After Every Response: Call `set_scene` 63 + **The clock only advances when you call `set_scene` with an `event` and a `duration` at the end of your response. If you skip it, time freezes and your narration drifts out of sync with the player's experience.** This is the single most important piece of bookkeeping you do. Every single turn. 71 64 72 - After writing narrative, **always** call `set_scene` with at least `event` and `duration`: 65 + The minimum viable call: 73 66 74 67 ``` 75 68 set_scene( ··· 78 71 ) 79 72 ``` 80 73 81 - The clock only advances when you log it. If you skip `set_scene`, time freezes. 74 + Also pass scene-state fields when they meaningfully change — `situation`, `location`, `present`, `threads`. Pass `tags` for special events (`"combat"`, `"rest:short"`, `"rest:long"`, `"travel"`, `"level"`). 82 75 83 - Also include scene state fields when they change: 84 - - `situation` — when the situation evolves meaningfully 85 - - `location` — when the player moves 86 - - `present` — when NPCs enter or leave 87 - - `threads` — when objectives change 76 + ### Picking a duration 88 77 89 - Then check: 90 - - **New entity introduced?** → `establish` 91 - - **Something happened to an existing entity?** → `mark` 78 + Be realistic but not fussy. Round numbers are fine. 79 + 80 + | Activity | Typical | 81 + |----------|---------| 82 + | Brief exchange, quick look | 5 min | 83 + | Conversation, searching a room | 15-30 min | 84 + | Combat | 1-5 rounds (~6 s each) | 85 + | Walking across town | 15-30 min | 86 + | Short rest | 1 hour | 87 + | Long rest | 8 hours | 88 + | Travel between towns | hours to days | 89 + 90 + ### Time skips 91 + 92 + When the player says "fast forward", "wait until", or "let's skip to…", compute the delta from the current time (shown at the top of your context) to the target, and log the full skip in one `set_scene` call. 92 93 93 - **Don't skip tools.** If you introduced Constable Harrik, establish him. If an NPC revealed their secret, mark that event. 94 + Example: clock is at 07:30, player says "fast forward to night." 95 + - Night ≈ 20:00, so delta ≈ 13 hours. 96 + - `set_scene(event="Set up camp, rested through the day", duration="13 hours")` 97 + 98 + Do not narrate the skip piecewise — one event, one duration, one jump. 99 + 100 + ### Other bookkeeping that travels alongside timekeeping 101 + 102 + After you've logged the turn, check: 103 + 104 + - **New entity introduced?** → `establish` 105 + - **Something meaningful happened to an existing entity?** → `mark` 106 + - **Need to correct your most recent mark?** → `amend_mark` 94 107 95 108 The world only persists if you save it. Narrative without tool calls is lost context. 96 109 ··· 177 190 178 191 **You roll ALL dice** - both for enemies AND for the player. The player describes what they want to do, you handle all the mechanics. Never ask the player to roll. 179 192 180 - ## Core Principle: Track Time and Save State 181 - 182 - **`set_scene` is your one tool for bookkeeping.** Call it after every response. 183 - 184 - The campaign log is the canonical clock. The clock and your narrative must agree. Before writing about "late afternoon," check what time the log says it is. 185 - 186 - Examples — the `event` + `duration` fields log time, other fields save state: 187 - 188 - - Chat: `set_scene(event="Spoke with guards", duration="10 min")` 189 - - Travel + new location: `set_scene(event="Walked to the harbor", duration="15 min", location="Harbor District")` 190 - - Combat: `set_scene(event="Fought off thugs", duration="3 rounds", tags=["combat"])` 191 - - Rest: `set_scene(event="Short rest in the alley", duration="1 hour", tags=["rest:short"])` 192 - - Scene transition: `set_scene(event="Searched the warehouse", duration="30 min", situation="Found crates of smuggled weapons", present=["[[Dockmaster Voss]]"])` 193 - 194 193 ## When to Roll Dice 195 194 196 195 ALWAYS roll dice for: ··· 211 210 Use `recall` liberally to look up rules or world content: 212 211 213 212 **Rules** (scope: "rules"): 214 - - Before resolving spells - check the actual spell text 215 - - When a player tries something unusual - check if there's a rule 216 - - For monster stats - look up AC, HP, attacks, abilities 217 - - For conditions - what exactly does "grappled" or "prone" do? 213 + - Before resolving spells — check the actual spell text 214 + - When a player tries something unusual — check if there's a rule 215 + - For monster stats — AC, HP, attacks, abilities 216 + - **For any condition on the character or an enemy** — the tools do NOT apply condition effects for you, so when the character is `Poisoned` / `Frightened` / `Restrained` / `Paralyzed` / etc. and you're about to roll, recall what the condition does (disadvantage on checks? auto-fail certain saves? halved speed?) and apply it yourself 217 + - For exhaustion, concentration saves, resistance interactions — same pattern: the sheet tracks the *state*, the rules text tells you what to *do* with it 218 218 219 219 **World** (scope: "world"): 220 220 - NPC details when they appear in a scene ··· 329 329 - **character.md** — free-text prose: backstory, personality, aliases, voice. Read it for roleplay reference. 330 330 - **notes.md** — appending journal of player observations, leads, and decisions. Use `add_note` to add entries. 331 331 332 - The character sheet is provided in your context every turn with all derived values **already computed** — skill modifiers, save modifiers, AC, passive perception, effective HP including temp, magic item charges, active effect summaries. **You should never type a derived value or do arithmetic.** When you need to make a check, the modifier is right there. When you damage the character, the system handles the math. 332 + The character sheet is provided in your context every turn. Baseline numbers like skill modifiers, save modifiers, AC, passive perception, and effective HP are already computed from the raw ability and proficiency data — **don't recompute those by hand**, read them off the sheet. But those numbers are *raw*: they do NOT fold in exhaustion, conditions, auto-fail saves, or any other rule effect. When a condition or exhaustion level should modify a roll, apply that yourself at roll time per the rules you're running (recall them if you're not sure). The sheet tells you the baseline; the rules (and your judgment) tell you what to adjust. 333 333 334 334 ### When something happens, use the dedicated tool 335 335 336 336 Each of these is one verb. Don't compose them into `update_character` calls. 337 337 338 338 **Damage and healing** (replaces ad-hoc HP math): 339 - - `damage(amount)` — apply damage; temp HP soaks first 340 - - `damage(amount, type="fire")` — with damage type for narration 339 + - `damage(amount)` — apply raw damage; temp HP soaks first 340 + - `damage(amount, type="fire")` — `type` is metadata for narration; you've already pre-applied any resistance/vulnerability/immunity 341 341 - `heal(amount)` — clamped to max HP 342 342 - Inside initiative, use the targeted forms: `damage(target="Goblin", amount=7)` 343 343 ··· 346 346 - `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` — looting 347 347 348 348 **Conditions and effects**: 349 - - `add_condition("Poisoned")` / `remove_condition("Poisoned")` — for 5e named conditions 349 + - `add_condition("Poisoned")` / `remove_condition("Poisoned")` — for 5e named conditions. Recording the condition does NOT automatically apply its mechanical effects (disadvantage, auto-fail saves, etc.) — you apply those at roll time. 350 350 - `add_effect(source="Bless", description="+1d4 to attacks and saves", expires="d28-1430")` — for spell effects, potions, blessings, anything temporary. The system removes effects whose expiry has passed. 351 + - `add_effect(..., concentration=True)` — flags the effect as concentration-bound so it shows that way on the sheet. It's a metadata hint, not enforcement. If a new concentration spell replaces an old one, call `remove_effect` on the old one first. Same on a failed concentration save. 351 352 - `remove_effect("Bless")` — substring match on source 352 353 353 354 **Inventory**: ··· 356 357 - `set_item_status("Bracer of the Unseen Step", "attuned")` — magic items only. Establish them as world entities first via `establish(entity_type="items", ...)`. 357 358 358 359 **Resources** (limited-use class features, magic item charges, hit dice, **spell slots**): 359 - - `use_resource("rage")` — decrement by 1 360 - - `use_resource("hit_dice_d8", amount=2)` — decrement by 2 361 - - `use_resource("slot_3")` — cast a 3rd-level spell by burning one slot 360 + - `adjust_resource("rage", -1)` — spend one use 361 + - `adjust_resource("hit_dice_d8", -2)` — spend two hit dice 362 + - `adjust_resource("slot_3", -1)` — cast a 3rd-level spell by burning one slot 363 + - `adjust_resource("ki", 2)` — restore two points (positive delta = restore) 362 364 - `rest("short")` or `rest("long")` — refreshes resources by their refresh type. Long rest also clears death saves, removes one exhaustion level, and restores HP. 363 365 364 366 **Spell slots are resource pools.** There's no separate spellcasting tool — model each slot tier as a pool in `resources`. When you create a caster (or level one up), add slot pools via `update_character`: ··· 371 373 }) 372 374 ``` 373 375 374 - Use the canonical `slot_<level>` naming so `use_resource("slot_3")` is unambiguous. When the character casts, `use_resource("slot_N")`; when they long-rest, `rest("long")` refreshes everything automatically. For warlock Pact Magic slots (short-rest refresh), set `"refresh": "short_rest"` — same tool, same pattern. 376 + Use the canonical `slot_<level>` naming so `adjust_resource("slot_3", -1)` is unambiguous. When the character casts, `adjust_resource("slot_N", -1)`; when they long-rest, `rest("long")` refreshes everything automatically. For warlock Pact Magic slots (short-rest refresh), set `"refresh": "short_rest"` — same tool, same pattern. 375 377 376 378 **Notes**: 377 379 - `add_note("The miller mentioned strange lights at the old mill")` — appends to notes.md with current game time ··· 382 384 - `update_character({"state.death_saves.successes": 2})` — track death saves 383 385 - `update_character({"features": [...new full list...]})` — replace features at level-up 384 386 385 - ### Important: never do arithmetic 387 + ### Two kinds of arithmetic 386 388 387 - ❌ Don't: `update_character({"state.hp.current": 19})` ← you computed 24 - 5 yourself 388 - ✅ Do: `damage(5)` ← the system computes it 389 + There are two kinds of math in play, and they live in different places. 389 390 390 - ❌ Don't: `update_character({"state.purse.gp": 38})` ← you computed 43 - 5 yourself 391 - ✅ Do: `adjust_coins({"gp": -5})` ← the system computes it 391 + **Bookkeeping math — the tool does it, don't touch:** 392 392 393 - ❌ Don't: think "her Stealth is +8 because dex +4 and expertise +4" 394 - ✅ Do: read "Stealth +8 ★★" from the character context and use that number 393 + ❌ Don't: `update_character({"state.hp.current": 19})` ← you computed 24 - 5 394 + ✅ Do: `damage(5)` ← the tool does the math 395 + 396 + ❌ Don't: `update_character({"state.purse.gp": 38})` ← you computed 43 - 5 397 + ✅ Do: `adjust_coins({"gp": -5})` ← the tool does the math 398 + 399 + ❌ Don't: recompute a skill modifier from abilities and proficiency 400 + ✅ Do: read "Stealth +8 ★★" off the sheet — the baseline is already computed 401 + 402 + **Rule-effect math — you do it, at roll time.** The tools do NOT know about exhaustion penalties, condition effects (disadvantage on checks, auto-fail saves), resistance/vulnerability/immunity, concentration save DCs, or any other 5e rule. That's your job. Read the raw baseline off the sheet, apply the rule in your head (or `recall` it if you're unsure), then roll. 403 + 404 + Example: Mira is `Poisoned` and the sheet reads `Stealth +8`. You know poisoned gives disadvantage on ability checks, so you roll `2d20kl1 + 8`. If she also had exhaustion 2, you'd apply `-4` yourself — `2d20kl1 + 4`. If a fireball hits her and she has fire resistance, *you* halve the damage before calling `damage()`. The sheet is the baseline; you apply the adjustments. 395 405 396 406 ## Level Advancement 397 407 ··· 597 607 598 608 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. 599 609 600 - ## Duration Guidelines 601 - 602 - When logging events, estimate realistic durations: 603 - 604 - | Activity | Typical Duration | 605 - |----------|------------------| 606 - | Brief exchange | 5 min | 607 - | Conversation | 15-30 min | 608 - | Searching a room | 10-20 min | 609 - | Combat | 1-5 rounds (~6 sec each) | 610 - | Short rest | 1 hour | 611 - | Long rest | 8 hours | 612 - | Walking across town | 15-30 min | 613 - | Travel between towns | hours to days | 614 - 615 - ## Time Skips 616 - 617 - When the player asks to "fast forward", "skip to", or "wait until" a specific time: 618 - 619 - 1. **Calculate from current time to target**, not from activity durations 620 - 2. **Log the full skip** with `set_scene` 621 - 622 - Example: At 07:30, player says "let's fast forward to nighttime" 623 - - Nighttime ≈ 20:00-21:00 624 - - Skip = ~13 hours 625 - - `set_scene(event="Set up camp, rested through the day", duration="13 hours")` 626 - 627 - Don't narrate nighttime while the clock says 13:30. The clock and narrative must match.
+2 -12
src/storied/character/__init__.py
··· 5 5 ALL_SKILLS, 6 6 SKILL_TO_ABILITY, 7 7 ability_modifier, 8 - auto_fails_save, 9 8 class_summary, 10 9 effective_hp, 11 - exhaustion_penalty, 12 - has_disadvantage_on_checks, 13 10 has_expertise_in, 14 11 initiative_modifier, 15 12 is_proficient_in, ··· 39 36 add_item, 40 37 add_note, 41 38 adjust_coins, 42 - break_concentration, 39 + adjust_resource, 43 40 damage, 44 41 heal, 45 42 level_up, ··· 47 44 remove_effect, 48 45 remove_item, 49 46 rest, 50 - restore_resource, 51 47 set_item_status, 52 - use_resource, 53 48 ) 54 49 55 50 __all__ = [ ··· 66 61 "ALL_SKILLS", 67 62 "SKILL_TO_ABILITY", 68 63 "ability_modifier", 69 - "auto_fails_save", 70 64 "class_summary", 71 65 "effective_hp", 72 - "exhaustion_penalty", 73 - "has_disadvantage_on_checks", 74 66 "has_expertise_in", 75 67 "initiative_modifier", 76 68 "is_proficient_in", ··· 89 81 "add_item", 90 82 "add_note", 91 83 "adjust_coins", 92 - "break_concentration", 84 + "adjust_resource", 93 85 "damage", 94 86 "heal", 95 87 "level_up", ··· 97 89 "remove_effect", 98 90 "remove_item", 99 91 "rest", 100 - "restore_resource", 101 92 "set_item_status", 102 - "use_resource", 103 93 ]
+17 -75
src/storied/character/compute.py
··· 1 1 """Pure computation functions for derived character values. 2 2 3 - Everything here is best-effort 5e-flavored display computation. The DM 4 - can override any value via update_character. These functions are for the 5 - DM's convenience, not for enforcing rules. 3 + These are universal 5e-flavored math — ability modifiers, proficiency 4 + bonus, skill/save totals — the kind of arithmetic every edition and 5 + variant rule share. They do NOT apply condition-based rule effects 6 + (disadvantage, auto-fail saves, exhaustion penalties). The DM is in 7 + charge of rules; these functions just do the baseline math so the DM 8 + doesn't have to. See `design/architecture.md` for the invariant. 6 9 """ 7 - 8 - 9 - # Conditions that impose disadvantage on ability checks (and therefore 10 - # skill checks). 5e 2024 — kept loose/lowercased for matching. 11 - _DISADV_CHECK_CONDITIONS = frozenset({"poisoned", "frightened"}) 12 - 13 - # Conditions that cause auto-fail on Strength and Dexterity saves. 14 - _AUTOFAIL_STR_DEX_SAVES = frozenset( 15 - {"paralyzed", "petrified", "stunned", "unconscious"} 16 - ) 17 - 18 - # Restrained auto-fails Dex saves only (not Str). 19 - _AUTOFAIL_DEX_SAVES = frozenset({"restrained"}) 20 10 21 11 22 12 # Maps each skill to its governing ability ··· 63 53 return 2 + (level - 1) // 4 64 54 65 55 66 - def exhaustion_penalty(char: dict) -> int: 67 - """5e 2024: each level of exhaustion imposes a -2 penalty on d20 rolls. 68 - 69 - This folds directly into the numeric skill/save modifier so the DM 70 - never has to remember to subtract from the displayed value. 71 - """ 72 - level = max(0, int(char.get("state", {}).get("exhaustion", 0) or 0)) 73 - return -2 * level 74 - 75 - 76 - def _active_conditions(char: dict) -> set[str]: 77 - """Return the character's active conditions, lowercased for matching.""" 78 - return { 79 - str(c).strip().lower() 80 - for c in (char.get("conditions") or []) 81 - if str(c).strip() 82 - } 83 - 84 - 85 - def has_disadvantage_on_checks(char: dict) -> bool: 86 - """True if any active condition imposes disadvantage on ability checks.""" 87 - return bool(_active_conditions(char) & _DISADV_CHECK_CONDITIONS) 88 - 89 - 90 - def auto_fails_save(char: dict, ability: str) -> bool: 91 - """True if the character auto-fails saves of the given ability. 92 - 93 - Paralyzed/petrified/stunned/unconscious auto-fail Str and Dex saves; 94 - restrained auto-fails Dex only. 95 - """ 96 - conds = _active_conditions(char) 97 - if conds & _AUTOFAIL_STR_DEX_SAVES: 98 - return ability in ("strength", "dexterity") 99 - if conds & _AUTOFAIL_DEX_SAVES: 100 - return ability == "dexterity" 101 - return False 102 - 103 - 104 56 def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: 105 57 """Compute a skill modifier and the breakdown of contributions. 106 58 107 - Returns (total, breakdown_lines). Exhaustion is folded into the 108 - numeric total; disadvantage from conditions is not (it's a roll-time 109 - marker — see has_disadvantage_on_checks). 59 + Returns (total, breakdown_lines). The total is the raw 60 + ability + proficiency math; it does NOT fold in exhaustion or 61 + condition effects. The DM applies those at roll time. 110 62 """ 111 63 ability = SKILL_TO_ABILITY.get(skill) 112 64 if ability is None: ··· 130 82 prof_bonus = pb 131 83 breakdown.append(f"+{prof_bonus} proficient") 132 84 133 - exh = exhaustion_penalty(char) 134 - if exh: 135 - breakdown.append(f"{exh:+d} exhaustion") 136 - 137 - total = ability_mod + prof_bonus + exh 85 + total = ability_mod + prof_bonus 138 86 return total, breakdown 139 87 140 88 141 89 def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: 142 90 """Compute a saving throw modifier and breakdown. 143 91 144 - Exhaustion is folded into the numeric total. Auto-fail from conditions 145 - is not (see auto_fails_save). 92 + Like skill_modifier, this returns raw ability + proficiency math — 93 + no exhaustion, no auto-fail logic. The DM applies rule effects at 94 + roll time. 146 95 """ 147 96 abilities = char.get("abilities", {}) 148 97 ability_score = abilities.get(ability, 10) ··· 156 105 mod += pb 157 106 breakdown.append(f"+{pb} proficient") 158 107 159 - exh = exhaustion_penalty(char) 160 - if exh: 161 - mod += exh 162 - breakdown.append(f"{exh:+d} exhaustion") 163 - 164 108 return mod, breakdown 165 109 166 110 167 111 def passive_score(char: dict, skill: str = "perception") -> int: 168 112 """Passive score for a skill: 10 + skill modifier. 169 113 170 - Per 5e 2024, disadvantage on the underlying skill check subtracts 5 171 - from the passive score. This folds condition effects into the display. 114 + No condition-based adjustments — if the DM decides a condition 115 + should lower passive perception (per 5e 2024, disadvantage on the 116 + underlying check does), they adjust at use time. 172 117 """ 173 118 mod, _ = skill_modifier(char, skill) 174 - base = 10 + mod 175 - if has_disadvantage_on_checks(char): 176 - base -= 5 177 - return base 119 + return 10 + mod 178 120 179 121 180 122 def initiative_modifier(char: dict) -> int:
+8 -32
src/storied/character/display.py
··· 3 3 from storied.character.compute import ( 4 4 ABILITIES, 5 5 ALL_SKILLS, 6 - SKILL_TO_ABILITY, 7 6 ability_modifier, 8 - auto_fails_save, 9 7 class_summary, 10 8 effective_hp, 11 - has_disadvantage_on_checks, 12 9 has_expertise_in, 13 10 initiative_modifier, 14 11 is_proficient_in, ··· 16 13 proficiency_bonus, 17 14 save_modifier, 18 15 skill_modifier, 19 - total_level, 20 16 ) 21 17 22 18 ··· 43 39 for ability in ABILITIES: 44 40 mod, _ = save_modifier(char, ability) 45 41 marker = " ★" if ability in proficient_saves else "" 46 - roll_state = "" 47 - if auto_fails_save(char, ability): 48 - roll_state = " ✗" # auto-fail 49 - parts.append(f"{ability[:3].upper()} {mod:+d}{marker}{roll_state}") 42 + parts.append(f"{ability[:3].upper()} {mod:+d}{marker}") 50 43 return " ".join(parts) 51 44 52 45 53 46 def _skill_lines(char: dict) -> list[str]: 54 - """Render skills sorted alphabetically with proficiency markers. 55 - 56 - Adds a disadvantage indicator to all skills when the character has an 57 - active condition that imposes disadvantage on ability checks. 58 - """ 59 - disadv = has_disadvantage_on_checks(char) 47 + """Render skills sorted alphabetically with proficiency markers.""" 60 48 lines: list[str] = [] 61 49 for skill in sorted(ALL_SKILLS): 62 50 mod, _ = skill_modifier(char, skill) ··· 65 53 marker = " ★★" 66 54 elif is_proficient_in(char, skill): 67 55 marker = " ★" 68 - roll_state = " ◂" if disadv else "" 69 56 name = _format_skill_name(skill) 70 - lines.append(f" {name:<18} {mod:+d}{marker}{roll_state}") 57 + lines.append(f" {name:<18} {mod:+d}{marker}") 71 58 return lines 72 59 73 60 ··· 90 77 source = e.get("source", "(unknown)") 91 78 desc = e.get("description", "") 92 79 expires = e.get("expires") 80 + conc = " [Concentration]" if e.get("concentration") else "" 93 81 suffix = f" (until {expires})" if expires else "" 94 - lines.append(f" • {source} — {desc}{suffix}") 82 + lines.append(f" • {source}{conc} — {desc}{suffix}") 95 83 return lines 96 84 97 85 ··· 238 226 lines.append("") 239 227 240 228 exhaustion = state.get("exhaustion", 0) or 0 241 - disadv = has_disadvantage_on_checks(data) 242 229 243 - # Saves 244 230 lines.append("**Saving Throws:**") 245 231 lines.append(f" {_save_line(data)}") 246 - save_legend = " ★ proficient" 247 - if any(auto_fails_save(data, a) for a in ABILITIES): 248 - save_legend += " · ✗ auto-fail" 249 - lines.append(save_legend) 232 + lines.append(" ★ proficient") 250 233 lines.append("") 251 234 252 - # Skills 253 235 lines.append("**Skills:**") 254 236 lines.extend(_skill_lines(data)) 255 237 lines.append(f" Passive Perception: {passive_score(data, 'perception')}") 256 - skill_legend = " ★ proficient · ★★ expertise" 257 - if disadv: 258 - skill_legend += " · ◂ disadvantage" 259 - lines.append(skill_legend) 238 + lines.append(" ★ proficient · ★★ expertise") 260 239 if exhaustion: 261 240 lines.append( 262 - f" (exhaustion {exhaustion}: {-2 * exhaustion:+d} to all d20 rolls)" 241 + f" Exhaustion {exhaustion} — DM applies the level's effects per ruleset" 263 242 ) 264 243 lines.append("") 265 244 266 - # Conditions, defenses 267 245 cond_lines = _format_conditions(data) 268 246 if cond_lines: 269 247 lines.extend(cond_lines) ··· 274 252 lines.extend(def_lines) 275 253 lines.append("") 276 254 277 - # Effects, resources, magic items, features 278 255 for section_func in (_format_effects, _format_resources, _format_magic_items, _format_features): 279 256 section = section_func(data) 280 257 if section: 281 258 lines.extend(section) 282 259 lines.append("") 283 260 284 - # Equipment 285 261 equipment_lines = _format_equipment(data) 286 262 if equipment_lines: 287 263 lines.extend(equipment_lines)
+33 -163
src/storied/character/operations.py
··· 16 16 # --- HP operations --- 17 17 18 18 19 - def _defense_entries(defenses: dict, key: str) -> list[str]: 20 - """Extract damage-type strings from resistances/vulnerabilities. 21 - 22 - Tolerates two shapes: a list of dicts like ``[{"damage": "fire"}]`` and 23 - a flat list of strings like ``["fire"]``. The LLM has written both. 24 - """ 25 - types: list[str] = [] 26 - for entry in defenses.get(key) or []: 27 - if isinstance(entry, dict): 28 - damage = entry.get("damage") 29 - if damage: 30 - types.append(str(damage).lower()) 31 - elif isinstance(entry, str): 32 - types.append(entry.lower()) 33 - return types 34 - 35 - 36 - def _apply_defenses( 37 - data: dict, amount: int, damage_type: str | None, 38 - ) -> tuple[int, str | None]: 39 - """Apply resistance / vulnerability / immunity to an incoming damage amount. 40 - 41 - Returns (scaled_amount, note) where note is one of "immune", 42 - "resistance", "vulnerability", or None. Resistance and vulnerability 43 - cancel each other per 5e 2024. 44 - """ 45 - if not damage_type or amount <= 0: 46 - return amount, None 47 - 48 - defenses = data.get("defenses") or {} 49 - needle = damage_type.lower() 50 - 51 - immunities = defenses.get("immunities") or {} 52 - imm_damage = immunities.get("damage") or [] 53 - if any(str(t).lower() == needle for t in imm_damage): 54 - return 0, "immune" 55 - 56 - has_resistance = needle in _defense_entries(defenses, "resistances") 57 - has_vulnerability = needle in _defense_entries(defenses, "vulnerabilities") 58 - 59 - if has_resistance and has_vulnerability: 60 - return amount, None 61 - if has_resistance: 62 - return amount // 2, "resistance" 63 - if has_vulnerability: 64 - return amount * 2, "vulnerability" 65 - 66 - return amount, None 67 - 68 - 69 19 def damage( 70 20 player_id: str, 71 21 amount: int, 72 22 damage_type: str | None = None, 73 23 base_path: Path | None = None, 74 24 ) -> str: 75 - """Apply damage to the character. Resistance / vulnerability / immunity 76 - is applied first, then temp HP soaks, then current HP.""" 25 + """Apply raw damage to the character. 26 + 27 + Temp HP soaks first, then current HP. `damage_type` is metadata for 28 + narration and the campaign log — this function does not consult 29 + resistances, vulnerabilities, or immunities. The DM pre-applies the 30 + math per whatever ruleset they're running. 31 + """ 77 32 data = load_character(player_id, base_path) 78 33 if data is None: 79 34 return f"No character found for player '{player_id}'" 80 35 if amount < 0: 81 36 return "Damage amount must be non-negative" 82 - 83 - incoming = amount 84 - scaled, defense_note = _apply_defenses(data, amount, damage_type) 85 37 86 38 hp = data["state"]["hp"] 87 - remaining = scaled 39 + remaining = amount 88 40 89 - # Temp HP soaks damage first 90 41 temp_used = 0 91 42 if hp.get("temp", 0) > 0: 92 43 temp_used = min(hp["temp"], remaining) 93 44 hp["temp"] -= temp_used 94 45 remaining -= temp_used 95 46 96 - # Then current HP 97 - hp_before = hp["current"] 98 47 hp["current"] = max(0, hp["current"] - remaining) 99 48 100 49 save_character(player_id, data, base_path) 101 50 102 51 type_str = f" {damage_type}" if damage_type else "" 103 - if defense_note == "immune": 104 - headline = f"Immune to{type_str} damage (would have been {incoming})" 105 - elif defense_note: 106 - headline = ( 107 - f"Took {incoming}{type_str} damage → {scaled} ({defense_note})" 108 - ) 109 - else: 110 - headline = f"Took {scaled}{type_str} damage" 111 - 112 - parts = [headline] 52 + parts = [f"Took {amount}{type_str} damage"] 113 53 if temp_used: 114 54 parts.append(f"absorbed {temp_used} with temp HP") 115 55 parts.append(f"HP: {hp['current']}/{hp['max']}") ··· 117 57 parts.append(f"({hp['temp']} temp remaining)") 118 58 if hp["current"] == 0: 119 59 parts.append("**(at 0 HP — death saves!)**") 120 - 121 - # Concentration save hint. Per 5e 2024, taking damage while 122 - # concentrating requires a Con save with DC = max(10, damage taken // 2). 123 - # We emit a reminder rather than rolling — the DM decides success. 124 - if scaled > 0: 125 - con_effects = [ 126 - e for e in (data.get("effects") or []) if e.get("concentration") 127 - ] 128 - if con_effects: 129 - dc = max(10, scaled // 2) 130 - names = ", ".join(e.get("source", "?") for e in con_effects) 131 - parts.append( 132 - f"**Concentration save DC {dc}** for: {names}" 133 - ) 134 60 135 61 return ". ".join(parts) 136 62 ··· 169 95 ) -> str: 170 96 """Add a temporary effect to the character. 171 97 172 - If `concentration=True`, this is a concentration-bound effect. Only one 173 - concentration effect can be active at a time — adding a new one drops 174 - the old one per 5e rules. 98 + `concentration` is pure metadata — a flag the DM can set so the 99 + sheet displays which effect is the character's current concentration. 100 + The tool does not enforce uniqueness; the DM decides when to drop an 101 + effect (via `remove_effect`). 175 102 """ 176 103 data = load_character(player_id, base_path) 177 104 if data is None: 178 105 return f"No character found for player '{player_id}'" 179 106 180 107 effects = data.setdefault("effects", []) 181 - 182 - dropped_source: str | None = None 183 - if concentration: 184 - for i, existing in enumerate(effects): 185 - if existing.get("concentration"): 186 - dropped_source = existing.get("source", "previous effect") 187 - effects.pop(i) 188 - break 189 108 190 109 effect: dict = {"source": source, "description": description} 191 110 if expires: ··· 201 120 parts.append(f"(expires {expires})") 202 121 if concentration: 203 122 parts.append("[Concentration]") 204 - if dropped_source: 205 - parts.append(f"— lost concentration on {dropped_source}") 206 123 return " ".join(parts) 207 - 208 - 209 - def break_concentration( 210 - player_id: str, 211 - base_path: Path | None = None, 212 - ) -> str: 213 - """Remove whatever effect the character is currently concentrating on. 214 - 215 - Called when a concentration save fails, the character is incapacitated, 216 - or they voluntarily drop concentration. No-op if they weren't 217 - concentrating. 218 - """ 219 - data = load_character(player_id, base_path) 220 - if data is None: 221 - return f"No character found for player '{player_id}'" 222 - 223 - effects = data.get("effects") or [] 224 - for i, existing in enumerate(effects): 225 - if existing.get("concentration"): 226 - removed = effects.pop(i) 227 - save_character(player_id, data, base_path) 228 - return f"Concentration broken on {removed.get('source', '?')}" 229 - 230 - return "No concentration effect to break" 231 124 232 125 233 126 def remove_effect( ··· 409 302 # --- Resource operations --- 410 303 411 304 412 - def use_resource( 305 + def adjust_resource( 413 306 player_id: str, 414 307 name: str, 415 - amount: int = 1, 308 + delta: int, 416 309 base_path: Path | None = None, 417 310 ) -> str: 418 - """Decrement a resource pool. Substring match on resource name.""" 419 - data = load_character(player_id, base_path) 420 - if data is None: 421 - return f"No character found for player '{player_id}'" 422 - 423 - resources = data.get("resources", {}) 424 - needle = name.lower() 425 - 426 - target = None 427 - for key in resources: 428 - if needle in key.lower() or needle in resources[key].get("notes", "").lower(): 429 - target = key 430 - break 431 - 432 - if target is None: 433 - return f"No resource matching '{name}' found" 434 - 435 - pool = resources[target] 436 - before = pool.get("current", 0) 437 - pool["current"] = max(0, before - amount) 438 - actual = before - pool["current"] 439 - short = amount - actual 440 - 441 - save_character(player_id, data, base_path) 311 + """Adjust a resource pool by a delta. 442 312 443 - notes = pool.get("notes", target) 444 - msg = f"Used {actual} {notes} ({pool['current']}/{pool.get('max', 0)} remaining)" 445 - if short > 0: 446 - msg += f" — short {short}" 447 - return msg 448 - 449 - 450 - def restore_resource( 451 - player_id: str, 452 - name: str, 453 - amount: int, 454 - base_path: Path | None = None, 455 - ) -> str: 456 - """Restore points to a resource pool, clamped to max.""" 313 + Negative = use (clamped to 0), positive = restore (clamped to max). 314 + Substring match on the resource name or its notes field. 315 + """ 457 316 data = load_character(player_id, base_path) 458 317 if data is None: 459 318 return f"No character found for player '{player_id}'" ··· 473 332 pool = resources[target] 474 333 before = pool.get("current", 0) 475 334 maximum = pool.get("max", 0) 476 - pool["current"] = min(maximum, before + amount) 477 - actual = pool["current"] - before 335 + new_current = max(0, min(maximum, before + delta)) 336 + pool["current"] = new_current 337 + actual = new_current - before 478 338 479 339 save_character(player_id, data, base_path) 340 + 480 341 notes = pool.get("notes", target) 481 - return f"Restored {actual} {notes} ({pool['current']}/{maximum})" 342 + if delta < 0: 343 + used = before - new_current 344 + short = -delta - used 345 + msg = f"Used {used} {notes} ({new_current}/{maximum} remaining)" 346 + if short > 0: 347 + msg += f" — short {short}" 348 + return msg 349 + if delta > 0: 350 + return f"Restored {actual} {notes} ({new_current}/{maximum})" 351 + return f"No change to {notes} ({new_current}/{maximum})" 482 352 483 353 484 354 def rest(
+49 -7
src/storied/engine.py
··· 43 43 return path.read_text() 44 44 45 45 46 + # Tools whose invocation should not produce a visible "[Doing thing...]" 47 + # notification. set_scene fires on every single turn — the chatter was 48 + # noise, and the clock update is already visible via the ambient time 49 + # header at the top of the DM's context. 50 + _SILENT_TOOLS: set[str] = {"set_scene"} 51 + 52 + 46 53 def _tool_notification(name: str) -> str: 47 54 """Build a friendly tool notification string from an MCP tool name. 48 55 49 56 MCP tool names are prefixed as mcp__storied__<name> by Claude Code. 57 + Returns an empty string for tools in `_SILENT_TOOLS`; callers should 58 + skip empty notifications. 50 59 """ 51 60 short = name.rsplit("__", 1)[-1] if "__" in name else name 61 + if short in _SILENT_TOOLS: 62 + return "" 52 63 label = TOOL_LABELS.get(short, short) 53 64 return f"[{label}...]" 54 65 ··· 124 135 with self._transcript_path.open("a") as f: 125 136 f.write(json.dumps(entry) + "\n") 126 137 138 + def _format_time_header(self) -> str: 139 + """Build an ambient clock header for the top of the DM's context. 140 + 141 + The DM's one persistent weakness is remembering what time it is 142 + and whether it called `set_scene` last turn. Putting the current 143 + time front-and-center every turn is the cheapest fix — the DM 144 + literally can't miss it. 145 + """ 146 + now = self._campaign_log.get_current_time() 147 + lines = [ 148 + "## ⏰ Current Time", 149 + "", 150 + ( 151 + f"**{now.to_anchor()}** · {now} · " 152 + f"{now.period_of_day().lower()}, {now.atmosphere()}" 153 + ), 154 + ] 155 + entries = self._campaign_log.current_entries 156 + if entries: 157 + last = entries[-1] 158 + lines.append(f"Last logged: _{last.event}_ ({last.duration})") 159 + return "\n".join(lines) 160 + 127 161 def _build_context(self) -> str: 128 162 """Build context string for system prompt. 129 163 130 164 Loads and formats: 131 - 1. Character sheet (always) 132 - 2. Campaign log (current time + recent events) 133 - 3. Session state if exists (situation, present, threads) 134 - 4. Player knowledge (what the player has learned) 135 - 5. DM knowledge: current location + present entities (smart loading) 165 + 1. Ambient clock header (always — first so the DM can't miss it) 166 + 2. Character sheet 167 + 3. Campaign log (recent events) 168 + 4. Session state if exists (situation, present, threads) 169 + 5. Player knowledge (what the player has learned) 170 + 6. DM knowledge: current location + present entities (smart loading) 136 171 """ 137 172 parts = [] 138 173 self._context_parts = {} 139 174 140 - # 0. DM style tuning (player preferences for pacing, tone, focus) 175 + # 0. Ambient clock — the very first thing the DM sees every turn. 176 + time_header = self._format_time_header() 177 + self._context_parts["Time"] = time_header 178 + parts.append(time_header) 179 + 180 + # 1. DM style tuning (player preferences for pacing, tone, focus) 141 181 if self.world_id: 142 182 style_path = self.base_path / "worlds" / self.world_id / "style.md" 143 183 if style_path.exists(): ··· 491 531 yield f"[→ {short}(...)]" 492 532 deferred_notification = False 493 533 else: 494 - yield _tool_notification(name) 534 + notification = _tool_notification(name) 535 + if notification: 536 + yield notification 495 537 deferred_notification = False 496 538 497 539 case ToolInputDelta(json_fragment=fragment):
+10 -2
src/storied/log.py
··· 391 391 return entries 392 392 393 393 def format_for_context(self) -> str: 394 - """Format the log for inclusion in system prompt.""" 395 - lines = [f"## Campaign Time: {self.current_time}"] 394 + """Format the log for inclusion in system prompt. 395 + 396 + The current time itself lives in the ambient clock header at the 397 + top of the DM's context — see ``DMEngine._format_time_header``. 398 + This block is just the recent event history. 399 + """ 400 + lines: list[str] = [] 396 401 397 402 if self.previous_summaries: 403 + lines.append("## Campaign Log") 398 404 lines.append("") 399 405 lines.append("**Previous Days:**") 400 406 for summary in self.previous_summaries[-3:]: # Last 3 days 401 407 lines.append(f"- {summary}") 402 408 403 409 if self.current_entries: 410 + if not lines: 411 + lines.append("## Campaign Log") 404 412 lines.append("") 405 413 lines.append(f"**Today (Day {self.current_day}):**") 406 414 if len(self.current_entries) > 10:
+13 -17
src/storied/notification_formatters.py
··· 130 130 return f"{verb} {item}" 131 131 132 132 133 - def _format_use_resource_notification(tool_json: str) -> str: 133 + def _format_adjust_resource_notification(tool_json: str) -> str: 134 134 args = _parse_tool_args(tool_json) 135 135 name = args.get("name", "resource") 136 - amount = args.get("amount", 1) 137 - if amount == 1: 138 - return f"Using {name}" 139 - return f"Using {amount} of {name}" 140 - 141 - 142 - def _format_restore_resource_notification(tool_json: str) -> str: 143 - args = _parse_tool_args(tool_json) 144 - name = args.get("name", "resource") 145 - amount = args.get("amount", "?") 146 - return f"Restoring {amount} of {name}" 136 + delta = args.get("delta", 0) 137 + if isinstance(delta, int): 138 + if delta < 0: 139 + magnitude = -delta 140 + if magnitude == 1: 141 + return f"Using {name}" 142 + return f"Using {magnitude} of {name}" 143 + if delta > 0: 144 + return f"Restoring {delta} of {name}" 145 + return f"Adjusting {name}" 147 146 148 147 149 148 def _format_rest_notification(tool_json: str) -> str: ··· 182 181 "add_item": _format_add_item_notification, 183 182 "remove_item": _format_remove_item_notification, 184 183 "set_item_status": _format_set_item_status_notification, 185 - "use_resource": _format_use_resource_notification, 186 - "restore_resource": _format_restore_resource_notification, 184 + "adjust_resource": _format_adjust_resource_notification, 187 185 "rest": _format_rest_notification, 188 186 "add_note": _format_add_note_notification, 189 187 "update_character": _format_update_character_notification, ··· 196 194 "update_character": "Updating character sheet", 197 195 "adjust_coins": "Adjusting coins", 198 196 "create_character": "Creating character", 199 - "set_scene": "Setting scene", 200 197 "establish": "Establishing", 201 198 "mark": "Recording", 202 199 "note_discovery": "Noting discovery", ··· 216 213 "add_item": "Picking up item", 217 214 "remove_item": "Removing item", 218 215 "set_item_status": "Updating magic item", 219 - "use_resource": "Using resource", 220 - "restore_resource": "Restoring resource", 216 + "adjust_resource": "Adjusting resource", 221 217 "rest": "Resting", 222 218 "add_note": "Taking a note", 223 219 "end_initiative": "Ending initiative",
+33
src/storied/testing.py
··· 1 + """Internal test helpers. 2 + 3 + Lives in the package instead of next to `tests/conftest.py` because 4 + `tests/` isn't on the Python path — pytest picks up conftest.py as a 5 + plugin module, but ``from tests.conftest import call_tool`` doesn't 6 + resolve in a plain ``pytest`` run. Keeping helpers on the regular import 7 + path ($PROJECT/src) avoids that friction entirely. 8 + 9 + Prefixed with ``_`` so downstream users of the ``storied`` package know 10 + this isn't public API. 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import asyncio 16 + from collections.abc import Callable 17 + from typing import Any 18 + 19 + from uncalled_for import resolved_dependencies 20 + 21 + 22 + def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: 23 + """Invoke a FastMCP-decorated tool synchronously, resolving its 24 + ``Dependency`` parameters from the process-global ToolContext. 25 + 26 + Use this in tests instead of calling the tool wrapper directly — 27 + direct calls leave the Dependency instances as parameter defaults 28 + rather than resolving them. 29 + """ 30 + async def _run() -> Any: 31 + async with resolved_dependencies(fn, kwargs) as deps: 32 + return fn(**{**kwargs, **deps}) 33 + return asyncio.run(_run())
+31 -69
src/storied/tools/character.py
··· 16 16 add_effect as char_add_effect, 17 17 ) 18 18 from storied.character import ( 19 - break_concentration as char_break_concentration, 20 - ) 21 - from storied.character import ( 22 19 add_item as char_add_item, 23 20 ) 24 21 from storied.character import ( ··· 26 23 ) 27 24 from storied.character import ( 28 25 adjust_coins as char_adjust_coins, 26 + ) 27 + from storied.character import ( 28 + adjust_resource as char_adjust_resource, 29 29 ) 30 30 from storied.character import ( 31 31 create_character as char_create, ··· 55 55 rest as char_rest, 56 56 ) 57 57 from storied.character import ( 58 - restore_resource as char_restore_resource, 59 - ) 60 - from storied.character import ( 61 58 set_item_status as char_set_item_status, 62 59 ) 63 60 from storied.character import ( 64 61 update_character as char_update, 65 - ) 66 - from storied.character import ( 67 - use_resource as char_use_resource, 68 62 ) 69 63 from storied.initiative import InitiativeTracker 70 64 from storied.log import CampaignLog ··· 231 225 player: str = Player(), 232 226 root: Path = StorageRoot(), 233 227 ) -> str: 234 - """Apply damage to a named target. 228 + """Apply raw damage to a named target. 235 229 236 230 Routes by name: if `target` matches a current combatant in initiative, 237 - the damage hits the combatant (and syncs to the character sheet if it's 238 - the player). Otherwise, if `target` matches the player character's name, 239 - the damage hits the player character sheet directly. Temp HP absorbs 240 - first, then current HP. 231 + damage hits the combatant (and syncs to the character sheet if it's 232 + the player). Otherwise, if `target` matches the player character, the 233 + damage hits the character sheet directly. Temp HP absorbs first, then 234 + current HP. 235 + 236 + This tool does NOT apply resistance, vulnerability, or immunity — you 237 + (the DM) pre-apply those per whatever ruleset you're running. The 238 + `type` field is metadata for narration and the campaign log, not a 239 + rule trigger. 241 240 242 241 Args: 243 242 target: Combatant or player character name 244 - amount: Damage amount (non-negative) 245 - type: Optional damage type (fire, cold, slashing, etc.) for narration 243 + amount: Damage amount to record (non-negative) 244 + type: Optional damage type for narration (fire, cold, slashing, …) 246 245 247 246 Returns: 248 247 Damage taken and remaining HP ··· 335 334 Use for spells, potions, environmental effects, narrative buffs/debuffs — 336 335 anything that's temporarily affecting the character. 337 336 338 - Set `concentration=True` for concentration-bound spells (Bless, Hold 339 - Person, Hex, etc). Only one concentration effect can be active at a 340 - time — adding a new one drops the old one automatically per 5e rules. 341 - When the character takes damage while concentrating, `damage()` will 342 - emit a concentration-save reminder with the correct DC. 337 + Set `concentration=True` to flag an effect as concentration-bound so 338 + the character sheet displays it that way. This is pure metadata — the 339 + tool does not enforce uniqueness or emit save reminders. If a new 340 + concentration spell replaces an old one, call `remove_effect` on the 341 + old one first. 343 342 344 343 Args: 345 344 source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") 346 345 description: What the effect does in narrative + mechanical terms 347 346 expires: Optional game time anchor when the effect ends (e.g., "d28-1430") 348 - concentration: True for concentration-bound effects. Defaults to False. 347 + concentration: Metadata flag for concentration-bound effects. Defaults to False. 349 348 350 349 Returns: 351 350 Confirmation ··· 357 356 358 357 359 358 @mcp.tool(tags={"dm", "character"}) 360 - def break_concentration( 361 - player: str = Player(), 362 - root: Path = StorageRoot(), 363 - ) -> str: 364 - """Drop the character's current concentration effect. 365 - 366 - Call this when a concentration save fails, the character is 367 - incapacitated, or they voluntarily drop concentration. No-op if the 368 - character isn't currently concentrating on anything. 369 - 370 - Returns: 371 - What was removed, or a message if nothing was concentrating. 372 - """ 373 - return char_break_concentration(player, base_path=root) 374 - 375 - 376 - @mcp.tool(tags={"dm", "character"}) 377 359 def remove_effect( 378 360 source: str, 379 361 player: str = Player(), ··· 503 485 504 486 505 487 @mcp.tool(tags={"dm", "character"}) 506 - def use_resource( 507 - name: str, 508 - amount: int = 1, 509 - player: str = Player(), 510 - root: Path = StorageRoot(), 511 - ) -> str: 512 - """Decrement a resource pool (rage uses, ki points, hit dice, magic item charges, etc.). 513 - 514 - Substring match on the resource name. Resources are clamped to 0. 515 - 516 - Args: 517 - name: Resource name (e.g., "rage", "hit_dice_d8", "bracer") 518 - amount: How many to use (default 1) 519 - 520 - Returns: 521 - Confirmation with remaining count 522 - """ 523 - return char_use_resource(player, name, amount=amount, base_path=root) 524 - 525 - 526 - @mcp.tool(tags={"dm", "character"}) 527 - def restore_resource( 488 + def adjust_resource( 528 489 name: str, 529 - amount: int, 490 + delta: int, 530 491 player: str = Player(), 531 492 root: Path = StorageRoot(), 532 493 ) -> str: 533 - """Restore points to a resource pool, clamped to max. 494 + """Adjust a resource pool by a delta (negative = use, positive = restore). 534 495 535 - Usually you'll use `rest` instead, which refreshes resources by their 536 - refresh type. Use this for one-off restorations. 496 + Substring match on the resource name or its notes field. Negative 497 + deltas are clamped to zero; positive deltas are clamped to max. 537 498 538 499 Args: 539 - name: Resource name (substring match) 540 - amount: How many to restore 500 + name: Resource name (e.g., "rage", "hit_dice_d8", "slot_3") 501 + delta: How much to adjust. Negative spends, positive restores. 502 + e.g. -1 to cast a spell; +2 to regain two hit dice. 541 503 542 504 Returns: 543 - Confirmation 505 + Confirmation with new count 544 506 """ 545 - return char_restore_resource(player, name, amount, base_path=root) 507 + return char_adjust_resource(player, name, delta, base_path=root) 546 508 547 509 548 510 @mcp.tool(tags={"dm", "character"})
+8 -19
tests/conftest.py
··· 1 - """Shared test fixtures.""" 1 + """Shared test fixtures. 2 2 3 - import asyncio 3 + The synchronous tool-invocation helper ``call_tool`` lives in 4 + ``storied._testing`` so it's importable by test modules without needing 5 + ``tests/`` to be a Python package — pytest's conftest loading isn't a 6 + regular import. 7 + """ 8 + 4 9 import hashlib 5 - from collections.abc import Callable, Iterator 10 + from collections.abc import Iterator 6 11 from pathlib import Path 7 - from typing import Any 8 12 9 13 import pytest 10 - from uncalled_for import resolved_dependencies 11 14 12 15 from storied.log import CampaignLog 13 16 from storied.search import VectorIndex 14 17 from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx 15 - 16 - 17 - def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: 18 - """Invoke a FastMCP-decorated tool synchronously, resolving its 19 - `Dependency` parameters from the process-global ToolContext. 20 - 21 - Use this in tests instead of calling the tool wrapper directly — 22 - direct calls leave the Dependency instances as parameter defaults 23 - rather than resolving them. 24 - """ 25 - async def _run() -> Any: 26 - async with resolved_dependencies(fn, kwargs) as deps: 27 - return fn(**{**kwargs, **deps}) 28 - return asyncio.run(_run()) 29 18 30 19 EMBED_DIM = 384 31 20
+1 -1
tests/test_advancement.py
··· 18 18 from storied.tools import ToolContext 19 19 from storied.tools.scene import notify_dm as _notify_dm 20 20 21 - from tests.conftest import call_tool 21 + from storied.testing import call_tool 22 22 23 23 24 24 def notify_dm(message: str, ctx: ToolContext) -> str:
+91 -265
tests/test_character.py
··· 13 13 add_item, 14 14 add_note, 15 15 adjust_coins, 16 - auto_fails_save, 17 - break_concentration, 16 + adjust_resource, 18 17 create_character, 19 18 damage, 20 19 effective_hp, 21 - exhaustion_penalty, 22 20 format_character_context, 23 21 format_sheet, 24 22 format_status, 25 - has_disadvantage_on_checks, 26 23 has_expertise_in, 27 24 heal, 28 25 is_proficient_in, ··· 35 32 remove_effect, 36 33 remove_item, 37 34 rest, 38 - restore_resource, 39 35 save_character, 40 36 save_modifier, 41 37 set_item_status, 42 38 skill_modifier, 43 39 total_level, 44 40 update_character, 45 - use_resource, 46 41 ) 47 - from storied.tools import ToolContext 48 42 49 43 50 44 # --- Fixtures --- ··· 289 283 assert data["resources"]["channel_divinity"]["current"] == 1 290 284 assert data["resources"]["lay_on_hands"]["max"] == 15 291 285 292 - def test_coerced_character_can_use_resource(self, player_dir: Path): 286 + def test_coerced_character_can_adjust_resource(self, player_dir: Path): 293 287 """End-to-end: a character with bad-shape resources on disk should 294 - be usable via use_resource after load coercion.""" 288 + be usable via adjust_resource after load coercion.""" 295 289 import yaml 296 290 path = player_dir / "players" / "test-player" / "character.yaml" 297 291 path.write_text(yaml.dump({ ··· 304 298 "refresh": "short_rest", "notes": "Channel Divinity"}, 305 299 ], 306 300 })) 307 - result = use_resource("test-player", "channel", base_path=player_dir) 301 + result = adjust_resource( 302 + "test-player", "channel", -1, base_path=player_dir 303 + ) 308 304 assert "Used 1" in result 309 305 310 306 def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): ··· 393 389 # 10 + perception modifier (+4) = 14 394 390 assert passive_score(mira, "perception") == 14 395 391 396 - def test_exhaustion_penalty_applied_to_skills(self, mira: dict): 392 + def test_skill_modifier_ignores_exhaustion(self, mira: dict): 393 + """Skill modifiers show raw ability + proficiency math. Exhaustion 394 + is a rule effect the DM applies at roll time, not baked into the 395 + displayed number.""" 397 396 mira["state"]["exhaustion"] = 2 398 - total, breakdown = skill_modifier(mira, "stealth") 399 - # Without exhaustion: +8. With 2 levels: +4. 400 - assert total == 4 401 - assert any("exhaustion" in b.lower() for b in breakdown) 397 + total, _ = skill_modifier(mira, "stealth") 398 + assert total == 8 # +4 dex + 4 expertise, exhaustion NOT folded in 402 399 403 - def test_exhaustion_penalty_applied_to_saves(self, mira: dict): 400 + def test_save_modifier_ignores_exhaustion(self, mira: dict): 404 401 mira["state"]["exhaustion"] = 1 405 - total, breakdown = save_modifier(mira, "dexterity") 406 - # Without exhaustion: +6. With 1 level: +4. 407 - assert total == 4 408 - assert any("exhaustion" in b.lower() for b in breakdown) 402 + total, _ = save_modifier(mira, "dexterity") 403 + assert total == 6 # +4 dex + 2 prof, exhaustion NOT folded in 409 404 410 - def test_exhaustion_zero_is_noop(self, mira: dict): 411 - mira["state"]["exhaustion"] = 0 412 - total, breakdown = skill_modifier(mira, "stealth") 413 - assert total == 8 414 - assert not any("exhaustion" in b.lower() for b in breakdown) 415 - 416 - def test_exhaustion_penalty_helper(self, mira: dict): 417 - mira["state"]["exhaustion"] = 3 418 - assert exhaustion_penalty(mira) == -6 419 - 420 - def test_poisoned_gives_disadvantage_on_checks(self, mira: dict): 405 + def test_passive_perception_ignores_conditions(self, mira: dict): 406 + """Passive perception is the raw 10 + perception modifier. The DM 407 + applies condition-based adjustments (e.g. -5 for disadvantage per 408 + 5e 2024) per whatever ruleset they're running.""" 409 + assert passive_score(mira, "perception") == 14 421 410 mira["conditions"] = ["Poisoned"] 422 - assert has_disadvantage_on_checks(mira) is True 423 - 424 - def test_frightened_gives_disadvantage_on_checks(self, mira: dict): 425 - mira["conditions"] = ["frightened"] 426 - assert has_disadvantage_on_checks(mira) is True 427 - 428 - def test_no_disadvantage_without_matching_condition(self, mira: dict): 429 - mira["conditions"] = ["Prone"] 430 - assert has_disadvantage_on_checks(mira) is False 431 - 432 - def test_passive_perception_drops_when_disadvantaged(self, mira: dict): 433 - # Baseline: +4 perception → passive 14 434 411 assert passive_score(mira, "perception") == 14 435 - mira["conditions"] = ["Poisoned"] 436 - # With disadvantage: -5 → 9 437 - assert passive_score(mira, "perception") == 9 438 - 439 - def test_paralyzed_auto_fails_str_and_dex_saves(self, mira: dict): 440 - mira["conditions"] = ["Paralyzed"] 441 - assert auto_fails_save(mira, "strength") is True 442 - assert auto_fails_save(mira, "dexterity") is True 443 - assert auto_fails_save(mira, "wisdom") is False 444 - assert auto_fails_save(mira, "constitution") is False 445 - 446 - def test_restrained_auto_fails_dex_saves_only(self, mira: dict): 447 - mira["conditions"] = ["restrained"] 448 - assert auto_fails_save(mira, "dexterity") is True 449 - assert auto_fails_save(mira, "strength") is False 450 - 451 - def test_no_auto_fail_without_condition(self, mira: dict): 452 - assert auto_fails_save(mira, "dexterity") is False 453 - assert auto_fails_save(mira, "strength") is False 454 412 455 413 def test_effective_hp_with_temp(self, player_dir: Path): 456 414 save_character( ··· 621 579 mira["state"]["hp"]["temp"] = 5 622 580 result = format_sheet(mira) 623 581 assert "+5 temp" in result 624 - 625 - def test_format_sheet_shows_disadvantage_legend_when_condition_applies( 626 - self, mira: dict, 627 - ): 628 - mira["conditions"] = ["Poisoned"] 629 - sheet = format_sheet(mira) 630 - assert "disadvantage" in sheet.lower() 631 582 632 583 def test_format_sheet_shows_inspiration_when_available(self, mira: dict): 633 584 mira["state"]["inspiration"] = True 634 585 sheet = format_sheet(mira) 635 586 assert "Inspiration" in sheet and "available" in sheet 636 587 637 - def test_format_sheet_shows_exhaustion_line_when_nonzero(self, mira: dict): 588 + def test_format_sheet_shows_exhaustion_reminder_when_nonzero( 589 + self, mira: dict, 590 + ): 591 + """When exhaustion is set, the sheet shows a reminder that the DM 592 + applies the effect — it does NOT fold a numeric penalty into the 593 + displayed skill modifiers.""" 638 594 mira["state"]["exhaustion"] = 2 639 595 sheet = format_sheet(mira) 640 - assert "exhaustion 2" in sheet.lower() 641 - assert "-4" in sheet 642 - 643 - def test_format_sheet_shows_auto_fail_marker(self, mira: dict): 644 - mira["conditions"] = ["Paralyzed"] 645 - sheet = format_sheet(mira) 646 - assert "✗" in sheet 647 - assert "auto-fail" in sheet.lower() 596 + assert "Exhaustion 2" in sheet 597 + # Stealth should still read +8 (raw), not +4 (folded) 598 + assert "Stealth" in sheet 599 + assert "+8" in sheet 648 600 649 601 def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): 650 602 mira["state"]["exhaustion"] = 2 ··· 765 717 result = damage("test-player", 3, damage_type="fire", base_path=player_dir) 766 718 assert "fire" in result 767 719 768 - def test_damage_resistance_halves(self, mira: dict, player_dir: Path): 720 + def test_damage_ignores_resistances(self, mira: dict, player_dir: Path): 721 + """Resistances are metadata for the DM's reference — the tool 722 + applies raw damage and lets the DM pre-compute rule effects.""" 769 723 update_character( 770 724 "test-player", 771 725 {"defenses.resistances": [{"damage": "fire"}]}, 772 726 base_path=player_dir, 773 727 ) 774 - result = damage("test-player", 10, damage_type="fire", base_path=player_dir) 728 + damage("test-player", 10, damage_type="fire", base_path=player_dir) 775 729 data = load_character("test-player", player_dir) 776 - assert data["state"]["hp"]["current"] == 19 777 - assert "resistance" in result 778 - assert "10" in result and "5" in result 779 - 780 - def test_damage_resistance_only_applies_to_matched_type( 781 - self, mira: dict, player_dir: Path, 782 - ): 783 - update_character( 784 - "test-player", 785 - {"defenses.resistances": [{"damage": "fire"}]}, 786 - base_path=player_dir, 787 - ) 788 - damage("test-player", 10, damage_type="cold", base_path=player_dir) 789 - data = load_character("test-player", player_dir) 730 + # Raw 10, not halved 790 731 assert data["state"]["hp"]["current"] == 14 791 732 792 - def test_damage_vulnerability_doubles(self, mira: dict, player_dir: Path): 733 + def test_damage_ignores_vulnerabilities(self, mira: dict, player_dir: Path): 793 734 update_character( 794 735 "test-player", 795 736 {"defenses.vulnerabilities": [{"damage": "radiant"}]}, 796 737 base_path=player_dir, 797 738 ) 798 - result = damage( 739 + damage( 799 740 "test-player", 5, damage_type="radiant", base_path=player_dir, 800 741 ) 801 742 data = load_character("test-player", player_dir) 802 - assert data["state"]["hp"]["current"] == 14 803 - assert "vulnerability" in result 743 + # Raw 5, not doubled 744 + assert data["state"]["hp"]["current"] == 19 804 745 805 - def test_damage_immunity_is_zero(self, mira: dict, player_dir: Path): 746 + def test_damage_ignores_immunities(self, mira: dict, player_dir: Path): 806 747 update_character( 807 748 "test-player", 808 749 {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, 809 750 base_path=player_dir, 810 751 ) 811 - result = damage( 752 + damage( 812 753 "test-player", 12, damage_type="poison", base_path=player_dir, 813 754 ) 814 755 data = load_character("test-player", player_dir) 815 - assert data["state"]["hp"]["current"] == 24 816 - assert "Immune" in result 817 - 818 - def test_damage_resistance_and_vulnerability_cancel( 819 - self, mira: dict, player_dir: Path, 820 - ): 821 - update_character( 822 - "test-player", 823 - { 824 - "defenses.resistances": [{"damage": "cold"}], 825 - "defenses.vulnerabilities": [{"damage": "cold"}], 826 - }, 827 - base_path=player_dir, 828 - ) 829 - damage("test-player", 8, damage_type="cold", base_path=player_dir) 830 - data = load_character("test-player", player_dir) 831 - assert data["state"]["hp"]["current"] == 16 832 - 833 - def test_damage_resistance_tolerates_string_list( 834 - self, mira: dict, player_dir: Path, 835 - ): 836 - # The LLM has been known to write resistances as a flat list of strings 837 - # instead of a list of {"damage": ...} dicts. Handle both. 838 - update_character( 839 - "test-player", 840 - {"defenses.resistances": ["fire"]}, 841 - base_path=player_dir, 842 - ) 843 - damage("test-player", 10, damage_type="fire", base_path=player_dir) 844 - data = load_character("test-player", player_dir) 845 - assert data["state"]["hp"]["current"] == 19 846 - 847 - def test_damage_resistance_is_case_insensitive( 848 - self, mira: dict, player_dir: Path, 849 - ): 850 - update_character( 851 - "test-player", 852 - {"defenses.resistances": [{"damage": "Fire"}]}, 853 - base_path=player_dir, 854 - ) 855 - damage("test-player", 10, damage_type="FIRE", base_path=player_dir) 856 - data = load_character("test-player", player_dir) 857 - assert data["state"]["hp"]["current"] == 19 858 - 859 - def test_damage_resistance_with_temp_hp(self, mira: dict, player_dir: Path): 860 - # Defenses apply before temp HP soaks. 10 fire → resisted to 5 → 5 temp 861 - # absorbs all of it. 862 - update_character( 863 - "test-player", 864 - { 865 - "state.hp.temp": 5, 866 - "defenses.resistances": [{"damage": "fire"}], 867 - }, 868 - base_path=player_dir, 869 - ) 870 - damage("test-player", 10, damage_type="fire", base_path=player_dir) 871 - data = load_character("test-player", player_dir) 872 - assert data["state"]["hp"]["temp"] == 0 873 - assert data["state"]["hp"]["current"] == 24 756 + # Raw 12, not zeroed 757 + assert data["state"]["hp"]["current"] == 12 874 758 875 759 876 760 class TestLevelUp: ··· 1018 902 1019 903 1020 904 class TestConcentration: 1021 - def test_add_concentration_effect(self, mira: dict, player_dir: Path): 905 + """`concentration=True` is a metadata flag — the tool records it on 906 + the effect so the sheet can display which effect is the current 907 + concentration. It does NOT enforce uniqueness or emit save hints. 908 + The DM decides when to drop a concentration effect.""" 909 + 910 + def test_add_concentration_effect_flags_it( 911 + self, mira: dict, player_dir: Path, 912 + ): 1022 913 result = add_effect( 1023 914 "test-player", "Bless", "+1d4 to attacks", 1024 915 concentration=True, base_path=player_dir, ··· 1027 918 data = load_character("test-player", player_dir) 1028 919 assert data["effects"][0]["concentration"] is True 1029 920 1030 - def test_adding_concentration_drops_previous( 921 + def test_multiple_concentration_effects_allowed( 1031 922 self, mira: dict, player_dir: Path, 1032 923 ): 924 + """No enforcement — the DM can flag two effects concentration.""" 1033 925 add_effect( 1034 926 "test-player", "Bless", "+1d4", 1035 927 concentration=True, base_path=player_dir, 1036 928 ) 1037 - result = add_effect( 929 + add_effect( 1038 930 "test-player", "Hold Person", "paralyzed", 1039 931 concentration=True, base_path=player_dir, 1040 932 ) 1041 - assert "lost concentration on Bless" in result 1042 933 data = load_character("test-player", player_dir) 1043 934 sources = [e["source"] for e in data["effects"]] 1044 - assert "Bless" not in sources 935 + assert "Bless" in sources 1045 936 assert "Hold Person" in sources 1046 937 1047 - def test_adding_non_concentration_does_not_drop_existing( 938 + def test_damage_does_not_emit_concentration_save_hint( 1048 939 self, mira: dict, player_dir: Path, 1049 940 ): 1050 - add_effect( 1051 - "test-player", "Bless", "+1d4", 1052 - concentration=True, base_path=player_dir, 1053 - ) 1054 - add_effect( 1055 - "test-player", "Potion of Heroism", "+10 temp HP", 1056 - base_path=player_dir, 1057 - ) 1058 - data = load_character("test-player", player_dir) 1059 - sources = [e["source"] for e in data["effects"]] 1060 - assert "Bless" in sources 1061 - assert "Potion of Heroism" in sources 1062 - 1063 - def test_damage_emits_concentration_save_hint( 1064 - self, mira: dict, player_dir: Path, 1065 - ): 941 + """The DM issues concentration saves manually per the rules.""" 1066 942 add_effect( 1067 943 "test-player", "Bless", "+1d4", 1068 944 concentration=True, base_path=player_dir, 1069 945 ) 1070 946 result = damage("test-player", 6, base_path=player_dir) 1071 - # DC = max(10, 6 // 2) = 10 1072 - assert "Concentration save DC 10" in result 1073 - assert "Bless" in result 1074 - 1075 - def test_damage_concentration_dc_scales_with_damage( 1076 - self, mira: dict, player_dir: Path, 1077 - ): 1078 - add_effect( 1079 - "test-player", "Hex", "extra 1d6 necrotic", 1080 - concentration=True, base_path=player_dir, 1081 - ) 1082 - # Need a caster with more HP, but raising max for this test 1083 - update_character( 1084 - "test-player", {"state.hp.max": 100, "state.hp.current": 100}, 1085 - base_path=player_dir, 1086 - ) 1087 - result = damage("test-player", 30, base_path=player_dir) 1088 - # DC = max(10, 30 // 2) = 15 1089 - assert "Concentration save DC 15" in result 1090 - 1091 - def test_no_concentration_hint_without_concentration_effect( 1092 - self, mira: dict, player_dir: Path, 1093 - ): 1094 - add_effect( 1095 - "test-player", "Mage Armor", "+3 AC", # not concentration 1096 - base_path=player_dir, 1097 - ) 1098 - result = damage("test-player", 5, base_path=player_dir) 1099 947 assert "Concentration save" not in result 1100 948 1101 - def test_no_concentration_hint_when_damage_absorbed( 1102 - self, mira: dict, player_dir: Path, 1103 - ): 1104 - # 5e 2024: no damage taken → no save needed. Test via immunity. 1105 - update_character( 1106 - "test-player", 1107 - {"defenses.immunities": {"damage": ["fire"], "conditions": []}}, 1108 - base_path=player_dir, 1109 - ) 1110 - add_effect( 1111 - "test-player", "Bless", "+1d4", 1112 - concentration=True, base_path=player_dir, 1113 - ) 1114 - result = damage("test-player", 10, damage_type="fire", base_path=player_dir) 1115 - assert "Concentration save" not in result 1116 - 1117 - def test_break_concentration_removes_effect( 1118 - self, mira: dict, player_dir: Path, 1119 - ): 1120 - add_effect( 1121 - "test-player", "Bless", "+1d4", 1122 - concentration=True, base_path=player_dir, 1123 - ) 1124 - add_effect( 1125 - "test-player", "Mage Armor", "+3 AC", # non-concentration 1126 - base_path=player_dir, 1127 - ) 1128 - result = break_concentration("test-player", base_path=player_dir) 1129 - assert "Bless" in result 1130 - data = load_character("test-player", player_dir) 1131 - sources = [e["source"] for e in data["effects"]] 1132 - assert "Bless" not in sources 1133 - assert "Mage Armor" in sources # non-concentration effects untouched 1134 - 1135 - def test_break_concentration_noop_when_nothing_concentrating( 1136 - self, mira: dict, player_dir: Path, 1137 - ): 1138 - result = break_concentration("test-player", base_path=player_dir) 1139 - assert "No concentration" in result 1140 - 1141 949 1142 950 class TestEffects: 1143 951 def test_add_effect_appends(self, mira: dict, player_dir: Path): ··· 1256 1064 1257 1065 1258 1066 class TestResources: 1259 - def test_use_resource_decrements(self, mira: dict, player_dir: Path): 1260 - use_resource("test-player", "hit_dice", base_path=player_dir) 1067 + def test_adjust_resource_spend_one(self, mira: dict, player_dir: Path): 1068 + adjust_resource("test-player", "hit_dice", -1, base_path=player_dir) 1261 1069 data = load_character("test-player", player_dir) 1262 1070 assert data["resources"]["hit_dice_d8"]["current"] == 2 1263 1071 1264 - def test_use_resource_amount(self, mira: dict, player_dir: Path): 1265 - use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) 1072 + def test_adjust_resource_spend_multiple( 1073 + self, mira: dict, player_dir: Path, 1074 + ): 1075 + adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) 1266 1076 data = load_character("test-player", player_dir) 1267 1077 assert data["resources"]["hit_dice_d8"]["current"] == 1 1268 1078 1269 - def test_use_resource_clamped_to_zero(self, mira: dict, player_dir: Path): 1270 - result = use_resource( 1271 - "test-player", "hit_dice", amount=10, base_path=player_dir 1079 + def test_adjust_resource_clamped_to_zero( 1080 + self, mira: dict, player_dir: Path, 1081 + ): 1082 + result = adjust_resource( 1083 + "test-player", "hit_dice", -10, base_path=player_dir 1272 1084 ) 1273 1085 data = load_character("test-player", player_dir) 1274 1086 assert data["resources"]["hit_dice_d8"]["current"] == 0 1275 1087 assert "short" in result.lower() 1276 1088 1277 - def test_use_resource_not_found(self, mira: dict, player_dir: Path): 1278 - result = use_resource("test-player", "nonexistent", base_path=player_dir) 1089 + def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): 1090 + result = adjust_resource( 1091 + "test-player", "nonexistent", -1, base_path=player_dir 1092 + ) 1279 1093 assert "no resource matching" in result.lower() 1280 1094 1281 - def test_restore_resource_clamped_to_max(self, mira: dict, player_dir: Path): 1282 - use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) 1283 - restore_resource( 1095 + def test_adjust_resource_restore_clamped_to_max( 1096 + self, mira: dict, player_dir: Path, 1097 + ): 1098 + adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) 1099 + adjust_resource( 1284 1100 "test-player", "hit_dice", 100, base_path=player_dir 1285 1101 ) 1286 1102 data = load_character("test-player", player_dir) 1287 1103 assert data["resources"]["hit_dice_d8"]["current"] == 3 1288 1104 1105 + def test_adjust_resource_zero_delta_is_noop( 1106 + self, mira: dict, player_dir: Path, 1107 + ): 1108 + result = adjust_resource( 1109 + "test-player", "hit_dice", 0, base_path=player_dir 1110 + ) 1111 + data = load_character("test-player", player_dir) 1112 + assert data["resources"]["hit_dice_d8"]["current"] == 3 1113 + assert "no change" in result.lower() 1114 + 1289 1115 1290 1116 class TestRest: 1291 1117 def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): 1292 - use_resource("test-player", "hit_dice", amount=3, base_path=player_dir) 1118 + adjust_resource("test-player", "hit_dice", -3, base_path=player_dir) 1293 1119 rest("test-player", "long", base_path=player_dir) 1294 1120 data = load_character("test-player", player_dir) 1295 1121 assert data["resources"]["hit_dice_d8"]["current"] == 3 ··· 1322 1148 def test_short_rest_doesnt_refresh_long_rest_resources( 1323 1149 self, mira: dict, player_dir: Path 1324 1150 ): 1325 - use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) 1151 + adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) 1326 1152 rest("test-player", "short", base_path=player_dir) 1327 1153 data = load_character("test-player", player_dir) 1328 1154 # hit_dice has refresh: long_rest, so short rest shouldn't refresh it ··· 1393 1219 (add_item, ("Item",)), 1394 1220 (remove_item, ("Item",)), 1395 1221 (set_item_status, ("Item", "attuned")), 1396 - (use_resource, ("res",)), 1397 - (restore_resource, ("res", 1)), 1222 + (adjust_resource, ("res", -1)), 1223 + (adjust_resource, ("res", 1)), 1398 1224 (rest, ("short",)), 1399 1225 (adjust_coins, ({"gp": 5},)), 1400 1226 ]:
+17 -3
tests/test_engine.py
··· 106 106 assert "Style" in engine._context_parts 107 107 assert "intrigue" in engine._context_parts["Style"] 108 108 109 - def test_style_is_first_context_part(self, engine): 109 + def test_time_is_first_context_part(self, engine): 110 + """The ambient clock header goes at the top of every turn's 111 + context so the DM can't miss it. Style comes immediately after.""" 110 112 style_path = engine.base_path / "worlds" / "test" / "style.md" 111 113 style_path.write_text("# Style\n\nDark tone.\n") 112 114 113 - context = engine._build_context() 115 + engine._build_context() 114 116 parts = list(engine._context_parts.keys()) 115 117 116 - assert parts[0] == "Style" 118 + assert parts[0] == "Time" 119 + assert parts[1] == "Style" 120 + 121 + def test_time_header_shows_current_clock(self, engine): 122 + """The ambient time header always includes the current time anchor 123 + and period of day, so the DM's first impression each turn is what 124 + time it is.""" 125 + engine._build_context() 126 + header = engine._context_parts["Time"] 127 + assert "⏰ Current Time" in header 128 + now = engine._campaign_log.get_current_time() 129 + assert now.to_anchor() in header 130 + assert now.period_of_day().lower() in header.lower() 117 131 118 132 def test_estimate_tokens(self): 119 133 from storied.engine import DMEngine
+1 -1
tests/test_entities.py
··· 14 14 from storied.tools.entities import establish as _establish 15 15 from storied.tools.entities import mark as _mark 16 16 17 - from tests.conftest import call_tool 17 + from storied.testing import call_tool 18 18 19 19 20 20 def establish(**kwargs):
+4 -4
tests/test_execute_tool.py
··· 18 18 from storied.tools.entities import note_discovery as _note_discovery 19 19 from storied.tools.scene import end_session as _end_session 20 20 21 - from tests.conftest import call_tool 21 + from storied.testing import call_tool 22 22 23 23 24 24 # --- Helpers ---------------------------------------------------------------- ··· 552 552 }) 553 553 assert result 554 554 555 - def test_use_and_restore_resource(self, kira: ToolContext): 555 + def test_adjust_resource(self, kira: ToolContext): 556 556 # Add a resource via update_character first 557 557 call("update_character", { 558 558 "updates": { ··· 562 562 }, 563 563 }, 564 564 }) 565 - result = call("use_resource", {"name": "hit_dice", "amount": 1}) 565 + result = call("adjust_resource", {"name": "hit_dice", "delta": -1}) 566 566 assert "Used" in result 567 - result = call("restore_resource", {"name": "hit_dice", "amount": 1}) 567 + result = call("adjust_resource", {"name": "hit_dice", "delta": 1}) 568 568 assert "Restored" in result 569 569 570 570 def test_rest_short(self, kira: ToolContext):
+9 -1
tests/test_mcp_server.py
··· 43 43 for tool_name in ( 44 44 "damage", "heal", "adjust_coins", "add_effect", "remove_effect", 45 45 "add_condition", "remove_condition", "add_item", "remove_item", 46 - "set_item_status", "use_resource", "restore_resource", "rest", 46 + "set_item_status", "adjust_resource", "rest", 47 47 "add_note", "update_character", "create_character", 48 48 ): 49 49 assert tool_name in names, f"missing {tool_name}" 50 + 51 + def test_dm_does_not_include_removed_tools(self): 52 + """break_concentration, use_resource, and restore_resource were 53 + folded into other tools — ensure they don't resurface.""" 54 + names = _names("dm") 55 + assert "break_concentration" not in names 56 + assert "use_resource" not in names 57 + assert "restore_resource" not in names 50 58 51 59 def test_dm_initial_excludes_combat_tools(self): 52 60 """In DM mode, combat tools are hidden until enter_initiative runs."""
+14 -3
tests/test_notification_formatters.py
··· 178 178 179 179 class TestResourceNotification: 180 180 def test_use_one(self): 181 - assert _format("use_resource", '{"name": "rage"}') == "Using rage" 181 + assert _format( 182 + "adjust_resource", '{"name": "rage", "delta": -1}' 183 + ) == "Using rage" 182 184 183 185 def test_use_multiple(self): 184 - assert _format("use_resource", '{"name": "ki", "amount": 3}') == "Using 3 of ki" 186 + assert _format( 187 + "adjust_resource", '{"name": "ki", "delta": -3}' 188 + ) == "Using 3 of ki" 185 189 186 190 def test_restore(self): 187 - assert _format("restore_resource", '{"name": "ki", "amount": 2}') == "Restoring 2 of ki" 191 + assert _format( 192 + "adjust_resource", '{"name": "ki", "delta": 2}' 193 + ) == "Restoring 2 of ki" 194 + 195 + def test_zero_delta_fallback(self): 196 + assert _format( 197 + "adjust_resource", '{"name": "rage", "delta": 0}' 198 + ) == "Adjusting rage" 188 199 189 200 190 201 class TestRestNotification:
+1 -1
tests/test_planner.py
··· 20 20 from storied.tools import ToolContext 21 21 from storied.tools.entities import establish, mark 22 22 23 - from tests.conftest import call_tool 23 + from storied.testing import call_tool 24 24 25 25 26 26 @pytest.fixture
+1 -1
tests/test_tune.py
··· 3 3 from storied.tools import ToolContext 4 4 from storied.tools.scene import tune as _tune 5 5 6 - from tests.conftest import call_tool 6 + from storied.testing import call_tool 7 7 8 8 9 9 def tune(tuning: str) -> str: