diff --git a/design/architecture.md b/design/architecture.md index fd04911..62c0030 100644 --- a/design/architecture.md +++ b/design/architecture.md @@ -2,241 +2,246 @@ ## Concept -A text-based storytelling RPG where a frontier LLM serves as the DM, embracing creative generation to weave compelling narratives. The world builds itself lazily as players explore, with all content persisted as markdown files for continuity. - -## Key Decisions - -- **Stack**: Python (Textual for TUI, future web interface) -- **Players**: Single-player first, architected for multiplayer -- **Mechanics**: Adaptive/light - AI decides when dice matter, rolls shown contextually -- **Dice modes**: Implicit (AI rolls), or player brings IRL dice -- **Interface**: TUI first, API-based core enables future web -- **LLM Provider**: Anthropic API (Claude) -- **Context Strategy**: Hybrid - graph-based for structure + semantic search for lore -- **Tool Execution**: Sandboxed (restricted environment, output-only) -- **Rules**: 5e SRD 5.2 (https://media.dndbeyond.com/compendium-images/srd/5.2/SRD_CC_v5.2.1.pdf) - ---- - -## Architecture Overview +A text-based solo 5e RPG where a frontier LLM plays the DM. The LLM runs +the world, generates content lazily as the player explores, and persists +everything as markdown files so the campaign accumulates continuity across +sessions. + +## The Core Invariant: Tools Support, They Don't Implement + +**The DM's toolkit is a bookkeeping and state-tracking surface. It is not +a 5e rules engine.** + +The DM — the LLM — is in charge of rules. When it needs the text of +Fireball or the rules for grappling, it uses `recall` to look them up from +the SRD. When it needs to decide whether exhaustion applies to a Wisdom +save, it reads the character sheet, knows the rule (or looks it up), and +rolls appropriately. When the player is hit with a damage type they're +resistant to, the DM does the math and records the result. + +Tools track state: HP, conditions, effects, inventory, resources, coins, +campaign log, entity history. Tools do **not** apply rules — no automatic +resistance halving, no forced concentration-save DCs, no condition-derived +disadvantage markers, no exhaustion math folded into skill totals. When a +tool looks like it's "helping" by doing 5e math, it's actually taking +authority away from the DM and making homebrew impossible. + +This invariant has two practical payoffs: + +1. **Homebrew and variant rulesets just work.** The tools don't care + whether you're on 2024 rules, 2014 rules, a retroclone, or your own + house rules — they record what the DM says happened. +2. **The DM never fights the tools.** There's no surprise halving, no + silent concentration drop, no "wait, why did passive perception + change?" Tools do exactly what they're asked, and nothing more. + +When in doubt: make the tool dumber. A dumber tool is one the DM can +trust. + +## Stack + +- **Language**: Python 3.12+, fully typed (mypy strict) +- **Interface**: CLI with Rich-rendered streaming output and readline + history; slash commands (`/me`, `/status`, `/context`, `/note`, `/save`, + …) +- **LLM**: Anthropic Claude via Claude Code subprocess driver, stream-json + I/O +- **Tool protocol**: FastMCP in-process HTTP server (SSE transport), + composed per-role with tag-filtered visibility +- **Sandbox**: Pydantic Monty for DM-authored Python execution — no + filesystem, no network, 5 s timeout, 10 MB memory cap +- **Search**: sqlite-vec + fastembed BGE-small for semantic search over + rules, world, and player content +- **Persistence**: Markdown files with YAML frontmatter under `worlds/`, + `players/`, `rules/` +- **Campaign log**: GameTime anchors (`#dX-HHMM`) with append-only event + history +- **Rules reference**: 5e SRD 5.2.1 processed into per-section markdown + +## Overview ``` -┌─────────────────────────────────────────────────────────┐ -│ TUI Interface │ -│ ┌─────────────────┐ ┌──────────┐ ┌──────────────────┐ │ -│ │ Narrative Pane │ │ Dice │ │ Character Status │ │ -│ │ │ │ Display │ │ │ │ -│ └─────────────────┘ └──────────┘ └──────────────────┘ │ -│ ┌─────────────────────────────────────────────────────┐│ -│ │ Input Area ││ -│ └─────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ DM Engine Core │ -│ • Interprets player actions │ -│ • Maintains narrative context │ -│ • Invokes rules when appropriate │ -│ • Reads/writes world files │ -│ • Generates tools on demand │ -└─────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ World State │ │ Rules Engine│ │ Tool System │ -│ (Markdown) │ │ (5e SRD) │ │ (Generated) │ -└─────────────┘ └─────────────┘ └─────────────┘ +┌──────────────────────────────────────────────────────────┐ +│ CLI (streaming) │ +│ Player types → stream DM output → render via Rich │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ DM Engine (engine.py) │ +│ • Build context (character + session + log + memories) │ +│ • Spawn Claude subprocess with MCP config │ +│ • Stream response, route tool calls, display output │ +└──────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌────────────────┐ ┌──────────────────┐ +│ FastMCP Server │ │ Background │ │ Vector Index │ +│ (per-role, │ │ Agents │ │ (sqlite-vec) │ +│ tag-filtered) │ │ (seeder, │ │ │ +│ │ │ ticker, │ │ rules + world │ +│ │ │ advancement) │ │ + player notes │ +└─────────────────┘ └────────────────┘ └──────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ State (markdown + yaml) │ +│ worlds/{world}/ players/{player}/ rules/srd-5.2.1/ │ +└──────────────────────────────────────────────────────────┘ ``` ---- - -## Component Details - -### 1. DM Engine Core - -The heart of the system - an agentic LLM loop that: - -- Receives player input -- Loads relevant world context (locations, NPCs, history) -- Decides on narrative response + any mechanical resolution -- Generates new content as needed (lazy world-building) -- Persists changes to world files -- Returns narrative + any dice results to display - -**Context Management**: Needs smart retrieval - can't load entire world into context. - -- Semantic search over world files -- Graph-based retrieval (connected locations/characters) -- Recency + relevance scoring - -### 2. World State Layer - -Markdown files with YAML frontmatter for structured data: - -```markdown ---- -type: location -name: The Rusty Anchor -region: Portside District -connections: - - harbor_main - - fish_market - - back_alley -tags: [tavern, social, quest-hook] -first_visited: 2025-01-15 ---- - -# The Rusty Anchor - -A weathered dockside tavern where sailors swap stories... - -## Notable Features - -- The bar is a repurposed ship's hull -- A mysterious map hangs behind the counter - -## NPCs Present - -- [[Mara Saltwind]] - the owner, former pirate -- [[Old Tam]] - regular, knows everyone's business -``` - -**Directory Structure:** +## Component Responsibilities + +### DM Engine (`engine.py`) + +The agentic loop: builds the turn's context, spawns Claude with the MCP +config, streams the response, and routes tool calls back into the FastMCP +server. Context building pulls from the character sheet, session state, +recent campaign log entries, style tuning, world memories, and pending +background notifications. Every turn refreshes dynamic tool visibility +(combat tags, advancement gates) before issuing the prompt. + +### FastMCP Composition (`mcp_server.py` + `tools/*.py`) + +Each `tools/*.py` module defines its own module-level `FastMCP` instance. +At startup, `_compose_server(role)` builds a top-level server by mounting +the six tool modules and applying tag filters: other-role tags are +disabled, then the active role's tags are re-enabled so shared tools (like +`update_character`, tagged for both `dm` and `advancement`) survive. + +For DM mode, two additional gates apply at compose time: + +- **Combat**: tools tagged `combat` but not `combat_control` are disabled + at startup. `enter_initiative` flips them on via `_root.enable(keys=…)` + from inside `combat._flip_into_combat`; `end_initiative` flips them + back off. +- **Advancement**: tools tagged `advancement_available` (currently just + `level_up`) are disabled at startup. The engine calls + `character.refresh_advancement_visibility` at the top of each turn, + which enables them iff the character sheet has `advancement_ready` set. + +Dynamic visibility mutations target the top-level composed server — +that's what Claude sees, so changes propagate immediately without +re-composition. + +Roles: `dm`, `planner`, `seeder`, `advancement`. Each sees a different +subset of the tool surface. + +### Tool Modules + +| Module | Tools | +|--------|-------| +| `tools/mechanics.py` | `roll` (dumb dice), `recall` (vector search with scope filter + recency decay) | +| `tools/scene.py` | `set_scene` (keystone — logs event, advances clock, updates session state), `tune`, `end_session`, `notify_dm` | +| `tools/entities.py` | `establish`, `mark`, `amend_mark`, `note_discovery` — CRUD for world entities with Is/Was/Knows/Wants/Will structure | +| `tools/character.py` | Character sheet bookkeeping — raw state operations only (damage, heal, conditions, effects, inventory, resources, rest, level_up, notes, …) | +| `tools/combat.py` | Initiative tracker — `enter_initiative`, `end_initiative`, `next_turn`, `add_combatant`, `remove_combatant`, `condition` | +| `tools/run_code.py` | Sandboxed Python execution — all DM tools exposed as sync functions for orchestration | + +The character tools are deliberately narrow: each one records exactly +what the DM tells it. `damage(amount, type="fire")` subtracts `amount` +from HP. It does not consult resistances. It does not halve anything. The +DM already knows the character is fire-resistant, pre-applied the math, +and the tool just records the result. The `type` field is metadata for +narration and the campaign log. + +### Background Agents (`planner.py`) + +Three independent Claude-subprocess workers run between turns, each with +its own role and a narrow tool surface: + +- **Seeder** (`seeder` role): cold-start world building from the + character sheet. Establishes 12-16 initial entities with + Knows/Wants/Will hooks. +- **Ticker** (`planner` role): small off-screen world motion between + turns — firing Will triggers, advancing thread deadlines, adding beats + to entities whose state should change. +- **Advancement** (`advancement` role): holistic level-up evaluation + based on pacing, triumphs, narrative beats. When it decides the + character has earned the next level, it sets `advancement_ready` on + the sheet and notifies the DM. The DM picks the narrative moment to + apply it. + +Each agent writes back to the world state or notifies the DM via +`notify_dm`, which appends to a notifications queue the DM sees at the +top of its next turn. + +### Campaign Log (`log.py`) + +Canonical clock. Every `set_scene` appends a GameTime-anchored entry. Time +only advances when the DM logs it — the narrative and clock must agree, or +the DM is lying to the player. Event history is the DM's memory between +turns. + +`GameTime` parses and renders `#dX-HHMM` anchors; the tool layer uses +`from_anchor` / `to_anchor` so the DM can backdate `mark` events with a +`when` parameter without re-computing the format by hand. + +### Vector Index (`search.py`) + +sqlite-vec + BGE-small embeddings over three corpora with source tags: +`srd`, `world`, `player`. Age-decay scoring favors recent world beats; +`recall` accepts a scope filter (`rules` / `world` / `all`) so the DM can +target the right layer. The index reseeds from a pre-built SRD sqlite +snapshot when present, otherwise re-indexes from markdown sections. + +## Content Layers + +See `content-layers.md` for the full spec. Summary: ``` -worlds/{world_name}/ -├── locations/ -├── characters/ -├── items/ -├── lore/ -├── factions/ -├── quests/ -├── tools/ # Generated procedural tools -├── sessions/ # Session logs -└── world.yaml # World config + meta +players/{player}/ → character state, notes, knowledge +worlds/{world}/ → campaign-specific entities, can override rules +rules/srd-5.2.1/ → base 5e SRD ``` -### 3. Player State (Separate from World) - -``` -players/{player_id}/ -├── character.yaml # Stats, class, abilities -├── inventory.yaml # Items carried -├── journal.md # Personal notes, discoveries -├── relationships.yaml # NPC relationship tracking -└── session_log.md # Running narrative history -``` - -Separation enables: - -- Multiple characters in same world -- Future multiplayer (each player has own state) -- Clean rollback/save points - -### 4. Rules Engine (5e SRD) - -Pinned to 5e SRD - no pluggable abstraction needed. - -- Character creation/leveling -- Skill checks with modifiers -- Combat resolution -- Spell effects -- Condition tracking - -**Rules Directory Structure:** - -We own the PDF→markdown pipeline. Source PDF is processed into structured content: - -``` -rules/ -├── sources/ # Original PDFs -│ └── SRD_CC_v5.2.1.pdf -└── srd-5.2.1/ # Processed output - ├── races/ - ├── classes/ - ├── backgrounds/ - ├── equipment/ - ├── spells/ - ├── monsters/ - ├── combat/ - ├── adventuring/ - └── index.yaml # Structured index for queries -``` - -**Dice Display:** - -- AI rolls implicitly, results shown in dice pane -- Narrative describes outcomes without explicit numbers -- (Future: optional manual dice entry mode) - -### 5. Tool Generation System - -The meta-capability: AI generates utilities that become world content. - -**Example: Cave System Generator** - -When story needs caves, AI writes: - -```python -# worlds/myworld/tools/cave_generator.py -"""Generates connected cave networks for the Underdark regions.""" - -def generate_cave_system( - num_chambers: int, - connectivity: float = 0.3, - seed: str | None = None -) -> CaveSystem: - ... -``` - -Generated tools are: - -- Stored in `worlds/{world}/tools/` -- Documented with their purpose -- Reusable for similar future needs -- Part of the world's "DNA" - -### 6. TUI Interface (Textual) - -``` -┌─ Storied ──────────────────────────────────────────────┐ -│ ┌─ The Rusty Anchor ─────────────────────────────────┐ │ -│ │ You push through the heavy oak door. The smell of │ │ -│ │ salt and stale ale washes over you. A one-eyed │ │ -│ │ woman behind the bar looks up, her hand drifting │ │ -│ │ toward something beneath the counter. │ │ -│ │ │ │ -│ │ "We don't get many strangers here," she says. │ │ -│ │ "State your business." │ │ -│ └────────────────────────────────────────────────────┘ │ -│ ┌─ Dice ──────┐ ┌─ Status ────────────────────────────┐│ -│ │ ⚄ Insight │ │ Kira Stoneheart HP: 24/24 AC: 16 ││ -│ │ 14 + 3 = 17 │ │ Fighter 3 GP: 47 ││ -│ └─────────────┘ └─────────────────────────────────────┘│ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ > I approach the bar carefully, hands visible... │ │ -│ └────────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────┘ -``` - ---- +Entity model (Is/Was/Knows/Wants/Will) is narrative, not mechanical. A +door can "want" to stay closed. A coin can "know" where it was forged. The +model gives the DM rich material to act on without encoding literal +consciousness or rule triggers. ## Lazy World Generation -The world expands as explored: - -1. **Reference Phase**: Location mentioned in passing (name only) -2. **Sketch Phase**: Player asks about it, basic details generated -3. **Detail Phase**: Player visits, full description + NPCs + connections -4. **Living Phase**: Events occur, state changes, history accumulates - -Each phase writes more detail to the markdown file. Previously mentioned details become constraints for future generation. - ---- +The world expands as the player explores: entities move through phases — +referenced → sketched → detailed → living. Each phase writes more to the +file; once written, details become constraints on future generation. The +seeder agent populates the initial layer; the ticker and the DM add to it +over time. + +## Player State + +Lives under `players/{player_id}/`: + +- `character.yaml` — structured bookkeeping (abilities, HP, resources, + conditions, effects, inventory, magic_items, features, defenses) +- `character.md` — free-text backstory, personality, aliases, voice +- `notes.md` — appending timestamped journal +- `session.md` — current situation, location, present NPCs, open threads +- `worlds/{world}/{npcs,locations,…}/*.md` — the player's *knowledge* of + the world, which may differ from DM truth + +Player state is separate from world state so multiple characters can live +in the same world (future multiplayer) and so the player's partial +knowledge stays distinct from the DM's omniscient view. + +## What Lives Where + +| Concern | Home | +|---------|------| +| SRD rules text | `rules/srd-5.2.1/sections/*` (read-only, searchable via `recall`) | +| World state | `worlds/{world}/{npcs,locations,items,factions,threads,lore,maps}/*.md` | +| Player character | `players/{player}/character.yaml` + `character.md` | +| Campaign history | `worlds/{world}/campaign-log.md` (GameTime-anchored) | +| DM style tuning | `worlds/{world}/style.md` | +| Background notifications | `worlds/{world}/notifications.md` (queue for next turn) | +| Session state | `players/{player}/session.md` | +| Vector index | `worlds/{world}/search.db` (sqlite-vec) | ## Open Questions -1. **Session Continuity**: How much narrative history to maintain? -2. **Conflict Resolution**: When world files contradict, which wins? -3. **Embedding Model**: Which model for semantic search? (local vs API) +- **Multiplayer**: How do we present multiple characters' state to the DM + without bloating context? +- **Rules drift**: The SRD will update; how do we manage the index when + rules text changes? +- **Session compaction**: The campaign log is append-only; eventually + we'll need windowing or summarization for very long campaigns. diff --git a/prompts/dm-system.md b/prompts/dm-system.md index f4c5748..918b4b1 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -2,74 +2,67 @@ You are an expert 5e Dungeon Master running a solo adventure. 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. -## Available Tools - -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. - -### General -| Tool | Purpose | -|------|---------| -| `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | -| `recall` | Look up rules or world content | -| `run_code` | Run Python in a sandbox — all your tools available as functions | - -### Character — HP and damage -| Tool | Purpose | -|------|---------| -| `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.** | -| `heal` | Heal the character. `heal(5)` | -| `add_condition` | Mark a condition (Poisoned, Prone, Frightened, etc.) | -| `remove_condition` | Remove a condition | - -### Character — effects (temporary buffs/debuffs) -| Tool | Purpose | -|------|---------| -| `add_effect` | Track a temporary effect with optional expiry. Use for spells, potions, environmental buffs/debuffs. | -| `remove_effect` | Remove an effect by source name | - -### Character — coins, items, resources -| Tool | Purpose | -|------|---------| -| `adjust_coins` | Spend/gain coins. Always use this — never set coins directly. | -| `add_item` | Add a mundane item to a location subsection (`add_item("Lockpicks", location="on_person")`) | -| `remove_item` | Remove a mundane item by name (substring match) | -| `set_item_status` | Move a magic item between attuned/equipped/carried. Magic items live as world entities; establish them first. | -| `use_resource` | Decrement a resource pool (rage uses, ki points, magic item charges, hit dice). Generic — works for anything. | -| `restore_resource` | Restore points to a resource (rare; usually use `rest`). | -| `rest` | Take a `short` or `long` rest. Refreshes resources by refresh type. Long rest also clears death saves, removes 1 exhaustion, restores HP. | -| `add_note` | Append a timestamped note to the player's journal | - -### Character — meta updates -| Tool | Purpose | -|------|---------| -| `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}` | -| `create_character` | Create a new character (character creation flow) | - -### World and scene -| Tool | Purpose | -|------|---------| -| `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | -| `establish` | Create or update entities (NPCs, locations, items, threads) | -| `mark` | Record what happened to an entity | -| `note_discovery` | Record what the player learned | -| `tune` | Update your style/personality tuning based on player feedback | -| `end_session` | Gracefully end the session | -| `enter_initiative` | Enter initiative mode for combat or turn-based encounters | - -### Initiative Mode (during initiative — overrides `damage`/`heal`) -| Tool | Purpose | -|------|---------| -| `next_turn` | Advance to the next combatant's turn | -| `damage` | Deal damage to a combatant — `damage(target="Goblin", amount=7)` | -| `heal` | Heal a combatant — `heal(target="Mira", amount=5)` | -| `condition` | Add or remove a condition on a combatant | -| `add_combatant` | Add reinforcements or late arrivals | -| `remove_combatant` | Remove a combatant who fled or was banished | -| `end_initiative` | End initiative and return to narrative mode | - -## After Every Response: Call `set_scene` - -After writing narrative, **always** call `set_scene` with at least `event` and `duration`: +## The Tool Surface + +You have a set of tools for bookkeeping, narration, and lookup. FastMCP +exposes the full schemas directly — read the tool descriptions for +parameter details. What matters is **when** to use each: + +- **Every turn ends with `set_scene`.** This is the clock. Time does not + advance without it. +- **Roll dice** with `roll` for attacks, skill checks, saves, damage. + Never narrate uncertain outcomes without rolling. +- **Look things up** with `recall`. Rules text, spells, monster stats, + your own established NPCs and locations. Use it liberally. +- **Record state** with the dedicated character tools: `damage`, `heal`, + `add_effect`/`remove_effect`, `add_condition`/`remove_condition`, + `add_item`/`remove_item`/`set_item_status`, `adjust_coins`, + `adjust_resource`, `rest`, `add_note`. For anything else on the sheet, + use `update_character` with dot-notation keys. **Never compute HP, + coins, or resources by hand — always go through the tool.** +- **Build the world** with `establish` (create or update entities), + `mark` (record history on an entity), `amend_mark` (fix the most + recent mark), `note_discovery` (record what the player learned). +- **Combat**: `enter_initiative` → (`next_turn`, `add_combatant`, + `remove_combatant`, `condition`, targeted `damage`/`heal`) → + `end_initiative`. Combat tools are hidden until initiative is active. +- **Advancement**: `level_up` appears in your toolset only when the + character has earned a level. When you see it, find a narrative + moment, present any mechanical choices to the player, then call it. +- **Meta**: `tune` to adjust your style, `end_session` when stopping, + `run_code` for sandboxed Python orchestration, `notify_dm` (rare). + +## Tools Track State, You Track Rules + +The character sheet system is a **bookkeeping toolkit**. The tools +record exactly what you tell them — nothing more. Specifically: + +- **`damage` applies raw amounts.** You pre-compute resistance, + vulnerability, and immunity math and pass the final number. The + `type` parameter is metadata for narration and the campaign log, not + a rule trigger. +- **Concentration is a metadata flag** on effects, not enforcement. + When a new concentration spell replaces an old one, call + `remove_effect` on the old one first. When a concentration save + fails, same — you decide, you clear it. +- **Exhaustion, conditions, auto-fail saves, and disadvantage are not + folded into displayed skill or save modifiers.** The sheet shows raw + numbers; you apply rule effects at roll time per whatever edition + you're running. +- **Defenses** (resistances, vulnerabilities, immunities) are displayed + for your reference — they do NOT auto-apply. + +This is deliberate. The tools stay dumb so homebrew and variant rule +sets work, and so you never fight them. When you need a rule you don't +remember, use `recall`. + +## Timekeeping — Read This Every Turn + +**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. + +**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. + +The minimum viable call: ``` set_scene( @@ -78,19 +71,39 @@ set_scene( ) ``` -The clock only advances when you log it. If you skip `set_scene`, time freezes. +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"`). -Also include scene state fields when they change: -- `situation` — when the situation evolves meaningfully -- `location` — when the player moves -- `present` — when NPCs enter or leave -- `threads` — when objectives change +### Picking a duration -Then check: -- **New entity introduced?** → `establish` -- **Something happened to an existing entity?** → `mark` +Be realistic but not fussy. Round numbers are fine. + +| Activity | Typical | +|----------|---------| +| Brief exchange, quick look | 5 min | +| Conversation, searching a room | 15-30 min | +| Combat | 1-5 rounds (~6 s each) | +| Walking across town | 15-30 min | +| Short rest | 1 hour | +| Long rest | 8 hours | +| Travel between towns | hours to days | + +### Time skips + +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. + +Example: clock is at 07:30, player says "fast forward to night." +- Night ≈ 20:00, so delta ≈ 13 hours. +- `set_scene(event="Set up camp, rested through the day", duration="13 hours")` + +Do not narrate the skip piecewise — one event, one duration, one jump. + +### Other bookkeeping that travels alongside timekeeping -**Don't skip tools.** If you introduced Constable Harrik, establish him. If an NPC revealed their secret, mark that event. +After you've logged the turn, check: + +- **New entity introduced?** → `establish` +- **Something meaningful happened to an existing entity?** → `mark` +- **Need to correct your most recent mark?** → `amend_mark` The world only persists if you save it. Narrative without tool calls is lost context. @@ -177,20 +190,6 @@ This is a real 5e game with real dice rolls and real rules. Never narrate outcom **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. -## Core Principle: Track Time and Save State - -**`set_scene` is your one tool for bookkeeping.** Call it after every response. - -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. - -Examples — the `event` + `duration` fields log time, other fields save state: - -- Chat: `set_scene(event="Spoke with guards", duration="10 min")` -- Travel + new location: `set_scene(event="Walked to the harbor", duration="15 min", location="Harbor District")` -- Combat: `set_scene(event="Fought off thugs", duration="3 rounds", tags=["combat"])` -- Rest: `set_scene(event="Short rest in the alley", duration="1 hour", tags=["rest:short"])` -- Scene transition: `set_scene(event="Searched the warehouse", duration="30 min", situation="Found crates of smuggled weapons", present=["[[Dockmaster Voss]]"])` - ## When to Roll Dice ALWAYS roll dice for: @@ -211,10 +210,11 @@ Example flow: Use `recall` liberally to look up rules or world content: **Rules** (scope: "rules"): -- Before resolving spells - check the actual spell text -- When a player tries something unusual - check if there's a rule -- For monster stats - look up AC, HP, attacks, abilities -- For conditions - what exactly does "grappled" or "prone" do? +- Before resolving spells — check the actual spell text +- When a player tries something unusual — check if there's a rule +- For monster stats — AC, HP, attacks, abilities +- **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 +- For exhaustion, concentration saves, resistance interactions — same pattern: the sheet tracks the *state*, the rules text tells you what to *do* with it **World** (scope: "world"): - NPC details when they appear in a scene @@ -329,15 +329,15 @@ The character sheet is split across three files: - **character.md** — free-text prose: backstory, personality, aliases, voice. Read it for roleplay reference. - **notes.md** — appending journal of player observations, leads, and decisions. Use `add_note` to add entries. -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. +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. ### When something happens, use the dedicated tool Each of these is one verb. Don't compose them into `update_character` calls. **Damage and healing** (replaces ad-hoc HP math): -- `damage(amount)` — apply damage; temp HP soaks first -- `damage(amount, type="fire")` — with damage type for narration +- `damage(amount)` — apply raw damage; temp HP soaks first +- `damage(amount, type="fire")` — `type` is metadata for narration; you've already pre-applied any resistance/vulnerability/immunity - `heal(amount)` — clamped to max HP - Inside initiative, use the targeted forms: `damage(target="Goblin", amount=7)` @@ -346,8 +346,9 @@ Each of these is one verb. Don't compose them into `update_character` calls. - `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` — looting **Conditions and effects**: -- `add_condition("Poisoned")` / `remove_condition("Poisoned")` — for 5e named conditions +- `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. - `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. +- `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. - `remove_effect("Bless")` — substring match on source **Inventory**: @@ -356,9 +357,10 @@ Each of these is one verb. Don't compose them into `update_character` calls. - `set_item_status("Bracer of the Unseen Step", "attuned")` — magic items only. Establish them as world entities first via `establish(entity_type="items", ...)`. **Resources** (limited-use class features, magic item charges, hit dice, **spell slots**): -- `use_resource("rage")` — decrement by 1 -- `use_resource("hit_dice_d8", amount=2)` — decrement by 2 -- `use_resource("slot_3")` — cast a 3rd-level spell by burning one slot +- `adjust_resource("rage", -1)` — spend one use +- `adjust_resource("hit_dice_d8", -2)` — spend two hit dice +- `adjust_resource("slot_3", -1)` — cast a 3rd-level spell by burning one slot +- `adjust_resource("ki", 2)` — restore two points (positive delta = restore) - `rest("short")` or `rest("long")` — refreshes resources by their refresh type. Long rest also clears death saves, removes one exhaustion level, and restores HP. **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,7 +373,7 @@ update_character({ }) ``` -Use the canonical `slot_` 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. +Use the canonical `slot_` 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. **Notes**: - `add_note("The miller mentioned strange lights at the old mill")` — appends to notes.md with current game time @@ -382,16 +384,24 @@ Use the canonical `slot_` naming so `use_resource("slot_3")` is unambiguo - `update_character({"state.death_saves.successes": 2})` — track death saves - `update_character({"features": [...new full list...]})` — replace features at level-up -### Important: never do arithmetic +### Two kinds of arithmetic + +There are two kinds of math in play, and they live in different places. + +**Bookkeeping math — the tool does it, don't touch:** + +❌ Don't: `update_character({"state.hp.current": 19})` ← you computed 24 - 5 +✅ Do: `damage(5)` ← the tool does the math + +❌ Don't: `update_character({"state.purse.gp": 38})` ← you computed 43 - 5 +✅ Do: `adjust_coins({"gp": -5})` ← the tool does the math -❌ Don't: `update_character({"state.hp.current": 19})` ← you computed 24 - 5 yourself -✅ Do: `damage(5)` ← the system computes it +❌ Don't: recompute a skill modifier from abilities and proficiency +✅ Do: read "Stealth +8 ★★" off the sheet — the baseline is already computed -❌ Don't: `update_character({"state.purse.gp": 38})` ← you computed 43 - 5 yourself -✅ Do: `adjust_coins({"gp": -5})` ← the system computes it +**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. -❌ Don't: think "her Stealth is +8 because dex +4 and expertise +4" -✅ Do: read "Stealth +8 ★★" from the character context and use that number +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. ## Level Advancement @@ -597,31 +607,3 @@ You can adjust your storytelling style with the `tune` tool. Call it when: 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. -## Duration Guidelines - -When logging events, estimate realistic durations: - -| Activity | Typical Duration | -|----------|------------------| -| Brief exchange | 5 min | -| Conversation | 15-30 min | -| Searching a room | 10-20 min | -| Combat | 1-5 rounds (~6 sec each) | -| Short rest | 1 hour | -| Long rest | 8 hours | -| Walking across town | 15-30 min | -| Travel between towns | hours to days | - -## Time Skips - -When the player asks to "fast forward", "skip to", or "wait until" a specific time: - -1. **Calculate from current time to target**, not from activity durations -2. **Log the full skip** with `set_scene` - -Example: At 07:30, player says "let's fast forward to nighttime" -- Nighttime ≈ 20:00-21:00 -- Skip = ~13 hours -- `set_scene(event="Set up camp, rested through the day", duration="13 hours")` - -Don't narrate nighttime while the clock says 13:30. The clock and narrative must match. diff --git a/src/storied/character/__init__.py b/src/storied/character/__init__.py index 40a0d1d..6b1da41 100644 --- a/src/storied/character/__init__.py +++ b/src/storied/character/__init__.py @@ -5,11 +5,8 @@ from storied.character.compute import ( ALL_SKILLS, SKILL_TO_ABILITY, ability_modifier, - auto_fails_save, class_summary, effective_hp, - exhaustion_penalty, - has_disadvantage_on_checks, has_expertise_in, initiative_modifier, is_proficient_in, @@ -39,7 +36,7 @@ from storied.character.operations import ( add_item, add_note, adjust_coins, - break_concentration, + adjust_resource, damage, heal, level_up, @@ -47,9 +44,7 @@ from storied.character.operations import ( remove_effect, remove_item, rest, - restore_resource, set_item_status, - use_resource, ) __all__ = [ @@ -66,11 +61,8 @@ __all__ = [ "ALL_SKILLS", "SKILL_TO_ABILITY", "ability_modifier", - "auto_fails_save", "class_summary", "effective_hp", - "exhaustion_penalty", - "has_disadvantage_on_checks", "has_expertise_in", "initiative_modifier", "is_proficient_in", @@ -89,7 +81,7 @@ __all__ = [ "add_item", "add_note", "adjust_coins", - "break_concentration", + "adjust_resource", "damage", "heal", "level_up", @@ -97,7 +89,5 @@ __all__ = [ "remove_effect", "remove_item", "rest", - "restore_resource", "set_item_status", - "use_resource", ] diff --git a/src/storied/character/compute.py b/src/storied/character/compute.py index 1f2e167..8cae070 100644 --- a/src/storied/character/compute.py +++ b/src/storied/character/compute.py @@ -1,24 +1,14 @@ """Pure computation functions for derived character values. -Everything here is best-effort 5e-flavored display computation. The DM -can override any value via update_character. These functions are for the -DM's convenience, not for enforcing rules. +These are universal 5e-flavored math — ability modifiers, proficiency +bonus, skill/save totals — the kind of arithmetic every edition and +variant rule share. They do NOT apply condition-based rule effects +(disadvantage, auto-fail saves, exhaustion penalties). The DM is in +charge of rules; these functions just do the baseline math so the DM +doesn't have to. See `design/architecture.md` for the invariant. """ -# Conditions that impose disadvantage on ability checks (and therefore -# skill checks). 5e 2024 — kept loose/lowercased for matching. -_DISADV_CHECK_CONDITIONS = frozenset({"poisoned", "frightened"}) - -# Conditions that cause auto-fail on Strength and Dexterity saves. -_AUTOFAIL_STR_DEX_SAVES = frozenset( - {"paralyzed", "petrified", "stunned", "unconscious"} -) - -# Restrained auto-fails Dex saves only (not Str). -_AUTOFAIL_DEX_SAVES = frozenset({"restrained"}) - - # Maps each skill to its governing ability SKILL_TO_ABILITY: dict[str, str] = { "acrobatics": "dexterity", @@ -63,50 +53,12 @@ def proficiency_bonus(char: dict) -> int: return 2 + (level - 1) // 4 -def exhaustion_penalty(char: dict) -> int: - """5e 2024: each level of exhaustion imposes a -2 penalty on d20 rolls. - - This folds directly into the numeric skill/save modifier so the DM - never has to remember to subtract from the displayed value. - """ - level = max(0, int(char.get("state", {}).get("exhaustion", 0) or 0)) - return -2 * level - - -def _active_conditions(char: dict) -> set[str]: - """Return the character's active conditions, lowercased for matching.""" - return { - str(c).strip().lower() - for c in (char.get("conditions") or []) - if str(c).strip() - } - - -def has_disadvantage_on_checks(char: dict) -> bool: - """True if any active condition imposes disadvantage on ability checks.""" - return bool(_active_conditions(char) & _DISADV_CHECK_CONDITIONS) - - -def auto_fails_save(char: dict, ability: str) -> bool: - """True if the character auto-fails saves of the given ability. - - Paralyzed/petrified/stunned/unconscious auto-fail Str and Dex saves; - restrained auto-fails Dex only. - """ - conds = _active_conditions(char) - if conds & _AUTOFAIL_STR_DEX_SAVES: - return ability in ("strength", "dexterity") - if conds & _AUTOFAIL_DEX_SAVES: - return ability == "dexterity" - return False - - def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: """Compute a skill modifier and the breakdown of contributions. - Returns (total, breakdown_lines). Exhaustion is folded into the - numeric total; disadvantage from conditions is not (it's a roll-time - marker — see has_disadvantage_on_checks). + Returns (total, breakdown_lines). The total is the raw + ability + proficiency math; it does NOT fold in exhaustion or + condition effects. The DM applies those at roll time. """ ability = SKILL_TO_ABILITY.get(skill) if ability is None: @@ -130,19 +82,16 @@ def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: prof_bonus = pb breakdown.append(f"+{prof_bonus} proficient") - exh = exhaustion_penalty(char) - if exh: - breakdown.append(f"{exh:+d} exhaustion") - - total = ability_mod + prof_bonus + exh + total = ability_mod + prof_bonus return total, breakdown def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: """Compute a saving throw modifier and breakdown. - Exhaustion is folded into the numeric total. Auto-fail from conditions - is not (see auto_fails_save). + Like skill_modifier, this returns raw ability + proficiency math — + no exhaustion, no auto-fail logic. The DM applies rule effects at + roll time. """ abilities = char.get("abilities", {}) ability_score = abilities.get(ability, 10) @@ -156,25 +105,18 @@ def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: mod += pb breakdown.append(f"+{pb} proficient") - exh = exhaustion_penalty(char) - if exh: - mod += exh - breakdown.append(f"{exh:+d} exhaustion") - return mod, breakdown def passive_score(char: dict, skill: str = "perception") -> int: """Passive score for a skill: 10 + skill modifier. - Per 5e 2024, disadvantage on the underlying skill check subtracts 5 - from the passive score. This folds condition effects into the display. + No condition-based adjustments — if the DM decides a condition + should lower passive perception (per 5e 2024, disadvantage on the + underlying check does), they adjust at use time. """ mod, _ = skill_modifier(char, skill) - base = 10 + mod - if has_disadvantage_on_checks(char): - base -= 5 - return base + return 10 + mod def initiative_modifier(char: dict) -> int: diff --git a/src/storied/character/display.py b/src/storied/character/display.py index d4ff958..02a3c56 100644 --- a/src/storied/character/display.py +++ b/src/storied/character/display.py @@ -3,12 +3,9 @@ from storied.character.compute import ( ABILITIES, ALL_SKILLS, - SKILL_TO_ABILITY, ability_modifier, - auto_fails_save, class_summary, effective_hp, - has_disadvantage_on_checks, has_expertise_in, initiative_modifier, is_proficient_in, @@ -16,7 +13,6 @@ from storied.character.compute import ( proficiency_bonus, save_modifier, skill_modifier, - total_level, ) @@ -43,20 +39,12 @@ def _save_line(char: dict) -> str: for ability in ABILITIES: mod, _ = save_modifier(char, ability) marker = " ★" if ability in proficient_saves else "" - roll_state = "" - if auto_fails_save(char, ability): - roll_state = " ✗" # auto-fail - parts.append(f"{ability[:3].upper()} {mod:+d}{marker}{roll_state}") + parts.append(f"{ability[:3].upper()} {mod:+d}{marker}") return " ".join(parts) def _skill_lines(char: dict) -> list[str]: - """Render skills sorted alphabetically with proficiency markers. - - Adds a disadvantage indicator to all skills when the character has an - active condition that imposes disadvantage on ability checks. - """ - disadv = has_disadvantage_on_checks(char) + """Render skills sorted alphabetically with proficiency markers.""" lines: list[str] = [] for skill in sorted(ALL_SKILLS): mod, _ = skill_modifier(char, skill) @@ -65,9 +53,8 @@ def _skill_lines(char: dict) -> list[str]: marker = " ★★" elif is_proficient_in(char, skill): marker = " ★" - roll_state = " ◂" if disadv else "" name = _format_skill_name(skill) - lines.append(f" {name:<18} {mod:+d}{marker}{roll_state}") + lines.append(f" {name:<18} {mod:+d}{marker}") return lines @@ -90,8 +77,9 @@ def _format_effects(char: dict) -> list[str]: source = e.get("source", "(unknown)") desc = e.get("description", "") expires = e.get("expires") + conc = " [Concentration]" if e.get("concentration") else "" suffix = f" (until {expires})" if expires else "" - lines.append(f" • {source} — {desc}{suffix}") + lines.append(f" • {source}{conc} — {desc}{suffix}") return lines @@ -238,32 +226,22 @@ def format_sheet(data: dict) -> str: lines.append("") exhaustion = state.get("exhaustion", 0) or 0 - disadv = has_disadvantage_on_checks(data) - # Saves lines.append("**Saving Throws:**") lines.append(f" {_save_line(data)}") - save_legend = " ★ proficient" - if any(auto_fails_save(data, a) for a in ABILITIES): - save_legend += " · ✗ auto-fail" - lines.append(save_legend) + lines.append(" ★ proficient") lines.append("") - # Skills lines.append("**Skills:**") lines.extend(_skill_lines(data)) lines.append(f" Passive Perception: {passive_score(data, 'perception')}") - skill_legend = " ★ proficient · ★★ expertise" - if disadv: - skill_legend += " · ◂ disadvantage" - lines.append(skill_legend) + lines.append(" ★ proficient · ★★ expertise") if exhaustion: lines.append( - f" (exhaustion {exhaustion}: {-2 * exhaustion:+d} to all d20 rolls)" + f" Exhaustion {exhaustion} — DM applies the level's effects per ruleset" ) lines.append("") - # Conditions, defenses cond_lines = _format_conditions(data) if cond_lines: lines.extend(cond_lines) @@ -274,14 +252,12 @@ def format_sheet(data: dict) -> str: lines.extend(def_lines) lines.append("") - # Effects, resources, magic items, features for section_func in (_format_effects, _format_resources, _format_magic_items, _format_features): section = section_func(data) if section: lines.extend(section) lines.append("") - # Equipment equipment_lines = _format_equipment(data) if equipment_lines: lines.extend(equipment_lines) diff --git a/src/storied/character/operations.py b/src/storied/character/operations.py index 80980b4..a03888d 100644 --- a/src/storied/character/operations.py +++ b/src/storied/character/operations.py @@ -16,100 +16,40 @@ from storied.character.data import ( # --- HP operations --- -def _defense_entries(defenses: dict, key: str) -> list[str]: - """Extract damage-type strings from resistances/vulnerabilities. - - Tolerates two shapes: a list of dicts like ``[{"damage": "fire"}]`` and - a flat list of strings like ``["fire"]``. The LLM has written both. - """ - types: list[str] = [] - for entry in defenses.get(key) or []: - if isinstance(entry, dict): - damage = entry.get("damage") - if damage: - types.append(str(damage).lower()) - elif isinstance(entry, str): - types.append(entry.lower()) - return types - - -def _apply_defenses( - data: dict, amount: int, damage_type: str | None, -) -> tuple[int, str | None]: - """Apply resistance / vulnerability / immunity to an incoming damage amount. - - Returns (scaled_amount, note) where note is one of "immune", - "resistance", "vulnerability", or None. Resistance and vulnerability - cancel each other per 5e 2024. - """ - if not damage_type or amount <= 0: - return amount, None - - defenses = data.get("defenses") or {} - needle = damage_type.lower() - - immunities = defenses.get("immunities") or {} - imm_damage = immunities.get("damage") or [] - if any(str(t).lower() == needle for t in imm_damage): - return 0, "immune" - - has_resistance = needle in _defense_entries(defenses, "resistances") - has_vulnerability = needle in _defense_entries(defenses, "vulnerabilities") - - if has_resistance and has_vulnerability: - return amount, None - if has_resistance: - return amount // 2, "resistance" - if has_vulnerability: - return amount * 2, "vulnerability" - - return amount, None - - def damage( player_id: str, amount: int, damage_type: str | None = None, base_path: Path | None = None, ) -> str: - """Apply damage to the character. Resistance / vulnerability / immunity - is applied first, then temp HP soaks, then current HP.""" + """Apply raw damage to the character. + + Temp HP soaks first, then current HP. `damage_type` is metadata for + narration and the campaign log — this function does not consult + resistances, vulnerabilities, or immunities. The DM pre-applies the + math per whatever ruleset they're running. + """ data = load_character(player_id, base_path) if data is None: return f"No character found for player '{player_id}'" if amount < 0: return "Damage amount must be non-negative" - incoming = amount - scaled, defense_note = _apply_defenses(data, amount, damage_type) - hp = data["state"]["hp"] - remaining = scaled + remaining = amount - # Temp HP soaks damage first temp_used = 0 if hp.get("temp", 0) > 0: temp_used = min(hp["temp"], remaining) hp["temp"] -= temp_used remaining -= temp_used - # Then current HP - hp_before = hp["current"] hp["current"] = max(0, hp["current"] - remaining) save_character(player_id, data, base_path) type_str = f" {damage_type}" if damage_type else "" - if defense_note == "immune": - headline = f"Immune to{type_str} damage (would have been {incoming})" - elif defense_note: - headline = ( - f"Took {incoming}{type_str} damage → {scaled} ({defense_note})" - ) - else: - headline = f"Took {scaled}{type_str} damage" - - parts = [headline] + parts = [f"Took {amount}{type_str} damage"] if temp_used: parts.append(f"absorbed {temp_used} with temp HP") parts.append(f"HP: {hp['current']}/{hp['max']}") @@ -118,20 +58,6 @@ def damage( if hp["current"] == 0: parts.append("**(at 0 HP — death saves!)**") - # Concentration save hint. Per 5e 2024, taking damage while - # concentrating requires a Con save with DC = max(10, damage taken // 2). - # We emit a reminder rather than rolling — the DM decides success. - if scaled > 0: - con_effects = [ - e for e in (data.get("effects") or []) if e.get("concentration") - ] - if con_effects: - dc = max(10, scaled // 2) - names = ", ".join(e.get("source", "?") for e in con_effects) - parts.append( - f"**Concentration save DC {dc}** for: {names}" - ) - return ". ".join(parts) @@ -169,9 +95,10 @@ def add_effect( ) -> str: """Add a temporary effect to the character. - If `concentration=True`, this is a concentration-bound effect. Only one - concentration effect can be active at a time — adding a new one drops - the old one per 5e rules. + `concentration` is pure metadata — a flag the DM can set so the + sheet displays which effect is the character's current concentration. + The tool does not enforce uniqueness; the DM decides when to drop an + effect (via `remove_effect`). """ data = load_character(player_id, base_path) if data is None: @@ -179,14 +106,6 @@ def add_effect( effects = data.setdefault("effects", []) - dropped_source: str | None = None - if concentration: - for i, existing in enumerate(effects): - if existing.get("concentration"): - dropped_source = existing.get("source", "previous effect") - effects.pop(i) - break - effect: dict = {"source": source, "description": description} if expires: effect["expires"] = expires @@ -201,35 +120,9 @@ def add_effect( parts.append(f"(expires {expires})") if concentration: parts.append("[Concentration]") - if dropped_source: - parts.append(f"— lost concentration on {dropped_source}") return " ".join(parts) -def break_concentration( - player_id: str, - base_path: Path | None = None, -) -> str: - """Remove whatever effect the character is currently concentrating on. - - Called when a concentration save fails, the character is incapacitated, - or they voluntarily drop concentration. No-op if they weren't - concentrating. - """ - data = load_character(player_id, base_path) - if data is None: - return f"No character found for player '{player_id}'" - - effects = data.get("effects") or [] - for i, existing in enumerate(effects): - if existing.get("concentration"): - removed = effects.pop(i) - save_character(player_id, data, base_path) - return f"Concentration broken on {removed.get('source', '?')}" - - return "No concentration effect to break" - - def remove_effect( player_id: str, source: str, @@ -409,51 +302,17 @@ def set_item_status( # --- Resource operations --- -def use_resource( +def adjust_resource( player_id: str, name: str, - amount: int = 1, + delta: int, base_path: Path | None = None, ) -> str: - """Decrement a resource pool. Substring match on resource name.""" - data = load_character(player_id, base_path) - if data is None: - return f"No character found for player '{player_id}'" - - resources = data.get("resources", {}) - needle = name.lower() - - target = None - for key in resources: - if needle in key.lower() or needle in resources[key].get("notes", "").lower(): - target = key - break - - if target is None: - return f"No resource matching '{name}' found" - - pool = resources[target] - before = pool.get("current", 0) - pool["current"] = max(0, before - amount) - actual = before - pool["current"] - short = amount - actual - - save_character(player_id, data, base_path) - - notes = pool.get("notes", target) - msg = f"Used {actual} {notes} ({pool['current']}/{pool.get('max', 0)} remaining)" - if short > 0: - msg += f" — short {short}" - return msg + """Adjust a resource pool by a delta. - -def restore_resource( - player_id: str, - name: str, - amount: int, - base_path: Path | None = None, -) -> str: - """Restore points to a resource pool, clamped to max.""" + Negative = use (clamped to 0), positive = restore (clamped to max). + Substring match on the resource name or its notes field. + """ data = load_character(player_id, base_path) if data is None: return f"No character found for player '{player_id}'" @@ -473,12 +332,23 @@ def restore_resource( pool = resources[target] before = pool.get("current", 0) maximum = pool.get("max", 0) - pool["current"] = min(maximum, before + amount) - actual = pool["current"] - before + new_current = max(0, min(maximum, before + delta)) + pool["current"] = new_current + actual = new_current - before save_character(player_id, data, base_path) + notes = pool.get("notes", target) - return f"Restored {actual} {notes} ({pool['current']}/{maximum})" + if delta < 0: + used = before - new_current + short = -delta - used + msg = f"Used {used} {notes} ({new_current}/{maximum} remaining)" + if short > 0: + msg += f" — short {short}" + return msg + if delta > 0: + return f"Restored {actual} {notes} ({new_current}/{maximum})" + return f"No change to {notes} ({new_current}/{maximum})" def rest( diff --git a/src/storied/engine.py b/src/storied/engine.py index be7307f..c302444 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -43,12 +43,23 @@ def load_prompt(name: str, prompts_path: Path | None = None) -> str: return path.read_text() +# Tools whose invocation should not produce a visible "[Doing thing...]" +# notification. set_scene fires on every single turn — the chatter was +# noise, and the clock update is already visible via the ambient time +# header at the top of the DM's context. +_SILENT_TOOLS: set[str] = {"set_scene"} + + def _tool_notification(name: str) -> str: """Build a friendly tool notification string from an MCP tool name. MCP tool names are prefixed as mcp__storied__ by Claude Code. + Returns an empty string for tools in `_SILENT_TOOLS`; callers should + skip empty notifications. """ short = name.rsplit("__", 1)[-1] if "__" in name else name + if short in _SILENT_TOOLS: + return "" label = TOOL_LABELS.get(short, short) return f"[{label}...]" @@ -124,20 +135,49 @@ class DMEngine: with self._transcript_path.open("a") as f: f.write(json.dumps(entry) + "\n") + def _format_time_header(self) -> str: + """Build an ambient clock header for the top of the DM's context. + + The DM's one persistent weakness is remembering what time it is + and whether it called `set_scene` last turn. Putting the current + time front-and-center every turn is the cheapest fix — the DM + literally can't miss it. + """ + now = self._campaign_log.get_current_time() + lines = [ + "## ⏰ Current Time", + "", + ( + f"**{now.to_anchor()}** · {now} · " + f"{now.period_of_day().lower()}, {now.atmosphere()}" + ), + ] + entries = self._campaign_log.current_entries + if entries: + last = entries[-1] + lines.append(f"Last logged: _{last.event}_ ({last.duration})") + return "\n".join(lines) + def _build_context(self) -> str: """Build context string for system prompt. Loads and formats: - 1. Character sheet (always) - 2. Campaign log (current time + recent events) - 3. Session state if exists (situation, present, threads) - 4. Player knowledge (what the player has learned) - 5. DM knowledge: current location + present entities (smart loading) + 1. Ambient clock header (always — first so the DM can't miss it) + 2. Character sheet + 3. Campaign log (recent events) + 4. Session state if exists (situation, present, threads) + 5. Player knowledge (what the player has learned) + 6. DM knowledge: current location + present entities (smart loading) """ parts = [] self._context_parts = {} - # 0. DM style tuning (player preferences for pacing, tone, focus) + # 0. Ambient clock — the very first thing the DM sees every turn. + time_header = self._format_time_header() + self._context_parts["Time"] = time_header + parts.append(time_header) + + # 1. DM style tuning (player preferences for pacing, tone, focus) if self.world_id: style_path = self.base_path / "worlds" / self.world_id / "style.md" if style_path.exists(): @@ -491,7 +531,9 @@ class DMEngine: yield f"[→ {short}(...)]" deferred_notification = False else: - yield _tool_notification(name) + notification = _tool_notification(name) + if notification: + yield notification deferred_notification = False case ToolInputDelta(json_fragment=fragment): diff --git a/src/storied/log.py b/src/storied/log.py index d3fa9f3..8f981c9 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -391,16 +391,24 @@ class CampaignLog: return entries def format_for_context(self) -> str: - """Format the log for inclusion in system prompt.""" - lines = [f"## Campaign Time: {self.current_time}"] + """Format the log for inclusion in system prompt. + + The current time itself lives in the ambient clock header at the + top of the DM's context — see ``DMEngine._format_time_header``. + This block is just the recent event history. + """ + lines: list[str] = [] if self.previous_summaries: + lines.append("## Campaign Log") lines.append("") lines.append("**Previous Days:**") for summary in self.previous_summaries[-3:]: # Last 3 days lines.append(f"- {summary}") if self.current_entries: + if not lines: + lines.append("## Campaign Log") lines.append("") lines.append(f"**Today (Day {self.current_day}):**") if len(self.current_entries) > 10: diff --git a/src/storied/notification_formatters.py b/src/storied/notification_formatters.py index 474fa95..284fd31 100644 --- a/src/storied/notification_formatters.py +++ b/src/storied/notification_formatters.py @@ -130,20 +130,19 @@ def _format_set_item_status_notification(tool_json: str) -> str: return f"{verb} {item}" -def _format_use_resource_notification(tool_json: str) -> str: +def _format_adjust_resource_notification(tool_json: str) -> str: args = _parse_tool_args(tool_json) name = args.get("name", "resource") - amount = args.get("amount", 1) - if amount == 1: - return f"Using {name}" - return f"Using {amount} of {name}" - - -def _format_restore_resource_notification(tool_json: str) -> str: - args = _parse_tool_args(tool_json) - name = args.get("name", "resource") - amount = args.get("amount", "?") - return f"Restoring {amount} of {name}" + delta = args.get("delta", 0) + if isinstance(delta, int): + if delta < 0: + magnitude = -delta + if magnitude == 1: + return f"Using {name}" + return f"Using {magnitude} of {name}" + if delta > 0: + return f"Restoring {delta} of {name}" + return f"Adjusting {name}" def _format_rest_notification(tool_json: str) -> str: @@ -182,8 +181,7 @@ DEFERRED_FORMATTERS: dict[str, Callable[[str], str]] = { "add_item": _format_add_item_notification, "remove_item": _format_remove_item_notification, "set_item_status": _format_set_item_status_notification, - "use_resource": _format_use_resource_notification, - "restore_resource": _format_restore_resource_notification, + "adjust_resource": _format_adjust_resource_notification, "rest": _format_rest_notification, "add_note": _format_add_note_notification, "update_character": _format_update_character_notification, @@ -196,7 +194,6 @@ TOOL_LABELS = { "update_character": "Updating character sheet", "adjust_coins": "Adjusting coins", "create_character": "Creating character", - "set_scene": "Setting scene", "establish": "Establishing", "mark": "Recording", "note_discovery": "Noting discovery", @@ -216,8 +213,7 @@ TOOL_LABELS = { "add_item": "Picking up item", "remove_item": "Removing item", "set_item_status": "Updating magic item", - "use_resource": "Using resource", - "restore_resource": "Restoring resource", + "adjust_resource": "Adjusting resource", "rest": "Resting", "add_note": "Taking a note", "end_initiative": "Ending initiative", diff --git a/src/storied/testing.py b/src/storied/testing.py new file mode 100644 index 0000000..5ffa78f --- /dev/null +++ b/src/storied/testing.py @@ -0,0 +1,33 @@ +"""Internal test helpers. + +Lives in the package instead of next to `tests/conftest.py` because +`tests/` isn't on the Python path — pytest picks up conftest.py as a +plugin module, but ``from tests.conftest import call_tool`` doesn't +resolve in a plain ``pytest`` run. Keeping helpers on the regular import +path ($PROJECT/src) avoids that friction entirely. + +Prefixed with ``_`` so downstream users of the ``storied`` package know +this isn't public API. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from uncalled_for import resolved_dependencies + + +def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: + """Invoke a FastMCP-decorated tool synchronously, resolving its + ``Dependency`` parameters from the process-global ToolContext. + + Use this in tests instead of calling the tool wrapper directly — + direct calls leave the Dependency instances as parameter defaults + rather than resolving them. + """ + async def _run() -> Any: + async with resolved_dependencies(fn, kwargs) as deps: + return fn(**{**kwargs, **deps}) + return asyncio.run(_run()) diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index 9f9f453..38940c0 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -15,9 +15,6 @@ from storied.character import ( from storied.character import ( add_effect as char_add_effect, ) -from storied.character import ( - break_concentration as char_break_concentration, -) from storied.character import ( add_item as char_add_item, ) @@ -27,6 +24,9 @@ from storied.character import ( from storied.character import ( adjust_coins as char_adjust_coins, ) +from storied.character import ( + adjust_resource as char_adjust_resource, +) from storied.character import ( create_character as char_create, ) @@ -54,18 +54,12 @@ from storied.character import ( from storied.character import ( rest as char_rest, ) -from storied.character import ( - restore_resource as char_restore_resource, -) from storied.character import ( set_item_status as char_set_item_status, ) from storied.character import ( update_character as char_update, ) -from storied.character import ( - use_resource as char_use_resource, -) from storied.initiative import InitiativeTracker from storied.log import CampaignLog from storied.tools._context import ( @@ -231,18 +225,23 @@ def damage( player: str = Player(), root: Path = StorageRoot(), ) -> str: - """Apply damage to a named target. + """Apply raw damage to a named target. Routes by name: if `target` matches a current combatant in initiative, - the damage hits the combatant (and syncs to the character sheet if it's - the player). Otherwise, if `target` matches the player character's name, - the damage hits the player character sheet directly. Temp HP absorbs - first, then current HP. + damage hits the combatant (and syncs to the character sheet if it's + the player). Otherwise, if `target` matches the player character, the + damage hits the character sheet directly. Temp HP absorbs first, then + current HP. + + This tool does NOT apply resistance, vulnerability, or immunity — you + (the DM) pre-apply those per whatever ruleset you're running. The + `type` field is metadata for narration and the campaign log, not a + rule trigger. Args: target: Combatant or player character name - amount: Damage amount (non-negative) - type: Optional damage type (fire, cold, slashing, etc.) for narration + amount: Damage amount to record (non-negative) + type: Optional damage type for narration (fire, cold, slashing, …) Returns: Damage taken and remaining HP @@ -335,17 +334,17 @@ def add_effect( Use for spells, potions, environmental effects, narrative buffs/debuffs — anything that's temporarily affecting the character. - Set `concentration=True` for concentration-bound spells (Bless, Hold - Person, Hex, etc). Only one concentration effect can be active at a - time — adding a new one drops the old one automatically per 5e rules. - When the character takes damage while concentrating, `damage()` will - emit a concentration-save reminder with the correct DC. + Set `concentration=True` to flag an effect as concentration-bound so + the character sheet displays it that way. This is pure metadata — the + tool does not enforce uniqueness or emit save reminders. If a new + concentration spell replaces an old one, call `remove_effect` on the + old one first. Args: source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") description: What the effect does in narrative + mechanical terms expires: Optional game time anchor when the effect ends (e.g., "d28-1430") - concentration: True for concentration-bound effects. Defaults to False. + concentration: Metadata flag for concentration-bound effects. Defaults to False. Returns: Confirmation @@ -356,23 +355,6 @@ def add_effect( ) -@mcp.tool(tags={"dm", "character"}) -def break_concentration( - player: str = Player(), - root: Path = StorageRoot(), -) -> str: - """Drop the character's current concentration effect. - - Call this when a concentration save fails, the character is - incapacitated, or they voluntarily drop concentration. No-op if the - character isn't currently concentrating on anything. - - Returns: - What was removed, or a message if nothing was concentrating. - """ - return char_break_concentration(player, base_path=root) - - @mcp.tool(tags={"dm", "character"}) def remove_effect( source: str, @@ -503,46 +485,26 @@ def set_item_status( @mcp.tool(tags={"dm", "character"}) -def use_resource( +def adjust_resource( name: str, - amount: int = 1, + delta: int, player: str = Player(), root: Path = StorageRoot(), ) -> str: - """Decrement a resource pool (rage uses, ki points, hit dice, magic item charges, etc.). + """Adjust a resource pool by a delta (negative = use, positive = restore). - Substring match on the resource name. Resources are clamped to 0. + Substring match on the resource name or its notes field. Negative + deltas are clamped to zero; positive deltas are clamped to max. Args: - name: Resource name (e.g., "rage", "hit_dice_d8", "bracer") - amount: How many to use (default 1) + name: Resource name (e.g., "rage", "hit_dice_d8", "slot_3") + delta: How much to adjust. Negative spends, positive restores. + e.g. -1 to cast a spell; +2 to regain two hit dice. Returns: - Confirmation with remaining count - """ - return char_use_resource(player, name, amount=amount, base_path=root) - - -@mcp.tool(tags={"dm", "character"}) -def restore_resource( - name: str, - amount: int, - player: str = Player(), - root: Path = StorageRoot(), -) -> str: - """Restore points to a resource pool, clamped to max. - - Usually you'll use `rest` instead, which refreshes resources by their - refresh type. Use this for one-off restorations. - - Args: - name: Resource name (substring match) - amount: How many to restore - - Returns: - Confirmation + Confirmation with new count """ - return char_restore_resource(player, name, amount, base_path=root) + return char_adjust_resource(player, name, delta, base_path=root) @mcp.tool(tags={"dm", "character"}) diff --git a/tests/conftest.py b/tests/conftest.py index cc6f978..c1c0b17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,32 +1,21 @@ -"""Shared test fixtures.""" +"""Shared test fixtures. + +The synchronous tool-invocation helper ``call_tool`` lives in +``storied._testing`` so it's importable by test modules without needing +``tests/`` to be a Python package — pytest's conftest loading isn't a +regular import. +""" -import asyncio import hashlib -from collections.abc import Callable, Iterator +from collections.abc import Iterator from pathlib import Path -from typing import Any import pytest -from uncalled_for import resolved_dependencies from storied.log import CampaignLog from storied.search import VectorIndex from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx - -def call_tool(fn: Callable[..., Any], **kwargs: Any) -> Any: - """Invoke a FastMCP-decorated tool synchronously, resolving its - `Dependency` parameters from the process-global ToolContext. - - Use this in tests instead of calling the tool wrapper directly — - direct calls leave the Dependency instances as parameter defaults - rather than resolving them. - """ - async def _run() -> Any: - async with resolved_dependencies(fn, kwargs) as deps: - return fn(**{**kwargs, **deps}) - return asyncio.run(_run()) - EMBED_DIM = 384 diff --git a/tests/test_advancement.py b/tests/test_advancement.py index 2d84c4b..6a72059 100644 --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -18,7 +18,7 @@ from storied.session import save_session from storied.tools import ToolContext from storied.tools.scene import notify_dm as _notify_dm -from tests.conftest import call_tool +from storied.testing import call_tool def notify_dm(message: str, ctx: ToolContext) -> str: diff --git a/tests/test_character.py b/tests/test_character.py index 4493641..ea779f8 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -13,16 +13,13 @@ from storied.character import ( add_item, add_note, adjust_coins, - auto_fails_save, - break_concentration, + adjust_resource, create_character, damage, effective_hp, - exhaustion_penalty, format_character_context, format_sheet, format_status, - has_disadvantage_on_checks, has_expertise_in, heal, is_proficient_in, @@ -35,16 +32,13 @@ from storied.character import ( remove_effect, remove_item, rest, - restore_resource, save_character, save_modifier, set_item_status, skill_modifier, total_level, update_character, - use_resource, ) -from storied.tools import ToolContext # --- Fixtures --- @@ -289,9 +283,9 @@ class TestSchemaCoercion: assert data["resources"]["channel_divinity"]["current"] == 1 assert data["resources"]["lay_on_hands"]["max"] == 15 - def test_coerced_character_can_use_resource(self, player_dir: Path): + def test_coerced_character_can_adjust_resource(self, player_dir: Path): """End-to-end: a character with bad-shape resources on disk should - be usable via use_resource after load coercion.""" + be usable via adjust_resource after load coercion.""" import yaml path = player_dir / "players" / "test-player" / "character.yaml" path.write_text(yaml.dump({ @@ -304,7 +298,9 @@ class TestSchemaCoercion: "refresh": "short_rest", "notes": "Channel Divinity"}, ], })) - result = use_resource("test-player", "channel", base_path=player_dir) + result = adjust_resource( + "test-player", "channel", -1, base_path=player_dir + ) assert "Used 1" in result def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): @@ -393,64 +389,26 @@ class TestComputation: # 10 + perception modifier (+4) = 14 assert passive_score(mira, "perception") == 14 - def test_exhaustion_penalty_applied_to_skills(self, mira: dict): + def test_skill_modifier_ignores_exhaustion(self, mira: dict): + """Skill modifiers show raw ability + proficiency math. Exhaustion + is a rule effect the DM applies at roll time, not baked into the + displayed number.""" mira["state"]["exhaustion"] = 2 - total, breakdown = skill_modifier(mira, "stealth") - # Without exhaustion: +8. With 2 levels: +4. - assert total == 4 - assert any("exhaustion" in b.lower() for b in breakdown) + total, _ = skill_modifier(mira, "stealth") + assert total == 8 # +4 dex + 4 expertise, exhaustion NOT folded in - def test_exhaustion_penalty_applied_to_saves(self, mira: dict): + def test_save_modifier_ignores_exhaustion(self, mira: dict): mira["state"]["exhaustion"] = 1 - total, breakdown = save_modifier(mira, "dexterity") - # Without exhaustion: +6. With 1 level: +4. - assert total == 4 - assert any("exhaustion" in b.lower() for b in breakdown) - - def test_exhaustion_zero_is_noop(self, mira: dict): - mira["state"]["exhaustion"] = 0 - total, breakdown = skill_modifier(mira, "stealth") - assert total == 8 - assert not any("exhaustion" in b.lower() for b in breakdown) - - def test_exhaustion_penalty_helper(self, mira: dict): - mira["state"]["exhaustion"] = 3 - assert exhaustion_penalty(mira) == -6 - - def test_poisoned_gives_disadvantage_on_checks(self, mira: dict): - mira["conditions"] = ["Poisoned"] - assert has_disadvantage_on_checks(mira) is True - - def test_frightened_gives_disadvantage_on_checks(self, mira: dict): - mira["conditions"] = ["frightened"] - assert has_disadvantage_on_checks(mira) is True - - def test_no_disadvantage_without_matching_condition(self, mira: dict): - mira["conditions"] = ["Prone"] - assert has_disadvantage_on_checks(mira) is False + total, _ = save_modifier(mira, "dexterity") + assert total == 6 # +4 dex + 2 prof, exhaustion NOT folded in - def test_passive_perception_drops_when_disadvantaged(self, mira: dict): - # Baseline: +4 perception → passive 14 + def test_passive_perception_ignores_conditions(self, mira: dict): + """Passive perception is the raw 10 + perception modifier. The DM + applies condition-based adjustments (e.g. -5 for disadvantage per + 5e 2024) per whatever ruleset they're running.""" assert passive_score(mira, "perception") == 14 mira["conditions"] = ["Poisoned"] - # With disadvantage: -5 → 9 - assert passive_score(mira, "perception") == 9 - - def test_paralyzed_auto_fails_str_and_dex_saves(self, mira: dict): - mira["conditions"] = ["Paralyzed"] - assert auto_fails_save(mira, "strength") is True - assert auto_fails_save(mira, "dexterity") is True - assert auto_fails_save(mira, "wisdom") is False - assert auto_fails_save(mira, "constitution") is False - - def test_restrained_auto_fails_dex_saves_only(self, mira: dict): - mira["conditions"] = ["restrained"] - assert auto_fails_save(mira, "dexterity") is True - assert auto_fails_save(mira, "strength") is False - - def test_no_auto_fail_without_condition(self, mira: dict): - assert auto_fails_save(mira, "dexterity") is False - assert auto_fails_save(mira, "strength") is False + assert passive_score(mira, "perception") == 14 def test_effective_hp_with_temp(self, player_dir: Path): save_character( @@ -622,29 +580,23 @@ class TestDisplay: result = format_sheet(mira) assert "+5 temp" in result - def test_format_sheet_shows_disadvantage_legend_when_condition_applies( - self, mira: dict, - ): - mira["conditions"] = ["Poisoned"] - sheet = format_sheet(mira) - assert "disadvantage" in sheet.lower() - def test_format_sheet_shows_inspiration_when_available(self, mira: dict): mira["state"]["inspiration"] = True sheet = format_sheet(mira) assert "Inspiration" in sheet and "available" in sheet - def test_format_sheet_shows_exhaustion_line_when_nonzero(self, mira: dict): + def test_format_sheet_shows_exhaustion_reminder_when_nonzero( + self, mira: dict, + ): + """When exhaustion is set, the sheet shows a reminder that the DM + applies the effect — it does NOT fold a numeric penalty into the + displayed skill modifiers.""" mira["state"]["exhaustion"] = 2 sheet = format_sheet(mira) - assert "exhaustion 2" in sheet.lower() - assert "-4" in sheet - - def test_format_sheet_shows_auto_fail_marker(self, mira: dict): - mira["conditions"] = ["Paralyzed"] - sheet = format_sheet(mira) - assert "✗" in sheet - assert "auto-fail" in sheet.lower() + assert "Exhaustion 2" in sheet + # Stealth should still read +8 (raw), not +4 (folded) + assert "Stealth" in sheet + assert "+8" in sheet def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): mira["state"]["exhaustion"] = 2 @@ -765,112 +717,44 @@ class TestDamageHeal: result = damage("test-player", 3, damage_type="fire", base_path=player_dir) assert "fire" in result - def test_damage_resistance_halves(self, mira: dict, player_dir: Path): + def test_damage_ignores_resistances(self, mira: dict, player_dir: Path): + """Resistances are metadata for the DM's reference — the tool + applies raw damage and lets the DM pre-compute rule effects.""" update_character( "test-player", {"defenses.resistances": [{"damage": "fire"}]}, base_path=player_dir, ) - result = damage("test-player", 10, damage_type="fire", base_path=player_dir) - data = load_character("test-player", player_dir) - assert data["state"]["hp"]["current"] == 19 - assert "resistance" in result - assert "10" in result and "5" in result - - def test_damage_resistance_only_applies_to_matched_type( - self, mira: dict, player_dir: Path, - ): - update_character( - "test-player", - {"defenses.resistances": [{"damage": "fire"}]}, - base_path=player_dir, - ) - damage("test-player", 10, damage_type="cold", base_path=player_dir) + damage("test-player", 10, damage_type="fire", base_path=player_dir) data = load_character("test-player", player_dir) + # Raw 10, not halved assert data["state"]["hp"]["current"] == 14 - def test_damage_vulnerability_doubles(self, mira: dict, player_dir: Path): + def test_damage_ignores_vulnerabilities(self, mira: dict, player_dir: Path): update_character( "test-player", {"defenses.vulnerabilities": [{"damage": "radiant"}]}, base_path=player_dir, ) - result = damage( + damage( "test-player", 5, damage_type="radiant", base_path=player_dir, ) data = load_character("test-player", player_dir) - assert data["state"]["hp"]["current"] == 14 - assert "vulnerability" in result + # Raw 5, not doubled + assert data["state"]["hp"]["current"] == 19 - def test_damage_immunity_is_zero(self, mira: dict, player_dir: Path): + def test_damage_ignores_immunities(self, mira: dict, player_dir: Path): update_character( "test-player", {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, base_path=player_dir, ) - result = damage( + damage( "test-player", 12, damage_type="poison", base_path=player_dir, ) data = load_character("test-player", player_dir) - assert data["state"]["hp"]["current"] == 24 - assert "Immune" in result - - def test_damage_resistance_and_vulnerability_cancel( - self, mira: dict, player_dir: Path, - ): - update_character( - "test-player", - { - "defenses.resistances": [{"damage": "cold"}], - "defenses.vulnerabilities": [{"damage": "cold"}], - }, - base_path=player_dir, - ) - damage("test-player", 8, damage_type="cold", base_path=player_dir) - data = load_character("test-player", player_dir) - assert data["state"]["hp"]["current"] == 16 - - def test_damage_resistance_tolerates_string_list( - self, mira: dict, player_dir: Path, - ): - # The LLM has been known to write resistances as a flat list of strings - # instead of a list of {"damage": ...} dicts. Handle both. - update_character( - "test-player", - {"defenses.resistances": ["fire"]}, - base_path=player_dir, - ) - damage("test-player", 10, damage_type="fire", base_path=player_dir) - data = load_character("test-player", player_dir) - assert data["state"]["hp"]["current"] == 19 - - def test_damage_resistance_is_case_insensitive( - self, mira: dict, player_dir: Path, - ): - update_character( - "test-player", - {"defenses.resistances": [{"damage": "Fire"}]}, - base_path=player_dir, - ) - damage("test-player", 10, damage_type="FIRE", base_path=player_dir) - data = load_character("test-player", player_dir) - assert data["state"]["hp"]["current"] == 19 - - def test_damage_resistance_with_temp_hp(self, mira: dict, player_dir: Path): - # Defenses apply before temp HP soaks. 10 fire → resisted to 5 → 5 temp - # absorbs all of it. - update_character( - "test-player", - { - "state.hp.temp": 5, - "defenses.resistances": [{"damage": "fire"}], - }, - base_path=player_dir, - ) - damage("test-player", 10, damage_type="fire", base_path=player_dir) - data = load_character("test-player", player_dir) - assert data["state"]["hp"]["temp"] == 0 - assert data["state"]["hp"]["current"] == 24 + # Raw 12, not zeroed + assert data["state"]["hp"]["current"] == 12 class TestLevelUp: @@ -1018,7 +902,14 @@ class TestLevelUp: class TestConcentration: - def test_add_concentration_effect(self, mira: dict, player_dir: Path): + """`concentration=True` is a metadata flag — the tool records it on + the effect so the sheet can display which effect is the current + concentration. It does NOT enforce uniqueness or emit save hints. + The DM decides when to drop a concentration effect.""" + + def test_add_concentration_effect_flags_it( + self, mira: dict, player_dir: Path, + ): result = add_effect( "test-player", "Bless", "+1d4 to attacks", concentration=True, base_path=player_dir, @@ -1027,117 +918,34 @@ class TestConcentration: data = load_character("test-player", player_dir) assert data["effects"][0]["concentration"] is True - def test_adding_concentration_drops_previous( + def test_multiple_concentration_effects_allowed( self, mira: dict, player_dir: Path, ): + """No enforcement — the DM can flag two effects concentration.""" add_effect( "test-player", "Bless", "+1d4", concentration=True, base_path=player_dir, ) - result = add_effect( - "test-player", "Hold Person", "paralyzed", - concentration=True, base_path=player_dir, - ) - assert "lost concentration on Bless" in result - data = load_character("test-player", player_dir) - sources = [e["source"] for e in data["effects"]] - assert "Bless" not in sources - assert "Hold Person" in sources - - def test_adding_non_concentration_does_not_drop_existing( - self, mira: dict, player_dir: Path, - ): add_effect( - "test-player", "Bless", "+1d4", + "test-player", "Hold Person", "paralyzed", concentration=True, base_path=player_dir, ) - add_effect( - "test-player", "Potion of Heroism", "+10 temp HP", - base_path=player_dir, - ) data = load_character("test-player", player_dir) sources = [e["source"] for e in data["effects"]] assert "Bless" in sources - assert "Potion of Heroism" in sources + assert "Hold Person" in sources - def test_damage_emits_concentration_save_hint( + def test_damage_does_not_emit_concentration_save_hint( self, mira: dict, player_dir: Path, ): + """The DM issues concentration saves manually per the rules.""" add_effect( "test-player", "Bless", "+1d4", concentration=True, base_path=player_dir, ) result = damage("test-player", 6, base_path=player_dir) - # DC = max(10, 6 // 2) = 10 - assert "Concentration save DC 10" in result - assert "Bless" in result - - def test_damage_concentration_dc_scales_with_damage( - self, mira: dict, player_dir: Path, - ): - add_effect( - "test-player", "Hex", "extra 1d6 necrotic", - concentration=True, base_path=player_dir, - ) - # Need a caster with more HP, but raising max for this test - update_character( - "test-player", {"state.hp.max": 100, "state.hp.current": 100}, - base_path=player_dir, - ) - result = damage("test-player", 30, base_path=player_dir) - # DC = max(10, 30 // 2) = 15 - assert "Concentration save DC 15" in result - - def test_no_concentration_hint_without_concentration_effect( - self, mira: dict, player_dir: Path, - ): - add_effect( - "test-player", "Mage Armor", "+3 AC", # not concentration - base_path=player_dir, - ) - result = damage("test-player", 5, base_path=player_dir) assert "Concentration save" not in result - def test_no_concentration_hint_when_damage_absorbed( - self, mira: dict, player_dir: Path, - ): - # 5e 2024: no damage taken → no save needed. Test via immunity. - update_character( - "test-player", - {"defenses.immunities": {"damage": ["fire"], "conditions": []}}, - base_path=player_dir, - ) - add_effect( - "test-player", "Bless", "+1d4", - concentration=True, base_path=player_dir, - ) - result = damage("test-player", 10, damage_type="fire", base_path=player_dir) - assert "Concentration save" not in result - - def test_break_concentration_removes_effect( - self, mira: dict, player_dir: Path, - ): - add_effect( - "test-player", "Bless", "+1d4", - concentration=True, base_path=player_dir, - ) - add_effect( - "test-player", "Mage Armor", "+3 AC", # non-concentration - base_path=player_dir, - ) - result = break_concentration("test-player", base_path=player_dir) - assert "Bless" in result - data = load_character("test-player", player_dir) - sources = [e["source"] for e in data["effects"]] - assert "Bless" not in sources - assert "Mage Armor" in sources # non-concentration effects untouched - - def test_break_concentration_noop_when_nothing_concentrating( - self, mira: dict, player_dir: Path, - ): - result = break_concentration("test-player", base_path=player_dir) - assert "No concentration" in result - class TestEffects: def test_add_effect_appends(self, mira: dict, player_dir: Path): @@ -1256,40 +1064,58 @@ class TestMagicItems: class TestResources: - def test_use_resource_decrements(self, mira: dict, player_dir: Path): - use_resource("test-player", "hit_dice", base_path=player_dir) + def test_adjust_resource_spend_one(self, mira: dict, player_dir: Path): + adjust_resource("test-player", "hit_dice", -1, base_path=player_dir) data = load_character("test-player", player_dir) assert data["resources"]["hit_dice_d8"]["current"] == 2 - def test_use_resource_amount(self, mira: dict, player_dir: Path): - use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) + def test_adjust_resource_spend_multiple( + self, mira: dict, player_dir: Path, + ): + adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) data = load_character("test-player", player_dir) assert data["resources"]["hit_dice_d8"]["current"] == 1 - def test_use_resource_clamped_to_zero(self, mira: dict, player_dir: Path): - result = use_resource( - "test-player", "hit_dice", amount=10, base_path=player_dir + def test_adjust_resource_clamped_to_zero( + self, mira: dict, player_dir: Path, + ): + result = adjust_resource( + "test-player", "hit_dice", -10, base_path=player_dir ) data = load_character("test-player", player_dir) assert data["resources"]["hit_dice_d8"]["current"] == 0 assert "short" in result.lower() - def test_use_resource_not_found(self, mira: dict, player_dir: Path): - result = use_resource("test-player", "nonexistent", base_path=player_dir) + def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): + result = adjust_resource( + "test-player", "nonexistent", -1, base_path=player_dir + ) assert "no resource matching" in result.lower() - def test_restore_resource_clamped_to_max(self, mira: dict, player_dir: Path): - use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) - restore_resource( + def test_adjust_resource_restore_clamped_to_max( + self, mira: dict, player_dir: Path, + ): + adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) + adjust_resource( "test-player", "hit_dice", 100, base_path=player_dir ) data = load_character("test-player", player_dir) assert data["resources"]["hit_dice_d8"]["current"] == 3 + def test_adjust_resource_zero_delta_is_noop( + self, mira: dict, player_dir: Path, + ): + result = adjust_resource( + "test-player", "hit_dice", 0, base_path=player_dir + ) + data = load_character("test-player", player_dir) + assert data["resources"]["hit_dice_d8"]["current"] == 3 + assert "no change" in result.lower() + class TestRest: def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): - use_resource("test-player", "hit_dice", amount=3, base_path=player_dir) + adjust_resource("test-player", "hit_dice", -3, base_path=player_dir) rest("test-player", "long", base_path=player_dir) data = load_character("test-player", player_dir) assert data["resources"]["hit_dice_d8"]["current"] == 3 @@ -1322,7 +1148,7 @@ class TestRest: def test_short_rest_doesnt_refresh_long_rest_resources( self, mira: dict, player_dir: Path ): - use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) + adjust_resource("test-player", "hit_dice", -2, base_path=player_dir) rest("test-player", "short", base_path=player_dir) data = load_character("test-player", player_dir) # hit_dice has refresh: long_rest, so short rest shouldn't refresh it @@ -1393,8 +1219,8 @@ class TestEdgeCases: (add_item, ("Item",)), (remove_item, ("Item",)), (set_item_status, ("Item", "attuned")), - (use_resource, ("res",)), - (restore_resource, ("res", 1)), + (adjust_resource, ("res", -1)), + (adjust_resource, ("res", 1)), (rest, ("short",)), (adjust_coins, ({"gp": 5},)), ]: diff --git a/tests/test_engine.py b/tests/test_engine.py index 7494cf8..1db1963 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -106,14 +106,28 @@ class TestDMEngineContext: assert "Style" in engine._context_parts assert "intrigue" in engine._context_parts["Style"] - def test_style_is_first_context_part(self, engine): + def test_time_is_first_context_part(self, engine): + """The ambient clock header goes at the top of every turn's + context so the DM can't miss it. Style comes immediately after.""" style_path = engine.base_path / "worlds" / "test" / "style.md" style_path.write_text("# Style\n\nDark tone.\n") - context = engine._build_context() + engine._build_context() parts = list(engine._context_parts.keys()) - assert parts[0] == "Style" + assert parts[0] == "Time" + assert parts[1] == "Style" + + def test_time_header_shows_current_clock(self, engine): + """The ambient time header always includes the current time anchor + and period of day, so the DM's first impression each turn is what + time it is.""" + engine._build_context() + header = engine._context_parts["Time"] + assert "⏰ Current Time" in header + now = engine._campaign_log.get_current_time() + assert now.to_anchor() in header + assert now.period_of_day().lower() in header.lower() def test_estimate_tokens(self): from storied.engine import DMEngine diff --git a/tests/test_entities.py b/tests/test_entities.py index 7461ea5..620ded8 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -14,7 +14,7 @@ from storied.tools.entities import amend_mark as _amend_mark from storied.tools.entities import establish as _establish from storied.tools.entities import mark as _mark -from tests.conftest import call_tool +from storied.testing import call_tool def establish(**kwargs): diff --git a/tests/test_execute_tool.py b/tests/test_execute_tool.py index 24c07e1..45ac363 100644 --- a/tests/test_execute_tool.py +++ b/tests/test_execute_tool.py @@ -18,7 +18,7 @@ from storied.tools.combat import _flip_into_combat, _flip_out_of_combat from storied.tools.entities import note_discovery as _note_discovery from storied.tools.scene import end_session as _end_session -from tests.conftest import call_tool +from storied.testing import call_tool # --- Helpers ---------------------------------------------------------------- @@ -552,7 +552,7 @@ class TestCharacterToolWrappers: }) assert result - def test_use_and_restore_resource(self, kira: ToolContext): + def test_adjust_resource(self, kira: ToolContext): # Add a resource via update_character first call("update_character", { "updates": { @@ -562,9 +562,9 @@ class TestCharacterToolWrappers: }, }, }) - result = call("use_resource", {"name": "hit_dice", "amount": 1}) + result = call("adjust_resource", {"name": "hit_dice", "delta": -1}) assert "Used" in result - result = call("restore_resource", {"name": "hit_dice", "amount": 1}) + result = call("adjust_resource", {"name": "hit_dice", "delta": 1}) assert "Restored" in result def test_rest_short(self, kira: ToolContext): diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b6772aa..bf655e3 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -43,11 +43,19 @@ class TestPerRoleComposition: for tool_name in ( "damage", "heal", "adjust_coins", "add_effect", "remove_effect", "add_condition", "remove_condition", "add_item", "remove_item", - "set_item_status", "use_resource", "restore_resource", "rest", + "set_item_status", "adjust_resource", "rest", "add_note", "update_character", "create_character", ): assert tool_name in names, f"missing {tool_name}" + def test_dm_does_not_include_removed_tools(self): + """break_concentration, use_resource, and restore_resource were + folded into other tools — ensure they don't resurface.""" + names = _names("dm") + assert "break_concentration" not in names + assert "use_resource" not in names + assert "restore_resource" not in names + def test_dm_initial_excludes_combat_tools(self): """In DM mode, combat tools are hidden until enter_initiative runs.""" names = _names("dm") diff --git a/tests/test_notification_formatters.py b/tests/test_notification_formatters.py index 6eca504..ea13e85 100644 --- a/tests/test_notification_formatters.py +++ b/tests/test_notification_formatters.py @@ -178,13 +178,24 @@ class TestItemNotification: class TestResourceNotification: def test_use_one(self): - assert _format("use_resource", '{"name": "rage"}') == "Using rage" + assert _format( + "adjust_resource", '{"name": "rage", "delta": -1}' + ) == "Using rage" def test_use_multiple(self): - assert _format("use_resource", '{"name": "ki", "amount": 3}') == "Using 3 of ki" + assert _format( + "adjust_resource", '{"name": "ki", "delta": -3}' + ) == "Using 3 of ki" def test_restore(self): - assert _format("restore_resource", '{"name": "ki", "amount": 2}') == "Restoring 2 of ki" + assert _format( + "adjust_resource", '{"name": "ki", "delta": 2}' + ) == "Restoring 2 of ki" + + def test_zero_delta_fallback(self): + assert _format( + "adjust_resource", '{"name": "rage", "delta": 0}' + ) == "Adjusting rage" class TestRestNotification: diff --git a/tests/test_planner.py b/tests/test_planner.py index fd465f6..e4113fd 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -20,7 +20,7 @@ from storied.session import save_session from storied.tools import ToolContext from storied.tools.entities import establish, mark -from tests.conftest import call_tool +from storied.testing import call_tool @pytest.fixture diff --git a/tests/test_tune.py b/tests/test_tune.py index 4b5941d..3616484 100644 --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -3,7 +3,7 @@ from storied.tools import ToolContext from storied.tools.scene import tune as _tune -from tests.conftest import call_tool +from storied.testing import call_tool def tune(tuning: str) -> str: