A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Lean on the tools harder for 5e bookkeeping

The dm-system prompt has been claiming "this is a real 5e game with real
rules" for a while, but a lot of the mechanical enforcement was still
living in the LLM's head. This pulls the parts that are genuinely
unambiguous arithmetic down into the tool layer, and leaves the judgment
calls where they belong.

Highlights from the character-side work:

- `damage()` now applies resistance / vulnerability / immunity before
temp HP soaks. Same with the routing through `_sync_player_hp`.
- Exhaustion folds directly into displayed skill and save modifiers;
Poisoned/Frightened show a disadvantage marker on checks; Paralyzed/
Stunned/etc show ✗ auto-fail on Str/Dex saves. Passive perception
drops 5 when the character is disadvantaged on checks. Inspiration
and exhaustion get their own lines on the sheet.
- `add_effect(concentration=True)` enforces "one concentration at a
time" per 5e 2024, and `damage()` emits a "Concentration save DC X"
hint whenever the character takes damage while concentrating.
`break_concentration()` for failed saves.
- `level_up(class_name, new_level, hp_gain, features)` is an atomic
replacement for the five-step update_character dance at level-up.
It only appears in the DM's toolset when `advancement_ready` is set
on the sheet — the evaluator agent grants access, the DM picks the
narrative moment. The prompt spells out that the *player* still
makes the mechanical choices (ASI/feat, subclass, spells, etc).
- Spell slots turn out to be shape-compatible with ResourcePool, so
no schema change. The prompt just documents the `resources.slot_N`
convention so `use_resource("slot_3")` and `rest("long")` handle
casting and refreshing for free.

World-state side:

- `establish()` refuses to create an NPC entity that matches the
player character's name. The PC lives on the sheet, not as an NPC,
and the prompts have always said so — this makes it actually true.
- `mark()` grew a `when:` parameter so the world-tick agent can
backdate off-screen events without manually prefixing the event
text. Kills the `#dX-HHMM | #dY-HHMM | ...` double-anchor pattern.
- `_auto_mark_present` is rate-limited (20 in-game-minute cooldown
per entity) and skips session-lifecycle events. Scene-dense NPCs
like a constant travel companion will stop accumulating one Was
entry per set_scene.
- `amend_mark` replaces the most recent Was entry instead of
appending, so "Retcon:" lines stop leaking into the fiction.
- `maps` is now in the EstablishType enum — the seed and DM prompts
had been telling agents to use it, but the tool was rejecting it.

Small things I tripped over on the way:

- `Duration.parse`'s rounds-to-minutes math was `rounds // 10`, so a
3-round fight advanced the clock by 1 minute instead of ~0, and a
15-round fight also advanced by 1. Now uses `round(rounds * 6 / 60)`
with a 1-minute floor, so 10 rounds is 1 minute and 20 rounds is 2.
- `/save` and `/note` were puppeteering the DM via `[System: ... call
add_note ...]` injected into the user message. Both are operational,
not in-fiction events, so they now call the ops layer directly.
- `/context` was missing the MCP tool-schema surface from its estimate
entirely — those definitions are non-trivial and flow into the model
every turn. Added a `Tool Schemas` section that walks the composed
server's list_tools() and sums up name + description + serialized
parameters.
- Deleted a `lore/Search Test.md` stray I found while reviewing the
campaign and purged it from the vector index.
- Cleaned up the `TYPE_CHECKING → _FastMCP` alias pattern in both
combat.py and character.py after noticing it was cargo-culted from
an earlier refactor — the runtime FastMCP import is already there.
- Suppressed ruff B008 project-wide. It fires on every FastMCP tool's
`root: Path = StorageRoot()` default, but those are uncalled_for
Dependency markers, not mutable defaults. Was adding 49 pre-existing
errors to the baseline and masking real regressions.

710 tests passing, coverage at 95.78%. A few files pushed past the loq
500-line cap as I added features — character/operations.py, tools/
entities.py, tools/character.py, and the matching test files. They're
on the list to split when we pick up the architectural refactors.

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

+1661 -64
+70 -5
prompts/dm-system.md
··· 355 355 - `remove_item("Boots")` — substring match across subsections 356 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 357 358 - **Resources** (limited-use class features, magic item charges, hit dice): 358 + **Resources** (limited-use class features, magic item charges, hit dice, **spell slots**): 359 359 - `use_resource("rage")` — decrement by 1 360 360 - `use_resource("hit_dice_d8", amount=2)` — decrement by 2 361 + - `use_resource("slot_3")` — cast a 3rd-level spell by burning one slot 361 362 - `rest("short")` or `rest("long")` — refreshes resources by their refresh type. Long rest also clears death saves, removes one exhaustion level, and restores HP. 363 + 364 + **Spell slots are resource pools.** There's no separate spellcasting tool — model each slot tier as a pool in `resources`. When you create a caster (or level one up), add slot pools via `update_character`: 365 + 366 + ``` 367 + update_character({ 368 + "resources.slot_1": {"current": 4, "max": 4, "refresh": "long_rest", "notes": "1st-level spell slots"}, 369 + "resources.slot_2": {"current": 3, "max": 3, "refresh": "long_rest", "notes": "2nd-level spell slots"}, 370 + "resources.slot_3": {"current": 2, "max": 2, "refresh": "long_rest", "notes": "3rd-level spell slots"} 371 + }) 372 + ``` 373 + 374 + Use the canonical `slot_<level>` naming so `use_resource("slot_3")` is unambiguous. When the character casts, `use_resource("slot_N")`; when they long-rest, `rest("long")` refreshes everything automatically. For warlock Pact Magic slots (short-rest refresh), set `"refresh": "short_rest"` — same tool, same pattern. 362 375 363 376 **Notes**: 364 377 - `add_note("The miller mentioned strange lights at the old mill")` — appends to notes.md with current game time ··· 384 397 385 398 A background system evaluates whether the character has earned a level-up. When it decides they have, you'll see an `advancement_ready` field on the character sheet and a notification in "Recent World Changes." 386 399 400 + **The `level_up` tool only appears in your toolset when `advancement_ready` is set.** If you see it, the character has earned their next level — it's up to you to choose the right narrative moment to apply it. 401 + 387 402 **When `advancement_ready` is set:** 388 403 1. Don't rush it — find a narratively appropriate moment (a rest, a quiet pause, after a triumph) 389 404 2. Use `recall` to look up the character's class features for the new level 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 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...]}` 392 - 5. Include `level` in the `set_scene` tags for this moment: `tags=["level"]` 405 + 3. **Let the player make their choices.** This is one of the few moments where the player's mechanical agency matters. Before calling `level_up`, present any decisions the new level brings and wait for the player to decide: 406 + - **Subclass** if they're hitting the subclass-choice level for their class (usually level 3 for most classes, 1 for Cleric/Sorcerer/Warlock, 2 for Druid/Wizard) 407 + - **Ability Score Improvement or Feat** at levels 4, 8, 12, 16, and 19 408 + - **New spells known / prepared** for casters 409 + - **Feature choices** like Fighting Style, Eldritch Invocations, Metamagic, Expertise skills, etc. 410 + - **Multiclass consideration** if the player hinted they're thinking about branching out — offer it as an option if it fits, don't steer them 411 + Present the options in-fiction where you can ("you find yourself reflecting on two paths..."), mechanics where you must. Let them sit with the choice if they want to. 412 + 4. Narrate the growth as part of the story — the character reflects on what they've learned, feels a new confidence, discovers a new ability 413 + 5. Once the choices are resolved, call `level_up` with the class name, new level, HP gain (the rolled/averaged hit die plus Con modifier), and the full updated features list reflecting the player's choices. The tool handles the rest — max HP, current HP, `level_since`, and clearing the flag — in one call. Don't compose an `update_character` by hand for this. 414 + 6. If the level added new spell slots or changed existing ones, update the `resources` pools accordingly (`update_character({"resources.slot_3": {...}})` — see the Resources section). 415 + 7. Include `level` in the `set_scene` tags for this moment: `tags=["level"]` 416 + 417 + ``` 418 + level_up( 419 + class_name="Rogue", 420 + new_level=4, 421 + hp_gain=6, 422 + features=[ ...full list including the new level's features and any player choices... ] 423 + ) 424 + ``` 393 425 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. 426 + 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. But **never make their mechanical choices for them** — if level 4 offers ASI vs. feat, that's the player's call, not yours. 395 427 396 428 ## Session State 397 429 ··· 485 517 - An item is used or transferred 486 518 - A trigger from "Will" fires 487 519 520 + **Don't prefix the event text with a timestamp.** If you need to backdate 521 + an event (something that happened earlier today but you're only recording 522 + it now), pass the time anchor via the `when` parameter: 523 + 524 + ``` 525 + mark( 526 + entity_type="npcs", 527 + name="Dortha Cray", 528 + event="Sat at her kitchen table studying the family maps before dawn", 529 + when="d29-0500" # earlier than the current clock 530 + ) 531 + ``` 532 + 533 + Without `when`, the mark uses the current game time — which is what you 534 + want for most in-scene events. Note: `set_scene` auto-marks present 535 + entities once per scene, rate-limited. You don't need to call `mark` 536 + explicitly for "so-and-so was in the room." 537 + 488 538 If the event resolves a Will trigger, include `resolves` to remove it: 489 539 490 540 Example: ··· 498 548 ``` 499 549 500 550 This builds history. Over time, entities accumulate a story of what happened to them, creating continuity across sessions. 551 + 552 + ### Fixing a Recent Mark 553 + 554 + If you want to correct or extend the most recent Was entry on an entity — 555 + a retcon, a typo, more detail — use `amend_mark`. It replaces the most 556 + recent entry's text while keeping the original timestamp anchor. Don't 557 + append a new `mark` labeled "Retcon:" — use `amend_mark` instead. 558 + 559 + ``` 560 + amend_mark( 561 + entity_type="npcs", 562 + name="Margit", 563 + event="Walked Mira to the cabin under a half-moon — held her hand on the road." 564 + ) 565 + ``` 501 566 502 567 ## Wiki References 503 568
+5 -2
prompts/world-tick.md
··· 6 6 |------|---------| 7 7 | `recall` | Look up existing world content | 8 8 | `establish` | Update entities that changed | 9 - | `mark` | Record events that happened off-screen | 9 + | `mark` | Record events that happened off-screen (use `when` to backdate) | 10 + | `amend_mark` | Correct or extend the most recent Was entry on an entity | 10 11 | `notify_dm` | Tell the DM what changed so they can weave it in | 11 12 12 13 ## What You're Given ··· 26 27 - It creates interesting consequences the DM can narrate 27 28 28 29 When a trigger fires: 29 - 1. `mark` the entity with what happened 30 + 1. `mark` the entity with what happened. Pass `when="dX-HHMM"` if the 31 + event happened at a specific in-fiction time earlier than the current 32 + clock — don't prefix the event text with a timestamp yourself. 30 33 2. `establish` to update their state (new location, changed disposition, etc.) 31 34 3. Remove the fired trigger by updating the Will section 32 35
+5
pyproject.toml
··· 64 64 65 65 [tool.ruff.lint] 66 66 select = ["E", "F", "I", "UP", "B", "SIM"] 67 + # B008 fires on the FastMCP + uncalled_for dependency-injection idiom 68 + # (`root: Path = StorageRoot()` etc). Those aren't mutable defaults — 69 + # they're DI markers resolved at call time. Suppressing project-wide 70 + # so real B008 regressions would still surface elsewhere. 71 + ignore = ["B008"] 67 72 68 73 [tool.coverage.run] 69 74 source = ["src/storied"]
+10
src/storied/character/__init__.py
··· 5 5 ALL_SKILLS, 6 6 SKILL_TO_ABILITY, 7 7 ability_modifier, 8 + auto_fails_save, 8 9 class_summary, 9 10 effective_hp, 11 + exhaustion_penalty, 12 + has_disadvantage_on_checks, 10 13 has_expertise_in, 11 14 initiative_modifier, 12 15 is_proficient_in, ··· 36 39 add_item, 37 40 add_note, 38 41 adjust_coins, 42 + break_concentration, 39 43 damage, 40 44 heal, 45 + level_up, 41 46 remove_condition, 42 47 remove_effect, 43 48 remove_item, ··· 61 66 "ALL_SKILLS", 62 67 "SKILL_TO_ABILITY", 63 68 "ability_modifier", 69 + "auto_fails_save", 64 70 "class_summary", 65 71 "effective_hp", 72 + "exhaustion_penalty", 73 + "has_disadvantage_on_checks", 66 74 "has_expertise_in", 67 75 "initiative_modifier", 68 76 "is_proficient_in", ··· 81 89 "add_item", 82 90 "add_note", 83 91 "adjust_coins", 92 + "break_concentration", 84 93 "damage", 85 94 "heal", 95 + "level_up", 86 96 "remove_condition", 87 97 "remove_effect", 88 98 "remove_item",
+78 -5
src/storied/character/compute.py
··· 6 6 """ 7 7 8 8 9 + # Conditions that impose disadvantage on ability checks (and therefore 10 + # skill checks). 5e 2024 — kept loose/lowercased for matching. 11 + _DISADV_CHECK_CONDITIONS = frozenset({"poisoned", "frightened"}) 12 + 13 + # Conditions that cause auto-fail on Strength and Dexterity saves. 14 + _AUTOFAIL_STR_DEX_SAVES = frozenset( 15 + {"paralyzed", "petrified", "stunned", "unconscious"} 16 + ) 17 + 18 + # Restrained auto-fails Dex saves only (not Str). 19 + _AUTOFAIL_DEX_SAVES = frozenset({"restrained"}) 20 + 21 + 9 22 # Maps each skill to its governing ability 10 23 SKILL_TO_ABILITY: dict[str, str] = { 11 24 "acrobatics": "dexterity", ··· 50 63 return 2 + (level - 1) // 4 51 64 52 65 66 + def exhaustion_penalty(char: dict) -> int: 67 + """5e 2024: each level of exhaustion imposes a -2 penalty on d20 rolls. 68 + 69 + This folds directly into the numeric skill/save modifier so the DM 70 + never has to remember to subtract from the displayed value. 71 + """ 72 + level = max(0, int(char.get("state", {}).get("exhaustion", 0) or 0)) 73 + return -2 * level 74 + 75 + 76 + def _active_conditions(char: dict) -> set[str]: 77 + """Return the character's active conditions, lowercased for matching.""" 78 + return { 79 + str(c).strip().lower() 80 + for c in (char.get("conditions") or []) 81 + if str(c).strip() 82 + } 83 + 84 + 85 + def has_disadvantage_on_checks(char: dict) -> bool: 86 + """True if any active condition imposes disadvantage on ability checks.""" 87 + return bool(_active_conditions(char) & _DISADV_CHECK_CONDITIONS) 88 + 89 + 90 + def auto_fails_save(char: dict, ability: str) -> bool: 91 + """True if the character auto-fails saves of the given ability. 92 + 93 + Paralyzed/petrified/stunned/unconscious auto-fail Str and Dex saves; 94 + restrained auto-fails Dex only. 95 + """ 96 + conds = _active_conditions(char) 97 + if conds & _AUTOFAIL_STR_DEX_SAVES: 98 + return ability in ("strength", "dexterity") 99 + if conds & _AUTOFAIL_DEX_SAVES: 100 + return ability == "dexterity" 101 + return False 102 + 103 + 53 104 def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: 54 105 """Compute a skill modifier and the breakdown of contributions. 55 106 56 - Returns (total, breakdown_lines). 107 + Returns (total, breakdown_lines). Exhaustion is folded into the 108 + numeric total; disadvantage from conditions is not (it's a roll-time 109 + marker — see has_disadvantage_on_checks). 57 110 """ 58 111 ability = SKILL_TO_ABILITY.get(skill) 59 112 if ability is None: ··· 77 130 prof_bonus = pb 78 131 breakdown.append(f"+{prof_bonus} proficient") 79 132 80 - total = ability_mod + prof_bonus 133 + exh = exhaustion_penalty(char) 134 + if exh: 135 + breakdown.append(f"{exh:+d} exhaustion") 136 + 137 + total = ability_mod + prof_bonus + exh 81 138 return total, breakdown 82 139 83 140 84 141 def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: 85 - """Compute a saving throw modifier and breakdown.""" 142 + """Compute a saving throw modifier and breakdown. 143 + 144 + Exhaustion is folded into the numeric total. Auto-fail from conditions 145 + is not (see auto_fails_save). 146 + """ 86 147 abilities = char.get("abilities", {}) 87 148 ability_score = abilities.get(ability, 10) 88 149 mod = ability_modifier(ability_score) ··· 95 156 mod += pb 96 157 breakdown.append(f"+{pb} proficient") 97 158 159 + exh = exhaustion_penalty(char) 160 + if exh: 161 + mod += exh 162 + breakdown.append(f"{exh:+d} exhaustion") 163 + 98 164 return mod, breakdown 99 165 100 166 101 167 def passive_score(char: dict, skill: str = "perception") -> int: 102 - """Passive score for a skill: 10 + skill modifier.""" 168 + """Passive score for a skill: 10 + skill modifier. 169 + 170 + Per 5e 2024, disadvantage on the underlying skill check subtracts 5 171 + from the passive score. This folds condition effects into the display. 172 + """ 103 173 mod, _ = skill_modifier(char, skill) 104 - return 10 + mod 174 + base = 10 + mod 175 + if has_disadvantage_on_checks(char): 176 + base -= 5 177 + return base 105 178 106 179 107 180 def initiative_modifier(char: dict) -> int:
+36 -6
src/storied/character/display.py
··· 5 5 ALL_SKILLS, 6 6 SKILL_TO_ABILITY, 7 7 ability_modifier, 8 + auto_fails_save, 8 9 class_summary, 9 10 effective_hp, 11 + has_disadvantage_on_checks, 10 12 has_expertise_in, 11 13 initiative_modifier, 12 14 is_proficient_in, ··· 37 39 38 40 def _save_line(char: dict) -> str: 39 41 parts = [] 42 + proficient_saves = char.get("proficiencies", {}).get("saves", []) 40 43 for ability in ABILITIES: 41 44 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}") 45 + marker = " ★" if ability in proficient_saves else "" 46 + roll_state = "" 47 + if auto_fails_save(char, ability): 48 + roll_state = " ✗" # auto-fail 49 + parts.append(f"{ability[:3].upper()} {mod:+d}{marker}{roll_state}") 44 50 return " ".join(parts) 45 51 46 52 47 53 def _skill_lines(char: dict) -> list[str]: 48 - """Render skills sorted alphabetically with proficiency markers.""" 54 + """Render skills sorted alphabetically with proficiency markers. 55 + 56 + Adds a disadvantage indicator to all skills when the character has an 57 + active condition that imposes disadvantage on ability checks. 58 + """ 59 + disadv = has_disadvantage_on_checks(char) 49 60 lines: list[str] = [] 50 61 for skill in sorted(ALL_SKILLS): 51 62 mod, _ = skill_modifier(char, skill) ··· 54 65 marker = " ★★" 55 66 elif is_proficient_in(char, skill): 56 67 marker = " ★" 68 + roll_state = " ◂" if disadv else "" 57 69 name = _format_skill_name(skill) 58 - lines.append(f" {name:<18} {mod:+d}{marker}") 70 + lines.append(f" {name:<18} {mod:+d}{marker}{roll_state}") 59 71 return lines 60 72 61 73 ··· 220 232 """Full character sheet for /me command.""" 221 233 lines = [format_status(data, include_equipment=False), ""] 222 234 235 + state = data.get("state", {}) 236 + if state.get("inspiration"): 237 + lines.append("**Inspiration:** available") 238 + lines.append("") 239 + 240 + exhaustion = state.get("exhaustion", 0) or 0 241 + disadv = has_disadvantage_on_checks(data) 242 + 223 243 # Saves 224 244 lines.append("**Saving Throws:**") 225 245 lines.append(f" {_save_line(data)}") 226 - lines.append(" ★ proficient") 246 + save_legend = " ★ proficient" 247 + if any(auto_fails_save(data, a) for a in ABILITIES): 248 + save_legend += " · ✗ auto-fail" 249 + lines.append(save_legend) 227 250 lines.append("") 228 251 229 252 # Skills 230 253 lines.append("**Skills:**") 231 254 lines.extend(_skill_lines(data)) 232 255 lines.append(f" Passive Perception: {passive_score(data, 'perception')}") 233 - lines.append(" ★ proficient · ★★ expertise") 256 + skill_legend = " ★ proficient · ★★ expertise" 257 + if disadv: 258 + skill_legend += " · ◂ disadvantage" 259 + lines.append(skill_legend) 260 + if exhaustion: 261 + lines.append( 262 + f" (exhaustion {exhaustion}: {-2 * exhaustion:+d} to all d20 rolls)" 263 + ) 234 264 lines.append("") 235 265 236 266 # Conditions, defenses
+211 -12
src/storied/character/operations.py
··· 16 16 # --- HP operations --- 17 17 18 18 19 + def _defense_entries(defenses: dict, key: str) -> list[str]: 20 + """Extract damage-type strings from resistances/vulnerabilities. 21 + 22 + Tolerates two shapes: a list of dicts like ``[{"damage": "fire"}]`` and 23 + a flat list of strings like ``["fire"]``. The LLM has written both. 24 + """ 25 + types: list[str] = [] 26 + for entry in defenses.get(key) or []: 27 + if isinstance(entry, dict): 28 + damage = entry.get("damage") 29 + if damage: 30 + types.append(str(damage).lower()) 31 + elif isinstance(entry, str): 32 + types.append(entry.lower()) 33 + return types 34 + 35 + 36 + def _apply_defenses( 37 + data: dict, amount: int, damage_type: str | None, 38 + ) -> tuple[int, str | None]: 39 + """Apply resistance / vulnerability / immunity to an incoming damage amount. 40 + 41 + Returns (scaled_amount, note) where note is one of "immune", 42 + "resistance", "vulnerability", or None. Resistance and vulnerability 43 + cancel each other per 5e 2024. 44 + """ 45 + if not damage_type or amount <= 0: 46 + return amount, None 47 + 48 + defenses = data.get("defenses") or {} 49 + needle = damage_type.lower() 50 + 51 + immunities = defenses.get("immunities") or {} 52 + imm_damage = immunities.get("damage") or [] 53 + if any(str(t).lower() == needle for t in imm_damage): 54 + return 0, "immune" 55 + 56 + has_resistance = needle in _defense_entries(defenses, "resistances") 57 + has_vulnerability = needle in _defense_entries(defenses, "vulnerabilities") 58 + 59 + if has_resistance and has_vulnerability: 60 + return amount, None 61 + if has_resistance: 62 + return amount // 2, "resistance" 63 + if has_vulnerability: 64 + return amount * 2, "vulnerability" 65 + 66 + return amount, None 67 + 68 + 19 69 def damage( 20 70 player_id: str, 21 71 amount: int, 22 72 damage_type: str | None = None, 23 73 base_path: Path | None = None, 24 74 ) -> str: 25 - """Apply damage to the character. Temp HP absorbs first, then current HP.""" 75 + """Apply damage to the character. Resistance / vulnerability / immunity 76 + is applied first, then temp HP soaks, then current HP.""" 26 77 data = load_character(player_id, base_path) 27 78 if data is None: 28 79 return f"No character found for player '{player_id}'" 29 80 if amount < 0: 30 81 return "Damage amount must be non-negative" 31 82 83 + incoming = amount 84 + scaled, defense_note = _apply_defenses(data, amount, damage_type) 85 + 32 86 hp = data["state"]["hp"] 33 - remaining = amount 87 + remaining = scaled 34 88 35 89 # Temp HP soaks damage first 36 90 temp_used = 0 ··· 42 96 # Then current HP 43 97 hp_before = hp["current"] 44 98 hp["current"] = max(0, hp["current"] - remaining) 45 - hp_actual = hp_before - hp["current"] 46 99 47 100 save_character(player_id, data, base_path) 48 101 49 - parts = [] 50 102 type_str = f" {damage_type}" if damage_type else "" 51 - parts.append(f"Took {amount}{type_str} damage") 103 + if defense_note == "immune": 104 + headline = f"Immune to{type_str} damage (would have been {incoming})" 105 + elif defense_note: 106 + headline = ( 107 + f"Took {incoming}{type_str} damage → {scaled} ({defense_note})" 108 + ) 109 + else: 110 + headline = f"Took {scaled}{type_str} damage" 111 + 112 + parts = [headline] 52 113 if temp_used: 53 114 parts.append(f"absorbed {temp_used} with temp HP") 54 115 parts.append(f"HP: {hp['current']}/{hp['max']}") ··· 56 117 parts.append(f"({hp['temp']} temp remaining)") 57 118 if hp["current"] == 0: 58 119 parts.append("**(at 0 HP — death saves!)**") 120 + 121 + # Concentration save hint. Per 5e 2024, taking damage while 122 + # concentrating requires a Con save with DC = max(10, damage taken // 2). 123 + # We emit a reminder rather than rolling — the DM decides success. 124 + if scaled > 0: 125 + con_effects = [ 126 + e for e in (data.get("effects") or []) if e.get("concentration") 127 + ] 128 + if con_effects: 129 + dc = max(10, scaled // 2) 130 + names = ", ".join(e.get("source", "?") for e in con_effects) 131 + parts.append( 132 + f"**Concentration save DC {dc}** for: {names}" 133 + ) 134 + 59 135 return ". ".join(parts) 60 136 61 137 ··· 88 164 source: str, 89 165 description: str, 90 166 expires: str | None = None, 167 + concentration: bool = False, 91 168 base_path: Path | None = None, 92 169 ) -> str: 93 - """Add a temporary effect to the character.""" 170 + """Add a temporary effect to the character. 171 + 172 + If `concentration=True`, this is a concentration-bound effect. Only one 173 + concentration effect can be active at a time — adding a new one drops 174 + the old one per 5e rules. 175 + """ 94 176 data = load_character(player_id, base_path) 95 177 if data is None: 96 178 return f"No character found for player '{player_id}'" 97 179 98 180 effects = data.setdefault("effects", []) 99 - effect = { 100 - "source": source, 101 - "description": description, 102 - } 181 + 182 + dropped_source: str | None = None 183 + if concentration: 184 + for i, existing in enumerate(effects): 185 + if existing.get("concentration"): 186 + dropped_source = existing.get("source", "previous effect") 187 + effects.pop(i) 188 + break 189 + 190 + effect: dict = {"source": source, "description": description} 103 191 if expires: 104 192 effect["expires"] = expires 193 + if concentration: 194 + effect["concentration"] = True 105 195 effects.append(effect) 106 196 107 197 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}" 198 + 199 + parts = [f"Effect added: {source} — {description}"] 200 + if expires: 201 + parts.append(f"(expires {expires})") 202 + if concentration: 203 + parts.append("[Concentration]") 204 + if dropped_source: 205 + parts.append(f"— lost concentration on {dropped_source}") 206 + return " ".join(parts) 207 + 208 + 209 + def break_concentration( 210 + player_id: str, 211 + base_path: Path | None = None, 212 + ) -> str: 213 + """Remove whatever effect the character is currently concentrating on. 214 + 215 + Called when a concentration save fails, the character is incapacitated, 216 + or they voluntarily drop concentration. No-op if they weren't 217 + concentrating. 218 + """ 219 + data = load_character(player_id, base_path) 220 + if data is None: 221 + return f"No character found for player '{player_id}'" 222 + 223 + effects = data.get("effects") or [] 224 + for i, existing in enumerate(effects): 225 + if existing.get("concentration"): 226 + removed = effects.pop(i) 227 + save_character(player_id, data, base_path) 228 + return f"Concentration broken on {removed.get('source', '?')}" 229 + 230 + return "No concentration effect to break" 110 231 111 232 112 233 def remove_effect( ··· 460 581 purse_str = ", ".join(coins) if coins else "empty" 461 582 462 583 return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" 584 + 585 + 586 + def level_up( 587 + player_id: str, 588 + class_name: str, 589 + new_level: int, 590 + hp_gain: int, 591 + features: list[dict] | None = None, 592 + time_anchor: str | None = None, 593 + base_path: Path | None = None, 594 + ) -> str: 595 + """Atomically level up the character. 596 + 597 + Handles the cluster of updates 5e milestone leveling needs in one step: 598 + the class level, max HP (and current HP by the same delta), the 599 + level_since anchor for pacing, clearing the advancement_ready flag, 600 + and optionally replacing the features list. 601 + 602 + The DM should look up the new-level class features before calling this 603 + and pass the full replacement features list. 604 + """ 605 + data = load_character(player_id, base_path) 606 + if data is None: 607 + return f"No character found for player '{player_id}'" 608 + 609 + if hp_gain < 0: 610 + return "HP gain must be non-negative" 611 + 612 + classes = data.get("identity", {}).get("classes") or [] 613 + needle = class_name.strip().lower() 614 + target_idx = None 615 + for i, cls in enumerate(classes): 616 + if str(cls.get("class", "")).strip().lower() == needle: 617 + target_idx = i 618 + break 619 + 620 + if target_idx is None: 621 + known = [str(c.get("class", "")) for c in classes] 622 + return ( 623 + f"No class matching '{class_name}' on this character. " 624 + f"Known classes: {', '.join(known) if known else '(none)'}" 625 + ) 626 + 627 + old_level = classes[target_idx].get("level", 0) 628 + if new_level <= old_level: 629 + return ( 630 + f"New level {new_level} is not higher than current level " 631 + f"{old_level}. Refusing to downgrade." 632 + ) 633 + 634 + classes[target_idx]["level"] = new_level 635 + 636 + hp = data.setdefault("state", {}).setdefault( 637 + "hp", {"max": 0, "current": 0, "temp": 0} 638 + ) 639 + hp["max"] = hp.get("max", 0) + hp_gain 640 + hp["current"] = hp.get("current", 0) + hp_gain 641 + 642 + if features is not None: 643 + data["features"] = features 644 + 645 + if time_anchor: 646 + data["level_since"] = time_anchor 647 + 648 + if "advancement_ready" in data: 649 + data["advancement_ready"] = None 650 + 651 + save_character(player_id, data, base_path) 652 + 653 + parts = [ 654 + f"Level up! {class_name} {old_level} → {new_level}.", 655 + f"+{hp_gain} HP (max {hp['max']}, current {hp['current']}).", 656 + ] 657 + if features is not None: 658 + parts.append(f"Features updated ({len(features)} entries).") 659 + if time_anchor: 660 + parts.append(f"level_since = {time_anchor}.") 661 + return " ".join(parts) 463 662 464 663 465 664 def add_note(
+31 -5
src/storied/cli.py
··· 431 431 # Build section list from all context parts 432 432 sections: list[tuple[str, int, str]] = [ 433 433 ("DM Instructions", stats["system_prompt"], "bright_blue"), 434 + ("Tool Schemas", stats.get("tool_surface", 0), "bright_magenta"), 434 435 ] 435 436 for key, tokens in stats["context_parts"].items(): 436 437 if key.startswith("Entity:") or key.startswith("Linked:"): ··· 515 516 console.print() 516 517 continue 517 518 518 - # Handle /save command (asks DM to save without quitting) 519 + # Handle /save command — everything is already durably written 520 + # on each set_scene; this just confirms state without a round-trip. 519 521 if action.strip().lower() == "/save": 522 + from storied.session import load_session 520 523 console.print() 521 - console.print("[dim]Saving session...[/dim]") 522 - action = "[System: Player requested a session save. Please save the current game state using set_scene, but do not end the session. Briefly confirm what was saved.]" 524 + game_time = engine.get_current_time() 525 + session = load_session(player_id, base_path=base_path) 526 + location = (session or {}).get("location", "unknown") 527 + console.print( 528 + f"[green]Saved.[/green] [dim]{game_time} · {location}[/dim]" 529 + ) 530 + console.print( 531 + "[dim]Session state is written after every turn — " 532 + "quit any time.[/dim]" 533 + ) 534 + continue 523 535 524 536 # Handle /dm command (out-of-character message to the DM) 525 537 if action.strip().lower().startswith("/dm"): ··· 534 546 f"{ooc_msg}]" 535 547 ) 536 548 537 - # Handle /note command (add a note to the player's notes.md) 549 + # Handle /note command — append directly to the player's notes.md 550 + # without a DM round-trip. The DM still sees the latest notes on 551 + # the next turn via its character context. 538 552 if action.strip().lower().startswith("/note"): 539 553 note_msg = action.strip()[5:].strip() 540 554 if not note_msg: 541 555 console.print("[dim]Usage: /note <what to remember>[/dim]") 542 556 continue 543 - action = f"[System: The player wants to remember something. Call add_note with this exact text: {note_msg}]" 557 + from storied.character import add_note as char_add_note 558 + time_anchor = engine._campaign_log.get_current_time().to_anchor() 559 + char_add_note( 560 + player_id, 561 + note_msg, 562 + time_anchor=time_anchor, 563 + base_path=base_path, 564 + ) 565 + console.print() 566 + console.print( 567 + f"[dim]Noted ({time_anchor}): {note_msg}[/dim]" 568 + ) 569 + continue 544 570 545 571 try: 546 572 console.print(Rule(style="dim blue"))
+50 -1
src/storied/engine.py
··· 152 152 self._context_parts["Character"] = char_context 153 153 parts.append(char_context) 154 154 155 + # Flip level_up visibility based on whether the advancement 156 + # evaluator has queued a pending level. The tool is hidden at 157 + # compose time; the engine reveals it for the duration of any 158 + # turn where `advancement_ready` is set on the sheet. 159 + from storied.tools.character import refresh_advancement_visibility 160 + refresh_advancement_visibility(character) 161 + 155 162 # 2. Campaign log 156 163 log_context = self._campaign_log.format_for_context() 157 164 if log_context: ··· 343 350 """Rough token estimate (~4 chars per token).""" 344 351 return len(text) // 4 345 352 353 + def _estimate_tool_surface_tokens(self) -> int: 354 + """Approximate the token cost of the live MCP tool surface. 355 + 356 + The claude subprocess fetches `tools/list` from our in-process 357 + FastMCP server and those definitions land in the model's context 358 + every turn — they're a non-trivial slice of the window and the 359 + old estimate missed them entirely. 360 + 361 + Walks the composed server's currently visible tools and sums 362 + name + description + serialized JSON schema characters. The 363 + ~4-chars-per-token heuristic is the same one used for prompts 364 + and context parts; close enough for a usage indicator. 365 + """ 366 + import asyncio 367 + import json 368 + 369 + async def _gather() -> int: 370 + tools = await self._mcp.server.list_tools() 371 + chars = 0 372 + for tool in tools: 373 + name = getattr(tool, "name", "") or "" 374 + description = getattr(tool, "description", "") or "" 375 + parameters = getattr(tool, "parameters", None) or {} 376 + try: 377 + params_str = json.dumps(parameters) 378 + except (TypeError, ValueError): 379 + params_str = str(parameters) 380 + chars += len(name) + len(description) + len(params_str) 381 + return chars 382 + 383 + try: 384 + chars = asyncio.run(_gather()) 385 + except Exception: 386 + return 0 387 + return chars // 4 388 + 346 389 def get_context_stats(self) -> dict: 347 390 """Get breakdown of context window usage.""" 348 391 model_limit = 200_000 349 392 350 393 base_prompt_tokens = self._estimate_tokens(self._base_prompt) 394 + tool_surface_tokens = self._estimate_tool_surface_tokens() 351 395 352 396 context_breakdown = {} 353 397 for name, content in self._context_parts.items(): 354 398 context_breakdown[name] = self._estimate_tokens(content) 355 399 356 - context_total = base_prompt_tokens + sum(context_breakdown.values()) 400 + context_total = ( 401 + base_prompt_tokens 402 + + tool_surface_tokens 403 + + sum(context_breakdown.values()) 404 + ) 357 405 358 406 # Usage from last result event 359 407 last_input = 0 ··· 365 413 return { 366 414 "model_limit": model_limit, 367 415 "system_prompt": base_prompt_tokens, 416 + "tool_surface": tool_surface_tokens, 368 417 "context_parts": context_breakdown, 369 418 "context_total": context_total, 370 419 "last_input": last_input,
+8 -2
src/storied/log.py
··· 96 96 """Parse duration from text like '30 min', '2 hours', '3 days', '5 rounds'.""" 97 97 text = text.strip().lower() 98 98 99 - # Try common patterns 99 + # Try common patterns. A 5e round is 6 seconds, so rounds convert 100 + # to minutes via round(n * 6 / 60) with a 1-minute floor — ten 101 + # rounds = one minute, twenty rounds = two minutes, and a short 102 + # skirmish under a minute still advances the clock by one. 100 103 patterns = [ 101 104 (r"(\d+)\s*(?:day|days)", lambda m: int(m.group(1)) * 24 * 60), 102 105 (r"(\d+)\s*(?:hour|hours|hr|hrs|h)", lambda m: int(m.group(1)) * 60), 103 106 (r"(\d+)\s*(?:minute|minutes|min|mins|m)", lambda m: int(m.group(1))), 104 - (r"(\d+)\s*(?:round|rounds|rnd|rnds|r)", lambda m: max(1, int(m.group(1)) // 10)), 107 + ( 108 + r"(\d+)\s*(?:round|rounds|rnd|rnds|r)", 109 + lambda m: max(1, round(int(m.group(1)) * 6 / 60)), 110 + ), 105 111 (r"scene", lambda m: 5), # Scene change is ~5 min 106 112 ] 107 113
+6
src/storied/mcp_server.py
··· 103 103 104 104 if role == "dm": 105 105 combat_keys: set[str] = set() 106 + advancement_keys: set[str] = set() 106 107 for tool in await server.list_tools(): 107 108 if "combat" in tool.tags and "combat_control" not in tool.tags: 108 109 combat_keys.add(tool.key) 110 + if "advancement_available" in tool.tags: 111 + advancement_keys.add(tool.key) 109 112 if combat_keys: 110 113 server.disable(keys=combat_keys) 114 + if advancement_keys: 115 + server.disable(keys=advancement_keys) 111 116 combat.set_root(server, combat_keys) 117 + character.set_root(server, advancement_keys) 112 118 113 119 # Substitute {tool_signatures} placeholder in tool descriptions 114 120 global _tool_signatures
+3 -1
src/storied/session.py
··· 165 165 166 166 167 167 # Priority order for wikilink resolution 168 - ENTITY_TYPES = ["npcs", "locations", "items", "factions", "threads", "lore"] 168 + ENTITY_TYPES = [ 169 + "npcs", "locations", "items", "factions", "threads", "lore", "maps", 170 + ] 169 171 170 172 171 173 def resolve_wiki_link(
+134 -1
src/storied/tools/character.py
··· 1 1 """Character management tools — bookkeeping primitives for the DM.""" 2 2 3 + from __future__ import annotations 4 + 3 5 from pathlib import Path 4 6 from typing import Literal 5 7 ··· 12 14 ) 13 15 from storied.character import ( 14 16 add_effect as char_add_effect, 17 + ) 18 + from storied.character import ( 19 + break_concentration as char_break_concentration, 15 20 ) 16 21 from storied.character import ( 17 22 add_item as char_add_item, ··· 32 37 heal as char_heal, 33 38 ) 34 39 from storied.character import ( 40 + level_up as char_level_up, 41 + ) 42 + from storied.character import ( 35 43 load_character, 36 44 ) 37 45 from storied.character import ( ··· 69 77 ) 70 78 71 79 mcp = FastMCP("character") 80 + 81 + 82 + # Dynamic visibility state for advancement-gated tools. 83 + # 84 + # `level_up` is tagged `advancement_available` and hidden at compose time. 85 + # The engine calls `refresh_advancement_visibility()` at the top of each 86 + # turn — when the character's sheet carries `advancement_ready`, the tool 87 + # is enabled; otherwise it's hidden. This mirrors the combat tool flip in 88 + # tools/combat.py. 89 + _root: FastMCP | None = None 90 + _advancement_keys: set[str] = set() 91 + 92 + 93 + def set_root(root: FastMCP, advancement_keys: set[str]) -> None: 94 + """Register the composed top-level FastMCP server for visibility flips. 95 + 96 + Called from mcp_server.start_server() after composition. 97 + """ 98 + global _root, _advancement_keys 99 + _root = root 100 + _advancement_keys = advancement_keys 101 + 102 + 103 + def enable_advancement_tools() -> None: 104 + """Show `level_up` on the parent server.""" 105 + if _root is not None and _advancement_keys: 106 + _root.enable(keys=_advancement_keys) 107 + 108 + 109 + def disable_advancement_tools() -> None: 110 + """Hide `level_up` on the parent server.""" 111 + if _root is not None and _advancement_keys: 112 + _root.disable(keys=_advancement_keys) 113 + 114 + 115 + def refresh_advancement_visibility(character: dict | None) -> None: 116 + """Flip `level_up` visibility based on whether advancement is pending. 117 + 118 + Called by the engine at the start of each turn. Safe to call when 119 + level_up isn't in the current compose (e.g. non-DM roles) — the 120 + _advancement_keys set is empty in that case and both branches no-op. 121 + """ 122 + if character and character.get("advancement_ready"): 123 + enable_advancement_tools() 124 + else: 125 + disable_advancement_tools() 72 126 73 127 74 128 # --- Universal field setter --- ··· 272 326 source: str, 273 327 description: str, 274 328 expires: str | None = None, 329 + concentration: bool = False, 275 330 player: str = Player(), 276 331 root: Path = StorageRoot(), 277 332 ) -> str: ··· 279 334 280 335 Use for spells, potions, environmental effects, narrative buffs/debuffs — 281 336 anything that's temporarily affecting the character. 337 + 338 + Set `concentration=True` for concentration-bound spells (Bless, Hold 339 + Person, Hex, etc). Only one concentration effect can be active at a 340 + time — adding a new one drops the old one automatically per 5e rules. 341 + When the character takes damage while concentrating, `damage()` will 342 + emit a concentration-save reminder with the correct DC. 282 343 283 344 Args: 284 345 source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") 285 346 description: What the effect does in narrative + mechanical terms 286 347 expires: Optional game time anchor when the effect ends (e.g., "d28-1430") 348 + concentration: True for concentration-bound effects. Defaults to False. 287 349 288 350 Returns: 289 351 Confirmation 290 352 """ 291 - return char_add_effect(player, source, description, expires=expires, base_path=root) 353 + return char_add_effect( 354 + player, source, description, 355 + expires=expires, concentration=concentration, base_path=root, 356 + ) 357 + 358 + 359 + @mcp.tool(tags={"dm", "character"}) 360 + def break_concentration( 361 + player: str = Player(), 362 + root: Path = StorageRoot(), 363 + ) -> str: 364 + """Drop the character's current concentration effect. 365 + 366 + Call this when a concentration save fails, the character is 367 + incapacitated, or they voluntarily drop concentration. No-op if the 368 + character isn't currently concentrating on anything. 369 + 370 + Returns: 371 + What was removed, or a message if nothing was concentrating. 372 + """ 373 + return char_break_concentration(player, base_path=root) 292 374 293 375 294 376 @mcp.tool(tags={"dm", "character"}) ··· 482 564 Summary of what was refreshed 483 565 """ 484 566 return char_rest(player, type, base_path=root) 567 + 568 + 569 + # --- Level advancement --- 570 + 571 + 572 + @mcp.tool(tags={"dm", "character", "advancement_available"}) 573 + def level_up( 574 + class_name: str, 575 + new_level: int, 576 + hp_gain: int, 577 + features: list[dict] | None = None, 578 + timekeeper: CampaignLog = Timekeeper(), 579 + player: str = Player(), 580 + root: Path = StorageRoot(), 581 + ) -> str: 582 + """Atomically level up the character. 583 + 584 + This tool is only visible when `advancement_ready` is set on the 585 + character sheet — the advancement evaluator has already decided the 586 + character has earned the next level. Call this at a narratively 587 + appropriate moment (a rest, a triumph, a quiet reflection). Handles 588 + in one call: the class level, max HP (and current HP by the same 589 + delta), the `level_since` anchor for pacing, clearing 590 + `advancement_ready`, and optionally replacing the features list. 591 + 592 + Before calling, use `recall` to look up the character's class features 593 + for the new level so you can pass a complete `features` replacement. 594 + 595 + Args: 596 + class_name: The class that's leveling up (e.g. "Rogue"). For 597 + multiclass characters this picks which class. 598 + new_level: The new level for that class (must be higher than current). 599 + hp_gain: How much to add to max HP (and current HP). Usually the 600 + class's hit die (or its average) plus the constitution modifier. 601 + features: Optional full replacement for the features list. 602 + Include all existing features plus any new ones — this replaces 603 + wholesale. If omitted, features are left as-is. 604 + 605 + Returns: 606 + Summary of what was updated. 607 + """ 608 + time_anchor = timekeeper.get_current_time().to_anchor() 609 + return char_level_up( 610 + player_id=player, 611 + class_name=class_name, 612 + new_level=new_level, 613 + hp_gain=hp_gain, 614 + features=features, 615 + time_anchor=time_anchor, 616 + base_path=root, 617 + ) 485 618 486 619 487 620 # --- Notes ---
+3 -6
src/storied/tools/combat.py
··· 7 7 8 8 from __future__ import annotations 9 9 10 - from typing import TYPE_CHECKING, Literal 10 + from typing import Literal 11 11 12 12 from fastmcp import FastMCP 13 13 from pydantic import BaseModel, Field 14 14 15 15 from storied.initiative import Combatant, InitiativeTracker 16 16 from storied.tools._context import Combat 17 - 18 - if TYPE_CHECKING: 19 - from fastmcp import FastMCP as _FastMCP 20 17 21 18 mcp = FastMCP("combat") 22 19 ··· 41 38 # The composed top-level server and the set of combat tool keys to hide 42 39 # when leaving combat — both registered at start_server() time so the 43 40 # enter/end_initiative tools can flip combat tag visibility on the parent. 44 - _root: "_FastMCP | None" = None 41 + _root: FastMCP | None = None 45 42 _combat_keys_to_hide: set[str] = set() 46 43 47 44 48 - def set_root(root: "_FastMCP", combat_keys_to_hide: set[str]) -> None: 45 + def set_root(root: FastMCP, combat_keys_to_hide: set[str]) -> None: 49 46 """Register the composed top-level FastMCP server. 50 47 51 48 Called from mcp_server.start_server() after composition so that
+194 -11
src/storied/tools/entities.py
··· 7 7 import yaml 8 8 from fastmcp import FastMCP 9 9 10 + from storied.character import load_character 10 11 from storied.log import CampaignLog 11 12 from storied.search import VectorIndex 12 13 from storied.session import name_to_slug ··· 23 24 24 25 # Entity-type enums exposed to the LLM via JSON Schema. Each tool's set is 25 26 # slightly different — only the kinds that make sense for that operation. 26 - EstablishType = Literal["npcs", "locations", "items", "factions", "threads", "lore"] 27 - MarkType = Literal["npcs", "locations", "items", "factions", "threads"] 27 + EstablishType = Literal[ 28 + "npcs", "locations", "items", "factions", "threads", "lore", "maps", 29 + ] 30 + MarkType = Literal["npcs", "locations", "items", "factions", "threads", "maps"] 28 31 DiscoveryType = Literal["npcs", "locations", "factions", "lore"] 29 32 30 33 mcp = FastMCP("entities") ··· 210 213 return f"{action} {entity_type.rstrip('s')} '{name}'" 211 214 212 215 216 + def _normalize_anchor(when: str) -> str: 217 + """Normalize a caller-supplied time anchor to the canonical `#dX-HHMM` form. 218 + 219 + Accepts `#d16-0845`, `d16-0845`, `#16-0845`, and a few close misses. 220 + Returns the input unchanged if it can't be parsed — callers see the raw 221 + text in the Was entry either way, and that's better than silently 222 + dropping it. 223 + """ 224 + from storied.log import GameTime 225 + try: 226 + return GameTime.from_anchor(when).to_anchor() 227 + except ValueError: 228 + return when if when.startswith("#") else f"#{when}" 229 + 230 + 213 231 def _do_mark( 214 232 entity_type: str, 215 233 name: str, ··· 220 238 entity_index: EntityIndex, 221 239 lore: VectorIndex, 222 240 timekeeper: CampaignLog, 241 + when: str | None = None, 223 242 ) -> str: 224 243 """Plain bookkeeping form of mark.""" 225 244 file_path = entity_index.resolve(name) ··· 229 248 if not file_path.exists(): 230 249 return f"Error: Entity '{name}' not found in {entity_type}" 231 250 232 - timestamp = timekeeper.get_current_time().to_anchor() 251 + if when: 252 + timestamp = _normalize_anchor(when) 253 + else: 254 + timestamp = timekeeper.get_current_time().to_anchor() 233 255 234 256 lock = _get_file_lock(file_path) 235 257 with lock: ··· 264 286 return result 265 287 266 288 289 + # Auto-mark cooldown: if an entity was auto-marked less than this many game 290 + # minutes ago, the next auto-mark is skipped. This prevents Was-bloat on 291 + # entities who are "present" through a whole scene of sequential set_scenes. 292 + # The DM can still call `mark` directly for beats they want preserved. 293 + _AUTO_MARK_COOLDOWN_MINUTES = 20 294 + 295 + # set_scene events that represent session lifecycle rather than in-fiction 296 + # beats. These should never land in an entity's history. 297 + _OPERATIONAL_EVENT_PREFIXES = ( 298 + "session resumed", 299 + "session starting", 300 + "session saved", 301 + "session ended", 302 + "autosave", 303 + "[system", 304 + "player requested", 305 + ) 306 + 307 + 308 + def _is_operational_event(event: str) -> bool: 309 + """True if `event` is session lifecycle chatter, not an in-fiction beat.""" 310 + lower = event.strip().lower() 311 + return any(lower.startswith(p) for p in _OPERATIONAL_EVENT_PREFIXES) 312 + 313 + 314 + def _last_was_anchor(entity: dict) -> str | None: 315 + """Return the time anchor of the most recent Was entry, if any.""" 316 + was = entity.get("was") or [] 317 + if not was: 318 + return None 319 + match = re.match(r"^(#d\d+-\d{2}\d{2})", was[-1]) 320 + return match.group(1) if match else None 321 + 322 + 323 + def _minutes_since( 324 + anchor: str, now_hhmm_days: tuple[int, int, int], 325 + ) -> int | None: 326 + """Minutes from `anchor` to the given (day, hour, minute) tuple. 327 + 328 + Returns None if the anchor is malformed or in the future (which can 329 + happen with backdated marks or clock rewinds — in both cases we don't 330 + want the cooldown to fire). 331 + """ 332 + from storied.log import GameTime 333 + try: 334 + then = GameTime.from_anchor(anchor) 335 + except ValueError: 336 + return None 337 + day, hour, minute = now_hhmm_days 338 + now_total = day * 24 * 60 + hour * 60 + minute 339 + then_total = then.day * 24 * 60 + then.hour * 60 + then.minute 340 + delta = now_total - then_total 341 + return delta if delta >= 0 else None 342 + 343 + 267 344 def _auto_mark_present( 268 345 present: list[str], 269 346 event: str, ··· 277 354 278 355 Called by set_scene; uses _do_mark directly so it doesn't have to go 279 356 through dependency resolution again. 357 + 358 + Skip rules, in order: 359 + 1. Operational events (session lifecycle) never land in entity history. 360 + 2. If the entity was auto-marked less than _AUTO_MARK_COOLDOWN_MINUTES 361 + ago in game time, skip — the scene is already represented. 280 362 """ 363 + if _is_operational_event(event): 364 + return [] 365 + 366 + now = timekeeper.get_current_time() 367 + now_tuple = (now.day, now.hour, now.minute) 368 + 281 369 marked: list[str] = [] 282 370 for ref in present: 283 371 link_match = re.search(r"\[\[([^\]]+)\]\]", ref) ··· 293 381 file_path = candidate 294 382 break 295 383 296 - if file_path and file_path.exists(): 297 - entity_type = file_path.parent.name 298 - _do_mark( 299 - entity_type, name, event, None, 300 - base_path, world_id, entity_index, lore, timekeeper, 301 - ) 302 - marked.append(name) 384 + if not (file_path and file_path.exists()): 385 + continue 386 + 387 + # Cooldown: if the last Was entry on this entity is within the 388 + # cooldown window, skip. We use _load_entity rather than re-reading 389 + # the file so it benefits from the index cache. 390 + existing = _load_entity(file_path, entity_index) 391 + last_anchor = _last_was_anchor(existing) 392 + if last_anchor is not None: 393 + delta = _minutes_since(last_anchor, now_tuple) 394 + if delta is not None and delta < _AUTO_MARK_COOLDOWN_MINUTES: 395 + continue 396 + 397 + entity_type = file_path.parent.name 398 + _do_mark( 399 + entity_type, name, event, None, 400 + base_path, world_id, entity_index, lore, timekeeper, 401 + ) 402 + marked.append(name) 303 403 304 404 return marked 305 405 ··· 318 418 will: list[str] | None = None, 319 419 root: Path = StorageRoot(), 320 420 world: str = World(), 421 + player: str = Player(), 321 422 entities: EntityIndex = Entities(), 322 423 lore: VectorIndex = Lore(), 323 424 ) -> str: ··· 351 452 Returns: 352 453 Confirmation with the file path 353 454 """ 455 + if entity_type == "npcs": 456 + character = load_character(player, root) 457 + if character: 458 + pc_name = character.get("identity", {}).get("name", "") 459 + if pc_name and pc_name == name: 460 + return ( 461 + f"Refused: '{name}' is the player character. " 462 + f"The PC lives on the character sheet, not as an NPC entity." 463 + ) 464 + 354 465 return _do_establish( 355 466 entity_type, name, description, location, knows, wants, will, 356 467 root, world, entities, lore, ··· 363 474 name: str, 364 475 event: str, 365 476 resolves: list[str] | None = None, 477 + when: str | None = None, 366 478 root: Path = StorageRoot(), 367 479 world: str = World(), 368 480 entities: EntityIndex = Entities(), ··· 383 495 name: Entity name (exact filename match) 384 496 event: What happened - brief description for the Was section 385 497 resolves: Optional list of Will items to remove if this event fired triggers 498 + when: Optional time anchor (e.g. "d16-0845" or "#d16-0845") to backdate 499 + the event. Use this when recording something that happened earlier 500 + than the current clock — an off-screen event the world-tick agent 501 + is catching up on, or a beat the DM narrated retroactively. 502 + Defaults to the current game time. Do NOT prefix the event text 503 + with a timestamp; pass it through `when` instead. 386 504 387 505 Returns: 388 506 Confirmation message 389 507 """ 390 508 return _do_mark( 391 509 entity_type, name, event, resolves, 392 - root, world, entities, lore, timekeeper, 510 + root, world, entities, lore, timekeeper, when=when, 393 511 ) 512 + 513 + 514 + @mcp.tool(tags={"dm", "planner"}) 515 + def amend_mark( 516 + entity_type: MarkType, 517 + name: str, 518 + event: str, 519 + root: Path = StorageRoot(), 520 + world: str = World(), 521 + entities: EntityIndex = Entities(), 522 + lore: VectorIndex = Lore(), 523 + ) -> str: 524 + """Replace the most recent Was entry on an entity with new text. 525 + 526 + Use this when you want to correct or extend the most recent beat you 527 + recorded for an entity — a retcon, a typo fix, or additional detail. 528 + The timestamp anchor of the entry is preserved; only the event text 529 + changes. This avoids cluttering the history with "Retcon:" entries or 530 + chained corrections. 531 + 532 + If you want to record something genuinely new, use `mark` instead. 533 + 534 + Args: 535 + entity_type: Type of entity: npcs, locations, items, factions, threads, maps 536 + name: Entity name (exact filename match) 537 + event: The replacement event text. The anchor is reused from the 538 + existing entry. 539 + 540 + Returns: 541 + Confirmation of what was replaced, or an error if no Was entry exists. 542 + """ 543 + file_path = entities.resolve(name) 544 + if file_path is None: 545 + file_path = root / "worlds" / world / entity_type / f"{name}.md" 546 + 547 + if not file_path.exists(): 548 + return f"Error: Entity '{name}' not found in {entity_type}" 549 + 550 + lock = _get_file_lock(file_path) 551 + with lock: 552 + existing = _load_entity(file_path, entities) 553 + 554 + was = existing.get("was") or [] 555 + if not was: 556 + return ( 557 + f"Error: '{name}' has no history to amend. Use `mark` to " 558 + f"create the first entry." 559 + ) 560 + 561 + last_entry = was[-1] 562 + match = re.match(r"^(#d\d+-\d{4})\s*\|\s*", last_entry) 563 + anchor = match.group(1) if match else "" 564 + was[-1] = f"{anchor} | {event}" if anchor else event 565 + 566 + data = { 567 + "description": existing.get("description", ""), 568 + "location": existing.get("location", ""), 569 + "knows": existing.get("knows", []), 570 + "wants": existing.get("wants", []), 571 + "will": existing.get("will", []), 572 + "was": was, 573 + } 574 + _write_entity(file_path, name, entity_type, data, entities, lore) 575 + 576 + return f"Amended most recent entry on '{name}': {event}" 394 577 395 578 396 579 @mcp.tool(tags={"dm"})
+461
tests/test_character.py
··· 13 13 add_item, 14 14 add_note, 15 15 adjust_coins, 16 + auto_fails_save, 17 + break_concentration, 16 18 create_character, 17 19 damage, 18 20 effective_hp, 21 + exhaustion_penalty, 19 22 format_character_context, 20 23 format_sheet, 21 24 format_status, 25 + has_disadvantage_on_checks, 22 26 has_expertise_in, 23 27 heal, 24 28 is_proficient_in, 29 + level_up, 25 30 load_character, 26 31 load_character_prose, 27 32 passive_score, ··· 388 393 # 10 + perception modifier (+4) = 14 389 394 assert passive_score(mira, "perception") == 14 390 395 396 + def test_exhaustion_penalty_applied_to_skills(self, mira: dict): 397 + mira["state"]["exhaustion"] = 2 398 + total, breakdown = skill_modifier(mira, "stealth") 399 + # Without exhaustion: +8. With 2 levels: +4. 400 + assert total == 4 401 + assert any("exhaustion" in b.lower() for b in breakdown) 402 + 403 + def test_exhaustion_penalty_applied_to_saves(self, mira: dict): 404 + mira["state"]["exhaustion"] = 1 405 + total, breakdown = save_modifier(mira, "dexterity") 406 + # Without exhaustion: +6. With 1 level: +4. 407 + assert total == 4 408 + assert any("exhaustion" in b.lower() for b in breakdown) 409 + 410 + def test_exhaustion_zero_is_noop(self, mira: dict): 411 + mira["state"]["exhaustion"] = 0 412 + total, breakdown = skill_modifier(mira, "stealth") 413 + assert total == 8 414 + assert not any("exhaustion" in b.lower() for b in breakdown) 415 + 416 + def test_exhaustion_penalty_helper(self, mira: dict): 417 + mira["state"]["exhaustion"] = 3 418 + assert exhaustion_penalty(mira) == -6 419 + 420 + def test_poisoned_gives_disadvantage_on_checks(self, mira: dict): 421 + mira["conditions"] = ["Poisoned"] 422 + assert has_disadvantage_on_checks(mira) is True 423 + 424 + def test_frightened_gives_disadvantage_on_checks(self, mira: dict): 425 + mira["conditions"] = ["frightened"] 426 + assert has_disadvantage_on_checks(mira) is True 427 + 428 + def test_no_disadvantage_without_matching_condition(self, mira: dict): 429 + mira["conditions"] = ["Prone"] 430 + assert has_disadvantage_on_checks(mira) is False 431 + 432 + def test_passive_perception_drops_when_disadvantaged(self, mira: dict): 433 + # Baseline: +4 perception → passive 14 434 + assert passive_score(mira, "perception") == 14 435 + mira["conditions"] = ["Poisoned"] 436 + # With disadvantage: -5 → 9 437 + assert passive_score(mira, "perception") == 9 438 + 439 + def test_paralyzed_auto_fails_str_and_dex_saves(self, mira: dict): 440 + mira["conditions"] = ["Paralyzed"] 441 + assert auto_fails_save(mira, "strength") is True 442 + assert auto_fails_save(mira, "dexterity") is True 443 + assert auto_fails_save(mira, "wisdom") is False 444 + assert auto_fails_save(mira, "constitution") is False 445 + 446 + def test_restrained_auto_fails_dex_saves_only(self, mira: dict): 447 + mira["conditions"] = ["restrained"] 448 + assert auto_fails_save(mira, "dexterity") is True 449 + assert auto_fails_save(mira, "strength") is False 450 + 451 + def test_no_auto_fail_without_condition(self, mira: dict): 452 + assert auto_fails_save(mira, "dexterity") is False 453 + assert auto_fails_save(mira, "strength") is False 454 + 391 455 def test_effective_hp_with_temp(self, player_dir: Path): 392 456 save_character( 393 457 "test-player", ··· 558 622 result = format_sheet(mira) 559 623 assert "+5 temp" in result 560 624 625 + def test_format_sheet_shows_disadvantage_legend_when_condition_applies( 626 + self, mira: dict, 627 + ): 628 + mira["conditions"] = ["Poisoned"] 629 + sheet = format_sheet(mira) 630 + assert "disadvantage" in sheet.lower() 631 + 632 + def test_format_sheet_shows_inspiration_when_available(self, mira: dict): 633 + mira["state"]["inspiration"] = True 634 + sheet = format_sheet(mira) 635 + assert "Inspiration" in sheet and "available" in sheet 636 + 637 + def test_format_sheet_shows_exhaustion_line_when_nonzero(self, mira: dict): 638 + mira["state"]["exhaustion"] = 2 639 + sheet = format_sheet(mira) 640 + assert "exhaustion 2" in sheet.lower() 641 + assert "-4" in sheet 642 + 643 + def test_format_sheet_shows_auto_fail_marker(self, mira: dict): 644 + mira["conditions"] = ["Paralyzed"] 645 + sheet = format_sheet(mira) 646 + assert "✗" in sheet 647 + assert "auto-fail" in sheet.lower() 648 + 561 649 def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): 562 650 mira["state"]["exhaustion"] = 2 563 651 result = format_sheet(mira) ··· 676 764 def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): 677 765 result = damage("test-player", 3, damage_type="fire", base_path=player_dir) 678 766 assert "fire" in result 767 + 768 + def test_damage_resistance_halves(self, mira: dict, player_dir: Path): 769 + update_character( 770 + "test-player", 771 + {"defenses.resistances": [{"damage": "fire"}]}, 772 + base_path=player_dir, 773 + ) 774 + result = damage("test-player", 10, damage_type="fire", base_path=player_dir) 775 + data = load_character("test-player", player_dir) 776 + assert data["state"]["hp"]["current"] == 19 777 + assert "resistance" in result 778 + assert "10" in result and "5" in result 779 + 780 + def test_damage_resistance_only_applies_to_matched_type( 781 + self, mira: dict, player_dir: Path, 782 + ): 783 + update_character( 784 + "test-player", 785 + {"defenses.resistances": [{"damage": "fire"}]}, 786 + base_path=player_dir, 787 + ) 788 + damage("test-player", 10, damage_type="cold", base_path=player_dir) 789 + data = load_character("test-player", player_dir) 790 + assert data["state"]["hp"]["current"] == 14 791 + 792 + def test_damage_vulnerability_doubles(self, mira: dict, player_dir: Path): 793 + update_character( 794 + "test-player", 795 + {"defenses.vulnerabilities": [{"damage": "radiant"}]}, 796 + base_path=player_dir, 797 + ) 798 + result = damage( 799 + "test-player", 5, damage_type="radiant", base_path=player_dir, 800 + ) 801 + data = load_character("test-player", player_dir) 802 + assert data["state"]["hp"]["current"] == 14 803 + assert "vulnerability" in result 804 + 805 + def test_damage_immunity_is_zero(self, mira: dict, player_dir: Path): 806 + update_character( 807 + "test-player", 808 + {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, 809 + base_path=player_dir, 810 + ) 811 + result = damage( 812 + "test-player", 12, damage_type="poison", base_path=player_dir, 813 + ) 814 + data = load_character("test-player", player_dir) 815 + assert data["state"]["hp"]["current"] == 24 816 + assert "Immune" in result 817 + 818 + def test_damage_resistance_and_vulnerability_cancel( 819 + self, mira: dict, player_dir: Path, 820 + ): 821 + update_character( 822 + "test-player", 823 + { 824 + "defenses.resistances": [{"damage": "cold"}], 825 + "defenses.vulnerabilities": [{"damage": "cold"}], 826 + }, 827 + base_path=player_dir, 828 + ) 829 + damage("test-player", 8, damage_type="cold", base_path=player_dir) 830 + data = load_character("test-player", player_dir) 831 + assert data["state"]["hp"]["current"] == 16 832 + 833 + def test_damage_resistance_tolerates_string_list( 834 + self, mira: dict, player_dir: Path, 835 + ): 836 + # The LLM has been known to write resistances as a flat list of strings 837 + # instead of a list of {"damage": ...} dicts. Handle both. 838 + update_character( 839 + "test-player", 840 + {"defenses.resistances": ["fire"]}, 841 + base_path=player_dir, 842 + ) 843 + damage("test-player", 10, damage_type="fire", base_path=player_dir) 844 + data = load_character("test-player", player_dir) 845 + assert data["state"]["hp"]["current"] == 19 846 + 847 + def test_damage_resistance_is_case_insensitive( 848 + self, mira: dict, player_dir: Path, 849 + ): 850 + update_character( 851 + "test-player", 852 + {"defenses.resistances": [{"damage": "Fire"}]}, 853 + base_path=player_dir, 854 + ) 855 + damage("test-player", 10, damage_type="FIRE", base_path=player_dir) 856 + data = load_character("test-player", player_dir) 857 + assert data["state"]["hp"]["current"] == 19 858 + 859 + def test_damage_resistance_with_temp_hp(self, mira: dict, player_dir: Path): 860 + # Defenses apply before temp HP soaks. 10 fire → resisted to 5 → 5 temp 861 + # absorbs all of it. 862 + update_character( 863 + "test-player", 864 + { 865 + "state.hp.temp": 5, 866 + "defenses.resistances": [{"damage": "fire"}], 867 + }, 868 + base_path=player_dir, 869 + ) 870 + damage("test-player", 10, damage_type="fire", base_path=player_dir) 871 + data = load_character("test-player", player_dir) 872 + assert data["state"]["hp"]["temp"] == 0 873 + assert data["state"]["hp"]["current"] == 24 874 + 875 + 876 + class TestLevelUp: 877 + def test_level_up_increments_class_level( 878 + self, mira: dict, player_dir: Path, 879 + ): 880 + result = level_up( 881 + "test-player", 882 + class_name="Rogue", 883 + new_level=4, 884 + hp_gain=6, 885 + base_path=player_dir, 886 + ) 887 + data = load_character("test-player", player_dir) 888 + assert data["identity"]["classes"][0]["level"] == 4 889 + assert "3 → 4" in result 890 + 891 + def test_level_up_adds_hp_to_max_and_current( 892 + self, mira: dict, player_dir: Path, 893 + ): 894 + # mira starts with 24/24 895 + level_up( 896 + "test-player", "Rogue", 897 + new_level=4, hp_gain=6, base_path=player_dir, 898 + ) 899 + data = load_character("test-player", player_dir) 900 + assert data["state"]["hp"]["max"] == 30 901 + assert data["state"]["hp"]["current"] == 30 902 + 903 + def test_level_up_preserves_wounded_current_relative( 904 + self, mira: dict, player_dir: Path, 905 + ): 906 + # Wound the character first 907 + damage("test-player", 10, base_path=player_dir) 908 + # HP is now 14/24 909 + level_up( 910 + "test-player", "Rogue", 911 + new_level=4, hp_gain=6, base_path=player_dir, 912 + ) 913 + data = load_character("test-player", player_dir) 914 + # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) 915 + assert data["state"]["hp"]["max"] == 30 916 + assert data["state"]["hp"]["current"] == 20 917 + 918 + def test_level_up_sets_level_since( 919 + self, mira: dict, player_dir: Path, 920 + ): 921 + level_up( 922 + "test-player", "Rogue", 923 + new_level=4, hp_gain=6, 924 + time_anchor="#d12-1500", 925 + base_path=player_dir, 926 + ) 927 + data = load_character("test-player", player_dir) 928 + assert data["level_since"] == "#d12-1500" 929 + 930 + def test_level_up_clears_advancement_ready( 931 + self, mira: dict, player_dir: Path, 932 + ): 933 + update_character( 934 + "test-player", 935 + {"advancement_ready": 4}, 936 + base_path=player_dir, 937 + ) 938 + level_up( 939 + "test-player", "Rogue", 940 + new_level=4, hp_gain=6, base_path=player_dir, 941 + ) 942 + data = load_character("test-player", player_dir) 943 + assert data.get("advancement_ready") is None 944 + 945 + def test_level_up_replaces_features_when_provided( 946 + self, mira: dict, player_dir: Path, 947 + ): 948 + new_features = [ 949 + {"name": "Sneak Attack", "text": "2d6"}, 950 + {"name": "Uncanny Dodge", "text": "Reaction for half damage"}, 951 + ] 952 + level_up( 953 + "test-player", "Rogue", 954 + new_level=4, hp_gain=6, 955 + features=new_features, 956 + base_path=player_dir, 957 + ) 958 + data = load_character("test-player", player_dir) 959 + assert len(data["features"]) == 2 960 + assert data["features"][1]["name"] == "Uncanny Dodge" 961 + 962 + def test_level_up_preserves_features_when_omitted( 963 + self, mira: dict, player_dir: Path, 964 + ): 965 + update_character( 966 + "test-player", 967 + {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, 968 + base_path=player_dir, 969 + ) 970 + level_up( 971 + "test-player", "Rogue", 972 + new_level=4, hp_gain=6, base_path=player_dir, 973 + ) 974 + data = load_character("test-player", player_dir) 975 + assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] 976 + 977 + def test_level_up_rejects_downgrade( 978 + self, mira: dict, player_dir: Path, 979 + ): 980 + result = level_up( 981 + "test-player", "Rogue", 982 + new_level=2, hp_gain=0, base_path=player_dir, 983 + ) 984 + assert "Refusing" in result 985 + data = load_character("test-player", player_dir) 986 + assert data["identity"]["classes"][0]["level"] == 3 # unchanged 987 + 988 + def test_level_up_rejects_unknown_class( 989 + self, mira: dict, player_dir: Path, 990 + ): 991 + result = level_up( 992 + "test-player", "Wizard", 993 + new_level=4, hp_gain=4, base_path=player_dir, 994 + ) 995 + assert "No class matching" in result 996 + data = load_character("test-player", player_dir) 997 + assert data["identity"]["classes"][0]["level"] == 3 998 + 999 + def test_level_up_multiclass_finds_correct_class( 1000 + self, mira: dict, player_dir: Path, 1001 + ): 1002 + # Add a Fighter level to make Mira multiclass 1003 + update_character( 1004 + "test-player", 1005 + {"identity.classes": [ 1006 + {"class": "Rogue", "subclass": "Thief", "level": 3}, 1007 + {"class": "Fighter", "subclass": None, "level": 1}, 1008 + ]}, 1009 + base_path=player_dir, 1010 + ) 1011 + level_up( 1012 + "test-player", "Fighter", 1013 + new_level=2, hp_gain=7, base_path=player_dir, 1014 + ) 1015 + data = load_character("test-player", player_dir) 1016 + assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged 1017 + assert data["identity"]["classes"][1]["level"] == 2 # Fighter bumped 1018 + 1019 + 1020 + class TestConcentration: 1021 + def test_add_concentration_effect(self, mira: dict, player_dir: Path): 1022 + result = add_effect( 1023 + "test-player", "Bless", "+1d4 to attacks", 1024 + concentration=True, base_path=player_dir, 1025 + ) 1026 + assert "[Concentration]" in result 1027 + data = load_character("test-player", player_dir) 1028 + assert data["effects"][0]["concentration"] is True 1029 + 1030 + def test_adding_concentration_drops_previous( 1031 + self, mira: dict, player_dir: Path, 1032 + ): 1033 + add_effect( 1034 + "test-player", "Bless", "+1d4", 1035 + concentration=True, base_path=player_dir, 1036 + ) 1037 + result = add_effect( 1038 + "test-player", "Hold Person", "paralyzed", 1039 + concentration=True, base_path=player_dir, 1040 + ) 1041 + assert "lost concentration on Bless" in result 1042 + data = load_character("test-player", player_dir) 1043 + sources = [e["source"] for e in data["effects"]] 1044 + assert "Bless" not in sources 1045 + assert "Hold Person" in sources 1046 + 1047 + def test_adding_non_concentration_does_not_drop_existing( 1048 + self, mira: dict, player_dir: Path, 1049 + ): 1050 + add_effect( 1051 + "test-player", "Bless", "+1d4", 1052 + concentration=True, base_path=player_dir, 1053 + ) 1054 + add_effect( 1055 + "test-player", "Potion of Heroism", "+10 temp HP", 1056 + base_path=player_dir, 1057 + ) 1058 + data = load_character("test-player", player_dir) 1059 + sources = [e["source"] for e in data["effects"]] 1060 + assert "Bless" in sources 1061 + assert "Potion of Heroism" in sources 1062 + 1063 + def test_damage_emits_concentration_save_hint( 1064 + self, mira: dict, player_dir: Path, 1065 + ): 1066 + add_effect( 1067 + "test-player", "Bless", "+1d4", 1068 + concentration=True, base_path=player_dir, 1069 + ) 1070 + result = damage("test-player", 6, base_path=player_dir) 1071 + # DC = max(10, 6 // 2) = 10 1072 + assert "Concentration save DC 10" in result 1073 + assert "Bless" in result 1074 + 1075 + def test_damage_concentration_dc_scales_with_damage( 1076 + self, mira: dict, player_dir: Path, 1077 + ): 1078 + add_effect( 1079 + "test-player", "Hex", "extra 1d6 necrotic", 1080 + concentration=True, base_path=player_dir, 1081 + ) 1082 + # Need a caster with more HP, but raising max for this test 1083 + update_character( 1084 + "test-player", {"state.hp.max": 100, "state.hp.current": 100}, 1085 + base_path=player_dir, 1086 + ) 1087 + result = damage("test-player", 30, base_path=player_dir) 1088 + # DC = max(10, 30 // 2) = 15 1089 + assert "Concentration save DC 15" in result 1090 + 1091 + def test_no_concentration_hint_without_concentration_effect( 1092 + self, mira: dict, player_dir: Path, 1093 + ): 1094 + add_effect( 1095 + "test-player", "Mage Armor", "+3 AC", # not concentration 1096 + base_path=player_dir, 1097 + ) 1098 + result = damage("test-player", 5, base_path=player_dir) 1099 + assert "Concentration save" not in result 1100 + 1101 + def test_no_concentration_hint_when_damage_absorbed( 1102 + self, mira: dict, player_dir: Path, 1103 + ): 1104 + # 5e 2024: no damage taken → no save needed. Test via immunity. 1105 + update_character( 1106 + "test-player", 1107 + {"defenses.immunities": {"damage": ["fire"], "conditions": []}}, 1108 + base_path=player_dir, 1109 + ) 1110 + add_effect( 1111 + "test-player", "Bless", "+1d4", 1112 + concentration=True, base_path=player_dir, 1113 + ) 1114 + result = damage("test-player", 10, damage_type="fire", base_path=player_dir) 1115 + assert "Concentration save" not in result 1116 + 1117 + def test_break_concentration_removes_effect( 1118 + self, mira: dict, player_dir: Path, 1119 + ): 1120 + add_effect( 1121 + "test-player", "Bless", "+1d4", 1122 + concentration=True, base_path=player_dir, 1123 + ) 1124 + add_effect( 1125 + "test-player", "Mage Armor", "+3 AC", # non-concentration 1126 + base_path=player_dir, 1127 + ) 1128 + result = break_concentration("test-player", base_path=player_dir) 1129 + assert "Bless" in result 1130 + data = load_character("test-player", player_dir) 1131 + sources = [e["source"] for e in data["effects"]] 1132 + assert "Bless" not in sources 1133 + assert "Mage Armor" in sources # non-concentration effects untouched 1134 + 1135 + def test_break_concentration_noop_when_nothing_concentrating( 1136 + self, mira: dict, player_dir: Path, 1137 + ): 1138 + result = break_concentration("test-player", base_path=player_dir) 1139 + assert "No concentration" in result 679 1140 680 1141 681 1142 class TestEffects:
+210 -1
tests/test_entities.py
··· 10 10 resolve_wiki_link, 11 11 ) 12 12 from storied.tools import EntityIndex, ToolContext 13 + from storied.tools.entities import amend_mark as _amend_mark 13 14 from storied.tools.entities import establish as _establish 14 15 from storied.tools.entities import mark as _mark 15 16 ··· 26 27 """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" 27 28 kwargs.pop("ctx", None) 28 29 return call_tool(_mark, **kwargs) 30 + 31 + 32 + def amend_mark(**kwargs): 33 + """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" 34 + kwargs.pop("ctx", None) 35 + return call_tool(_amend_mark, **kwargs) 29 36 30 37 31 38 class TestEstablish: ··· 86 93 content = loc_file.read_text() 87 94 assert "Hidden tunnel in cellar" in content 88 95 96 + def test_establish_map(self, ctx: ToolContext): 97 + establish( 98 + entity_type="maps", 99 + name="Ashenmere District Map", 100 + ctx=ctx, 101 + description="```map\n┌────┐\n│ A │\n└────┘\n```", 102 + ) 103 + map_file = ctx.base_path / "worlds/test-world/maps/Ashenmere District Map.md" 104 + assert map_file.exists() 105 + 89 106 def test_establish_item(self, ctx: ToolContext): 90 107 establish( 91 108 entity_type="items", ··· 167 184 168 185 content = (ctx.base_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() 169 186 assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content 170 - assert "Heavyset man" in content 187 + assert "Heavyset man in his fifties." in content 188 + 189 + def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext): 190 + from storied.character import create_character 191 + 192 + create_character( 193 + player_id="default", 194 + name="Mira", 195 + race="Human", 196 + char_class="Rogue", 197 + level=3, 198 + abilities={"strength": 10, "dexterity": 16, "constitution": 12, 199 + "intelligence": 12, "wisdom": 12, "charisma": 14}, 200 + hp_max=24, ac=16, 201 + base_path=ctx.base_path, 202 + ) 203 + 204 + result = establish( 205 + entity_type="npcs", 206 + name="Mira", 207 + ctx=ctx, 208 + description="The player character, should never be here.", 209 + ) 210 + 211 + assert "Refused" in result 212 + assert "player character" in result 213 + assert not (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() 214 + 215 + def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext): 216 + """The guard is NPC-scoped — an NPC can't share the PC's name, but 217 + a location or thread happening to be named 'Mira' is fine.""" 218 + from storied.character import create_character 219 + 220 + create_character( 221 + player_id="default", 222 + name="Mira", 223 + race="Human", 224 + char_class="Rogue", 225 + level=1, 226 + abilities={"strength": 10, "dexterity": 16, "constitution": 12, 227 + "intelligence": 12, "wisdom": 12, "charisma": 14}, 228 + hp_max=8, ac=14, 229 + base_path=ctx.base_path, 230 + ) 231 + 232 + result = establish( 233 + entity_type="locations", 234 + name="Mira", 235 + ctx=ctx, 236 + description="A hamlet by the lake.", 237 + ) 238 + 239 + assert "Established" in result 240 + assert (ctx.base_path / "worlds/test-world/locations/Mira.md").exists() 241 + 242 + def test_establish_allows_npc_matching_pc_name_with_no_character( 243 + self, ctx: ToolContext, 244 + ): 245 + """Without a character sheet on disk, the guard should not fire.""" 246 + result = establish( 247 + entity_type="npcs", 248 + name="Mira", 249 + ctx=ctx, 250 + description="Some other Mira — no PC exists yet.", 251 + ) 252 + 253 + assert "Established" in result 254 + assert (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() 171 255 172 256 def test_establish_location_preserved_on_update(self, ctx: ToolContext): 173 257 # Create with location ··· 333 417 ctx=ctx, 334 418 ) 335 419 420 + assert "not found" in result.lower() 421 + 422 + def test_mark_with_when_backdates_the_entry(self, ctx: ToolContext): 423 + # Advance the clock to d5-1400 so the current time is clearly 424 + # distinct from the backdated time we're about to pass. 425 + ctx.campaign_log.append_entry("Clock advance", "5 days") 426 + 427 + establish( 428 + entity_type="npcs", 429 + name="Dortha Cray", 430 + ctx=ctx, 431 + description="Tanner.", 432 + ) 433 + 434 + mark( 435 + entity_type="npcs", 436 + name="Dortha Cray", 437 + event="Did something earlier today, off-screen", 438 + when="d1-0900", 439 + ctx=ctx, 440 + ) 441 + 442 + content = (ctx.base_path / "worlds/test-world/npcs/Dortha Cray.md").read_text() 443 + # The timestamp on the Was entry should be the backdated one, 444 + # not the current clock time. 445 + assert "#d1-0900" in content 446 + # And crucially there is NO double-timestamp prefix. 447 + assert "#d1-0900 | #" not in content 448 + 449 + def test_mark_with_when_accepts_hash_prefix(self, ctx: ToolContext): 450 + establish(entity_type="npcs", name="Somebody", ctx=ctx, description="x") 451 + mark( 452 + entity_type="npcs", 453 + name="Somebody", 454 + event="thing", 455 + when="#d2-1430", 456 + ctx=ctx, 457 + ) 458 + content = (ctx.base_path / "worlds/test-world/npcs/Somebody.md").read_text() 459 + assert "#d2-1430" in content 460 + 461 + def test_mark_with_invalid_when_falls_back_gracefully( 462 + self, ctx: ToolContext, 463 + ): 464 + # A garbage `when` value should not crash — the worst case is the 465 + # literal text landing in the Was prefix. Callers can tell they 466 + # fat-fingered it and retry. 467 + establish(entity_type="npcs", name="Nobody2", ctx=ctx, description="x") 468 + result = mark( 469 + entity_type="npcs", 470 + name="Nobody2", 471 + event="thing", 472 + when="not-a-timestamp", 473 + ctx=ctx, 474 + ) 475 + assert "Marked" in result 476 + 477 + 478 + class TestAmendMark: 479 + """Tests for the amend_mark tool — replaces the most recent Was entry.""" 480 + 481 + def test_amend_replaces_most_recent_entry(self, ctx: ToolContext): 482 + establish(entity_type="npcs", name="Vera", ctx=ctx, description="x") 483 + mark( 484 + entity_type="npcs", name="Vera", 485 + event="Told Mira a half-truth", ctx=ctx, 486 + ) 487 + 488 + result = amend_mark( 489 + entity_type="npcs", name="Vera", 490 + event="Told Mira the full truth about the smuggling ring", 491 + ctx=ctx, 492 + ) 493 + 494 + assert "Amended" in result 495 + content = (ctx.base_path / "worlds/test-world/npcs/Vera.md").read_text() 496 + assert "full truth" in content 497 + assert "half-truth" not in content 498 + 499 + def test_amend_preserves_anchor(self, ctx: ToolContext): 500 + establish(entity_type="npcs", name="Tam", ctx=ctx, description="x") 501 + mark( 502 + entity_type="npcs", name="Tam", 503 + event="Original beat", when="d5-1200", ctx=ctx, 504 + ) 505 + 506 + amend_mark( 507 + entity_type="npcs", name="Tam", 508 + event="Corrected beat", ctx=ctx, 509 + ) 510 + 511 + content = (ctx.base_path / "worlds/test-world/npcs/Tam.md").read_text() 512 + assert "#d5-1200 | Corrected beat" in content 513 + 514 + def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext): 515 + establish(entity_type="npcs", name="Oben", ctx=ctx, description="x") 516 + mark(entity_type="npcs", name="Oben", event="First beat", ctx=ctx) 517 + ctx.campaign_log.append_entry("advance", "1 hour") 518 + mark(entity_type="npcs", name="Oben", event="Second beat", ctx=ctx) 519 + 520 + amend_mark( 521 + entity_type="npcs", name="Oben", 522 + event="Second beat, corrected", ctx=ctx, 523 + ) 524 + 525 + content = (ctx.base_path / "worlds/test-world/npcs/Oben.md").read_text() 526 + assert "First beat" in content 527 + assert "Second beat, corrected" in content 528 + assert "- Second beat\n" not in content # old unamended line gone 529 + 530 + def test_amend_on_entity_with_no_history_returns_error( 531 + self, ctx: ToolContext, 532 + ): 533 + establish(entity_type="npcs", name="Fresh", ctx=ctx, description="x") 534 + result = amend_mark( 535 + entity_type="npcs", name="Fresh", 536 + event="Something", ctx=ctx, 537 + ) 538 + assert "no history" in result.lower() 539 + 540 + def test_amend_on_nonexistent_entity_returns_error(self, ctx: ToolContext): 541 + result = amend_mark( 542 + entity_type="npcs", name="Ghost", 543 + event="Something", ctx=ctx, 544 + ) 336 545 assert "not found" in result.lower() 337 546 338 547
+72
tests/test_execute_tool.py
··· 617 617 }) 618 618 assert "Auto-marked: Vera" in result 619 619 620 + def test_auto_mark_cooldown_suppresses_near_repeats( 621 + self, ctx: ToolContext, 622 + ): 623 + """A second set_scene with the same present entity within the 624 + cooldown window should NOT append another Was entry.""" 625 + call("establish", { 626 + "entity_type": "npcs", "name": "Margit", 627 + "description": "Chandler.", 628 + }) 629 + 630 + call("set_scene", { 631 + "event": "Met Mira at the candle shop", 632 + "duration": "10 min", 633 + "present": ["[[Margit]]"], 634 + }) 635 + result2 = call("set_scene", { 636 + "event": "Walked together to dinner", 637 + "duration": "10 min", 638 + "present": ["[[Margit]]"], 639 + }) 640 + 641 + assert "Auto-marked" not in result2 642 + content = ( 643 + ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Margit.md" 644 + ).read_text() 645 + assert content.count("Met Mira at the candle shop") == 1 646 + assert "Walked together to dinner" not in content 647 + 648 + def test_auto_mark_cooldown_clears_after_window(self, ctx: ToolContext): 649 + """After more than cooldown minutes have passed in-game, the next 650 + auto-mark for the same entity fires again.""" 651 + call("establish", { 652 + "entity_type": "npcs", "name": "Aldric", 653 + "description": "Bookseller.", 654 + }) 655 + 656 + call("set_scene", { 657 + "event": "Briefed Aldric on the investigation", 658 + "duration": "30 min", # advances the clock past the cooldown 659 + "present": ["[[Aldric]]"], 660 + }) 661 + call("set_scene", { 662 + "event": "Walked away to get lunch", 663 + "duration": "30 min", 664 + }) 665 + result3 = call("set_scene", { 666 + "event": "Returned and shared a new lead with Aldric", 667 + "duration": "20 min", 668 + "present": ["[[Aldric]]"], 669 + }) 670 + 671 + assert "Auto-marked: Aldric" in result3 672 + 673 + def test_auto_mark_skips_operational_events(self, ctx: ToolContext): 674 + """Session-lifecycle events (Session resumed, etc) must never land 675 + in an entity's history.""" 676 + call("establish", { 677 + "entity_type": "npcs", "name": "Dortha", 678 + "description": "Tanner.", 679 + }) 680 + result = call("set_scene", { 681 + "event": "Session resumed. Mira at Dortha's shop.", 682 + "duration": "0 min", 683 + "present": ["[[Dortha]]"], 684 + }) 685 + 686 + assert "Auto-marked" not in result 687 + content = ( 688 + ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md" 689 + ).read_text() 690 + assert "Session resumed" not in content 691 + 620 692 def test_set_scene_with_threads(self, ctx: ToolContext): 621 693 result = call("set_scene", { 622 694 "event": "Got a lead",
+15 -3
tests/test_log.py
··· 98 98 d = Duration.parse("3 days") 99 99 assert d.minutes == 3 * 24 * 60 100 100 101 - def test_parse_rounds(self): 102 - d = Duration.parse("5 rounds") 103 - assert d.minutes >= 0 # Rounds are very short 101 + def test_parse_rounds_short_fight_floors_to_one_minute(self): 102 + # 5 rounds = 30 seconds, but the clock's minimum precision is 103 + # one minute, so a sub-minute combat still advances the clock. 104 + assert Duration.parse("5 rounds").minutes == 1 105 + 106 + def test_parse_rounds_ten_rounds_is_one_minute(self): 107 + # 10 rounds = 60 seconds — the one natural minute boundary. 108 + assert Duration.parse("10 rounds").minutes == 1 109 + 110 + def test_parse_rounds_twenty_rounds_is_two_minutes(self): 111 + assert Duration.parse("20 rounds").minutes == 2 112 + 113 + def test_parse_rounds_long_combat(self): 114 + # 100 rounds = 10 minutes. 115 + assert Duration.parse("100 rounds").minutes == 10 104 116 105 117 def test_parse_scene(self): 106 118 d = Duration.parse("scene")
+59 -3
tests/test_mcp_server.py
··· 14 14 from storied.initiative import Combatant 15 15 from storied.mcp_server import _compose_server 16 16 from storied.tools import ToolContext, _context 17 + from storied.tools.character import refresh_advancement_visibility 17 18 from storied.tools.combat import _flip_into_combat, _flip_out_of_combat 18 19 19 20 ··· 62 63 assert "end_initiative" in names 63 64 64 65 def test_planner_only_has_its_tools(self): 65 - assert _names("planner") == {"establish", "mark", "notify_dm", "recall"} 66 + assert _names("planner") == { 67 + "establish", "mark", "amend_mark", "notify_dm", "recall", 68 + } 66 69 67 70 def test_seeder_only_has_its_tools(self): 68 71 assert _names("seeder") == {"establish", "set_scene"} ··· 131 134 ("set_item_status", "status", {"attuned", "equipped", "carried"}), 132 135 ("recall", "scope", {"rules", "world", "all"}), 133 136 ("establish", "entity_type", 134 - {"npcs", "locations", "items", "factions", "threads", "lore"}), 137 + {"npcs", "locations", "items", "factions", "threads", "lore", 138 + "maps"}), 135 139 ("mark", "entity_type", 136 - {"npcs", "locations", "items", "factions", "threads"}), 140 + {"npcs", "locations", "items", "factions", "threads", "maps"}), 137 141 ("note_discovery", "content_type", 138 142 {"npcs", "locations", "factions", "lore"}), 139 143 ], ··· 217 221 for state in (initial, during, after): 218 222 assert "enter_initiative" in state 219 223 assert "end_initiative" in state 224 + 225 + 226 + class TestAdvancementVisibility: 227 + """level_up is hidden until the character has advancement_ready set.""" 228 + 229 + def test_level_up_hidden_at_compose_time(self, ctx: ToolContext): 230 + """Fresh compose should not expose level_up — nothing has granted it yet.""" 231 + async def _gather() -> set[str]: 232 + server = await _compose_server("dm") 233 + return {t.name for t in await server.list_tools()} 234 + 235 + names = asyncio.run(_gather()) 236 + assert "level_up" not in names 237 + 238 + def test_level_up_revealed_when_advancement_ready(self, ctx: ToolContext): 239 + async def _run() -> set[str]: 240 + server = await _compose_server("dm") 241 + refresh_advancement_visibility({"advancement_ready": 4}) 242 + return {t.name for t in await server.list_tools()} 243 + 244 + names = asyncio.run(_run()) 245 + assert "level_up" in names 246 + refresh_advancement_visibility(None) # cleanup 247 + 248 + def test_level_up_hidden_again_when_flag_cleared(self, ctx: ToolContext): 249 + async def _run() -> set[str]: 250 + server = await _compose_server("dm") 251 + refresh_advancement_visibility({"advancement_ready": 4}) 252 + refresh_advancement_visibility({"advancement_ready": None}) 253 + return {t.name for t in await server.list_tools()} 254 + 255 + names = asyncio.run(_run()) 256 + assert "level_up" not in names 257 + 258 + def test_level_up_not_in_planner_compose(self, ctx: ToolContext): 259 + """Only the DM role cares about advancement visibility. Other roles 260 + don't have level_up at all, so the flip is a no-op for them.""" 261 + async def _run() -> set[str]: 262 + server = await _compose_server("planner") 263 + return {t.name for t in await server.list_tools()} 264 + 265 + names = asyncio.run(_run()) 266 + assert "level_up" not in names 267 + 268 + def test_refresh_with_none_character_is_safe(self, ctx: ToolContext): 269 + """A character sheet that doesn't exist yet (pre-creation) should 270 + not crash the visibility flip.""" 271 + async def _run() -> None: 272 + await _compose_server("dm") 273 + refresh_advancement_visibility(None) 274 + 275 + asyncio.run(_run()) 220 276 221 277 222 278 class TestPopulateIndex: