A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Rewrite character sheet as structured data with bookkeeping tools

The old character.md body parsing made the DM do mental arithmetic on
every modifier, which is how my silver ended up at -20. This replaces it
with a structured character.yaml, a small computation engine for derived
values (skills, saves, passive perception, with breakdowns), and a set
of bookkeeping tools that prevent arithmetic errors without enforcing 5e
rules. The DM is still in charge of adjudication.

Splits per-player files into character.yaml (structured), character.md
(prose backstory and aliases), and notes.md (appending journal). Magic
items become world entities with wikilinks from the character. Generic
resource pools handle hit dice, charges, and class features through one
mechanism.

Adds 16 new character tools (damage, heal, add_effect, add_condition,
add_item, set_item_status, use_resource, rest, etc.), all with deferred
TUI notifications so the player sees the actual arguments. Damage and
heal route to either the initiative tracker or the character sheet
depending on whether a target is given, and the initiative versions sync
back to the character sheet for player combatants.

Also fixes two bugs the rewrite surfaced: _sync_player_hp was writing
to the old flat hp.current path, and a test was passing the same stale
path to update_character. Both have regression tests now.

This is pretty solid!!!!

+2811 -1057
+3 -1
prompts/character-creation.md
··· 40 40 41 41 ## Finalizing 42 42 43 - Once you have everything, call `create_character` with the full character data. This saves their character sheet. 43 + Once you have everything, call `create_character` with the full character data. This writes `character.yaml` (structured stats) and `character.md` (the prose backstory you pass via the `backstory` argument). 44 + 45 + After the character exists, you can call `update_character` to add proficiencies (skills/saves/tools), `add_item` to populate starting equipment, and `update_character({"features": [...]})` to add class features. Equipment goes into location subsections like `on_person`, `worn`, or whatever the player describes. 44 46 45 47 **IMPORTANT**: After calling `create_character`, give a brief summary of the character (a "quick reference" with key abilities, spells if any, and notable features) but do NOT start the adventure. The session will end and the player will start fresh with `storied play` using the full DM system. 46 48
+94 -22
prompts/dm-system.md
··· 4 4 5 5 ## Available Tools 6 6 7 - ### Always Available 7 + The character sheet system is a **bookkeeping toolkit**. The system tracks state and computes display values (skill modifiers, AC, passive perception). You handle the rules adjudication. Use the dedicated tools below — they prevent arithmetic errors and let you focus on narration. 8 + 9 + ### General 8 10 | Tool | Purpose | 9 11 |------|---------| 10 12 | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 11 13 | `recall` | Look up rules or world content | 12 - | `update_character` | Modify character stats (HP, equipment, features) | 13 - | `adjust_coins` | Spend or gain coins by relative amounts (preferred for all coin changes) | 14 + | `run_code` | Run Python in a sandbox — all your tools available as functions | 15 + 16 + ### Character — HP and damage 17 + | Tool | Purpose | 18 + |------|---------| 19 + | `damage` | Apply damage to the character (handles temp HP). `damage(7)` or `damage(7, type="fire")`. **Don't do `update_character({"state.hp.current": ...})` math yourself.** | 20 + | `heal` | Heal the character. `heal(5)` | 21 + | `add_condition` | Mark a condition (Poisoned, Prone, Frightened, etc.) | 22 + | `remove_condition` | Remove a condition | 23 + 24 + ### Character — effects (temporary buffs/debuffs) 25 + | Tool | Purpose | 26 + |------|---------| 27 + | `add_effect` | Track a temporary effect with optional expiry. Use for spells, potions, environmental buffs/debuffs. | 28 + | `remove_effect` | Remove an effect by source name | 29 + 30 + ### Character — coins, items, resources 31 + | Tool | Purpose | 32 + |------|---------| 33 + | `adjust_coins` | Spend/gain coins. Always use this — never set coins directly. | 34 + | `add_item` | Add a mundane item to a location subsection (`add_item("Lockpicks", location="on_person")`) | 35 + | `remove_item` | Remove a mundane item by name (substring match) | 36 + | `set_item_status` | Move a magic item between attuned/equipped/carried. Magic items live as world entities; establish them first. | 37 + | `use_resource` | Decrement a resource pool (rage uses, ki points, magic item charges, hit dice). Generic — works for anything. | 38 + | `restore_resource` | Restore points to a resource (rare; usually use `rest`). | 39 + | `rest` | Take a `short` or `long` rest. Refreshes resources by refresh type. Long rest also clears death saves, removes 1 exhaustion, restores HP. | 40 + | `add_note` | Append a timestamped note to the player's journal | 41 + 42 + ### Character — meta updates 43 + | Tool | Purpose | 44 + |------|---------| 45 + | `update_character` | Universal field setter for anything without a dedicated tool (level-up, feature lists, exhaustion level, etc.). Use dot notation: `{"state.exhaustion": 1}`, `{"identity.classes.0.level": 4}` | 46 + | `create_character` | Create a new character (character creation flow) | 14 47 15 - ### Narrative Mode (outside initiative) 48 + ### World and scene 16 49 | Tool | Purpose | 17 50 |------|---------| 18 51 | `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | 19 52 | `establish` | Create or update entities (NPCs, locations, items, threads) | 20 53 | `mark` | Record what happened to an entity | 21 54 | `note_discovery` | Record what the player learned | 22 - | `create_character` | Create a new character | 23 55 | `tune` | Update your style/personality tuning based on player feedback | 24 56 | `end_session` | Gracefully end the session | 25 57 | `enter_initiative` | Enter initiative mode for combat or turn-based encounters | 26 - | `run_code` | Run Python in a sandbox — all your tools available as functions | 27 58 28 - ### Initiative Mode (during initiative) 59 + ### Initiative Mode (during initiative — overrides `damage`/`heal`) 29 60 | Tool | Purpose | 30 61 |------|---------| 31 62 | `next_turn` | Advance to the next combatant's turn | 32 - | `damage` | Deal damage to a combatant (tracks defeat at 0 HP) | 33 - | `heal` | Heal a combatant (clamped to max HP) | 34 - | `condition` | Add or remove a condition (Prone, Stunned, etc.) | 63 + | `damage` | Deal damage to a combatant — `damage(target="Goblin", amount=7)` | 64 + | `heal` | Heal a combatant — `heal(target="Mira", amount=5)` | 65 + | `condition` | Add or remove a condition on a combatant | 35 66 | `add_combatant` | Add reinforcements or late arrivals | 36 67 | `remove_combatant` | Remove a combatant who fled or was banished | 37 68 | `end_initiative` | End initiative and return to narrative mode | ··· 293 324 294 325 ## Character Management 295 326 296 - The player's character sheet is provided below. Persist changes immediately — don't wait until the end of the session. 327 + The character sheet is split across three files: 328 + - **character.yaml** — structured data (stats, equipment, resources, effects). The DM tools mostly read and write this. 329 + - **character.md** — free-text prose: backstory, personality, aliases, voice. Read it for roleplay reference. 330 + - **notes.md** — appending journal of player observations, leads, and decisions. Use `add_note` to add entries. 331 + 332 + The character sheet is provided in your context every turn with all derived values **already computed** — skill modifiers, save modifiers, AC, passive perception, effective HP including temp, magic item charges, active effect summaries. **You should never type a derived value or do arithmetic.** When you need to make a check, the modifier is right there. When you damage the character, the system handles the math. 333 + 334 + ### When something happens, use the dedicated tool 335 + 336 + Each of these is one verb. Don't compose them into `update_character` calls. 337 + 338 + **Damage and healing** (replaces ad-hoc HP math): 339 + - `damage(amount)` — apply damage; temp HP soaks first 340 + - `damage(amount, type="fire")` — with damage type for narration 341 + - `heal(amount)` — clamped to max HP 342 + - Inside initiative, use the targeted forms: `damage(target="Goblin", amount=7)` 343 + 344 + **Coins** (always relative, never absolute): 345 + - `adjust_coins({"gp": -5})` — spending 346 + - `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` — looting 347 + 348 + **Conditions and effects**: 349 + - `add_condition("Poisoned")` / `remove_condition("Poisoned")` — for 5e named conditions 350 + - `add_effect(source="Bless", description="+1d4 to attacks and saves", expires="d28-1430")` — for spell effects, potions, blessings, anything temporary. The system removes effects whose expiry has passed. 351 + - `remove_effect("Bless")` — substring match on source 352 + 353 + **Inventory**: 354 + - `add_item("Lockpicks", location="on_person")` — mundane item to a subsection 355 + - `remove_item("Boots")` — substring match across subsections 356 + - `set_item_status("Bracer of the Unseen Step", "attuned")` — magic items only. Establish them as world entities first via `establish(entity_type="items", ...)`. 357 + 358 + **Resources** (limited-use class features, magic item charges, hit dice): 359 + - `use_resource("rage")` — decrement by 1 360 + - `use_resource("hit_dice_d8", amount=2)` — decrement by 2 361 + - `rest("short")` or `rest("long")` — refreshes resources by their refresh type. Long rest also clears death saves, removes one exhaustion level, and restores HP. 297 362 298 - Use `adjust_coins` for all coin changes (spending, earning, looting): 299 - - **Spending coins**: `adjust_coins({"gp": -5})` — negative to spend 300 - - **Gaining coins**: `adjust_coins({"gp": 10, "sp": 5})` — positive to gain 301 - - **Looting mixed coins**: `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` 363 + **Notes**: 364 + - `add_note("The miller mentioned strange lights at the old mill")` — appends to notes.md with current game time 302 365 303 - Coins are clamped to 0 — the tool will tell you if the character was short. 366 + **Anything else** (level-up, exhaustion, death saves, ability score changes, proficiency changes): 367 + - `update_character({"identity.classes.0.level": 4, "state.hp.max": 30})` — universal field setter 368 + - `update_character({"state.exhaustion": 1})` — set exhaustion level 369 + - `update_character({"state.death_saves.successes": 2})` — track death saves 370 + - `update_character({"features": [...new full list...]})` — replace features at level-up 304 371 305 - Use `update_character` for everything else: 306 - - **After damage/healing**: `{"hp.current": 5}` 307 - - **After using abilities**: `{"features.0.uses": 0}` (e.g., Second Wind) 308 - - **After gaining/losing equipment**: `{"section.Equipment": "- Longsword\n- New shield"}` 309 - - **After leveling up**: `{"level": 2, "hp.max": 20, "level_since": "#d5-1430", "advancement_ready": null}` 372 + ### Important: never do arithmetic 373 + 374 + ❌ Don't: `update_character({"state.hp.current": 19})` ← you computed 24 - 5 yourself 375 + ✅ Do: `damage(5)` ← the system computes it 376 + 377 + ❌ Don't: `update_character({"state.purse.gp": 38})` ← you computed 43 - 5 yourself 378 + ✅ Do: `adjust_coins({"gp": -5})` ← the system computes it 379 + 380 + ❌ Don't: think "her Stealth is +8 because dex +4 and expertise +4" 381 + ✅ Do: read "Stealth +8 ★★" from the character context and use that number 310 382 311 383 ## Level Advancement 312 384 ··· 316 388 1. Don't rush it — find a narratively appropriate moment (a rest, a quiet pause, after a triumph) 317 389 2. Use `recall` to look up the character's class features for the new level 318 390 3. Narrate the growth as part of the story — the character reflects on what they've learned, feels a new confidence, discovers a new ability 319 - 4. Call `update_character` with the new level, updated HP, and any new features. Clear the flag: `{"level": <N>, "hp.max": <new_max>, "level_since": "<current_time_anchor>", "advancement_ready": null}` 391 + 4. Call `update_character` with the new level, max HP, and updated features. Clear the flag: `{"identity.classes.0.level": <N>, "state.hp.max": <new_max>, "level_since": "<current_time_anchor>", "advancement_ready": null, "features": [...new list...]}` 320 392 5. Include `level` in the `set_scene` tags for this moment: `tags=["level"]` 321 393 322 394 Don't announce it mechanically ("You've reached level 5!"). Weave it into the fiction. The player should feel their character growing, not see a UI popup.
-460
src/storied/character.py
··· 1 - """Player character loading and saving.""" 2 - 3 - import re 4 - from pathlib import Path 5 - 6 - import yaml 7 - 8 - 9 - def load_character(player_id: str, base_path: Path | None = None) -> dict | None: 10 - """Load a character file, returning frontmatter and body. 11 - 12 - Args: 13 - player_id: Player identifier (directory name under players/) 14 - base_path: Base path for players directory (defaults to cwd) 15 - 16 - Returns: 17 - Dict with frontmatter fields plus 'body' key, or None if not found 18 - """ 19 - if base_path is None: 20 - base_path = Path.cwd() 21 - 22 - char_path = base_path / "players" / player_id / "character.md" 23 - if not char_path.exists(): 24 - return None 25 - 26 - content = char_path.read_text() 27 - return parse_character(content) 28 - 29 - 30 - def parse_character(content: str) -> dict: 31 - """Parse character markdown with YAML frontmatter.""" 32 - if content.startswith("---"): 33 - # Find the closing --- 34 - end_match = re.search(r"\n---\s*\n", content[3:]) 35 - if end_match: 36 - frontmatter_end = end_match.start() + 3 37 - frontmatter_str = content[3:frontmatter_end] 38 - body = content[frontmatter_end + end_match.end() - end_match.start() :] 39 - 40 - result = yaml.safe_load(frontmatter_str) or {} 41 - result["body"] = body.strip() 42 - return result 43 - 44 - # No frontmatter, just body 45 - return {"body": content.strip()} 46 - 47 - 48 - def save_character( 49 - player_id: str, data: dict, base_path: Path | None = None 50 - ) -> None: 51 - """Save character data back to file. 52 - 53 - Args: 54 - player_id: Player identifier 55 - data: Character data with frontmatter fields and 'body' 56 - base_path: Base path for players directory 57 - """ 58 - if base_path is None: 59 - base_path = Path.cwd() 60 - 61 - char_path = base_path / "players" / player_id / "character.md" 62 - char_path.parent.mkdir(parents=True, exist_ok=True) 63 - 64 - # Separate body from frontmatter fields 65 - body = data.pop("body", "") 66 - frontmatter = data 67 - 68 - # Build the file content 69 - content = "---\n" 70 - content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) 71 - content += "---\n\n" 72 - content += body 73 - 74 - char_path.write_text(content) 75 - 76 - # Put body back in data dict 77 - data["body"] = body 78 - 79 - 80 - def update_character( 81 - player_id: str, 82 - updates: dict, 83 - base_path: Path | None = None, 84 - ) -> str: 85 - """Update specific fields in the character sheet. 86 - 87 - Args: 88 - player_id: Player identifier 89 - updates: Dict of updates. Use dot notation for nested fields: 90 - {"hp.current": 5, "gold": 25} 91 - Use "section.Name" for markdown sections: 92 - {"section.Equipment": "- Sword\\n- Shield"} 93 - base_path: Base path for players directory 94 - 95 - Returns: 96 - Confirmation message 97 - """ 98 - data = load_character(player_id, base_path) 99 - if data is None: 100 - return f"No character found for player '{player_id}'" 101 - 102 - changes = [] 103 - 104 - for key, value in updates.items(): 105 - if key.startswith("section."): 106 - # Update a markdown section 107 - section_name = key[8:] # Remove "section." prefix 108 - data["body"] = _update_section(data.get("body", ""), section_name, value) 109 - changes.append(f"Updated {section_name} section") 110 - elif "." in key: 111 - # Nested frontmatter field (e.g., "hp.current") 112 - _set_nested(data, key, value) 113 - changes.append(f"{key} = {value}") 114 - else: 115 - # Top-level frontmatter field 116 - data[key] = value 117 - changes.append(f"{key} = {value}") 118 - 119 - # Clamp HP to valid 5e range: 0 to max (no negative HP in 5e) 120 - hp = data.get("hp", {}) 121 - if isinstance(hp, dict) and "current" in hp: 122 - hp_max = hp.get("max", hp["current"]) 123 - original = hp["current"] 124 - hp["current"] = max(0, min(hp["current"], hp_max)) 125 - if hp["current"] != original: 126 - changes.append(f"(HP clamped to {hp['current']})") 127 - 128 - # Clamp purse: no negative coins 129 - purse = data.get("purse", {}) 130 - if isinstance(purse, dict): 131 - for denom in ("cp", "sp", "ep", "gp", "pp"): 132 - if denom in purse and purse[denom] < 0: 133 - purse[denom] = 0 134 - changes.append(f"({denom} clamped to 0)") 135 - 136 - save_character(player_id, data, base_path) 137 - return "Character updated: " + ", ".join(changes) 138 - 139 - 140 - def adjust_coins( 141 - player_id: str, 142 - deltas: dict[str, int], 143 - base_path: Path | None = None, 144 - ) -> str: 145 - """Apply relative coin changes (positive=gain, negative=spend). 146 - 147 - Each denomination is clamped to 0 minimum. Returns the new purse state. 148 - """ 149 - data = load_character(player_id, base_path) 150 - if data is None: 151 - return f"No character found for player '{player_id}'" 152 - 153 - purse = data.get("purse", {}) 154 - if not isinstance(purse, dict): 155 - purse = {} 156 - data["purse"] = purse 157 - 158 - changes = [] 159 - for denom, delta in deltas.items(): 160 - if denom not in ("cp", "sp", "ep", "gp", "pp"): 161 - continue 162 - old = purse.get(denom, 0) 163 - new = old + delta 164 - if new < 0: 165 - changes.append(f"{denom} {old} → 0 (short {-new} {denom})") 166 - purse[denom] = 0 167 - else: 168 - changes.append(f"{denom} {old} → {new}") 169 - purse[denom] = new 170 - 171 - save_character(player_id, data, base_path) 172 - 173 - # Format the current purse for the result 174 - coins = [] 175 - for denom in ("pp", "gp", "ep", "sp", "cp"): 176 - amount = purse.get(denom, 0) 177 - if amount: 178 - coins.append(f"{amount} {denom}") 179 - purse_str = ", ".join(coins) if coins else "empty" 180 - 181 - return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" 182 - 183 - 184 - def _set_nested(data: dict, key: str, value) -> None: 185 - """Set a nested value using dot notation.""" 186 - parts = key.split(".") 187 - current = data 188 - 189 - for part in parts[:-1]: 190 - if part not in current: 191 - current[part] = {} 192 - current = current[part] 193 - 194 - current[parts[-1]] = value 195 - 196 - 197 - def _update_section(body: str, section_name: str, new_content: str) -> str: 198 - """Update a markdown section by name.""" 199 - # Pattern to find ## Section Name and capture until next ## or end 200 - pattern = rf"(## {re.escape(section_name)}\n)(.*?)(?=\n## |\Z)" 201 - 202 - def replacer(match: re.Match) -> str: 203 - return match.group(1) + new_content.strip() + "\n" 204 - 205 - new_body, count = re.subn(pattern, replacer, body, flags=re.DOTALL) 206 - 207 - if count == 0: 208 - # Section doesn't exist, append it 209 - new_body = body.rstrip() + f"\n\n## {section_name}\n{new_content.strip()}\n" 210 - 211 - return new_body 212 - 213 - 214 - def create_character( 215 - player_id: str, 216 - name: str, 217 - race: str, 218 - char_class: str, 219 - level: int, 220 - abilities: dict[str, int], 221 - hp_max: int, 222 - ac: int, 223 - background: str | None = None, 224 - speed: int = 30, 225 - purse: dict[str, int] | None = None, 226 - equipment: list[str] | None = None, 227 - features: list[str] | None = None, 228 - proficiencies: str | None = None, 229 - backstory: str | None = None, 230 - base_path: Path | None = None, 231 - ) -> str: 232 - """Create a new character from scratch. 233 - 234 - Args: 235 - player_id: Player identifier 236 - name: Character name 237 - race: Character race (e.g., "Human", "Elf") 238 - char_class: Character class (e.g., "Fighter", "Wizard") 239 - level: Starting level (usually 1) 240 - abilities: Dict of ability scores {"strength": 15, "dexterity": 14, ...} 241 - hp_max: Maximum HP 242 - ac: Armor class 243 - background: Background (e.g., "Soldier", "Acolyte") 244 - speed: Movement speed in feet 245 - purse: Starting coins as {cp, sp, ep, gp, pp} (defaults to all zeros) 246 - equipment: List of equipment items 247 - features: List of class/racial features 248 - proficiencies: Proficiency description 249 - backstory: Character backstory 250 - base_path: Base path for players directory 251 - 252 - Returns: 253 - Confirmation message 254 - """ 255 - # Build frontmatter 256 - data = { 257 - "name": name, 258 - "race": race, 259 - "class": char_class, 260 - "level": level, 261 - "background": background, 262 - "hp": {"current": hp_max, "max": hp_max}, 263 - "ac": ac, 264 - "speed": speed, 265 - "abilities": abilities, 266 - "purse": purse or {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, 267 - } 268 - 269 - # Build body sections 270 - body_parts = [] 271 - 272 - if proficiencies: 273 - body_parts.append(f"## Proficiencies\n{proficiencies}") 274 - 275 - if features: 276 - body_parts.append("## Features\n" + "\n".join(f"- {f}" for f in features)) 277 - 278 - if equipment: 279 - body_parts.append("## Equipment\n" + "\n".join(f"- {e}" for e in equipment)) 280 - 281 - if backstory: 282 - body_parts.append(f"## Backstory\n{backstory}") 283 - 284 - data["body"] = "\n\n".join(body_parts) 285 - 286 - save_character(player_id, data, base_path) 287 - return f"Created character '{name}' - a level {level} {race} {char_class}!" 288 - 289 - 290 - _ABILITY_ORDER = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] 291 - 292 - 293 - def _ability_line(abilities: dict[str, int]) -> str: 294 - """Format abilities as a single pipe-separated line.""" 295 - parts = [] 296 - for ability in _ABILITY_ORDER: 297 - score = abilities.get(ability, 10) 298 - mod = (score - 10) // 2 299 - mod_str = f"+{mod}" if mod >= 0 else str(mod) 300 - parts.append(f"{ability[:3].upper()} {score} ({mod_str})") 301 - return " | ".join(parts) 302 - 303 - 304 - def _purse_line(data: dict) -> str | None: 305 - """Format purse as a string, or None if empty/missing.""" 306 - purse = data.get("purse") 307 - if purse and isinstance(purse, dict): 308 - coins = [] 309 - for denom in ["pp", "gp", "ep", "sp", "cp"]: 310 - amount = purse.get(denom, 0) 311 - if amount: 312 - coins.append(f"{amount} {denom}") 313 - return ", ".join(coins) if coins else "empty" 314 - if (gold := data.get("gold")) is not None: 315 - return f"{gold} gp" 316 - return None 317 - 318 - 319 - def _parse_sections(body: str) -> list[tuple[str, str]]: 320 - """Split markdown body into (section_name, content) pairs.""" 321 - sections: list[tuple[str, str]] = [] 322 - current_name: str | None = None 323 - current_lines: list[str] = [] 324 - 325 - for line in body.splitlines(): 326 - if line.startswith("## "): 327 - if current_name is not None: 328 - sections.append((current_name, "\n".join(current_lines).strip())) 329 - current_name = line[3:].strip() 330 - current_lines = [] 331 - elif current_name is not None: 332 - current_lines.append(line) 333 - 334 - if current_name is not None: 335 - sections.append((current_name, "\n".join(current_lines).strip())) 336 - 337 - return sections 338 - 339 - 340 - def format_status(data: dict, *, include_equipment: bool = True) -> str: 341 - """Compact character status for /status — renders instantly via Rich Markdown.""" 342 - name = data.get("name", "Unknown") 343 - race = data.get("race", "") 344 - char_class = data.get("class", "") 345 - level = data.get("level", 1) 346 - background = data.get("background") 347 - 348 - identity = f"**{name}** — {race} {char_class} {level}" 349 - if background: 350 - identity += f" ({background})" 351 - 352 - hp = data.get("hp", {}) 353 - if isinstance(hp, dict): 354 - hp_str = f"{hp.get('current', '?')}/{hp.get('max', '?')}" 355 - else: 356 - hp_str = str(hp) 357 - 358 - ac = data.get("ac", "?") 359 - speed = data.get("speed", 30) 360 - vitals = f"HP {hp_str} | AC {ac} | Speed {speed} ft" 361 - 362 - lines = [identity, vitals] 363 - 364 - abilities = data.get("abilities", {}) 365 - if abilities: 366 - lines.append("") 367 - lines.append(_ability_line(abilities)) 368 - 369 - purse = _purse_line(data) 370 - if purse: 371 - lines.append("") 372 - lines.append(f"**Purse:** {purse}") 373 - 374 - if include_equipment: 375 - sections = _parse_sections(data.get("body", "")) 376 - for section_name, content in sections: 377 - if section_name.lower() == "equipment": 378 - items = [line.lstrip("- ").strip() for line in content.splitlines() if line.strip()] 379 - if items: 380 - lines.append(f"**Equipment:** {', '.join(items)}") 381 - break 382 - 383 - return "\n".join(lines) 384 - 385 - 386 - def format_sheet(data: dict) -> str: 387 - """Full character sheet for /me — renders instantly via Rich Markdown.""" 388 - lines = [format_status(data, include_equipment=False)] 389 - 390 - sections = _parse_sections(data.get("body", "")) 391 - for section_name, content in sections: 392 - if not content: 393 - continue 394 - lines.append("") 395 - lines.append(f"**{section_name}**") 396 - lines.append(content) 397 - 398 - return "\n".join(lines) 399 - 400 - 401 - def format_character_context(data: dict) -> str: 402 - """Format character data for inclusion in the system prompt. 403 - 404 - Returns the full character sheet as readable text. 405 - """ 406 - lines = ["## Player Character\n"] 407 - 408 - # Format frontmatter as readable stats 409 - name = data.get("name", "Unknown") 410 - race = data.get("race", "") 411 - char_class = data.get("class", "") 412 - level = data.get("level", 1) 413 - 414 - lines.append(f"**{name}** - Level {level} {race} {char_class}\n") 415 - 416 - # HP and AC 417 - hp = data.get("hp", {}) 418 - if isinstance(hp, dict): 419 - lines.append(f"**HP:** {hp.get('current', '?')}/{hp.get('max', '?')}") 420 - else: 421 - lines.append(f"**HP:** {hp}") 422 - 423 - ac = data.get("ac", "?") 424 - speed = data.get("speed", 30) 425 - lines.append(f"**AC:** {ac} | **Speed:** {speed} ft\n") 426 - 427 - # Abilities 428 - abilities = data.get("abilities", {}) 429 - if abilities: 430 - ability_strs = [] 431 - for ability in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]: 432 - score = abilities.get(ability, 10) 433 - mod = (score - 10) // 2 434 - mod_str = f"+{mod}" if mod >= 0 else str(mod) 435 - ability_strs.append(f"{ability[:3].upper()} {score} ({mod_str})") 436 - lines.append(" | ".join(ability_strs) + "\n") 437 - 438 - # Purse (supports legacy "gold" field) 439 - purse = data.get("purse") 440 - if purse and isinstance(purse, dict): 441 - coins = [] 442 - for denom in ["pp", "gp", "ep", "sp", "cp"]: 443 - amount = purse.get(denom, 0) 444 - if amount: 445 - coins.append(f"{amount} {denom}") 446 - lines.append(f"**Purse:** {', '.join(coins) if coins else 'empty'}\n") 447 - elif (gold := data.get("gold")) is not None: 448 - lines.append(f"**Purse:** {gold} gp\n") 449 - 450 - # Advancement readiness flag (set by background evaluator) 451 - advancement_ready = data.get("advancement_ready") 452 - if advancement_ready: 453 - lines.append(f"**⚡ Advancement Ready: Level {advancement_ready}**\n") 454 - 455 - # Include the markdown body (equipment, features, notes, etc.) 456 - body = data.get("body", "") 457 - if body: 458 - lines.append(body) 459 - 460 - return "\n".join(lines)
+93
src/storied/character/__init__.py
··· 1 + """Character module — structured character data, computation, and operations.""" 2 + 3 + from storied.character.compute import ( 4 + ABILITIES, 5 + ALL_SKILLS, 6 + SKILL_TO_ABILITY, 7 + ability_modifier, 8 + class_summary, 9 + effective_hp, 10 + has_expertise_in, 11 + initiative_modifier, 12 + is_proficient_in, 13 + passive_score, 14 + proficiency_bonus, 15 + save_modifier, 16 + skill_modifier, 17 + total_level, 18 + ) 19 + from storied.character.data import ( 20 + DEFAULT_SCHEMA, 21 + create_character, 22 + load_character, 23 + load_character_prose, 24 + save_character, 25 + save_character_prose, 26 + update_character, 27 + ) 28 + from storied.character.display import ( 29 + format_character_context, 30 + format_sheet, 31 + format_status, 32 + ) 33 + from storied.character.operations import ( 34 + add_condition, 35 + add_effect, 36 + add_item, 37 + add_note, 38 + adjust_coins, 39 + damage, 40 + heal, 41 + remove_condition, 42 + remove_effect, 43 + remove_item, 44 + rest, 45 + restore_resource, 46 + set_item_status, 47 + use_resource, 48 + ) 49 + 50 + __all__ = [ 51 + # Data 52 + "DEFAULT_SCHEMA", 53 + "create_character", 54 + "load_character", 55 + "load_character_prose", 56 + "save_character", 57 + "save_character_prose", 58 + "update_character", 59 + # Compute 60 + "ABILITIES", 61 + "ALL_SKILLS", 62 + "SKILL_TO_ABILITY", 63 + "ability_modifier", 64 + "class_summary", 65 + "effective_hp", 66 + "has_expertise_in", 67 + "initiative_modifier", 68 + "is_proficient_in", 69 + "passive_score", 70 + "proficiency_bonus", 71 + "save_modifier", 72 + "skill_modifier", 73 + "total_level", 74 + # Display 75 + "format_character_context", 76 + "format_sheet", 77 + "format_status", 78 + # Operations 79 + "add_condition", 80 + "add_effect", 81 + "add_item", 82 + "add_note", 83 + "adjust_coins", 84 + "damage", 85 + "heal", 86 + "remove_condition", 87 + "remove_effect", 88 + "remove_item", 89 + "rest", 90 + "restore_resource", 91 + "set_item_status", 92 + "use_resource", 93 + ]
+151
src/storied/character/compute.py
··· 1 + """Pure computation functions for derived character values. 2 + 3 + Everything here is best-effort 5e-flavored display computation. The DM 4 + can override any value via update_character. These functions are for the 5 + DM's convenience, not for enforcing rules. 6 + """ 7 + 8 + 9 + # Maps each skill to its governing ability 10 + SKILL_TO_ABILITY: dict[str, str] = { 11 + "acrobatics": "dexterity", 12 + "animal_handling": "wisdom", 13 + "arcana": "intelligence", 14 + "athletics": "strength", 15 + "deception": "charisma", 16 + "history": "intelligence", 17 + "insight": "wisdom", 18 + "intimidation": "charisma", 19 + "investigation": "intelligence", 20 + "medicine": "wisdom", 21 + "nature": "intelligence", 22 + "perception": "wisdom", 23 + "performance": "charisma", 24 + "persuasion": "charisma", 25 + "religion": "intelligence", 26 + "sleight_of_hand": "dexterity", 27 + "stealth": "dexterity", 28 + "survival": "wisdom", 29 + } 30 + 31 + ALL_SKILLS = list(SKILL_TO_ABILITY.keys()) 32 + 33 + ABILITIES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] 34 + 35 + 36 + def ability_modifier(score: int) -> int: 37 + """Standard 5e ability modifier: (score - 10) // 2.""" 38 + return (score - 10) // 2 39 + 40 + 41 + def total_level(char: dict) -> int: 42 + """Sum levels across all classes (multiclass-aware).""" 43 + classes = char.get("identity", {}).get("classes", []) 44 + return sum(c.get("level", 0) for c in classes) 45 + 46 + 47 + def proficiency_bonus(char: dict) -> int: 48 + """5e proficiency bonus from total character level.""" 49 + level = max(1, total_level(char)) 50 + return 2 + (level - 1) // 4 51 + 52 + 53 + def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: 54 + """Compute a skill modifier and the breakdown of contributions. 55 + 56 + Returns (total, breakdown_lines). 57 + """ 58 + ability = SKILL_TO_ABILITY.get(skill) 59 + if ability is None: 60 + return 0, [f"Unknown skill: {skill}"] 61 + 62 + abilities = char.get("abilities", {}) 63 + ability_score = abilities.get(ability, 10) 64 + ability_mod = ability_modifier(ability_score) 65 + 66 + breakdown: list[str] = [] 67 + breakdown.append(f"{ability_mod:+d} {ability[:3].upper()}") 68 + 69 + skills = char.get("proficiencies", {}).get("skills", {}) 70 + prof_level = skills.get(skill) 71 + pb = proficiency_bonus(char) 72 + prof_bonus = 0 73 + if prof_level == "expertise": 74 + prof_bonus = pb * 2 75 + breakdown.append(f"+{prof_bonus} expertise") 76 + elif prof_level == "proficient": 77 + prof_bonus = pb 78 + breakdown.append(f"+{prof_bonus} proficient") 79 + 80 + total = ability_mod + prof_bonus 81 + return total, breakdown 82 + 83 + 84 + def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: 85 + """Compute a saving throw modifier and breakdown.""" 86 + abilities = char.get("abilities", {}) 87 + ability_score = abilities.get(ability, 10) 88 + mod = ability_modifier(ability_score) 89 + 90 + breakdown: list[str] = [f"{mod:+d} {ability[:3].upper()}"] 91 + 92 + saves = char.get("proficiencies", {}).get("saves", []) 93 + pb = proficiency_bonus(char) 94 + if ability in saves: 95 + mod += pb 96 + breakdown.append(f"+{pb} proficient") 97 + 98 + return mod, breakdown 99 + 100 + 101 + def passive_score(char: dict, skill: str = "perception") -> int: 102 + """Passive score for a skill: 10 + skill modifier.""" 103 + mod, _ = skill_modifier(char, skill) 104 + return 10 + mod 105 + 106 + 107 + def initiative_modifier(char: dict) -> int: 108 + """Initiative is just the dex modifier (no proficiency by default in 5e).""" 109 + return ability_modifier(char.get("abilities", {}).get("dexterity", 10)) 110 + 111 + 112 + def effective_hp(char: dict) -> dict: 113 + """Return current/max/temp/total HP info.""" 114 + hp = char.get("state", {}).get("hp", {}) 115 + current = hp.get("current", 0) 116 + max_hp = hp.get("max", 0) 117 + temp = hp.get("temp", 0) 118 + return { 119 + "current": current, 120 + "max": max_hp, 121 + "temp": temp, 122 + "effective": current + temp, 123 + } 124 + 125 + 126 + def is_proficient_in(char: dict, skill: str) -> bool: 127 + skills = char.get("proficiencies", {}).get("skills", {}) 128 + return skills.get(skill) in ("proficient", "expertise") 129 + 130 + 131 + def has_expertise_in(char: dict, skill: str) -> bool: 132 + skills = char.get("proficiencies", {}).get("skills", {}) 133 + return skills.get(skill) == "expertise" 134 + 135 + 136 + def class_summary(char: dict) -> str: 137 + """Format identity for display: 'Level 3 Rogue (Thief)' or multiclass.""" 138 + classes = char.get("identity", {}).get("classes", []) 139 + if not classes: 140 + return "Unknown" 141 + 142 + parts = [] 143 + for c in classes: 144 + cls = c.get("class", "?") 145 + sub = c.get("subclass") 146 + lvl = c.get("level", 0) 147 + if sub: 148 + parts.append(f"Level {lvl} {cls} ({sub})") 149 + else: 150 + parts.append(f"Level {lvl} {cls}") 151 + return " / ".join(parts)
+248
src/storied/character/data.py
··· 1 + """Character data persistence: load and save character.yaml + character.md.""" 2 + 3 + from copy import deepcopy 4 + from pathlib import Path 5 + 6 + import yaml 7 + 8 + 9 + # Default character schema — used when creating a new character 10 + DEFAULT_SCHEMA: dict = { 11 + "identity": { 12 + "name": "", 13 + "race": "", 14 + "subrace": None, 15 + "classes": [], 16 + "background": None, 17 + }, 18 + "abilities": { 19 + "strength": 10, 20 + "dexterity": 10, 21 + "constitution": 10, 22 + "intelligence": 10, 23 + "wisdom": 10, 24 + "charisma": 10, 25 + }, 26 + "proficiencies": { 27 + "saves": [], 28 + "skills": {}, 29 + "tools": {}, 30 + "weapons": [], 31 + "armor": [], 32 + "languages": [], 33 + }, 34 + "state": { 35 + "hp": {"max": 1, "current": 1, "temp": 0}, 36 + "ac": 10, 37 + "speed": 30, 38 + "movement_modes": {}, 39 + "senses": {}, 40 + "purse": {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, 41 + "inspiration": False, 42 + "exhaustion": 0, 43 + "death_saves": {"successes": 0, "failures": 0}, 44 + }, 45 + "defenses": { 46 + "resistances": [], 47 + "vulnerabilities": [], 48 + "immunities": {"damage": [], "conditions": []}, 49 + }, 50 + "conditions": [], 51 + "features": [], 52 + "resources": {}, 53 + "equipment": {}, 54 + "magic_items": {"attuned": [], "equipped": [], "carried": []}, 55 + "effects": [], 56 + "spellcasting": None, # populated only for spellcasters 57 + } 58 + 59 + 60 + def _character_yaml_path(player_id: str, base_path: Path | None = None) -> Path: 61 + if base_path is None: 62 + base_path = Path.cwd() 63 + return base_path / "players" / player_id / "character.yaml" 64 + 65 + 66 + def _character_md_path(player_id: str, base_path: Path | None = None) -> Path: 67 + if base_path is None: 68 + base_path = Path.cwd() 69 + return base_path / "players" / player_id / "character.md" 70 + 71 + 72 + def load_character(player_id: str, base_path: Path | None = None) -> dict | None: 73 + """Load a character's structured data from character.yaml. 74 + 75 + Returns None if no character exists. Returns the parsed YAML dict, with 76 + missing fields filled in from DEFAULT_SCHEMA so callers can rely on the 77 + full structure being present. 78 + """ 79 + yaml_path = _character_yaml_path(player_id, base_path) 80 + if not yaml_path.exists(): 81 + return None 82 + 83 + raw = yaml.safe_load(yaml_path.read_text()) or {} 84 + return _merge_defaults(raw) 85 + 86 + 87 + def load_character_prose(player_id: str, base_path: Path | None = None) -> str: 88 + """Load the character's free-text prose from character.md. 89 + 90 + Returns empty string if no prose file exists. 91 + """ 92 + md_path = _character_md_path(player_id, base_path) 93 + if not md_path.exists(): 94 + return "" 95 + return md_path.read_text() 96 + 97 + 98 + def save_character( 99 + player_id: str, data: dict, base_path: Path | None = None 100 + ) -> None: 101 + """Save character data back to character.yaml.""" 102 + yaml_path = _character_yaml_path(player_id, base_path) 103 + yaml_path.parent.mkdir(parents=True, exist_ok=True) 104 + yaml_path.write_text( 105 + yaml.dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False) 106 + ) 107 + 108 + 109 + def save_character_prose( 110 + player_id: str, prose: str, base_path: Path | None = None 111 + ) -> None: 112 + """Save the character's free-text prose to character.md.""" 113 + md_path = _character_md_path(player_id, base_path) 114 + md_path.parent.mkdir(parents=True, exist_ok=True) 115 + md_path.write_text(prose) 116 + 117 + 118 + def _merge_defaults(data: dict) -> dict: 119 + """Recursively merge user data over DEFAULT_SCHEMA so missing fields are filled.""" 120 + result = deepcopy(DEFAULT_SCHEMA) 121 + _deep_update(result, data) 122 + return result 123 + 124 + 125 + def _deep_update(target: dict, source: dict) -> None: 126 + """Recursively update target with values from source.""" 127 + for key, value in source.items(): 128 + if ( 129 + key in target 130 + and isinstance(target[key], dict) 131 + and isinstance(value, dict) 132 + ): 133 + _deep_update(target[key], value) 134 + else: 135 + target[key] = value 136 + 137 + 138 + def update_character( 139 + player_id: str, 140 + updates: dict, 141 + base_path: Path | None = None, 142 + ) -> str: 143 + """Update fields in character.yaml using dot notation. 144 + 145 + Example: {"state.hp.current": 5, "identity.classes.0.level": 4} 146 + """ 147 + data = load_character(player_id, base_path) 148 + if data is None: 149 + return f"No character found for player '{player_id}'" 150 + 151 + changes = [] 152 + for key, value in updates.items(): 153 + _set_nested(data, key, value) 154 + changes.append(f"{key} = {value}") 155 + 156 + # Clamp HP to 0..max 157 + hp = data.get("state", {}).get("hp", {}) 158 + if isinstance(hp, dict) and "current" in hp: 159 + hp_max = hp.get("max", hp["current"]) 160 + original = hp["current"] 161 + hp["current"] = max(0, min(hp["current"], hp_max)) 162 + if hp["current"] != original: 163 + changes.append(f"(HP clamped to {hp['current']})") 164 + 165 + # Clamp purse: no negative coins 166 + purse = data.get("state", {}).get("purse", {}) 167 + if isinstance(purse, dict): 168 + for denom in ("cp", "sp", "ep", "gp", "pp"): 169 + if denom in purse and purse[denom] < 0: 170 + purse[denom] = 0 171 + changes.append(f"({denom} clamped to 0)") 172 + 173 + save_character(player_id, data, base_path) 174 + return "Character updated: " + ", ".join(changes) 175 + 176 + 177 + def _set_nested(data: dict, key: str, value) -> None: 178 + """Set a nested value using dot notation. Supports list indices like 'classes.0.level'.""" 179 + parts = key.split(".") 180 + current = data 181 + 182 + for part in parts[:-1]: 183 + # Try as integer for list index 184 + if part.isdigit() and isinstance(current, list): 185 + current = current[int(part)] 186 + continue 187 + if part not in current: 188 + current[part] = {} 189 + current = current[part] 190 + 191 + last = parts[-1] 192 + if last.isdigit() and isinstance(current, list): 193 + current[int(last)] = value 194 + else: 195 + current[last] = value 196 + 197 + 198 + def create_character( 199 + player_id: str, 200 + name: str, 201 + race: str, 202 + char_class: str, 203 + level: int, 204 + abilities: dict[str, int], 205 + hp_max: int, 206 + ac: int, 207 + background: str | None = None, 208 + speed: int = 30, 209 + purse: dict[str, int] | None = None, 210 + subclass: str | None = None, 211 + subrace: str | None = None, 212 + proficiencies: dict | None = None, 213 + features: list[dict] | None = None, 214 + equipment: dict | None = None, 215 + backstory: str | None = None, 216 + base_path: Path | None = None, 217 + ) -> str: 218 + """Create a new character with the new schema. 219 + 220 + Writes character.yaml (structured) and character.md (prose). 221 + """ 222 + data = deepcopy(DEFAULT_SCHEMA) 223 + data["identity"] = { 224 + "name": name, 225 + "race": race, 226 + "subrace": subrace, 227 + "classes": [{"class": char_class, "subclass": subclass, "level": level}], 228 + "background": background, 229 + } 230 + data["abilities"] = abilities 231 + data["state"]["hp"] = {"max": hp_max, "current": hp_max, "temp": 0} 232 + data["state"]["ac"] = ac 233 + data["state"]["speed"] = speed 234 + if purse: 235 + data["state"]["purse"].update(purse) 236 + if proficiencies: 237 + data["proficiencies"].update(proficiencies) 238 + if features: 239 + data["features"] = features 240 + if equipment: 241 + data["equipment"] = equipment 242 + 243 + save_character(player_id, data, base_path) 244 + 245 + if backstory: 246 + save_character_prose(player_id, f"# {name}\n\n{backstory}\n", base_path) 247 + 248 + return f"Created character '{name}' - a level {level} {race} {char_class}!"
+271
src/storied/character/display.py
··· 1 + """Display formatting for character data — used by /me, /status, and the DM context.""" 2 + 3 + from storied.character.compute import ( 4 + ABILITIES, 5 + ALL_SKILLS, 6 + SKILL_TO_ABILITY, 7 + ability_modifier, 8 + class_summary, 9 + effective_hp, 10 + has_expertise_in, 11 + initiative_modifier, 12 + is_proficient_in, 13 + passive_score, 14 + proficiency_bonus, 15 + save_modifier, 16 + skill_modifier, 17 + total_level, 18 + ) 19 + 20 + 21 + def _purse_line(state: dict) -> str | None: 22 + purse = state.get("purse", {}) or {} 23 + coins = [] 24 + for denom in ("pp", "gp", "ep", "sp", "cp"): 25 + amount = purse.get(denom, 0) 26 + if amount: 27 + coins.append(f"{amount} {denom}") 28 + if not coins: 29 + return None 30 + return ", ".join(coins) 31 + 32 + 33 + def _format_skill_name(skill: str) -> str: 34 + """Convert 'sleight_of_hand' to 'Sleight of Hand'.""" 35 + return " ".join(word.capitalize() for word in skill.split("_")) 36 + 37 + 38 + def _save_line(char: dict) -> str: 39 + parts = [] 40 + for ability in ABILITIES: 41 + mod, _ = save_modifier(char, ability) 42 + marker = " ★" if ability in char.get("proficiencies", {}).get("saves", []) else "" 43 + parts.append(f"{ability[:3].upper()} {mod:+d}{marker}") 44 + return " ".join(parts) 45 + 46 + 47 + def _skill_lines(char: dict) -> list[str]: 48 + """Render skills sorted alphabetically with proficiency markers.""" 49 + lines: list[str] = [] 50 + for skill in sorted(ALL_SKILLS): 51 + mod, _ = skill_modifier(char, skill) 52 + marker = "" 53 + if has_expertise_in(char, skill): 54 + marker = " ★★" 55 + elif is_proficient_in(char, skill): 56 + marker = " ★" 57 + name = _format_skill_name(skill) 58 + lines.append(f" {name:<18} {mod:+d}{marker}") 59 + return lines 60 + 61 + 62 + def _ability_line(char: dict) -> str: 63 + abilities = char.get("abilities", {}) 64 + parts = [] 65 + for a in ABILITIES: 66 + score = abilities.get(a, 10) 67 + mod = ability_modifier(score) 68 + parts.append(f"{a[:3].upper()} {score} ({mod:+d})") 69 + return " | ".join(parts) 70 + 71 + 72 + def _format_effects(char: dict) -> list[str]: 73 + effects = char.get("effects", []) or [] 74 + if not effects: 75 + return [] 76 + lines = ["**Active Effects:**"] 77 + for e in effects: 78 + source = e.get("source", "(unknown)") 79 + desc = e.get("description", "") 80 + expires = e.get("expires") 81 + suffix = f" (until {expires})" if expires else "" 82 + lines.append(f" • {source} — {desc}{suffix}") 83 + return lines 84 + 85 + 86 + def _format_resources(char: dict) -> list[str]: 87 + resources = char.get("resources", {}) or {} 88 + if not resources: 89 + return [] 90 + lines = ["**Resources:**"] 91 + for name, pool in resources.items(): 92 + current = pool.get("current", 0) 93 + maximum = pool.get("max", 0) 94 + notes = pool.get("notes", name) 95 + die = pool.get("die") 96 + die_str = f" {die}" if die else "" 97 + lines.append(f" • {notes}: {current}/{maximum}{die_str}") 98 + return lines 99 + 100 + 101 + def _format_magic_items(char: dict) -> list[str]: 102 + mi = char.get("magic_items", {}) or {} 103 + if not (mi.get("attuned") or mi.get("equipped") or mi.get("carried")): 104 + return [] 105 + lines = ["**Magic Items:**"] 106 + if mi.get("attuned"): 107 + lines.append(f" Attuned: {', '.join(mi['attuned'])}") 108 + if mi.get("equipped"): 109 + lines.append(f" Equipped: {', '.join(mi['equipped'])}") 110 + if mi.get("carried"): 111 + lines.append(f" Carried: {', '.join(mi['carried'])}") 112 + return lines 113 + 114 + 115 + def _format_features(char: dict) -> list[str]: 116 + features = char.get("features", []) or [] 117 + if not features: 118 + return [] 119 + lines = ["**Features:**"] 120 + for f in features: 121 + name = f.get("name", "?") 122 + text = f.get("text", "") 123 + source = f.get("source", "") 124 + src_str = f" ({source})" if source else "" 125 + lines.append(f" • **{name}**{src_str} — {text}") 126 + return lines 127 + 128 + 129 + def _format_equipment(char: dict) -> list[str]: 130 + equipment = char.get("equipment", {}) or {} 131 + if not equipment: 132 + return [] 133 + lines = ["**Equipment:**"] 134 + for location, items in equipment.items(): 135 + if not items: 136 + continue 137 + loc_label = _format_skill_name(location) 138 + lines.append(f" *{loc_label}:*") 139 + for item in items: 140 + lines.append(f" - {item}") 141 + return lines 142 + 143 + 144 + def _format_conditions(char: dict) -> list[str]: 145 + conditions = char.get("conditions", []) or [] 146 + if not conditions: 147 + return [] 148 + return [f"**Conditions:** {', '.join(conditions)}"] 149 + 150 + 151 + def _format_defenses(char: dict) -> list[str]: 152 + defenses = char.get("defenses", {}) or {} 153 + lines = [] 154 + if defenses.get("resistances"): 155 + types = [r.get("damage", "?") for r in defenses["resistances"]] 156 + lines.append(f"**Resistances:** {', '.join(types)}") 157 + if defenses.get("vulnerabilities"): 158 + types = [r.get("damage", "?") for r in defenses["vulnerabilities"]] 159 + lines.append(f"**Vulnerabilities:** {', '.join(types)}") 160 + immunities = defenses.get("immunities", {}) or {} 161 + if immunities.get("damage"): 162 + lines.append(f"**Damage Immunities:** {', '.join(immunities['damage'])}") 163 + if immunities.get("conditions"): 164 + lines.append(f"**Condition Immunities:** {', '.join(immunities['conditions'])}") 165 + return lines 166 + 167 + 168 + def _vital_line(char: dict) -> str: 169 + state = char.get("state", {}) 170 + hp = effective_hp(char) 171 + hp_str = f"{hp['current']}/{hp['max']}" 172 + if hp["temp"]: 173 + hp_str += f" (+{hp['temp']} temp)" 174 + ac = state.get("ac", 10) 175 + speed = state.get("speed", 30) 176 + init = initiative_modifier(char) 177 + pb = proficiency_bonus(char) 178 + parts = [f"HP {hp_str}", f"AC {ac}", f"Speed {speed}", f"Init {init:+d}", f"PB +{pb}"] 179 + exhaustion = state.get("exhaustion", 0) 180 + if exhaustion: 181 + parts.append(f"Exhaustion {exhaustion}") 182 + return " · ".join(parts) 183 + 184 + 185 + def format_status(data: dict, *, include_equipment: bool = True) -> str: 186 + """Compact status block for /status command.""" 187 + name = data.get("identity", {}).get("name", "Unknown") 188 + summary = class_summary(data) 189 + 190 + lines = [f"**{name}** — {summary}", _vital_line(data), ""] 191 + lines.append(_ability_line(data)) 192 + 193 + purse = _purse_line(data.get("state", {})) 194 + if purse: 195 + lines.append("") 196 + lines.append(f"**Purse:** {purse}") 197 + 198 + if include_equipment: 199 + equipment = data.get("equipment", {}) or {} 200 + if equipment: 201 + items_flat = [] 202 + for items in equipment.values(): 203 + items_flat.extend(items) 204 + if items_flat: 205 + lines.append("") 206 + lines.append(f"**Equipment:** {', '.join(items_flat[:8])}") 207 + if len(items_flat) > 8: 208 + lines.append(f" ... and {len(items_flat) - 8} more") 209 + 210 + return "\n".join(lines) 211 + 212 + 213 + def format_sheet(data: dict) -> str: 214 + """Full character sheet for /me command.""" 215 + lines = [format_status(data, include_equipment=False), ""] 216 + 217 + # Saves 218 + lines.append("**Saving Throws:**") 219 + lines.append(f" {_save_line(data)}") 220 + lines.append(" ★ proficient") 221 + lines.append("") 222 + 223 + # Skills 224 + lines.append("**Skills:**") 225 + lines.extend(_skill_lines(data)) 226 + lines.append(f" Passive Perception: {passive_score(data, 'perception')}") 227 + lines.append(" ★ proficient · ★★ expertise") 228 + lines.append("") 229 + 230 + # Conditions, defenses 231 + cond_lines = _format_conditions(data) 232 + if cond_lines: 233 + lines.extend(cond_lines) 234 + lines.append("") 235 + 236 + def_lines = _format_defenses(data) 237 + if def_lines: 238 + lines.extend(def_lines) 239 + lines.append("") 240 + 241 + # Effects, resources, magic items, features 242 + for section_func in (_format_effects, _format_resources, _format_magic_items, _format_features): 243 + section = section_func(data) 244 + if section: 245 + lines.extend(section) 246 + lines.append("") 247 + 248 + # Equipment 249 + equipment_lines = _format_equipment(data) 250 + if equipment_lines: 251 + lines.extend(equipment_lines) 252 + lines.append("") 253 + 254 + return "\n".join(lines).rstrip() 255 + 256 + 257 + def format_character_context(data: dict) -> str: 258 + """Format character data for inclusion in the DM's system prompt. 259 + 260 + Same as format_sheet but with additional context like advancement_ready 261 + and a header. 262 + """ 263 + lines = ["## Player Character", ""] 264 + lines.append(format_sheet(data)) 265 + 266 + advancement = data.get("advancement_ready") 267 + if advancement: 268 + lines.append("") 269 + lines.append(f"**⚡ Advancement Ready: Level {advancement}**") 270 + 271 + return "\n".join(lines)
+485
src/storied/character/operations.py
··· 1 + """Action functions that modify character state. 2 + 3 + Each function loads the character, applies a change, saves, and returns 4 + a result message describing what happened. These are the underlying 5 + implementations behind the DM's bookkeeping tools. 6 + """ 7 + 8 + from pathlib import Path 9 + 10 + from storied.character.data import ( 11 + load_character, 12 + save_character, 13 + ) 14 + 15 + 16 + # --- HP operations --- 17 + 18 + 19 + def damage( 20 + player_id: str, 21 + amount: int, 22 + damage_type: str | None = None, 23 + base_path: Path | None = None, 24 + ) -> str: 25 + """Apply damage to the character. Temp HP absorbs first, then current HP.""" 26 + data = load_character(player_id, base_path) 27 + if data is None: 28 + return f"No character found for player '{player_id}'" 29 + if amount < 0: 30 + return "Damage amount must be non-negative" 31 + 32 + hp = data["state"]["hp"] 33 + remaining = amount 34 + 35 + # Temp HP soaks damage first 36 + temp_used = 0 37 + if hp.get("temp", 0) > 0: 38 + temp_used = min(hp["temp"], remaining) 39 + hp["temp"] -= temp_used 40 + remaining -= temp_used 41 + 42 + # Then current HP 43 + hp_before = hp["current"] 44 + hp["current"] = max(0, hp["current"] - remaining) 45 + hp_actual = hp_before - hp["current"] 46 + 47 + save_character(player_id, data, base_path) 48 + 49 + parts = [] 50 + type_str = f" {damage_type}" if damage_type else "" 51 + parts.append(f"Took {amount}{type_str} damage") 52 + if temp_used: 53 + parts.append(f"absorbed {temp_used} with temp HP") 54 + parts.append(f"HP: {hp['current']}/{hp['max']}") 55 + if hp.get("temp", 0): 56 + parts.append(f"({hp['temp']} temp remaining)") 57 + if hp["current"] == 0: 58 + parts.append("**(at 0 HP — death saves!)**") 59 + return ". ".join(parts) 60 + 61 + 62 + def heal( 63 + player_id: str, 64 + amount: int, 65 + base_path: Path | None = None, 66 + ) -> str: 67 + """Heal the character, clamped to max HP.""" 68 + data = load_character(player_id, base_path) 69 + if data is None: 70 + return f"No character found for player '{player_id}'" 71 + if amount < 0: 72 + return "Heal amount must be non-negative" 73 + 74 + hp = data["state"]["hp"] 75 + before = hp["current"] 76 + hp["current"] = min(hp["max"], hp["current"] + amount) 77 + actual = hp["current"] - before 78 + 79 + save_character(player_id, data, base_path) 80 + return f"Healed {actual} HP. HP: {hp['current']}/{hp['max']}" 81 + 82 + 83 + # --- Effect operations --- 84 + 85 + 86 + def add_effect( 87 + player_id: str, 88 + source: str, 89 + description: str, 90 + expires: str | None = None, 91 + base_path: Path | None = None, 92 + ) -> str: 93 + """Add a temporary effect to the character.""" 94 + data = load_character(player_id, base_path) 95 + if data is None: 96 + return f"No character found for player '{player_id}'" 97 + 98 + effects = data.setdefault("effects", []) 99 + effect = { 100 + "source": source, 101 + "description": description, 102 + } 103 + if expires: 104 + effect["expires"] = expires 105 + effects.append(effect) 106 + 107 + save_character(player_id, data, base_path) 108 + expires_str = f" (expires {expires})" if expires else "" 109 + return f"Effect added: {source} — {description}{expires_str}" 110 + 111 + 112 + def remove_effect( 113 + player_id: str, 114 + source: str, 115 + base_path: Path | None = None, 116 + ) -> str: 117 + """Remove an effect by source name (case-insensitive substring match).""" 118 + data = load_character(player_id, base_path) 119 + if data is None: 120 + return f"No character found for player '{player_id}'" 121 + 122 + effects = data.get("effects", []) 123 + needle = source.lower() 124 + 125 + for i, e in enumerate(effects): 126 + if needle in e.get("source", "").lower(): 127 + removed = effects.pop(i) 128 + save_character(player_id, data, base_path) 129 + return f"Effect removed: {removed.get('source', '?')}" 130 + 131 + return f"No effect matching '{source}' found" 132 + 133 + 134 + # --- Condition operations --- 135 + 136 + 137 + def add_condition( 138 + player_id: str, 139 + name: str, 140 + base_path: Path | None = None, 141 + ) -> str: 142 + """Add a condition to the character (no duplicates).""" 143 + data = load_character(player_id, base_path) 144 + if data is None: 145 + return f"No character found for player '{player_id}'" 146 + 147 + conditions = data.setdefault("conditions", []) 148 + name_lower = name.lower() 149 + if name_lower in [c.lower() for c in conditions]: 150 + return f"Already has condition: {name}" 151 + conditions.append(name) 152 + 153 + save_character(player_id, data, base_path) 154 + return f"Condition added: {name}" 155 + 156 + 157 + def remove_condition( 158 + player_id: str, 159 + name: str, 160 + base_path: Path | None = None, 161 + ) -> str: 162 + """Remove a condition (case-insensitive).""" 163 + data = load_character(player_id, base_path) 164 + if data is None: 165 + return f"No character found for player '{player_id}'" 166 + 167 + conditions = data.get("conditions", []) 168 + needle = name.lower() 169 + for i, c in enumerate(conditions): 170 + if c.lower() == needle: 171 + removed = conditions.pop(i) 172 + save_character(player_id, data, base_path) 173 + return f"Condition removed: {removed}" 174 + 175 + return f"No condition matching '{name}' found" 176 + 177 + 178 + # --- Inventory operations --- 179 + 180 + 181 + def add_item( 182 + player_id: str, 183 + item: str, 184 + location: str | None = None, 185 + base_path: Path | None = None, 186 + ) -> str: 187 + """Add an item to a location in the equipment dict. 188 + 189 + Substring match on existing location keys. Creates the location if it 190 + doesn't exist (using the given name as-is). If location is omitted, 191 + uses the first existing location, or 'on_person' as a default. 192 + """ 193 + data = load_character(player_id, base_path) 194 + if data is None: 195 + return f"No character found for player '{player_id}'" 196 + 197 + equipment = data.setdefault("equipment", {}) 198 + 199 + target_key = None 200 + if location: 201 + # Try substring match against existing keys 202 + needle = location.lower().replace(" ", "_") 203 + for key in equipment: 204 + if needle in key.lower() or key.lower() in needle: 205 + target_key = key 206 + break 207 + if target_key is None: 208 + # Create new location with the user-provided name (normalized) 209 + target_key = location.lower().replace(" ", "_") 210 + equipment[target_key] = [] 211 + else: 212 + if equipment: 213 + target_key = next(iter(equipment)) 214 + else: 215 + target_key = "on_person" 216 + equipment[target_key] = [] 217 + 218 + equipment[target_key].append(item) 219 + save_character(player_id, data, base_path) 220 + return f"Added '{item}' to {target_key}" 221 + 222 + 223 + def remove_item( 224 + player_id: str, 225 + item: str, 226 + base_path: Path | None = None, 227 + ) -> str: 228 + """Remove an item by case-insensitive substring match across all locations.""" 229 + data = load_character(player_id, base_path) 230 + if data is None: 231 + return f"No character found for player '{player_id}'" 232 + 233 + equipment = data.get("equipment", {}) 234 + needle = item.lower() 235 + 236 + for location, items in equipment.items(): 237 + for i, existing in enumerate(items): 238 + if needle in existing.lower(): 239 + removed = items.pop(i) 240 + save_character(player_id, data, base_path) 241 + return f"Removed '{removed}' from {location}" 242 + 243 + return f"No item matching '{item}' found" 244 + 245 + 246 + def set_item_status( 247 + player_id: str, 248 + item: str, 249 + status: str, 250 + base_path: Path | None = None, 251 + ) -> str: 252 + """Set a magic item's status (attuned, equipped, carried). 253 + 254 + The item is referenced by its world entity name. The function manages 255 + the wikilinks in the character's magic_items dict. 256 + """ 257 + valid_statuses = ("attuned", "equipped", "carried") 258 + if status not in valid_statuses: 259 + return f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}" 260 + 261 + data = load_character(player_id, base_path) 262 + if data is None: 263 + return f"No character found for player '{player_id}'" 264 + 265 + magic_items = data.setdefault( 266 + "magic_items", {"attuned": [], "equipped": [], "carried": []} 267 + ) 268 + 269 + # Normalize to wikilink format 270 + if not item.startswith("[["): 271 + wikilink = f"[[{item}]]" 272 + else: 273 + wikilink = item 274 + 275 + # Remove from all current statuses 276 + for s in valid_statuses: 277 + lst = magic_items.setdefault(s, []) 278 + if wikilink in lst: 279 + lst.remove(wikilink) 280 + 281 + # Add to the new status 282 + magic_items[status].append(wikilink) 283 + 284 + save_character(player_id, data, base_path) 285 + return f"{item} is now {status}" 286 + 287 + 288 + # --- Resource operations --- 289 + 290 + 291 + def use_resource( 292 + player_id: str, 293 + name: str, 294 + amount: int = 1, 295 + base_path: Path | None = None, 296 + ) -> str: 297 + """Decrement a resource pool. Substring match on resource name.""" 298 + data = load_character(player_id, base_path) 299 + if data is None: 300 + return f"No character found for player '{player_id}'" 301 + 302 + resources = data.get("resources", {}) 303 + needle = name.lower() 304 + 305 + target = None 306 + for key in resources: 307 + if needle in key.lower() or needle in resources[key].get("notes", "").lower(): 308 + target = key 309 + break 310 + 311 + if target is None: 312 + return f"No resource matching '{name}' found" 313 + 314 + pool = resources[target] 315 + before = pool.get("current", 0) 316 + pool["current"] = max(0, before - amount) 317 + actual = before - pool["current"] 318 + short = amount - actual 319 + 320 + save_character(player_id, data, base_path) 321 + 322 + notes = pool.get("notes", target) 323 + msg = f"Used {actual} {notes} ({pool['current']}/{pool.get('max', 0)} remaining)" 324 + if short > 0: 325 + msg += f" — short {short}" 326 + return msg 327 + 328 + 329 + def restore_resource( 330 + player_id: str, 331 + name: str, 332 + amount: int, 333 + base_path: Path | None = None, 334 + ) -> str: 335 + """Restore points to a resource pool, clamped to max.""" 336 + data = load_character(player_id, base_path) 337 + if data is None: 338 + return f"No character found for player '{player_id}'" 339 + 340 + resources = data.get("resources", {}) 341 + needle = name.lower() 342 + 343 + target = None 344 + for key in resources: 345 + if needle in key.lower() or needle in resources[key].get("notes", "").lower(): 346 + target = key 347 + break 348 + 349 + if target is None: 350 + return f"No resource matching '{name}' found" 351 + 352 + pool = resources[target] 353 + before = pool.get("current", 0) 354 + maximum = pool.get("max", 0) 355 + pool["current"] = min(maximum, before + amount) 356 + actual = pool["current"] - before 357 + 358 + save_character(player_id, data, base_path) 359 + notes = pool.get("notes", target) 360 + return f"Restored {actual} {notes} ({pool['current']}/{maximum})" 361 + 362 + 363 + def rest( 364 + player_id: str, 365 + rest_type: str, 366 + base_path: Path | None = None, 367 + ) -> str: 368 + """Take a short or long rest. Refreshes resources by refresh type. 369 + 370 + short rest: refreshes resources with refresh: short_rest 371 + long rest: refreshes short_rest AND long_rest resources, clears all 372 + death saves, removes one level of exhaustion, and clears expired effects 373 + """ 374 + if rest_type not in ("short", "long"): 375 + return f"Invalid rest type '{rest_type}'. Must be 'short' or 'long'." 376 + 377 + data = load_character(player_id, base_path) 378 + if data is None: 379 + return f"No character found for player '{player_id}'" 380 + 381 + refreshed = [] 382 + resources = data.get("resources", {}) 383 + for key, pool in resources.items(): 384 + refresh = pool.get("refresh") 385 + should_refresh = refresh == "short_rest" or ( 386 + rest_type == "long" and refresh in ("long_rest", "short_rest") 387 + ) 388 + if should_refresh and pool.get("current", 0) < pool.get("max", 0): 389 + pool["current"] = pool["max"] 390 + refreshed.append(pool.get("notes", key)) 391 + 392 + msg_parts = [f"Took a {rest_type} rest."] 393 + if refreshed: 394 + msg_parts.append(f"Refreshed: {', '.join(refreshed)}.") 395 + 396 + if rest_type == "long": 397 + # Reset death saves 398 + ds = data["state"].get("death_saves", {}) 399 + if ds.get("successes", 0) or ds.get("failures", 0): 400 + ds["successes"] = 0 401 + ds["failures"] = 0 402 + msg_parts.append("Death saves reset.") 403 + 404 + # Reduce exhaustion by 1 405 + exhaustion = data["state"].get("exhaustion", 0) 406 + if exhaustion > 0: 407 + data["state"]["exhaustion"] = exhaustion - 1 408 + msg_parts.append(f"Exhaustion: {exhaustion} → {exhaustion - 1}.") 409 + 410 + # Restore HP to max 411 + hp = data["state"]["hp"] 412 + if hp["current"] < hp["max"]: 413 + hp["current"] = hp["max"] 414 + msg_parts.append(f"HP restored to {hp['max']}.") 415 + 416 + save_character(player_id, data, base_path) 417 + return " ".join(msg_parts) 418 + 419 + 420 + # --- Coin and notes operations --- 421 + 422 + 423 + def adjust_coins( 424 + player_id: str, 425 + deltas: dict[str, int], 426 + base_path: Path | None = None, 427 + ) -> str: 428 + """Apply relative coin changes (positive=gain, negative=spend). 429 + 430 + Each denomination is clamped to 0 minimum. 431 + """ 432 + data = load_character(player_id, base_path) 433 + if data is None: 434 + return f"No character found for player '{player_id}'" 435 + 436 + purse = data["state"].setdefault( 437 + "purse", {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0} 438 + ) 439 + 440 + changes = [] 441 + for denom, delta in deltas.items(): 442 + if denom not in ("cp", "sp", "ep", "gp", "pp"): 443 + continue 444 + old = purse.get(denom, 0) 445 + new = old + delta 446 + if new < 0: 447 + changes.append(f"{denom} {old} → 0 (short {-new} {denom})") 448 + purse[denom] = 0 449 + else: 450 + changes.append(f"{denom} {old} → {new}") 451 + purse[denom] = new 452 + 453 + save_character(player_id, data, base_path) 454 + 455 + coins = [] 456 + for denom in ("pp", "gp", "ep", "sp", "cp"): 457 + amount = purse.get(denom, 0) 458 + if amount: 459 + coins.append(f"{amount} {denom}") 460 + purse_str = ", ".join(coins) if coins else "empty" 461 + 462 + return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" 463 + 464 + 465 + def add_note( 466 + player_id: str, 467 + text: str, 468 + time_anchor: str | None = None, 469 + base_path: Path | None = None, 470 + ) -> str: 471 + """Append a note to the player's notes.md file.""" 472 + if base_path is None: 473 + base_path = Path.cwd() 474 + notes_path = base_path / "players" / player_id / "notes.md" 475 + notes_path.parent.mkdir(parents=True, exist_ok=True) 476 + 477 + prefix = f"{time_anchor} | " if time_anchor else "" 478 + line = f"- {prefix}{text}\n" 479 + 480 + if not notes_path.exists(): 481 + notes_path.write_text("# Notes\n\n") 482 + with notes_path.open("a") as f: 483 + f.write(line) 484 + 485 + return f"Note added: {text}"
+2 -2
src/storied/cli.py
··· 519 519 f"{ooc_msg}]" 520 520 ) 521 521 522 - # Handle /note command (add a note to the character sheet) 522 + # Handle /note command (add a note to the player's notes.md) 523 523 if action.strip().lower().startswith("/note"): 524 524 note_msg = action.strip()[5:].strip() 525 525 if not note_msg: 526 526 console.print("[dim]Usage: /note <what to remember>[/dim]") 527 527 continue 528 - action = f"[System: The player wants to add a note to their character sheet. Use update_character with section.Notes to append this note, preserving any existing notes. Note to add: {note_msg}]" 528 + action = f"[System: The player wants to remember something. Call add_note with this exact text: {note_msg}]" 529 529 530 530 try: 531 531 console.print(Rule(style="dim blue"))
+16 -69
src/storied/engine.py
··· 10 10 11 11 from storied import notifications 12 12 from storied.character import format_character_context, load_character 13 + from storied.notification_formatters import ( 14 + DEFERRED_FORMATTERS, 15 + TOOL_LABELS, 16 + _extract_json_field, 17 + _extract_roll_reason, 18 + ) 13 19 from storied.claude import ( 14 20 Result, 15 21 TextDelta, ··· 37 43 return path.read_text() 38 44 39 45 40 - def _extract_json_field(tool_json: str, field: str) -> str | None: 41 - """Extract a named field from accumulated tool JSON.""" 42 - try: 43 - args = json.loads(tool_json) 44 - return args.get(field) 45 - except (json.JSONDecodeError, AttributeError): 46 - return None 47 - 48 - 49 - def _extract_roll_reason(tool_json: str) -> str | None: 50 - """Extract the reason field from accumulated roll tool JSON.""" 51 - return _extract_json_field(tool_json, "reason") 52 - 53 - 54 - def _format_coin_notification(tool_json: str) -> str: 55 - """Format an adjust_coins call as a friendly notification.""" 56 - try: 57 - args = json.loads(tool_json) 58 - deltas = args.get("deltas", {}) 59 - except (json.JSONDecodeError, AttributeError): 60 - return "Adjusting coins" 61 - 62 - spending = [] 63 - receiving = [] 64 - denom_names = {"cp": "copper", "sp": "silver", "ep": "electrum", "gp": "gold", "pp": "platinum"} 65 - for denom in ("pp", "gp", "ep", "sp", "cp"): 66 - amount = deltas.get(denom, 0) 67 - if amount < 0: 68 - spending.append(f"{-amount} {denom_names.get(denom, denom)}") 69 - elif amount > 0: 70 - receiving.append(f"{amount} {denom_names.get(denom, denom)}") 71 - 72 - if spending and receiving: 73 - return f"Spending {', '.join(spending)}; receiving {', '.join(receiving)}" 74 - elif spending: 75 - return f"Spending {', '.join(spending)}" 76 - elif receiving: 77 - return f"Receiving {', '.join(receiving)}" 78 - return "Adjusting coins" 79 - 80 - 81 46 def _tool_notification(name: str) -> str: 82 47 """Build a friendly tool notification string from an MCP tool name. 83 48 84 49 MCP tool names are prefixed as mcp__storied__<name> by Claude Code. 85 50 """ 86 51 short = name.rsplit("__", 1)[-1] if "__" in name else name 87 - 88 - labels = { 89 - "roll": "Rolling", 90 - "recall": "Recalling", 91 - "update_character": "Updating character sheet", 92 - "adjust_coins": "Adjusting coins", 93 - "create_character": "Creating character", 94 - "set_scene": "Setting scene", 95 - "establish": "Establishing", 96 - "mark": "Recording", 97 - "note_discovery": "Noting discovery", 98 - "tune": "Tuning style", 99 - "end_session": "Saving session", 100 - "enter_initiative": "Entering initiative", 101 - "next_turn": "Next turn", 102 - "add_combatant": "Adding combatant", 103 - "remove_combatant": "Removing combatant", 104 - "damage": "Applying damage", 105 - "heal": "Healing", 106 - "condition": "Updating condition", 107 - "end_initiative": "Ending initiative", 108 - "run_code": "Running code", 109 - } 110 - label = labels.get(short, short) 52 + label = TOOL_LABELS.get(short, short) 111 53 return f"[{label}...]" 112 54 113 55 ··· 480 422 if short == "end_initiative": 481 423 self.combat_ended = True 482 424 483 - if short in ("roll", "run_code", "adjust_coins") and not self.debug: 425 + is_deferred = ( 426 + short in ("roll", "run_code") 427 + or short in DEFERRED_FORMATTERS 428 + ) 429 + if is_deferred and not self.debug: 484 430 # Signal the CLI to flush the renderer before we 485 431 # wait for tool input (the notification comes at 486 - # ToolStop once we know the reason/description). 432 + # ToolStop once we know the arguments). 487 433 yield "" 488 434 deferred_notification = True 489 435 elif self.debug: ··· 505 451 desc = _extract_json_field(current_tool_json, "description") 506 452 label = desc if desc else "Running code" 507 453 yield f"[{label}...]" 508 - elif deferred_notification and current_tool_name == "adjust_coins": 509 - yield f"[{_format_coin_notification(current_tool_json)}...]" 454 + elif deferred_notification and current_tool_name in DEFERRED_FORMATTERS: 455 + formatter = DEFERRED_FORMATTERS[current_tool_name] 456 + yield f"[{formatter(current_tool_json)}...]" 510 457 511 458 if self.debug and current_tool_json: 512 459 truncated = current_tool_json[:200]
+226
src/storied/notification_formatters.py
··· 1 + """Friendly TUI notification formatters for deferred tool calls. 2 + 3 + Each formatter takes the accumulated tool input JSON and returns a 4 + human-readable label like "Taking 7 fire damage" or "Adding effect: Bless". 5 + """ 6 + 7 + import json 8 + from collections.abc import Callable 9 + 10 + 11 + def _parse_tool_args(tool_json: str) -> dict: 12 + """Safely parse accumulated tool JSON, returning {} on failure.""" 13 + try: 14 + return json.loads(tool_json) or {} 15 + except (json.JSONDecodeError, AttributeError): 16 + return {} 17 + 18 + 19 + def _extract_json_field(tool_json: str, field: str) -> str | None: 20 + """Extract a named field from accumulated tool JSON.""" 21 + return _parse_tool_args(tool_json).get(field) 22 + 23 + 24 + def _extract_roll_reason(tool_json: str) -> str | None: 25 + """Extract the reason field from accumulated roll tool JSON.""" 26 + return _extract_json_field(tool_json, "reason") 27 + 28 + 29 + _DENOM_NAMES = { 30 + "cp": "copper", 31 + "sp": "silver", 32 + "ep": "electrum", 33 + "gp": "gold", 34 + "pp": "platinum", 35 + } 36 + 37 + 38 + def _format_coin_notification(tool_json: str) -> str: 39 + """Format an adjust_coins call as a friendly notification.""" 40 + args = _parse_tool_args(tool_json) 41 + deltas = args.get("deltas", {}) 42 + 43 + spending = [] 44 + receiving = [] 45 + for denom in ("pp", "gp", "ep", "sp", "cp"): 46 + amount = deltas.get(denom, 0) 47 + if amount < 0: 48 + spending.append(f"{-amount} {_DENOM_NAMES.get(denom, denom)}") 49 + elif amount > 0: 50 + receiving.append(f"{amount} {_DENOM_NAMES.get(denom, denom)}") 51 + 52 + if spending and receiving: 53 + return f"Spending {', '.join(spending)}; receiving {', '.join(receiving)}" 54 + elif spending: 55 + return f"Spending {', '.join(spending)}" 56 + elif receiving: 57 + return f"Receiving {', '.join(receiving)}" 58 + return "Adjusting coins" 59 + 60 + 61 + def _format_damage_notification(tool_json: str) -> str: 62 + args = _parse_tool_args(tool_json) 63 + amount = args.get("amount", "?") 64 + dtype = args.get("type") 65 + target = args.get("target") 66 + if target: 67 + return f"{target} takes {amount} damage" 68 + if dtype: 69 + return f"Taking {amount} {dtype} damage" 70 + return f"Taking {amount} damage" 71 + 72 + 73 + def _format_heal_notification(tool_json: str) -> str: 74 + args = _parse_tool_args(tool_json) 75 + amount = args.get("amount", "?") 76 + target = args.get("target") 77 + if target: 78 + return f"Healing {target} for {amount}" 79 + return f"Healing {amount} HP" 80 + 81 + 82 + def _format_effect_notification(tool_json: str) -> str: 83 + args = _parse_tool_args(tool_json) 84 + source = args.get("source", "effect") 85 + expires = args.get("expires") 86 + if expires: 87 + return f"Adding effect: {source} (until {expires})" 88 + return f"Adding effect: {source}" 89 + 90 + 91 + def _format_remove_effect_notification(tool_json: str) -> str: 92 + args = _parse_tool_args(tool_json) 93 + source = args.get("source", "effect") 94 + return f"Removing effect: {source}" 95 + 96 + 97 + def _format_condition_notification(tool_json: str) -> str: 98 + args = _parse_tool_args(tool_json) 99 + name = args.get("name", "condition") 100 + return f"Becoming {name}" 101 + 102 + 103 + def _format_remove_condition_notification(tool_json: str) -> str: 104 + args = _parse_tool_args(tool_json) 105 + name = args.get("name", "condition") 106 + return f"Recovering from {name}" 107 + 108 + 109 + def _format_add_item_notification(tool_json: str) -> str: 110 + args = _parse_tool_args(tool_json) 111 + item = args.get("item", "item") 112 + location = args.get("location") 113 + if location: 114 + return f"Adding '{item}' to {location}" 115 + return f"Picking up '{item}'" 116 + 117 + 118 + def _format_remove_item_notification(tool_json: str) -> str: 119 + args = _parse_tool_args(tool_json) 120 + item = args.get("item", "item") 121 + return f"Removing '{item}'" 122 + 123 + 124 + def _format_set_item_status_notification(tool_json: str) -> str: 125 + args = _parse_tool_args(tool_json) 126 + item = args.get("item", "item") 127 + status = args.get("status", "status") 128 + verbs = {"attuned": "Attuning to", "equipped": "Equipping", "carried": "Stowing"} 129 + verb = verbs.get(status, "Setting status of") 130 + return f"{verb} {item}" 131 + 132 + 133 + def _format_use_resource_notification(tool_json: str) -> str: 134 + args = _parse_tool_args(tool_json) 135 + name = args.get("name", "resource") 136 + amount = args.get("amount", 1) 137 + if amount == 1: 138 + return f"Using {name}" 139 + return f"Using {amount} of {name}" 140 + 141 + 142 + def _format_restore_resource_notification(tool_json: str) -> str: 143 + args = _parse_tool_args(tool_json) 144 + name = args.get("name", "resource") 145 + amount = args.get("amount", "?") 146 + return f"Restoring {amount} of {name}" 147 + 148 + 149 + def _format_rest_notification(tool_json: str) -> str: 150 + args = _parse_tool_args(tool_json) 151 + rest_type = args.get("type", "short") 152 + return f"Taking a {rest_type} rest" 153 + 154 + 155 + def _format_add_note_notification(tool_json: str) -> str: 156 + args = _parse_tool_args(tool_json) 157 + text = args.get("text", "") 158 + snippet = text[:50] + "..." if len(text) > 50 else text 159 + return f"Noting: {snippet}" 160 + 161 + 162 + def _format_update_character_notification(tool_json: str) -> str: 163 + args = _parse_tool_args(tool_json) 164 + updates = args.get("updates", {}) 165 + if not updates: 166 + return "Updating character" 167 + keys = list(updates.keys())[:3] 168 + if len(updates) > 3: 169 + return f"Updating: {', '.join(keys)}, ..." 170 + return f"Updating: {', '.join(keys)}" 171 + 172 + 173 + # Maps tool name → formatter for deferred notifications 174 + DEFERRED_FORMATTERS: dict[str, Callable[[str], str]] = { 175 + "adjust_coins": _format_coin_notification, 176 + "damage": _format_damage_notification, 177 + "heal": _format_heal_notification, 178 + "add_effect": _format_effect_notification, 179 + "remove_effect": _format_remove_effect_notification, 180 + "add_condition": _format_condition_notification, 181 + "remove_condition": _format_remove_condition_notification, 182 + "add_item": _format_add_item_notification, 183 + "remove_item": _format_remove_item_notification, 184 + "set_item_status": _format_set_item_status_notification, 185 + "use_resource": _format_use_resource_notification, 186 + "restore_resource": _format_restore_resource_notification, 187 + "rest": _format_rest_notification, 188 + "add_note": _format_add_note_notification, 189 + "update_character": _format_update_character_notification, 190 + } 191 + 192 + 193 + TOOL_LABELS = { 194 + "roll": "Rolling", 195 + "recall": "Recalling", 196 + "update_character": "Updating character sheet", 197 + "adjust_coins": "Adjusting coins", 198 + "create_character": "Creating character", 199 + "set_scene": "Setting scene", 200 + "establish": "Establishing", 201 + "mark": "Recording", 202 + "note_discovery": "Noting discovery", 203 + "tune": "Tuning style", 204 + "end_session": "Saving session", 205 + "enter_initiative": "Entering initiative", 206 + "next_turn": "Next turn", 207 + "add_combatant": "Adding combatant", 208 + "remove_combatant": "Removing combatant", 209 + "damage": "Taking damage", 210 + "heal": "Healing", 211 + "condition": "Updating condition", 212 + "add_condition": "Becoming conditioned", 213 + "remove_condition": "Recovering", 214 + "add_effect": "Adding effect", 215 + "remove_effect": "Removing effect", 216 + "add_item": "Picking up item", 217 + "remove_item": "Removing item", 218 + "set_item_status": "Updating magic item", 219 + "use_resource": "Using resource", 220 + "restore_resource": "Restoring resource", 221 + "rest": "Resting", 222 + "add_note": "Taking a note", 223 + "end_initiative": "Ending initiative", 224 + "run_code": "Running code", 225 + "notify_dm": "Sending notification", 226 + }
+16 -6
src/storied/sandbox.py
··· 81 81 - all other tools go through execute_tool (return str) 82 82 """ 83 83 from storied.tools import ( 84 - adjust_coins, recall, establish, mark, note_discovery, 85 - set_scene, update_character, create_character, tune, end_session, 84 + add_condition, add_effect, add_item, add_note, 85 + adjust_coins, create_character, damage, heal, 86 + recall, establish, mark, note_discovery, 87 + remove_condition, remove_effect, remove_item, 88 + rest, restore_resource, set_item_status, set_scene, 89 + tune, end_session, update_character, use_resource, 86 90 ) 87 91 from storied.initiative import ( 88 92 enter_initiative, next_turn, add_combatant, remove_combatant, 89 - damage, heal, condition, end_initiative, 93 + condition, end_initiative, 90 94 ) 91 95 92 96 ctx_params = {"ctx", "tracker"} 93 97 str_fns = [ 94 - adjust_coins, recall, establish, mark, note_discovery, set_scene, 95 - update_character, create_character, tune, end_session, 98 + # Character/state operations 99 + damage, heal, adjust_coins, update_character, create_character, 100 + add_effect, remove_effect, add_condition, remove_condition, 101 + add_item, remove_item, set_item_status, 102 + use_resource, restore_resource, rest, add_note, 103 + # World operations 104 + recall, establish, mark, note_discovery, set_scene, tune, end_session, 105 + # Initiative 96 106 enter_initiative, next_turn, add_combatant, remove_combatant, 97 - damage, heal, condition, end_initiative, 107 + condition, end_initiative, 98 108 ] 99 109 100 110 lines: list[str] = [_sig(_roll_host, set(), "dict").replace("_roll_host", "roll")]
+69 -8
src/storied/tools/__init__.py
··· 16 16 _sync_player_hp, 17 17 ) 18 18 from storied.tools.character import ( 19 + add_condition, 20 + add_effect, 21 + add_item, 22 + add_note, 19 23 adjust_coins, 20 24 create_character, 25 + damage, 26 + heal, 27 + remove_condition, 28 + remove_effect, 29 + remove_item, 30 + rest, 31 + restore_resource, 32 + set_item_status, 21 33 update_character, 34 + use_resource, 22 35 ) 23 36 from storied.tools.entities import ( 24 37 _auto_mark_present, ··· 51 64 52 65 def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 53 66 """Execute a tool by name with the given input.""" 67 + # Initiative-only tools (enter_initiative, next_turn, end_initiative, 68 + # add/remove_combatant, condition) always route to the initiative module. 69 + # damage/heal route to initiative ONLY when called with a target — without 70 + # one they apply to the player character via the new operations module. 54 71 if tool_name in ALL_INITIATIVE_TOOL_NAMES: 55 - result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) 56 - if result is not None: 57 - if tool_name in ("damage", "heal"): 58 - result = _sync_player_hp(tool_input["target"], ctx, result) 59 - return result 72 + is_player_op = tool_name in ("damage", "heal") and "target" not in tool_input 73 + if not is_player_op: 74 + result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) 75 + if result is not None: 76 + if tool_name in ("damage", "heal"): 77 + result = _sync_player_hp(tool_input["target"], ctx, result) 78 + return result 60 79 61 80 if tool_name == "roll": 62 81 result = roll(tool_input["notation"]) ··· 95 114 background=tool_input.get("background"), 96 115 speed=tool_input.get("speed", 30), 97 116 purse=tool_input.get("purse"), 98 - equipment=tool_input.get("equipment"), 99 - features=tool_input.get("features"), 100 - proficiencies=tool_input.get("proficiencies"), 117 + subclass=tool_input.get("subclass"), 101 118 backstory=tool_input.get("backstory"), 102 119 ) 120 + 121 + elif tool_name == "damage": 122 + return damage(tool_input["amount"], ctx, type=tool_input.get("type")) 123 + 124 + elif tool_name == "heal": 125 + return heal(tool_input["amount"], ctx) 126 + 127 + elif tool_name == "add_effect": 128 + return add_effect( 129 + source=tool_input["source"], 130 + description=tool_input["description"], 131 + ctx=ctx, 132 + expires=tool_input.get("expires"), 133 + ) 134 + 135 + elif tool_name == "remove_effect": 136 + return remove_effect(tool_input["source"], ctx) 137 + 138 + elif tool_name == "add_condition": 139 + return add_condition(tool_input["name"], ctx) 140 + 141 + elif tool_name == "remove_condition": 142 + return remove_condition(tool_input["name"], ctx) 143 + 144 + elif tool_name == "add_item": 145 + return add_item(tool_input["item"], ctx, location=tool_input.get("location")) 146 + 147 + elif tool_name == "remove_item": 148 + return remove_item(tool_input["item"], ctx) 149 + 150 + elif tool_name == "set_item_status": 151 + return set_item_status(tool_input["item"], tool_input["status"], ctx) 152 + 153 + elif tool_name == "use_resource": 154 + return use_resource(tool_input["name"], ctx, amount=tool_input.get("amount", 1)) 155 + 156 + elif tool_name == "restore_resource": 157 + return restore_resource(tool_input["name"], tool_input["amount"], ctx) 158 + 159 + elif tool_name == "rest": 160 + return rest(tool_input["type"], ctx) 161 + 162 + elif tool_name == "add_note": 163 + return add_note(tool_input["text"], ctx) 103 164 104 165 elif tool_name == "set_scene": 105 166 return set_scene(
+1 -1
src/storied/tools/_context.py
··· 77 77 """Auto-sync player character sheet when damage/heal targets a player.""" 78 78 combatant = ctx.initiative._find(target) 79 79 if combatant and combatant.is_player: 80 - char_update(ctx.player_id, {"hp.current": combatant.hp}, ctx.base_path) 80 + char_update(ctx.player_id, {"state.hp.current": combatant.hp}, ctx.base_path) 81 81 result += f" (character sheet synced to {combatant.hp} HP)" 82 82 return result
+337 -126
src/storied/tools/character.py
··· 1 - """Character management tools.""" 1 + """Character management tools — bookkeeping primitives for the DM.""" 2 2 3 - from storied.character import adjust_coins as char_adjust_coins 4 - from storied.character import create_character as char_create 5 - from storied.character import update_character as char_update 3 + from storied.character import ( 4 + add_condition as char_add_condition, 5 + ) 6 + from storied.tools.character_schemas import SCHEMAS 7 + from storied.character import ( 8 + add_effect as char_add_effect, 9 + ) 10 + from storied.character import ( 11 + add_item as char_add_item, 12 + ) 13 + from storied.character import ( 14 + add_note as char_add_note, 15 + ) 16 + from storied.character import ( 17 + adjust_coins as char_adjust_coins, 18 + ) 19 + from storied.character import ( 20 + create_character as char_create, 21 + ) 22 + from storied.character import ( 23 + damage as char_damage, 24 + ) 25 + from storied.character import ( 26 + heal as char_heal, 27 + ) 28 + from storied.character import ( 29 + remove_condition as char_remove_condition, 30 + ) 31 + from storied.character import ( 32 + remove_effect as char_remove_effect, 33 + ) 34 + from storied.character import ( 35 + remove_item as char_remove_item, 36 + ) 37 + from storied.character import ( 38 + rest as char_rest, 39 + ) 40 + from storied.character import ( 41 + restore_resource as char_restore_resource, 42 + ) 43 + from storied.character import ( 44 + set_item_status as char_set_item_status, 45 + ) 46 + from storied.character import ( 47 + update_character as char_update, 48 + ) 49 + from storied.character import ( 50 + use_resource as char_use_resource, 51 + ) 6 52 from storied.tools._context import ToolContext 7 53 8 54 55 + # --- Universal field setter --- 56 + 57 + 9 58 def update_character(updates: dict, ctx: ToolContext) -> str: 10 - """Update the player's character sheet to persist changes. 59 + """Update arbitrary fields on the character sheet via dot notation. 11 60 12 - Call this after HP changes, equipment gained/lost, coins spent, level ups, etc. 13 - This ensures progress is saved and survives between sessions. 61 + Use this for any change that doesn't have a more specific tool. For HP, 62 + coins, items, effects, etc., prefer the dedicated tools (damage, heal, 63 + adjust_coins, add_item, add_effect, ...) — they prevent arithmetic errors. 14 64 15 65 Args: 16 - updates: Fields to update. Use dot notation for nested fields. 17 - Examples: 18 - - {"hp.current": 5} - set current HP to 5 19 - - {"purse.gp": 25} - set gold to 25 20 - - {"purse.sp": 10, "purse.cp": 50} - set silver and copper 21 - - {"level": 2, "hp.max": 20} - level up 22 - For markdown sections, use "section.Name": 23 - - {"section.Equipment": "- Longsword\\n- New shield"} 24 - player_id: Player identifier (usually "default") 25 - base_path: Base path for players directory 66 + updates: Fields to update by dot path. Examples: 67 + - {"identity.classes.0.level": 4} — level up 68 + - {"state.hp.max": 30} — increase max HP 69 + - {"state.exhaustion": 1} — set exhaustion level 70 + - {"state.death_saves.successes": 2} — record a death save 71 + - {"proficiencies.skills.persuasion": "proficient"} — add proficiency 72 + - {"advancement_ready": null} — clear advancement flag 73 + - {"features": [...]} — replace features list 26 74 27 75 Returns: 28 76 Confirmation of what was updated ··· 42 90 background: str | None = None, 43 91 speed: int = 30, 44 92 purse: dict[str, int] | None = None, 45 - equipment: list[str] | None = None, 46 - features: list[str] | None = None, 47 - proficiencies: str | None = None, 93 + subclass: str | None = None, 48 94 backstory: str | None = None, 49 95 ) -> str: 50 - """Create a new player character and save to disk. 96 + """Create a new player character with the new structured schema. 51 97 52 - Call this when character creation is complete. Include all the mechanical 53 - details needed to play: abilities, HP, AC, equipment, and features. 98 + Writes character.yaml (structured data) and character.md (prose backstory). 54 99 55 100 Args: 56 101 name: Character name 57 - race: Race (e.g., "Human", "High Elf", "Hill Dwarf") 58 - char_class: Class (e.g., "Fighter", "Wizard", "Rogue") 102 + race: Race (e.g., "Human", "High Elf") 103 + char_class: Class (e.g., "Fighter", "Wizard") 59 104 level: Starting level (usually 1) 60 - abilities: All six ability scores as a dict: 61 - {"strength": 15, "dexterity": 14, "constitution": 13, 62 - "intelligence": 12, "wisdom": 10, "charisma": 8} 105 + abilities: All six ability scores 63 106 hp_max: Maximum hit points 64 107 ac: Armor class 65 - background: Background (e.g., "Soldier", "Sage", "Criminal") 66 - speed: Movement speed in feet (default 30) 67 - purse: Starting coins by denomination: 68 - {"cp": 0, "sp": 0, "ep": 0, "gp": 15, "pp": 0} 69 - Denominations: cp (copper), sp (silver), ep (electrum), 70 - gp (gold), pp (platinum). Omit denominations for 0. 71 - equipment: List of equipment items 72 - features: List of racial and class features 73 - proficiencies: Description of proficiencies (armor, weapons, tools, saves, skills) 74 - backstory: Character backstory and personality 108 + background: Background (e.g., "Soldier", "Criminal") 109 + speed: Movement speed in feet 110 + purse: Starting coins {cp, sp, ep, gp, pp} 111 + subclass: Subclass if chosen 112 + backstory: Character backstory and personality (goes in character.md) 75 113 76 114 Returns: 77 115 Confirmation message ··· 88 126 background=background, 89 127 speed=speed, 90 128 purse=purse, 91 - equipment=equipment, 92 - features=features, 93 - proficiencies=proficiencies, 129 + subclass=subclass, 94 130 backstory=backstory, 95 131 base_path=ctx.base_path, 96 132 ) 97 133 98 134 135 + # --- HP operations --- 136 + 137 + 138 + def damage(amount: int, ctx: ToolContext, type: str | None = None) -> str: 139 + """Apply damage to the character. Temp HP absorbs first, then current HP. 140 + 141 + Args: 142 + amount: Damage amount (non-negative) 143 + type: Optional damage type (fire, cold, slashing, etc.) for narration 144 + 145 + Returns: 146 + Damage taken and remaining HP 147 + """ 148 + return char_damage(ctx.player_id, amount, damage_type=type, base_path=ctx.base_path) 149 + 150 + 151 + def heal(amount: int, ctx: ToolContext) -> str: 152 + """Heal the character. Clamped to max HP. 153 + 154 + Args: 155 + amount: HP to restore (non-negative) 156 + 157 + Returns: 158 + Healing applied and current HP 159 + """ 160 + return char_heal(ctx.player_id, amount, base_path=ctx.base_path) 161 + 162 + 163 + # --- Coin operations --- 164 + 165 + 99 166 def adjust_coins(deltas: dict[str, int], ctx: ToolContext) -> str: 100 167 """Adjust the player's coins by relative amounts. 101 168 102 - Use negative values to spend, positive to gain. Coins are clamped to 0 103 - (the character can't go into debt). Returns the new purse balance. 169 + Use negative values to spend, positive to gain. Coins are clamped to 0. 104 170 105 171 Args: 106 - deltas: Coin changes by denomination. Negative to spend, positive to gain. 107 - Examples: {"gp": -5} to spend 5 gold, 108 - {"gp": 10, "sp": 5} to gain 10 gold and 5 silver. 109 - Keys: cp (copper), sp (silver), ep (electrum), gp (gold), pp (platinum). 172 + deltas: Coin changes by denomination (cp/sp/ep/gp/pp). 173 + Examples: {"gp": -5}, {"gp": 10, "sp": 5} 110 174 111 175 Returns: 112 176 Summary of changes and new purse balance ··· 114 178 return char_adjust_coins(ctx.player_id, deltas, ctx.base_path) 115 179 116 180 181 + # --- Effect operations --- 182 + 183 + 184 + def add_effect( 185 + source: str, 186 + description: str, 187 + ctx: ToolContext, 188 + expires: str | None = None, 189 + ) -> str: 190 + """Track a temporary effect on the character. 191 + 192 + Use for spells, potions, environmental effects, narrative buffs/debuffs — 193 + anything that's temporarily affecting the character. 194 + 195 + Args: 196 + source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") 197 + description: What the effect does in narrative + mechanical terms 198 + expires: Optional game time anchor when the effect ends (e.g., "d28-1430") 199 + 200 + Returns: 201 + Confirmation 202 + """ 203 + return char_add_effect( 204 + ctx.player_id, source, description, expires=expires, base_path=ctx.base_path 205 + ) 206 + 207 + 208 + def remove_effect(source: str, ctx: ToolContext) -> str: 209 + """Remove an effect by source name (case-insensitive substring match). 210 + 211 + Args: 212 + source: The effect's source name to remove (e.g., "Potion of Heroism") 213 + 214 + Returns: 215 + Confirmation of what was removed 216 + """ 217 + return char_remove_effect(ctx.player_id, source, base_path=ctx.base_path) 218 + 219 + 220 + # --- Condition operations --- 221 + 222 + 223 + def add_condition(name: str, ctx: ToolContext) -> str: 224 + """Mark a condition on the character (5e conditions or any custom name). 225 + 226 + Args: 227 + name: Condition name (e.g., "Poisoned", "Prone", "Frightened") 228 + 229 + Returns: 230 + Confirmation 231 + """ 232 + return char_add_condition(ctx.player_id, name, base_path=ctx.base_path) 233 + 234 + 235 + def remove_condition(name: str, ctx: ToolContext) -> str: 236 + """Remove a condition from the character. 237 + 238 + Args: 239 + name: Condition name to remove 240 + 241 + Returns: 242 + Confirmation 243 + """ 244 + return char_remove_condition(ctx.player_id, name, base_path=ctx.base_path) 245 + 246 + 247 + # --- Inventory operations --- 248 + 249 + 250 + def add_item(item: str, ctx: ToolContext, location: str | None = None) -> str: 251 + """Add a mundane item to the character's equipment. 252 + 253 + Items are organized by location subsection (e.g., "on_person", "stashed_at_inn"). 254 + If location is omitted, uses the first existing location, or 'on_person' as default. 255 + Substring match against existing locations, so 'On Person' matches 'on_person'. 256 + 257 + For magic items, use set_item_status instead (after establishing the item as a 258 + world entity). 259 + 260 + Args: 261 + item: Item description (e.g., "Lockpicks", "Healing potion (2d4+2)") 262 + location: Optional subsection name (e.g., "on_person", "backpack") 263 + 264 + Returns: 265 + Confirmation with the location it was added to 266 + """ 267 + return char_add_item(ctx.player_id, item, location=location, base_path=ctx.base_path) 268 + 269 + 270 + def remove_item(item: str, ctx: ToolContext) -> str: 271 + """Remove an item from the character's equipment by name. 272 + 273 + Uses case-insensitive substring matching across all equipment subsections. 274 + Removes the first match. 275 + 276 + Args: 277 + item: Item name to remove (e.g., "Lockpicks", "knife") 278 + 279 + Returns: 280 + Confirmation with the item that was removed 281 + """ 282 + return char_remove_item(ctx.player_id, item, base_path=ctx.base_path) 283 + 284 + 285 + def set_item_status(item: str, status: str, ctx: ToolContext) -> str: 286 + """Set a magic item's status (attuned, equipped, or carried). 287 + 288 + The item should already exist as a world entity in worlds/{world}/items/. 289 + This function manages where the wikilink lives in the character's 290 + magic_items dict. 291 + 292 + Args: 293 + item: Magic item name (matches the world entity name) 294 + status: One of "attuned", "equipped", or "carried" 295 + 296 + Returns: 297 + Confirmation 298 + """ 299 + return char_set_item_status( 300 + ctx.player_id, item, status, base_path=ctx.base_path 301 + ) 302 + 303 + 304 + # --- Resource operations --- 305 + 306 + 307 + def use_resource(name: str, ctx: ToolContext, amount: int = 1) -> str: 308 + """Decrement a resource pool (rage uses, ki points, hit dice, magic item charges, etc.). 309 + 310 + Substring match on the resource name. Resources are clamped to 0. 311 + 312 + Args: 313 + name: Resource name (e.g., "rage", "hit_dice_d8", "bracer") 314 + amount: How many to use (default 1) 315 + 316 + Returns: 317 + Confirmation with remaining count 318 + """ 319 + return char_use_resource( 320 + ctx.player_id, name, amount=amount, base_path=ctx.base_path 321 + ) 322 + 323 + 324 + def restore_resource(name: str, amount: int, ctx: ToolContext) -> str: 325 + """Restore points to a resource pool, clamped to max. 326 + 327 + Usually you'll use `rest` instead, which refreshes resources by their 328 + refresh type. Use this for one-off restorations. 329 + 330 + Args: 331 + name: Resource name (substring match) 332 + amount: How many to restore 333 + 334 + Returns: 335 + Confirmation 336 + """ 337 + return char_restore_resource( 338 + ctx.player_id, name, amount, base_path=ctx.base_path 339 + ) 340 + 341 + 342 + def rest(type: str, ctx: ToolContext) -> str: 343 + """Take a short or long rest. 344 + 345 + Refreshes resources by refresh type (long rest also refreshes short_rest 346 + resources). Long rest also clears death saves, removes one exhaustion 347 + level, and restores HP to max. 348 + 349 + Args: 350 + type: "short" or "long" 351 + 352 + Returns: 353 + Summary of what was refreshed 354 + """ 355 + return char_rest(ctx.player_id, type, base_path=ctx.base_path) 356 + 357 + 358 + # --- Notes --- 359 + 360 + 361 + def add_note(text: str, ctx: ToolContext) -> str: 362 + """Append a note to the player's notes.md journal. 363 + 364 + Notes accumulate over time and are stamped with the current game time. 365 + Use this for player observations, leads, decisions, things to remember. 366 + 367 + Args: 368 + text: The note text 369 + 370 + Returns: 371 + Confirmation 372 + """ 373 + time_anchor = ctx.campaign_log.get_current_time().to_anchor() 374 + return char_add_note( 375 + ctx.player_id, text, time_anchor=time_anchor, base_path=ctx.base_path 376 + ) 377 + 378 + 379 + 380 + # --- Tool definitions for the API --- 381 + # Wrapper docstrings + schemas from character_schemas.py 382 + 383 + _WRAPPERS = { 384 + "update_character": update_character, 385 + "create_character": create_character, 386 + "damage": damage, 387 + "heal": heal, 388 + "adjust_coins": adjust_coins, 389 + "add_effect": add_effect, 390 + "remove_effect": remove_effect, 391 + "add_condition": add_condition, 392 + "remove_condition": remove_condition, 393 + "add_item": add_item, 394 + "remove_item": remove_item, 395 + "set_item_status": set_item_status, 396 + "use_resource": use_resource, 397 + "restore_resource": restore_resource, 398 + "rest": rest, 399 + "add_note": add_note, 400 + } 401 + 117 402 DEFINITIONS: list[dict] = [ 118 403 { 119 - "name": "update_character", 120 - "description": update_character.__doc__, 121 - "input_schema": { 122 - "type": "object", 123 - "properties": { 124 - "updates": { 125 - "type": "object", 126 - "description": "Fields to update. Use dot notation for nested (e.g., 'hp.current': 5). Use 'section.Name' for markdown sections.", 127 - }, 128 - }, 129 - "required": ["updates"], 130 - }, 131 - }, 132 - { 133 - "name": "create_character", 134 - "description": create_character.__doc__, 135 - "input_schema": { 136 - "type": "object", 137 - "properties": { 138 - "name": {"type": "string", "description": "Character name"}, 139 - "race": {"type": "string", "description": "Race (e.g., 'Human', 'High Elf')"}, 140 - "char_class": {"type": "string", "description": "Class (e.g., 'Fighter', 'Wizard')"}, 141 - "level": {"type": "integer", "description": "Starting level (usually 1)"}, 142 - "abilities": { 143 - "type": "object", 144 - "description": "All six ability scores: strength, dexterity, constitution, intelligence, wisdom, charisma", 145 - }, 146 - "hp_max": {"type": "integer", "description": "Maximum hit points"}, 147 - "ac": {"type": "integer", "description": "Armor class"}, 148 - "background": {"type": "string", "description": "Background (e.g., 'Soldier', 'Sage')"}, 149 - "speed": {"type": "integer", "description": "Movement speed in feet"}, 150 - "purse": { 151 - "type": "object", 152 - "description": "Starting coins: {cp, sp, ep, gp, pp}. Omit denominations for 0.", 153 - "properties": { 154 - "cp": {"type": "integer", "description": "Copper pieces"}, 155 - "sp": {"type": "integer", "description": "Silver pieces"}, 156 - "ep": {"type": "integer", "description": "Electrum pieces"}, 157 - "gp": {"type": "integer", "description": "Gold pieces"}, 158 - "pp": {"type": "integer", "description": "Platinum pieces"}, 159 - }, 160 - }, 161 - "equipment": { 162 - "type": "array", 163 - "items": {"type": "string"}, 164 - "description": "List of equipment items", 165 - }, 166 - "features": { 167 - "type": "array", 168 - "items": {"type": "string"}, 169 - "description": "List of racial and class features", 170 - }, 171 - "proficiencies": {"type": "string", "description": "Proficiency description"}, 172 - "backstory": {"type": "string", "description": "Character backstory and personality"}, 173 - }, 174 - "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], 175 - }, 176 - }, 177 - { 178 - "name": "adjust_coins", 179 - "description": adjust_coins.__doc__, 180 - "input_schema": { 181 - "type": "object", 182 - "properties": { 183 - "deltas": { 184 - "type": "object", 185 - "description": "Coin changes by denomination. Negative to spend, positive to gain.", 186 - "properties": { 187 - "cp": {"type": "integer", "description": "Copper pieces"}, 188 - "sp": {"type": "integer", "description": "Silver pieces"}, 189 - "ep": {"type": "integer", "description": "Electrum pieces"}, 190 - "gp": {"type": "integer", "description": "Gold pieces"}, 191 - "pp": {"type": "integer", "description": "Platinum pieces"}, 192 - }, 193 - }, 194 - }, 195 - "required": ["deltas"], 196 - }, 197 - }, 404 + "name": s["name"], 405 + "description": _WRAPPERS[s["name"]].__doc__, 406 + "input_schema": s["input_schema"], 407 + } 408 + for s in SCHEMAS 198 409 ]
+214
src/storied/tools/character_schemas.py
··· 1 + """JSON schemas for character tool definitions. 2 + 3 + Imported by tools/character.py to construct the DEFINITIONS list. 4 + The descriptions come from the wrapper function docstrings, so this 5 + file holds only the input_schema portion plus the tool name. 6 + """ 7 + 8 + 9 + SCHEMAS: list[dict] = [ 10 + { 11 + "name": "update_character", 12 + "input_schema": { 13 + "type": "object", 14 + "properties": { 15 + "updates": { 16 + "type": "object", 17 + "description": "Fields to update by dot path (e.g., 'state.hp.max', 'identity.classes.0.level')", 18 + }, 19 + }, 20 + "required": ["updates"], 21 + }, 22 + }, 23 + { 24 + "name": "create_character", 25 + "input_schema": { 26 + "type": "object", 27 + "properties": { 28 + "name": {"type": "string"}, 29 + "race": {"type": "string"}, 30 + "char_class": {"type": "string"}, 31 + "level": {"type": "integer"}, 32 + "abilities": {"type": "object"}, 33 + "hp_max": {"type": "integer"}, 34 + "ac": {"type": "integer"}, 35 + "background": {"type": "string"}, 36 + "speed": {"type": "integer"}, 37 + "subclass": {"type": "string"}, 38 + "purse": { 39 + "type": "object", 40 + "properties": { 41 + "cp": {"type": "integer"}, "sp": {"type": "integer"}, 42 + "ep": {"type": "integer"}, "gp": {"type": "integer"}, 43 + "pp": {"type": "integer"}, 44 + }, 45 + }, 46 + "backstory": {"type": "string"}, 47 + }, 48 + "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], 49 + }, 50 + }, 51 + { 52 + "name": "damage", 53 + "input_schema": { 54 + "type": "object", 55 + "properties": { 56 + "amount": {"type": "integer", "description": "Damage amount (non-negative)"}, 57 + "type": {"type": "string", "description": "Damage type (fire, cold, slashing, etc.)"}, 58 + }, 59 + "required": ["amount"], 60 + }, 61 + }, 62 + { 63 + "name": "heal", 64 + "input_schema": { 65 + "type": "object", 66 + "properties": { 67 + "amount": {"type": "integer", "description": "HP to restore"}, 68 + }, 69 + "required": ["amount"], 70 + }, 71 + }, 72 + { 73 + "name": "adjust_coins", 74 + "input_schema": { 75 + "type": "object", 76 + "properties": { 77 + "deltas": { 78 + "type": "object", 79 + "description": "Coin deltas: negative=spend, positive=gain", 80 + "properties": { 81 + "cp": {"type": "integer"}, "sp": {"type": "integer"}, 82 + "ep": {"type": "integer"}, "gp": {"type": "integer"}, 83 + "pp": {"type": "integer"}, 84 + }, 85 + }, 86 + }, 87 + "required": ["deltas"], 88 + }, 89 + }, 90 + { 91 + "name": "add_effect", 92 + "input_schema": { 93 + "type": "object", 94 + "properties": { 95 + "source": {"type": "string", "description": "Where the effect comes from"}, 96 + "description": {"type": "string", "description": "What the effect does"}, 97 + "expires": {"type": "string", "description": "Optional game time anchor when the effect ends"}, 98 + }, 99 + "required": ["source", "description"], 100 + }, 101 + }, 102 + { 103 + "name": "remove_effect", 104 + "input_schema": { 105 + "type": "object", 106 + "properties": { 107 + "source": {"type": "string", "description": "Effect source name (substring match)"}, 108 + }, 109 + "required": ["source"], 110 + }, 111 + }, 112 + { 113 + "name": "add_condition", 114 + "input_schema": { 115 + "type": "object", 116 + "properties": { 117 + "name": {"type": "string", "description": "Condition name"}, 118 + }, 119 + "required": ["name"], 120 + }, 121 + }, 122 + { 123 + "name": "remove_condition", 124 + "input_schema": { 125 + "type": "object", 126 + "properties": { 127 + "name": {"type": "string", "description": "Condition name"}, 128 + }, 129 + "required": ["name"], 130 + }, 131 + }, 132 + { 133 + "name": "add_item", 134 + "input_schema": { 135 + "type": "object", 136 + "properties": { 137 + "item": {"type": "string", "description": "Item description"}, 138 + "location": {"type": "string", "description": "Optional location subsection"}, 139 + }, 140 + "required": ["item"], 141 + }, 142 + }, 143 + { 144 + "name": "remove_item", 145 + "input_schema": { 146 + "type": "object", 147 + "properties": { 148 + "item": {"type": "string", "description": "Item name (substring match)"}, 149 + }, 150 + "required": ["item"], 151 + }, 152 + }, 153 + { 154 + "name": "set_item_status", 155 + "input_schema": { 156 + "type": "object", 157 + "properties": { 158 + "item": {"type": "string", "description": "Magic item entity name"}, 159 + "status": { 160 + "type": "string", 161 + "enum": ["attuned", "equipped", "carried"], 162 + "description": "New status for the item", 163 + }, 164 + }, 165 + "required": ["item", "status"], 166 + }, 167 + }, 168 + { 169 + "name": "use_resource", 170 + "input_schema": { 171 + "type": "object", 172 + "properties": { 173 + "name": {"type": "string", "description": "Resource name (substring match)"}, 174 + "amount": {"type": "integer", "description": "How many to use (default 1)"}, 175 + }, 176 + "required": ["name"], 177 + }, 178 + }, 179 + { 180 + "name": "restore_resource", 181 + "input_schema": { 182 + "type": "object", 183 + "properties": { 184 + "name": {"type": "string", "description": "Resource name"}, 185 + "amount": {"type": "integer", "description": "How many to restore"}, 186 + }, 187 + "required": ["name", "amount"], 188 + }, 189 + }, 190 + { 191 + "name": "rest", 192 + "input_schema": { 193 + "type": "object", 194 + "properties": { 195 + "type": { 196 + "type": "string", 197 + "enum": ["short", "long"], 198 + "description": "Rest type", 199 + }, 200 + }, 201 + "required": ["type"], 202 + }, 203 + }, 204 + { 205 + "name": "add_note", 206 + "input_schema": { 207 + "type": "object", 208 + "properties": { 209 + "text": {"type": "string", "description": "Note text"}, 210 + }, 211 + "required": ["text"], 212 + }, 213 + }, 214 + ]
+15 -8
tests/test_advancement.py
··· 26 26 def character(ctx: ToolContext) -> dict: 27 27 """Create a level 3 rogue character.""" 28 28 data = { 29 - "name": "Kira", 30 - "race": "Human", 31 - "class": "Rogue", 32 - "level": 3, 33 - "hp": {"current": 24, "max": 24}, 34 - "ac": 14, 35 - "speed": 30, 29 + "identity": { 30 + "name": "Kira", 31 + "race": "Human", 32 + "classes": [{"class": "Rogue", "subclass": "Thief", "level": 3}], 33 + "background": "Criminal", 34 + }, 36 35 "abilities": { 37 36 "strength": 10, 38 37 "dexterity": 16, ··· 41 40 "wisdom": 10, 42 41 "charisma": 14, 43 42 }, 44 - "body": "## Features\n- Sneak Attack 2d6\n- Cunning Action\n- Thief subclass", 43 + "state": { 44 + "hp": {"current": 24, "max": 24, "temp": 0}, 45 + "ac": 14, 46 + "speed": 30, 47 + }, 48 + "features": [ 49 + {"source": "Rogue Lv1", "name": "Sneak Attack", "text": "2d6"}, 50 + {"source": "Rogue Lv2", "name": "Cunning Action", "text": ""}, 51 + ], 45 52 } 46 53 save_character("default", data, ctx.base_path) 47 54 return data
+545 -348
tests/test_character.py
··· 1 - """Tests for character loading, saving, updates, and display formatting.""" 1 + """Tests for the character system: data, computation, display, and operations.""" 2 + 3 + from pathlib import Path 2 4 3 5 import pytest 4 6 5 7 from storied.character import ( 8 + ABILITIES, 9 + SKILL_TO_ABILITY, 10 + ability_modifier, 11 + add_condition, 12 + add_effect, 13 + add_item, 14 + add_note, 15 + adjust_coins, 16 + create_character, 17 + damage, 18 + effective_hp, 6 19 format_character_context, 7 20 format_sheet, 8 21 format_status, 22 + has_expertise_in, 23 + heal, 24 + is_proficient_in, 9 25 load_character, 10 - parse_character, 26 + load_character_prose, 27 + passive_score, 28 + proficiency_bonus, 29 + remove_condition, 30 + remove_effect, 31 + remove_item, 32 + rest, 33 + restore_resource, 34 + save_character, 35 + save_modifier, 36 + set_item_status, 37 + skill_modifier, 38 + total_level, 39 + update_character, 40 + use_resource, 11 41 ) 12 - from storied.tools import ToolContext, create_character, update_character 42 + from storied.tools import ToolContext 43 + 44 + 45 + # --- Fixtures --- 13 46 14 47 15 48 @pytest.fixture 16 - def char_ctx(ctx: ToolContext) -> ToolContext: 17 - """ToolContext configured for character tests with players dir.""" 18 - ctx.player_id = "test-player" 19 - (ctx.base_path / "players" / "test-player").mkdir(parents=True) 20 - return ctx 49 + def player_dir(tmp_path: Path) -> Path: 50 + (tmp_path / "players" / "test-player").mkdir(parents=True) 51 + return tmp_path 21 52 22 53 23 54 @pytest.fixture 24 - def basic_character(char_ctx: ToolContext) -> dict: 25 - """Create a basic character for testing.""" 55 + def mira(player_dir: Path) -> dict: 56 + """A level 3 Rogue/Thief, modeled after Mira Ashvale.""" 26 57 create_character( 27 - name="Test Hero", 58 + player_id="test-player", 59 + name="Mira", 28 60 race="Human", 29 - char_class="Fighter", 30 - level=1, 61 + char_class="Rogue", 62 + subclass="Thief", 63 + level=3, 31 64 abilities={ 32 - "strength": 16, 33 - "dexterity": 14, 34 - "constitution": 15, 35 - "intelligence": 10, 36 - "wisdom": 12, 37 - "charisma": 8, 65 + "strength": 11, 66 + "dexterity": 18, 67 + "constitution": 14, 68 + "intelligence": 15, 69 + "wisdom": 14, 70 + "charisma": 18, 38 71 }, 39 - hp_max=12, 72 + hp_max=24, 40 73 ac=16, 41 - purse={"cp": 0, "sp": 0, "ep": 0, "gp": 50, "pp": 0}, 42 - ctx=char_ctx, 74 + background="Criminal", 75 + purse={"gp": 43, "cp": 66}, 76 + base_path=player_dir, 43 77 ) 44 - return load_character("test-player", char_ctx.base_path) 45 - 46 - 47 - class TestParseCharacter: 48 - """Tests for parsing character markdown.""" 49 - 50 - def test_parse_with_frontmatter(self): 51 - content = """--- 52 - name: Test 53 - hp: 54 - current: 10 55 - max: 10 56 - --- 78 + # Add proficiencies and resources via update_character 79 + update_character( 80 + "test-player", 81 + { 82 + "proficiencies.saves": ["dexterity", "intelligence"], 83 + "proficiencies.skills.stealth": "expertise", 84 + "proficiencies.skills.sleight_of_hand": "expertise", 85 + "proficiencies.skills.acrobatics": "proficient", 86 + "proficiencies.skills.perception": "proficient", 87 + "proficiencies.skills.deception": "proficient", 88 + "proficiencies.skills.insight": "proficient", 89 + }, 90 + base_path=player_dir, 91 + ) 92 + update_character( 93 + "test-player", 94 + { 95 + "resources.hit_dice_d8": { 96 + "current": 3, "max": 3, "refresh": "long_rest", 97 + "notes": "Hit Dice (d8)", 98 + }, 99 + }, 100 + base_path=player_dir, 101 + ) 102 + return load_character("test-player", player_dir) 57 103 58 - ## Equipment 59 - - Sword 60 - """ 61 - result = parse_character(content) 62 - assert result["name"] == "Test" 63 - assert result["hp"]["current"] == 10 64 - assert "Equipment" in result["body"] 65 104 66 - def test_parse_without_frontmatter(self): 67 - content = "Just a body" 68 - result = parse_character(content) 69 - assert result["body"] == "Just a body" 105 + # --- Data layer tests --- 70 106 71 107 72 - class TestCreateCharacter: 73 - """Tests for character creation.""" 108 + class TestDataLayer: 109 + def test_load_returns_none_for_missing(self, player_dir: Path): 110 + assert load_character("test-player", player_dir) is None 74 111 75 - def test_create_basic_character(self, char_ctx: ToolContext): 76 - result = create_character( 112 + def test_create_and_load(self, player_dir: Path): 113 + create_character( 114 + player_id="test-player", 77 115 name="Conan", 78 116 race="Human", 79 117 char_class="Barbarian", 80 118 level=1, 81 - abilities={ 82 - "strength": 18, 83 - "dexterity": 14, 84 - "constitution": 16, 85 - "intelligence": 8, 86 - "wisdom": 10, 87 - "charisma": 12, 88 - }, 119 + abilities={"strength": 18, "dexterity": 14, "constitution": 16, 120 + "intelligence": 8, "wisdom": 10, "charisma": 12}, 89 121 hp_max=15, 90 122 ac=14, 91 - ctx=char_ctx, 123 + base_path=player_dir, 92 124 ) 125 + data = load_character("test-player", player_dir) 126 + assert data["identity"]["name"] == "Conan" 127 + assert data["identity"]["classes"][0]["class"] == "Barbarian" 128 + assert data["identity"]["classes"][0]["level"] == 1 129 + assert data["abilities"]["strength"] == 18 130 + assert data["state"]["hp"]["max"] == 15 93 131 94 - assert "Created character 'Conan'" in result 95 - char_file = char_ctx.base_path / "players/test-player/character.md" 96 - assert char_file.exists() 132 + def test_load_fills_defaults(self, player_dir: Path): 133 + # Save a sparse character 134 + save_character("test-player", {"identity": {"name": "Sparse"}}, player_dir) 135 + data = load_character("test-player", player_dir) 136 + # Default schema should be merged in 137 + assert "abilities" in data 138 + assert "state" in data 139 + assert data["abilities"]["strength"] == 10 97 140 98 - def test_created_character_has_full_hp(self, char_ctx: ToolContext): 141 + def test_create_writes_backstory(self, player_dir: Path): 99 142 create_character( 100 - name="Test", 101 - race="Human", 102 - char_class="Fighter", 143 + player_id="test-player", 144 + name="Storyteller", 145 + race="Half-Elf", 146 + char_class="Bard", 103 147 level=1, 104 - abilities={"strength": 10, "dexterity": 10, "constitution": 10, 105 - "intelligence": 10, "wisdom": 10, "charisma": 10}, 106 - hp_max=10, 107 - ac=10, 108 - ctx=char_ctx, 148 + abilities={"strength": 8, "dexterity": 14, "constitution": 12, 149 + "intelligence": 13, "wisdom": 10, "charisma": 16}, 150 + hp_max=9, 151 + ac=12, 152 + backstory="A wandering minstrel with secrets.", 153 + base_path=player_dir, 109 154 ) 110 - 111 - char = load_character("test-player", char_ctx.base_path) 112 - assert char["hp"]["current"] == 10 113 - assert char["hp"]["max"] == 10 155 + prose = load_character_prose("test-player", player_dir) 156 + assert "wandering minstrel" in prose 114 157 115 158 116 159 class TestUpdateCharacter: 117 - """Tests for character updates.""" 160 + def test_update_simple_field(self, mira: dict, player_dir: Path): 161 + update_character("test-player", {"state.ac": 17}, base_path=player_dir) 162 + data = load_character("test-player", player_dir) 163 + assert data["state"]["ac"] == 17 118 164 119 - def test_update_purse(self, char_ctx: ToolContext, basic_character: dict): 120 - result = update_character( 121 - updates={"purse.gp": 100, "purse.sp": 25}, 122 - ctx=char_ctx, 165 + def test_update_nested_via_dot(self, mira: dict, player_dir: Path): 166 + update_character( 167 + "test-player", {"state.hp.max": 30}, base_path=player_dir 123 168 ) 169 + data = load_character("test-player", player_dir) 170 + assert data["state"]["hp"]["max"] == 30 124 171 125 - assert "purse.gp = 100" in result 126 - char = load_character("test-player", char_ctx.base_path) 127 - assert char["purse"]["gp"] == 100 128 - assert char["purse"]["sp"] == 25 172 + def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): 173 + update_character( 174 + "test-player", {"state.hp.current": -5}, base_path=player_dir 175 + ) 176 + data = load_character("test-player", player_dir) 177 + assert data["state"]["hp"]["current"] == 0 129 178 130 - def test_update_hp_current(self, char_ctx: ToolContext, basic_character: dict): 131 - result = update_character( 132 - updates={"hp.current": 5}, 133 - ctx=char_ctx, 179 + def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): 180 + update_character( 181 + "test-player", {"state.hp.current": 100}, base_path=player_dir 134 182 ) 183 + data = load_character("test-player", player_dir) 184 + assert data["state"]["hp"]["current"] == 24 135 185 136 - assert "hp.current = 5" in result 137 - char = load_character("test-player", char_ctx.base_path) 138 - assert char["hp"]["current"] == 5 139 - 140 - def test_update_section(self, char_ctx: ToolContext, basic_character: dict): 186 + def test_negative_coins_clamped(self, mira: dict, player_dir: Path): 141 187 update_character( 142 - updates={"section.Equipment": "- Longsword\n- Shield"}, 143 - ctx=char_ctx, 188 + "test-player", {"state.purse.sp": -20}, base_path=player_dir 144 189 ) 190 + data = load_character("test-player", player_dir) 191 + assert data["state"]["purse"]["sp"] == 0 145 192 146 - char = load_character("test-player", char_ctx.base_path) 147 - assert "Longsword" in char["body"] 148 - assert "Shield" in char["body"] 193 + def test_no_character_returns_error(self, player_dir: Path): 194 + result = update_character("missing", {"foo": "bar"}, base_path=player_dir) 195 + assert "no character" in result.lower() 149 196 150 197 151 - class TestHPClamping: 152 - """Tests for HP clamping to valid 5e range.""" 198 + # --- Computation tests --- 153 199 154 - def test_hp_cannot_go_negative(self, char_ctx: ToolContext, basic_character: dict): 155 - """In 5e, HP minimum is 0 (no negative HP).""" 156 - update_character( 157 - updates={"hp.current": -5}, 158 - ctx=char_ctx, 200 + 201 + class TestComputation: 202 + def test_ability_modifier(self): 203 + assert ability_modifier(10) == 0 204 + assert ability_modifier(11) == 0 205 + assert ability_modifier(12) == 1 206 + assert ability_modifier(18) == 4 207 + assert ability_modifier(8) == -1 208 + assert ability_modifier(20) == 5 209 + 210 + def test_total_level_single_class(self, mira: dict): 211 + assert total_level(mira) == 3 212 + 213 + def test_total_level_multiclass(self, player_dir: Path): 214 + save_character( 215 + "test-player", 216 + {"identity": {"classes": [ 217 + {"class": "Fighter", "level": 3}, 218 + {"class": "Wizard", "level": 2}, 219 + ]}}, 220 + player_dir, 159 221 ) 222 + data = load_character("test-player", player_dir) 223 + assert total_level(data) == 5 160 224 161 - char = load_character("test-player", char_ctx.base_path) 162 - assert char["hp"]["current"] == 0 225 + def test_proficiency_bonus_scaling(self, player_dir: Path): 226 + for level, expected in [(1, 2), (4, 2), (5, 3), (8, 3), (9, 4), 227 + (12, 4), (13, 5), (16, 5), (17, 6), (20, 6)]: 228 + save_character( 229 + "test-player", 230 + {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, 231 + player_dir, 232 + ) 233 + data = load_character("test-player", player_dir) 234 + assert proficiency_bonus(data) == expected, f"level {level}" 163 235 164 - def test_hp_clamped_message(self, char_ctx: ToolContext, basic_character: dict): 165 - """Update result should indicate HP was clamped.""" 166 - result = update_character( 167 - updates={"hp.current": -10}, 168 - ctx=char_ctx, 236 + def test_skill_modifier_with_expertise(self, mira: dict): 237 + total, breakdown = skill_modifier(mira, "stealth") 238 + # +4 dex, +4 expertise (2 prof bonus * 2) 239 + assert total == 8 240 + assert any("dex" in b.lower() for b in breakdown) 241 + assert any("expertise" in b.lower() for b in breakdown) 242 + 243 + def test_skill_modifier_proficient(self, mira: dict): 244 + total, breakdown = skill_modifier(mira, "perception") 245 + # +2 wis, +2 prof 246 + assert total == 4 247 + assert any("proficient" in b.lower() for b in breakdown) 248 + 249 + def test_skill_modifier_no_proficiency(self, mira: dict): 250 + total, _ = skill_modifier(mira, "athletics") 251 + # +0 str, no prof 252 + assert total == 0 253 + 254 + def test_save_modifier_proficient(self, mira: dict): 255 + total, breakdown = save_modifier(mira, "dexterity") 256 + # +4 dex, +2 prof 257 + assert total == 6 258 + assert any("proficient" in b.lower() for b in breakdown) 259 + 260 + def test_save_modifier_not_proficient(self, mira: dict): 261 + total, _ = save_modifier(mira, "wisdom") 262 + # +2 wis only 263 + assert total == 2 264 + 265 + def test_passive_perception(self, mira: dict): 266 + # 10 + perception modifier (+4) = 14 267 + assert passive_score(mira, "perception") == 14 268 + 269 + def test_effective_hp_with_temp(self, player_dir: Path): 270 + save_character( 271 + "test-player", 272 + {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}, 273 + player_dir, 169 274 ) 275 + data = load_character("test-player", player_dir) 276 + hp = effective_hp(data) 277 + assert hp["effective"] == 25 278 + assert hp["current"] == 20 279 + assert hp["temp"] == 5 170 280 171 - assert "clamped to 0" in result 281 + def test_is_proficient_in(self, mira: dict): 282 + assert is_proficient_in(mira, "stealth") 283 + assert is_proficient_in(mira, "perception") 284 + assert not is_proficient_in(mira, "athletics") 172 285 173 - def test_hp_cannot_exceed_max(self, char_ctx: ToolContext, basic_character: dict): 174 - """HP cannot exceed maximum.""" 286 + def test_has_expertise_in(self, mira: dict): 287 + assert has_expertise_in(mira, "stealth") 288 + assert not has_expertise_in(mira, "perception") 289 + assert not has_expertise_in(mira, "athletics") 290 + 291 + def test_skill_to_ability_complete(self): 292 + # Every skill mapped 293 + assert "acrobatics" in SKILL_TO_ABILITY 294 + assert "stealth" in SKILL_TO_ABILITY 295 + assert "investigation" in SKILL_TO_ABILITY 296 + assert len(SKILL_TO_ABILITY) == 18 297 + 298 + def test_abilities_constant(self): 299 + assert len(ABILITIES) == 6 300 + 301 + 302 + # --- Display tests --- 303 + 304 + 305 + class TestDisplay: 306 + def test_format_status_includes_name_and_class(self, mira: dict): 307 + result = format_status(mira) 308 + assert "Mira" in result 309 + assert "Rogue" in result 310 + assert "Thief" in result 311 + 312 + def test_format_status_includes_hp(self, mira: dict): 313 + result = format_status(mira) 314 + assert "24/24" in result 315 + 316 + def test_format_status_includes_purse(self, mira: dict): 317 + result = format_status(mira) 318 + assert "43 gp" in result 319 + 320 + def test_format_sheet_includes_skills(self, mira: dict): 321 + result = format_sheet(mira) 322 + assert "Stealth" in result 323 + assert "+8" in result 324 + 325 + def test_format_sheet_shows_expertise_marker(self, mira: dict): 326 + result = format_sheet(mira) 327 + # Stealth has expertise, marked with ★★ 328 + assert "★★" in result 329 + 330 + def test_format_sheet_includes_passive_perception(self, mira: dict): 331 + result = format_sheet(mira) 332 + assert "Passive Perception" in result 333 + assert "14" in result 334 + 335 + def test_format_character_context_includes_advancement( 336 + self, mira: dict, player_dir: Path 337 + ): 175 338 update_character( 176 - updates={"hp.current": 100}, 177 - ctx=char_ctx, 339 + "test-player", {"advancement_ready": 4}, base_path=player_dir 178 340 ) 341 + data = load_character("test-player", player_dir) 342 + result = format_character_context(data) 343 + assert "Advancement Ready" in result 344 + assert "Level 4" in result 179 345 180 - char = load_character("test-player", char_ctx.base_path) 181 - assert char["hp"]["current"] == 12 # max HP is 12 182 346 183 - def test_hp_exceeds_max_clamped_message(self, char_ctx: ToolContext, basic_character: dict): 184 - """Update result should indicate HP was clamped to max.""" 185 - result = update_character( 186 - updates={"hp.current": 999}, 187 - ctx=char_ctx, 347 + # --- Operations tests --- 348 + 349 + 350 + class TestDamageHeal: 351 + def test_damage_subtracts_hp(self, mira: dict, player_dir: Path): 352 + damage("test-player", 5, base_path=player_dir) 353 + data = load_character("test-player", player_dir) 354 + assert data["state"]["hp"]["current"] == 19 355 + 356 + def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): 357 + update_character( 358 + "test-player", {"state.hp.temp": 5}, base_path=player_dir 188 359 ) 360 + damage("test-player", 3, base_path=player_dir) 361 + data = load_character("test-player", player_dir) 362 + assert data["state"]["hp"]["temp"] == 2 363 + assert data["state"]["hp"]["current"] == 24 189 364 190 - assert "clamped to 12" in result 365 + def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): 366 + update_character( 367 + "test-player", {"state.hp.temp": 5}, base_path=player_dir 368 + ) 369 + damage("test-player", 8, base_path=player_dir) 370 + data = load_character("test-player", player_dir) 371 + assert data["state"]["hp"]["temp"] == 0 372 + assert data["state"]["hp"]["current"] == 21 191 373 192 - def test_valid_hp_not_clamped(self, char_ctx: ToolContext, basic_character: dict): 193 - """Valid HP values should not be modified.""" 194 - result = update_character( 195 - updates={"hp.current": 6}, 196 - ctx=char_ctx, 197 - ) 374 + def test_damage_clamps_to_zero(self, mira: dict, player_dir: Path): 375 + damage("test-player", 100, base_path=player_dir) 376 + data = load_character("test-player", player_dir) 377 + assert data["state"]["hp"]["current"] == 0 198 378 199 - assert "clamped" not in result 200 - char = load_character("test-player", char_ctx.base_path) 201 - assert char["hp"]["current"] == 6 379 + def test_damage_at_zero_mentions_death_saves( 380 + self, mira: dict, player_dir: Path 381 + ): 382 + result = damage("test-player", 100, base_path=player_dir) 383 + assert "death save" in result.lower() 202 384 203 - def test_hp_zero_is_valid(self, char_ctx: ToolContext, basic_character: dict): 204 - """Setting HP to exactly 0 is valid (unconscious).""" 205 - result = update_character( 206 - updates={"hp.current": 0}, 207 - ctx=char_ctx, 208 - ) 385 + def test_heal_restores_hp(self, mira: dict, player_dir: Path): 386 + damage("test-player", 10, base_path=player_dir) 387 + heal("test-player", 5, base_path=player_dir) 388 + data = load_character("test-player", player_dir) 389 + assert data["state"]["hp"]["current"] == 19 209 390 210 - assert "clamped" not in result 211 - char = load_character("test-player", char_ctx.base_path) 212 - assert char["hp"]["current"] == 0 391 + def test_heal_clamped_to_max(self, mira: dict, player_dir: Path): 392 + heal("test-player", 100, base_path=player_dir) 393 + data = load_character("test-player", player_dir) 394 + assert data["state"]["hp"]["current"] == 24 213 395 214 - def test_hp_max_is_valid(self, char_ctx: ToolContext, basic_character: dict): 215 - """Setting HP to exactly max is valid.""" 216 - result = update_character( 217 - updates={"hp.current": 12}, 218 - ctx=char_ctx, 219 - ) 396 + def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): 397 + result = damage("test-player", 3, damage_type="fire", base_path=player_dir) 398 + assert "fire" in result 220 399 221 - assert "clamped" not in result 222 - char = load_character("test-player", char_ctx.base_path) 223 - assert char["hp"]["current"] == 12 224 400 225 - def test_damage_calculation_example(self, char_ctx: ToolContext, basic_character: dict): 226 - """Simulate taking 20 damage when at 12 HP - should clamp to 0.""" 227 - # Character starts at 12/12 HP 228 - # Takes 20 damage, DM sets hp.current = -8 229 - # Should be clamped to 0 401 + class TestEffects: 402 + def test_add_effect_appends(self, mira: dict, player_dir: Path): 403 + add_effect("test-player", "Bless", "+1d4 to attacks", base_path=player_dir) 404 + data = load_character("test-player", player_dir) 405 + assert len(data["effects"]) == 1 406 + assert data["effects"][0]["source"] == "Bless" 230 407 231 - update_character( 232 - updates={"hp.current": -8}, 233 - ctx=char_ctx, 408 + def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): 409 + add_effect( 410 + "test-player", "Potion", "+10 temp HP", 411 + expires="d1-1430", base_path=player_dir, 234 412 ) 413 + data = load_character("test-player", player_dir) 414 + assert data["effects"][0]["expires"] == "d1-1430" 235 415 236 - char = load_character("test-player", char_ctx.base_path) 237 - assert char["hp"]["current"] == 0 416 + def test_remove_effect_by_source(self, mira: dict, player_dir: Path): 417 + add_effect("test-player", "Bless", "+1d4", base_path=player_dir) 418 + result = remove_effect("test-player", "bless", base_path=player_dir) 419 + data = load_character("test-player", player_dir) 420 + assert len(data["effects"]) == 0 421 + assert "Bless" in result 238 422 423 + def test_remove_effect_substring_match(self, mira: dict, player_dir: Path): 424 + add_effect("test-player", "Potion of Heroism", "+10 temp HP", base_path=player_dir) 425 + remove_effect("test-player", "Heroism", base_path=player_dir) 426 + data = load_character("test-player", player_dir) 427 + assert len(data["effects"]) == 0 239 428 240 - # ── Display formatting ─────────────────────────────────────────────────── 429 + def test_remove_effect_not_found(self, mira: dict, player_dir: Path): 430 + result = remove_effect("test-player", "Nonexistent", base_path=player_dir) 431 + assert "no effect matching" in result.lower() 241 432 242 433 243 - @pytest.fixture 244 - def rich_character() -> dict: 245 - """A character dict with all fields populated for display tests.""" 246 - return { 247 - "name": "Mira Ashvale", 248 - "race": "Human", 249 - "class": "Rogue", 250 - "level": 3, 251 - "background": "Criminal", 252 - "hp": {"current": 20, "max": 24}, 253 - "ac": 16, 254 - "speed": 30, 255 - "abilities": { 256 - "strength": 11, 257 - "dexterity": 18, 258 - "constitution": 14, 259 - "intelligence": 15, 260 - "wisdom": 14, 261 - "charisma": 18, 262 - }, 263 - "purse": {"cp": 40, "sp": 20, "ep": 0, "gp": 93, "pp": 0}, 264 - "body": ( 265 - "## Proficiencies\n" 266 - "Armor: Light.\n\n" 267 - "## Features\n" 268 - "- Sneak Attack 2d6\n" 269 - "- Cunning Action\n\n" 270 - "## Equipment\n" 271 - "- Thieves' tools\n" 272 - "- Dagger\n\n" 273 - "## Backstory\n" 274 - "A sharp-tongued grifter." 275 - ), 276 - } 434 + class TestConditions: 435 + def test_add_condition(self, mira: dict, player_dir: Path): 436 + add_condition("test-player", "Poisoned", base_path=player_dir) 437 + data = load_character("test-player", player_dir) 438 + assert "Poisoned" in data["conditions"] 277 439 440 + def test_add_condition_no_duplicate(self, mira: dict, player_dir: Path): 441 + add_condition("test-player", "Prone", base_path=player_dir) 442 + result = add_condition("test-player", "prone", base_path=player_dir) 443 + data = load_character("test-player", player_dir) 444 + assert len(data["conditions"]) == 1 445 + assert "already" in result.lower() 278 446 279 - class TestFormatStatus: 280 - """Tests for compact /status display.""" 447 + def test_remove_condition(self, mira: dict, player_dir: Path): 448 + add_condition("test-player", "Frightened", base_path=player_dir) 449 + remove_condition("test-player", "Frightened", base_path=player_dir) 450 + data = load_character("test-player", player_dir) 451 + assert "Frightened" not in data["conditions"] 281 452 282 - def test_identity_line(self, rich_character: dict): 283 - result = format_status(rich_character) 284 - assert "**Mira Ashvale**" in result 285 - assert "Human Rogue 3" in result 286 - assert "(Criminal)" in result 287 453 288 - def test_vitals(self, rich_character: dict): 289 - result = format_status(rich_character) 290 - assert "HP 20/24" in result 291 - assert "AC 16" in result 292 - assert "Speed 30 ft" in result 454 + class TestInventory: 455 + def test_add_item_to_default_location(self, mira: dict, player_dir: Path): 456 + add_item("test-player", "Lockpicks", base_path=player_dir) 457 + data = load_character("test-player", player_dir) 458 + # Should create on_person if no equipment exists 459 + all_items = [] 460 + for items in data["equipment"].values(): 461 + all_items.extend(items) 462 + assert "Lockpicks" in all_items 293 463 294 - def test_abilities(self, rich_character: dict): 295 - result = format_status(rich_character) 296 - assert "STR 11 (+0)" in result 297 - assert "DEX 18 (+4)" in result 298 - assert "CHA 18 (+4)" in result 464 + def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): 465 + add_item( 466 + "test-player", "Rope (50ft)", location="backpack", 467 + base_path=player_dir, 468 + ) 469 + data = load_character("test-player", player_dir) 470 + assert "Rope (50ft)" in data["equipment"]["backpack"] 299 471 300 - def test_purse_nonzero_only(self, rich_character: dict): 301 - result = format_status(rich_character) 302 - assert "93 gp" in result 303 - assert "20 sp" in result 304 - assert "40 cp" in result 305 - assert "ep" not in result 306 - assert "pp" not in result 472 + def test_add_item_substring_location_match(self, mira: dict, player_dir: Path): 473 + add_item("test-player", "First", location="on_person", base_path=player_dir) 474 + add_item("test-player", "Second", location="On Person", base_path=player_dir) 475 + data = load_character("test-player", player_dir) 476 + # Both should land in the same location 477 + assert "First" in data["equipment"]["on_person"] 478 + assert "Second" in data["equipment"]["on_person"] 307 479 308 - def test_equipment_one_liner(self, rich_character: dict): 309 - result = format_status(rich_character) 310 - assert "Thieves' tools" in result 311 - assert "Dagger" in result 480 + def test_remove_item_substring(self, mira: dict, player_dir: Path): 481 + add_item("test-player", "Boots of Elvenkind (worn)", base_path=player_dir) 482 + result = remove_item("test-player", "Boots", base_path=player_dir) 483 + assert "Boots of Elvenkind" in result 312 484 313 - def test_equipment_excluded(self, rich_character: dict): 314 - result = format_status(rich_character, include_equipment=False) 315 - assert "Thieves' tools" not in result 485 + def test_remove_item_not_found(self, mira: dict, player_dir: Path): 486 + result = remove_item("test-player", "Nonexistent", base_path=player_dir) 487 + assert "no item matching" in result.lower() 316 488 317 - def test_no_background(self): 318 - data = { 319 - "name": "Test", "race": "Elf", "class": "Wizard", "level": 1, 320 - "hp": {"current": 6, "max": 6}, "ac": 12, "speed": 30, 321 - "abilities": {}, "body": "", 322 - } 323 - result = format_status(data) 324 - assert "(" not in result 325 489 326 - def test_legacy_gold_field(self): 327 - data = { 328 - "name": "Old", "race": "Human", "class": "Fighter", "level": 1, 329 - "hp": 10, "ac": 14, "speed": 30, "gold": 50, "body": "", 330 - } 331 - result = format_status(data) 332 - assert "50 gp" in result 490 + class TestMagicItems: 491 + def test_set_item_status_attuned(self, mira: dict, player_dir: Path): 492 + set_item_status( 493 + "test-player", "Bracer of the Unseen Step", "attuned", 494 + base_path=player_dir, 495 + ) 496 + data = load_character("test-player", player_dir) 497 + assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] 333 498 334 - def test_empty_purse(self): 335 - data = { 336 - "name": "Broke", "race": "Human", "class": "Rogue", "level": 1, 337 - "hp": {"current": 5, "max": 5}, "ac": 10, "speed": 30, 338 - "purse": {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, 339 - "body": "", 340 - } 341 - result = format_status(data) 342 - assert "empty" in result 499 + def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): 500 + set_item_status( 501 + "test-player", "Cloak", "carried", base_path=player_dir 502 + ) 503 + set_item_status( 504 + "test-player", "Cloak", "equipped", base_path=player_dir 505 + ) 506 + data = load_character("test-player", player_dir) 507 + assert "[[Cloak]]" not in data["magic_items"]["carried"] 508 + assert "[[Cloak]]" in data["magic_items"]["equipped"] 343 509 510 + def test_set_item_status_invalid(self, mira: dict, player_dir: Path): 511 + result = set_item_status( 512 + "test-player", "Cloak", "invalid", base_path=player_dir 513 + ) 514 + assert "invalid status" in result.lower() 344 515 345 - class TestFormatSheet: 346 - """Tests for full /me display.""" 347 516 348 - def test_includes_status_header(self, rich_character: dict): 349 - result = format_sheet(rich_character) 350 - assert "**Mira Ashvale**" in result 351 - assert "HP 20/24" in result 517 + class TestResources: 518 + def test_use_resource_decrements(self, mira: dict, player_dir: Path): 519 + use_resource("test-player", "hit_dice", base_path=player_dir) 520 + data = load_character("test-player", player_dir) 521 + assert data["resources"]["hit_dice_d8"]["current"] == 2 352 522 353 - def test_includes_all_sections(self, rich_character: dict): 354 - result = format_sheet(rich_character) 355 - assert "**Proficiencies**" in result 356 - assert "**Features**" in result 357 - assert "**Equipment**" in result 358 - assert "**Backstory**" in result 523 + def test_use_resource_amount(self, mira: dict, player_dir: Path): 524 + use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) 525 + data = load_character("test-player", player_dir) 526 + assert data["resources"]["hit_dice_d8"]["current"] == 1 359 527 360 - def test_equipment_as_list(self, rich_character: dict): 361 - result = format_sheet(rich_character) 362 - assert "- Thieves' tools" in result 363 - assert "- Dagger" in result 528 + def test_use_resource_clamped_to_zero(self, mira: dict, player_dir: Path): 529 + result = use_resource( 530 + "test-player", "hit_dice", amount=10, base_path=player_dir 531 + ) 532 + data = load_character("test-player", player_dir) 533 + assert data["resources"]["hit_dice_d8"]["current"] == 0 534 + assert "short" in result.lower() 364 535 365 - def test_skips_empty_sections(self): 366 - data = { 367 - "name": "Sparse", "race": "Human", "class": "Fighter", "level": 1, 368 - "hp": {"current": 10, "max": 10}, "ac": 14, "speed": 30, 369 - "body": "## Features\n- Tough\n\n## Notes\n", 370 - } 371 - result = format_sheet(data) 372 - assert "**Features**" in result 373 - assert "**Notes**" not in result 536 + def test_use_resource_not_found(self, mira: dict, player_dir: Path): 537 + result = use_resource("test-player", "nonexistent", base_path=player_dir) 538 + assert "no resource matching" in result.lower() 374 539 375 - def test_no_equipment_one_liner(self, rich_character: dict): 376 - result = format_sheet(rich_character) 377 - equipment_one_liners = [ 378 - l for l in result.splitlines() if l.startswith("**Equipment:**") 379 - ] 380 - assert len(equipment_one_liners) == 0 540 + def test_restore_resource_clamped_to_max(self, mira: dict, player_dir: Path): 541 + use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) 542 + restore_resource( 543 + "test-player", "hit_dice", 100, base_path=player_dir 544 + ) 545 + data = load_character("test-player", player_dir) 546 + assert data["resources"]["hit_dice_d8"]["current"] == 3 381 547 382 548 383 - class TestFormatCharacterContext: 384 - """Tests for system prompt character context.""" 549 + class TestRest: 550 + def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): 551 + use_resource("test-player", "hit_dice", amount=3, base_path=player_dir) 552 + rest("test-player", "long", base_path=player_dir) 553 + data = load_character("test-player", player_dir) 554 + assert data["resources"]["hit_dice_d8"]["current"] == 3 385 555 386 - def test_includes_name_and_class(self, rich_character: dict): 387 - result = format_character_context(rich_character) 388 - assert "Mira Ashvale" in result 389 - assert "Rogue" in result 556 + def test_long_rest_restores_hp(self, mira: dict, player_dir: Path): 557 + damage("test-player", 10, base_path=player_dir) 558 + rest("test-player", "long", base_path=player_dir) 559 + data = load_character("test-player", player_dir) 560 + assert data["state"]["hp"]["current"] == 24 390 561 391 - def test_includes_abilities_with_modifiers(self, rich_character: dict): 392 - result = format_character_context(rich_character) 393 - assert "STR 11 (+0)" in result 394 - assert "DEX 18 (+4)" in result 562 + def test_long_rest_clears_death_saves(self, mira: dict, player_dir: Path): 563 + update_character( 564 + "test-player", 565 + {"state.death_saves.successes": 2, "state.death_saves.failures": 1}, 566 + base_path=player_dir, 567 + ) 568 + rest("test-player", "long", base_path=player_dir) 569 + data = load_character("test-player", player_dir) 570 + assert data["state"]["death_saves"]["successes"] == 0 571 + assert data["state"]["death_saves"]["failures"] == 0 395 572 396 - def test_includes_purse(self, rich_character: dict): 397 - result = format_character_context(rich_character) 398 - assert "93 gp" in result 573 + def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): 574 + update_character( 575 + "test-player", {"state.exhaustion": 3}, base_path=player_dir 576 + ) 577 + rest("test-player", "long", base_path=player_dir) 578 + data = load_character("test-player", player_dir) 579 + assert data["state"]["exhaustion"] == 2 399 580 400 - def test_includes_body(self, rich_character: dict): 401 - result = format_character_context(rich_character) 402 - assert "Sneak Attack" in result 581 + def test_short_rest_doesnt_refresh_long_rest_resources( 582 + self, mira: dict, player_dir: Path 583 + ): 584 + use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) 585 + rest("test-player", "short", base_path=player_dir) 586 + data = load_character("test-player", player_dir) 587 + # hit_dice has refresh: long_rest, so short rest shouldn't refresh it 588 + assert data["resources"]["hit_dice_d8"]["current"] == 1 403 589 590 + def test_invalid_rest_type(self, mira: dict, player_dir: Path): 591 + result = rest("test-player", "epic", base_path=player_dir) 592 + assert "invalid" in result.lower() 404 593 405 - class TestPurseClamping: 406 - """Tests for purse validation in update_character.""" 407 594 408 - def test_negative_coins_clamped_to_zero(self, basic_character: dict, char_ctx: ToolContext): 409 - result = update_character({"purse.sp": -20}, char_ctx) 410 - data = load_character("test-player", char_ctx.base_path) 411 - assert data["purse"]["sp"] == 0 412 - assert "clamped" in result.lower() 595 + class TestCoins: 596 + def test_adjust_coins_spending(self, mira: dict, player_dir: Path): 597 + adjust_coins("test-player", {"gp": -5}, base_path=player_dir) 598 + data = load_character("test-player", player_dir) 599 + assert data["state"]["purse"]["gp"] == 38 413 600 414 - def test_valid_coins_not_clamped(self, basic_character: dict, char_ctx: ToolContext): 415 - result = update_character({"purse.gp": 25}, char_ctx) 416 - data = load_character("test-player", char_ctx.base_path) 417 - assert data["purse"]["gp"] == 25 418 - assert "clamped" not in result.lower() 601 + def test_adjust_coins_gaining(self, mira: dict, player_dir: Path): 602 + adjust_coins("test-player", {"gp": 10, "sp": 5}, base_path=player_dir) 603 + data = load_character("test-player", player_dir) 604 + assert data["state"]["purse"]["gp"] == 53 605 + assert data["state"]["purse"]["sp"] == 5 419 606 420 - def test_zero_coins_not_clamped(self, basic_character: dict, char_ctx: ToolContext): 421 - update_character({"purse.gp": 0}, char_ctx) 422 - data = load_character("test-player", char_ctx.base_path) 423 - assert data["purse"]["gp"] == 0 607 + def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path): 608 + result = adjust_coins( 609 + "test-player", {"gp": -100}, base_path=player_dir 610 + ) 611 + data = load_character("test-player", player_dir) 612 + assert data["state"]["purse"]["gp"] == 0 613 + assert "short" in result.lower() 424 614 425 615 426 - class TestAdjustCoins: 427 - """Tests for the adjust_coins function.""" 616 + class TestNotes: 617 + def test_add_note_creates_file(self, mira: dict, player_dir: Path): 618 + add_note("test-player", "Found a secret door", base_path=player_dir) 619 + notes_path = player_dir / "players" / "test-player" / "notes.md" 620 + assert notes_path.exists() 621 + assert "secret door" in notes_path.read_text() 428 622 429 - def test_spend_coins(self, basic_character: dict, char_ctx: ToolContext): 430 - from storied.character import adjust_coins 431 - result = adjust_coins("test-player", {"gp": -5}, char_ctx.base_path) 432 - data = load_character("test-player", char_ctx.base_path) 433 - assert data["purse"]["gp"] == 45 434 - assert "45" in result 623 + def test_add_note_appends(self, mira: dict, player_dir: Path): 624 + add_note("test-player", "First note", base_path=player_dir) 625 + add_note("test-player", "Second note", base_path=player_dir) 626 + notes_path = player_dir / "players" / "test-player" / "notes.md" 627 + content = notes_path.read_text() 628 + assert "First note" in content 629 + assert "Second note" in content 435 630 436 - def test_gain_coins(self, basic_character: dict, char_ctx: ToolContext): 437 - from storied.character import adjust_coins 438 - adjust_coins("test-player", {"gp": 10}, char_ctx.base_path) 439 - data = load_character("test-player", char_ctx.base_path) 440 - assert data["purse"]["gp"] == 60 631 + def test_add_note_with_anchor(self, mira: dict, player_dir: Path): 632 + add_note( 633 + "test-player", "Witnessed the heist", 634 + time_anchor="d28-1330", base_path=player_dir, 635 + ) 636 + notes_path = player_dir / "players" / "test-player" / "notes.md" 637 + assert "d28-1330" in notes_path.read_text() 441 638 442 - def test_spend_more_than_have(self, basic_character: dict, char_ctx: ToolContext): 443 - from storied.character import adjust_coins 444 - result = adjust_coins("test-player", {"gp": -100}, char_ctx.base_path) 445 - data = load_character("test-player", char_ctx.base_path) 446 - assert data["purse"]["gp"] == 0 447 - assert "short" in result.lower() 448 639 449 - def test_multi_denomination(self, basic_character: dict, char_ctx: ToolContext): 450 - from storied.character import adjust_coins 451 - adjust_coins("test-player", {"gp": -5, "sp": 10}, char_ctx.base_path) 452 - data = load_character("test-player", char_ctx.base_path) 453 - assert data["purse"]["gp"] == 45 454 - assert data["purse"]["sp"] == 10 640 + # --- Edge cases --- 455 641 456 - def test_returns_new_balance(self, basic_character: dict, char_ctx: ToolContext): 457 - from storied.character import adjust_coins 458 - result = adjust_coins("test-player", {"gp": -5}, char_ctx.base_path) 459 - assert "gp" in result.lower() 460 642 461 - def test_no_character(self, char_ctx: ToolContext): 462 - from storied.character import adjust_coins 463 - result = adjust_coins("nonexistent", {"gp": 5}, char_ctx.base_path) 464 - assert "no character" in result.lower() 643 + class TestEdgeCases: 644 + def test_no_character_returns_error_for_each_op(self, player_dir: Path): 645 + for fn, args in [ 646 + (damage, (5,)), 647 + (heal, (5,)), 648 + (add_effect, ("Source", "Desc")), 649 + (remove_effect, ("Source",)), 650 + (add_condition, ("Poisoned",)), 651 + (remove_condition, ("Poisoned",)), 652 + (add_item, ("Item",)), 653 + (remove_item, ("Item",)), 654 + (set_item_status, ("Item", "attuned")), 655 + (use_resource, ("res",)), 656 + (restore_resource, ("res", 1)), 657 + (rest, ("short",)), 658 + (adjust_coins, ({"gp": 5},)), 659 + ]: 660 + result = fn("missing-player", *args, base_path=player_dir) 661 + assert "no character" in result.lower(), f"{fn.__name__} failed"
+21 -4
tests/test_execute_tool.py
··· 45 45 }, ctx) 46 46 47 47 result = execute_tool( 48 - "update_character", {"updates": {"hp.current": 8}}, ctx, 48 + "update_character", {"updates": {"state.hp.current": 8}}, ctx, 49 49 ) 50 + assert "updated" in result.lower() 50 51 51 - assert "updated" in result.lower() 52 + # Verify the value actually landed in the right place in the new schema 53 + from storied.character import load_character 54 + data = load_character(ctx.player_id, ctx.base_path) 55 + assert data["state"]["hp"]["current"] == 8, ( 56 + "update_character should write to state.hp.current with the new schema, " 57 + f"but state.hp.current is {data['state']['hp']['current']}" 58 + ) 52 59 53 60 def test_set_scene(self, ctx: ToolContext): 54 61 result = execute_tool("set_scene", { ··· 277 284 assert "synced" in result 278 285 from storied.character import load_character 279 286 char = load_character(ctx.player_id, ctx.base_path) 280 - assert char["hp"]["current"] == 18 287 + # Must land in the new nested schema location, not flat hp.current 288 + assert char["state"]["hp"]["current"] == 18, ( 289 + "_sync_player_hp must write to state.hp.current with the new schema" 290 + ) 281 291 282 292 def test_heal_syncs_player_hp(self, ctx: ToolContext): 283 293 execute_tool("create_character", { ··· 288 298 }, 289 299 "hp_max": 25, "ac": 16, 290 300 }, ctx) 301 + # Set the character's HP to 20 first (matching the combatant) 302 + execute_tool( 303 + "update_character", {"updates": {"state.hp.current": 20}}, ctx, 304 + ) 291 305 ctx.initiative.begin([ 292 306 Combatant(name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True), 293 307 ]) ··· 297 311 assert "synced" in result 298 312 from storied.character import load_character 299 313 char = load_character(ctx.player_id, ctx.base_path) 300 - assert char["hp"]["current"] == 23 314 + # Must land in the new nested schema location 315 + assert char["state"]["hp"]["current"] == 23, ( 316 + "_sync_player_hp must write to state.hp.current with the new schema" 317 + ) 301 318 302 319 def test_damage_no_sync_for_non_player(self, ctx: ToolContext): 303 320 ctx.initiative.begin([
+3 -1
tests/test_mcp_server.py
··· 61 61 names = {d["name"] for d in defs} 62 62 63 63 assert "next_turn" not in names 64 - assert "damage" not in names 65 64 assert "end_initiative" not in names 65 + # Note: damage/heal exist as character tools out of combat (no target), 66 + # and as initiative tools in combat (with target). They share names. 67 + assert "damage" in names # the character version 66 68 67 69 def test_narrative_mode_count(self, ctx: ToolContext): 68 70 defs = _dm_tool_definitions(ctx)
+1 -1
tests/test_seeder.py
··· 76 76 ac=16, 77 77 background="Soldier", 78 78 purse={"gp": 50}, 79 - equipment=["Longsword", "Chain mail", "Shield"], 79 + equipment={"on_person": ["Longsword", "Chain mail", "Shield"]}, 80 80 backstory="A former soldier haunted by a battle gone wrong.", 81 81 base_path=tmp_path, 82 82 )