diff --git a/prompts/dm-system.md b/prompts/dm-system.md index f37291b..f4c5748 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -355,11 +355,24 @@ Each of these is one verb. Don't compose them into `update_character` calls. - `remove_item("Boots")` — substring match across subsections - `set_item_status("Bracer of the Unseen Step", "attuned")` — magic items only. Establish them as world entities first via `establish(entity_type="items", ...)`. -**Resources** (limited-use class features, magic item charges, hit dice): +**Resources** (limited-use class features, magic item charges, hit dice, **spell slots**): - `use_resource("rage")` — decrement by 1 - `use_resource("hit_dice_d8", amount=2)` — decrement by 2 +- `use_resource("slot_3")` — cast a 3rd-level spell by burning one slot - `rest("short")` or `rest("long")` — refreshes resources by their refresh type. Long rest also clears death saves, removes one exhaustion level, and restores HP. +**Spell slots are resource pools.** There's no separate spellcasting tool — model each slot tier as a pool in `resources`. When you create a caster (or level one up), add slot pools via `update_character`: + +``` +update_character({ + "resources.slot_1": {"current": 4, "max": 4, "refresh": "long_rest", "notes": "1st-level spell slots"}, + "resources.slot_2": {"current": 3, "max": 3, "refresh": "long_rest", "notes": "2nd-level spell slots"}, + "resources.slot_3": {"current": 2, "max": 2, "refresh": "long_rest", "notes": "3rd-level spell slots"} +}) +``` + +Use the canonical `slot_` naming so `use_resource("slot_3")` is unambiguous. When the character casts, `use_resource("slot_N")`; when they long-rest, `rest("long")` refreshes everything automatically. For warlock Pact Magic slots (short-rest refresh), set `"refresh": "short_rest"` — same tool, same pattern. + **Notes**: - `add_note("The miller mentioned strange lights at the old mill")` — appends to notes.md with current game time @@ -384,14 +397,33 @@ Each of these is one verb. Don't compose them into `update_character` calls. 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." +**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. + **When `advancement_ready` is set:** 1. Don't rush it — find a narratively appropriate moment (a rest, a quiet pause, after a triumph) 2. Use `recall` to look up the character's class features for the new level -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 -4. Call `update_character` with the new level, max HP, and updated features. Clear the flag: `{"identity.classes.0.level": , "state.hp.max": , "level_since": "", "advancement_ready": null, "features": [...new list...]}` -5. Include `level` in the `set_scene` tags for this moment: `tags=["level"]` +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: + - **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) + - **Ability Score Improvement or Feat** at levels 4, 8, 12, 16, and 19 + - **New spells known / prepared** for casters + - **Feature choices** like Fighting Style, Eldritch Invocations, Metamagic, Expertise skills, etc. + - **Multiclass consideration** if the player hinted they're thinking about branching out — offer it as an option if it fits, don't steer them + 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. +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 +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. +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). +7. Include `level` in the `set_scene` tags for this moment: `tags=["level"]` -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. +``` +level_up( + class_name="Rogue", + new_level=4, + hp_gain=6, + features=[ ...full list including the new level's features and any player choices... ] +) +``` + +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. ## Session State @@ -485,6 +517,24 @@ Use `mark` when something significant happens to an entity: - An item is used or transferred - A trigger from "Will" fires +**Don't prefix the event text with a timestamp.** If you need to backdate +an event (something that happened earlier today but you're only recording +it now), pass the time anchor via the `when` parameter: + +``` +mark( + entity_type="npcs", + name="Dortha Cray", + event="Sat at her kitchen table studying the family maps before dawn", + when="d29-0500" # earlier than the current clock +) +``` + +Without `when`, the mark uses the current game time — which is what you +want for most in-scene events. Note: `set_scene` auto-marks present +entities once per scene, rate-limited. You don't need to call `mark` +explicitly for "so-and-so was in the room." + If the event resolves a Will trigger, include `resolves` to remove it: Example: @@ -499,6 +549,21 @@ mark( This builds history. Over time, entities accumulate a story of what happened to them, creating continuity across sessions. +### Fixing a Recent Mark + +If you want to correct or extend the most recent Was entry on an entity — +a retcon, a typo, more detail — use `amend_mark`. It replaces the most +recent entry's text while keeping the original timestamp anchor. Don't +append a new `mark` labeled "Retcon:" — use `amend_mark` instead. + +``` +amend_mark( + entity_type="npcs", + name="Margit", + event="Walked Mira to the cabin under a half-moon — held her hand on the road." +) +``` + ## Wiki References Use `[[Name]]` syntax when referencing saved entities in session state: diff --git a/prompts/world-tick.md b/prompts/world-tick.md index a32a15f..19166d1 100644 --- a/prompts/world-tick.md +++ b/prompts/world-tick.md @@ -6,7 +6,8 @@ You are a World Architect advancing a 5e solo adventure world between sessions. |------|---------| | `recall` | Look up existing world content | | `establish` | Update entities that changed | -| `mark` | Record events that happened off-screen | +| `mark` | Record events that happened off-screen (use `when` to backdate) | +| `amend_mark` | Correct or extend the most recent Was entry on an entity | | `notify_dm` | Tell the DM what changed so they can weave it in | ## What You're Given @@ -26,7 +27,9 @@ Scan each entity's Will section. A trigger should fire if: - It creates interesting consequences the DM can narrate When a trigger fires: -1. `mark` the entity with what happened +1. `mark` the entity with what happened. Pass `when="dX-HHMM"` if the + event happened at a specific in-fiction time earlier than the current + clock — don't prefix the event text with a timestamp yourself. 2. `establish` to update their state (new location, changed disposition, etc.) 3. Remove the fired trigger by updating the Will section diff --git a/pyproject.toml b/pyproject.toml index 491769e..c1786c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,11 @@ line-length = 88 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] +# B008 fires on the FastMCP + uncalled_for dependency-injection idiom +# (`root: Path = StorageRoot()` etc). Those aren't mutable defaults — +# they're DI markers resolved at call time. Suppressing project-wide +# so real B008 regressions would still surface elsewhere. +ignore = ["B008"] [tool.coverage.run] source = ["src/storied"] diff --git a/src/storied/character/__init__.py b/src/storied/character/__init__.py index 36e9ecd..40a0d1d 100644 --- a/src/storied/character/__init__.py +++ b/src/storied/character/__init__.py @@ -5,8 +5,11 @@ from storied.character.compute import ( ALL_SKILLS, SKILL_TO_ABILITY, ability_modifier, + auto_fails_save, class_summary, effective_hp, + exhaustion_penalty, + has_disadvantage_on_checks, has_expertise_in, initiative_modifier, is_proficient_in, @@ -36,8 +39,10 @@ from storied.character.operations import ( add_item, add_note, adjust_coins, + break_concentration, damage, heal, + level_up, remove_condition, remove_effect, remove_item, @@ -61,8 +66,11 @@ __all__ = [ "ALL_SKILLS", "SKILL_TO_ABILITY", "ability_modifier", + "auto_fails_save", "class_summary", "effective_hp", + "exhaustion_penalty", + "has_disadvantage_on_checks", "has_expertise_in", "initiative_modifier", "is_proficient_in", @@ -81,8 +89,10 @@ __all__ = [ "add_item", "add_note", "adjust_coins", + "break_concentration", "damage", "heal", + "level_up", "remove_condition", "remove_effect", "remove_item", diff --git a/src/storied/character/compute.py b/src/storied/character/compute.py index 6daf014..1f2e167 100644 --- a/src/storied/character/compute.py +++ b/src/storied/character/compute.py @@ -6,6 +6,19 @@ DM's convenience, not for enforcing rules. """ +# Conditions that impose disadvantage on ability checks (and therefore +# skill checks). 5e 2024 — kept loose/lowercased for matching. +_DISADV_CHECK_CONDITIONS = frozenset({"poisoned", "frightened"}) + +# Conditions that cause auto-fail on Strength and Dexterity saves. +_AUTOFAIL_STR_DEX_SAVES = frozenset( + {"paralyzed", "petrified", "stunned", "unconscious"} +) + +# Restrained auto-fails Dex saves only (not Str). +_AUTOFAIL_DEX_SAVES = frozenset({"restrained"}) + + # Maps each skill to its governing ability SKILL_TO_ABILITY: dict[str, str] = { "acrobatics": "dexterity", @@ -50,10 +63,50 @@ def proficiency_bonus(char: dict) -> int: return 2 + (level - 1) // 4 +def exhaustion_penalty(char: dict) -> int: + """5e 2024: each level of exhaustion imposes a -2 penalty on d20 rolls. + + This folds directly into the numeric skill/save modifier so the DM + never has to remember to subtract from the displayed value. + """ + level = max(0, int(char.get("state", {}).get("exhaustion", 0) or 0)) + return -2 * level + + +def _active_conditions(char: dict) -> set[str]: + """Return the character's active conditions, lowercased for matching.""" + return { + str(c).strip().lower() + for c in (char.get("conditions") or []) + if str(c).strip() + } + + +def has_disadvantage_on_checks(char: dict) -> bool: + """True if any active condition imposes disadvantage on ability checks.""" + return bool(_active_conditions(char) & _DISADV_CHECK_CONDITIONS) + + +def auto_fails_save(char: dict, ability: str) -> bool: + """True if the character auto-fails saves of the given ability. + + Paralyzed/petrified/stunned/unconscious auto-fail Str and Dex saves; + restrained auto-fails Dex only. + """ + conds = _active_conditions(char) + if conds & _AUTOFAIL_STR_DEX_SAVES: + return ability in ("strength", "dexterity") + if conds & _AUTOFAIL_DEX_SAVES: + return ability == "dexterity" + return False + + def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: """Compute a skill modifier and the breakdown of contributions. - Returns (total, breakdown_lines). + Returns (total, breakdown_lines). Exhaustion is folded into the + numeric total; disadvantage from conditions is not (it's a roll-time + marker — see has_disadvantage_on_checks). """ ability = SKILL_TO_ABILITY.get(skill) if ability is None: @@ -77,12 +130,20 @@ def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: prof_bonus = pb breakdown.append(f"+{prof_bonus} proficient") - total = ability_mod + prof_bonus + exh = exhaustion_penalty(char) + if exh: + breakdown.append(f"{exh:+d} exhaustion") + + total = ability_mod + prof_bonus + exh return total, breakdown def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: - """Compute a saving throw modifier and breakdown.""" + """Compute a saving throw modifier and breakdown. + + Exhaustion is folded into the numeric total. Auto-fail from conditions + is not (see auto_fails_save). + """ abilities = char.get("abilities", {}) ability_score = abilities.get(ability, 10) mod = ability_modifier(ability_score) @@ -95,13 +156,25 @@ def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: mod += pb breakdown.append(f"+{pb} proficient") + exh = exhaustion_penalty(char) + if exh: + mod += exh + breakdown.append(f"{exh:+d} exhaustion") + return mod, breakdown def passive_score(char: dict, skill: str = "perception") -> int: - """Passive score for a skill: 10 + skill modifier.""" + """Passive score for a skill: 10 + skill modifier. + + Per 5e 2024, disadvantage on the underlying skill check subtracts 5 + from the passive score. This folds condition effects into the display. + """ mod, _ = skill_modifier(char, skill) - return 10 + mod + base = 10 + mod + if has_disadvantage_on_checks(char): + base -= 5 + return base def initiative_modifier(char: dict) -> int: diff --git a/src/storied/character/display.py b/src/storied/character/display.py index bca6cc4..d4ff958 100644 --- a/src/storied/character/display.py +++ b/src/storied/character/display.py @@ -5,8 +5,10 @@ from storied.character.compute import ( ALL_SKILLS, SKILL_TO_ABILITY, ability_modifier, + auto_fails_save, class_summary, effective_hp, + has_disadvantage_on_checks, has_expertise_in, initiative_modifier, is_proficient_in, @@ -37,15 +39,24 @@ def _format_skill_name(skill: str) -> str: def _save_line(char: dict) -> str: parts = [] + proficient_saves = char.get("proficiencies", {}).get("saves", []) for ability in ABILITIES: mod, _ = save_modifier(char, ability) - marker = " ★" if ability in char.get("proficiencies", {}).get("saves", []) else "" - parts.append(f"{ability[:3].upper()} {mod:+d}{marker}") + marker = " ★" if ability in proficient_saves else "" + roll_state = "" + if auto_fails_save(char, ability): + roll_state = " ✗" # auto-fail + parts.append(f"{ability[:3].upper()} {mod:+d}{marker}{roll_state}") return " ".join(parts) def _skill_lines(char: dict) -> list[str]: - """Render skills sorted alphabetically with proficiency markers.""" + """Render skills sorted alphabetically with proficiency markers. + + Adds a disadvantage indicator to all skills when the character has an + active condition that imposes disadvantage on ability checks. + """ + disadv = has_disadvantage_on_checks(char) lines: list[str] = [] for skill in sorted(ALL_SKILLS): mod, _ = skill_modifier(char, skill) @@ -54,8 +65,9 @@ def _skill_lines(char: dict) -> list[str]: marker = " ★★" elif is_proficient_in(char, skill): marker = " ★" + roll_state = " ◂" if disadv else "" name = _format_skill_name(skill) - lines.append(f" {name:<18} {mod:+d}{marker}") + lines.append(f" {name:<18} {mod:+d}{marker}{roll_state}") return lines @@ -220,17 +232,35 @@ def format_sheet(data: dict) -> str: """Full character sheet for /me command.""" lines = [format_status(data, include_equipment=False), ""] + state = data.get("state", {}) + if state.get("inspiration"): + lines.append("**Inspiration:** available") + lines.append("") + + exhaustion = state.get("exhaustion", 0) or 0 + disadv = has_disadvantage_on_checks(data) + # Saves lines.append("**Saving Throws:**") lines.append(f" {_save_line(data)}") - lines.append(" ★ proficient") + save_legend = " ★ proficient" + if any(auto_fails_save(data, a) for a in ABILITIES): + save_legend += " · ✗ auto-fail" + lines.append(save_legend) lines.append("") # Skills lines.append("**Skills:**") lines.extend(_skill_lines(data)) lines.append(f" Passive Perception: {passive_score(data, 'perception')}") - lines.append(" ★ proficient · ★★ expertise") + skill_legend = " ★ proficient · ★★ expertise" + if disadv: + skill_legend += " · ◂ disadvantage" + lines.append(skill_legend) + if exhaustion: + lines.append( + f" (exhaustion {exhaustion}: {-2 * exhaustion:+d} to all d20 rolls)" + ) lines.append("") # Conditions, defenses diff --git a/src/storied/character/operations.py b/src/storied/character/operations.py index 7bb8d8e..80980b4 100644 --- a/src/storied/character/operations.py +++ b/src/storied/character/operations.py @@ -16,21 +16,75 @@ from storied.character.data import ( # --- HP operations --- +def _defense_entries(defenses: dict, key: str) -> list[str]: + """Extract damage-type strings from resistances/vulnerabilities. + + Tolerates two shapes: a list of dicts like ``[{"damage": "fire"}]`` and + a flat list of strings like ``["fire"]``. The LLM has written both. + """ + types: list[str] = [] + for entry in defenses.get(key) or []: + if isinstance(entry, dict): + damage = entry.get("damage") + if damage: + types.append(str(damage).lower()) + elif isinstance(entry, str): + types.append(entry.lower()) + return types + + +def _apply_defenses( + data: dict, amount: int, damage_type: str | None, +) -> tuple[int, str | None]: + """Apply resistance / vulnerability / immunity to an incoming damage amount. + + Returns (scaled_amount, note) where note is one of "immune", + "resistance", "vulnerability", or None. Resistance and vulnerability + cancel each other per 5e 2024. + """ + if not damage_type or amount <= 0: + return amount, None + + defenses = data.get("defenses") or {} + needle = damage_type.lower() + + immunities = defenses.get("immunities") or {} + imm_damage = immunities.get("damage") or [] + if any(str(t).lower() == needle for t in imm_damage): + return 0, "immune" + + has_resistance = needle in _defense_entries(defenses, "resistances") + has_vulnerability = needle in _defense_entries(defenses, "vulnerabilities") + + if has_resistance and has_vulnerability: + return amount, None + if has_resistance: + return amount // 2, "resistance" + if has_vulnerability: + return amount * 2, "vulnerability" + + return amount, None + + def damage( player_id: str, amount: int, damage_type: str | None = None, base_path: Path | None = None, ) -> str: - """Apply damage to the character. Temp HP absorbs first, then current HP.""" + """Apply damage to the character. Resistance / vulnerability / immunity + is applied first, then temp HP soaks, then current HP.""" data = load_character(player_id, base_path) if data is None: return f"No character found for player '{player_id}'" if amount < 0: return "Damage amount must be non-negative" + incoming = amount + scaled, defense_note = _apply_defenses(data, amount, damage_type) + hp = data["state"]["hp"] - remaining = amount + remaining = scaled # Temp HP soaks damage first temp_used = 0 @@ -42,13 +96,20 @@ def damage( # Then current HP hp_before = hp["current"] hp["current"] = max(0, hp["current"] - remaining) - hp_actual = hp_before - hp["current"] save_character(player_id, data, base_path) - parts = [] type_str = f" {damage_type}" if damage_type else "" - parts.append(f"Took {amount}{type_str} damage") + if defense_note == "immune": + headline = f"Immune to{type_str} damage (would have been {incoming})" + elif defense_note: + headline = ( + f"Took {incoming}{type_str} damage → {scaled} ({defense_note})" + ) + else: + headline = f"Took {scaled}{type_str} damage" + + parts = [headline] if temp_used: parts.append(f"absorbed {temp_used} with temp HP") parts.append(f"HP: {hp['current']}/{hp['max']}") @@ -56,6 +117,21 @@ def damage( parts.append(f"({hp['temp']} temp remaining)") if hp["current"] == 0: parts.append("**(at 0 HP — death saves!)**") + + # Concentration save hint. Per 5e 2024, taking damage while + # concentrating requires a Con save with DC = max(10, damage taken // 2). + # We emit a reminder rather than rolling — the DM decides success. + if scaled > 0: + con_effects = [ + e for e in (data.get("effects") or []) if e.get("concentration") + ] + if con_effects: + dc = max(10, scaled // 2) + names = ", ".join(e.get("source", "?") for e in con_effects) + parts.append( + f"**Concentration save DC {dc}** for: {names}" + ) + return ". ".join(parts) @@ -88,25 +164,70 @@ def add_effect( source: str, description: str, expires: str | None = None, + concentration: bool = False, base_path: Path | None = None, ) -> str: - """Add a temporary effect to the character.""" + """Add a temporary effect to the character. + + If `concentration=True`, this is a concentration-bound effect. Only one + concentration effect can be active at a time — adding a new one drops + the old one per 5e rules. + """ data = load_character(player_id, base_path) if data is None: return f"No character found for player '{player_id}'" effects = data.setdefault("effects", []) - effect = { - "source": source, - "description": description, - } + + dropped_source: str | None = None + if concentration: + for i, existing in enumerate(effects): + if existing.get("concentration"): + dropped_source = existing.get("source", "previous effect") + effects.pop(i) + break + + effect: dict = {"source": source, "description": description} if expires: effect["expires"] = expires + if concentration: + effect["concentration"] = True effects.append(effect) save_character(player_id, data, base_path) - expires_str = f" (expires {expires})" if expires else "" - return f"Effect added: {source} — {description}{expires_str}" + + parts = [f"Effect added: {source} — {description}"] + if expires: + parts.append(f"(expires {expires})") + if concentration: + parts.append("[Concentration]") + if dropped_source: + parts.append(f"— lost concentration on {dropped_source}") + return " ".join(parts) + + +def break_concentration( + player_id: str, + base_path: Path | None = None, +) -> str: + """Remove whatever effect the character is currently concentrating on. + + Called when a concentration save fails, the character is incapacitated, + or they voluntarily drop concentration. No-op if they weren't + concentrating. + """ + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + effects = data.get("effects") or [] + for i, existing in enumerate(effects): + if existing.get("concentration"): + removed = effects.pop(i) + save_character(player_id, data, base_path) + return f"Concentration broken on {removed.get('source', '?')}" + + return "No concentration effect to break" def remove_effect( @@ -462,6 +583,84 @@ def adjust_coins( return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" +def level_up( + player_id: str, + class_name: str, + new_level: int, + hp_gain: int, + features: list[dict] | None = None, + time_anchor: str | None = None, + base_path: Path | None = None, +) -> str: + """Atomically level up the character. + + Handles the cluster of updates 5e milestone leveling needs in one step: + the class level, max HP (and current HP by the same delta), the + level_since anchor for pacing, clearing the advancement_ready flag, + and optionally replacing the features list. + + The DM should look up the new-level class features before calling this + and pass the full replacement features list. + """ + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + if hp_gain < 0: + return "HP gain must be non-negative" + + classes = data.get("identity", {}).get("classes") or [] + needle = class_name.strip().lower() + target_idx = None + for i, cls in enumerate(classes): + if str(cls.get("class", "")).strip().lower() == needle: + target_idx = i + break + + if target_idx is None: + known = [str(c.get("class", "")) for c in classes] + return ( + f"No class matching '{class_name}' on this character. " + f"Known classes: {', '.join(known) if known else '(none)'}" + ) + + old_level = classes[target_idx].get("level", 0) + if new_level <= old_level: + return ( + f"New level {new_level} is not higher than current level " + f"{old_level}. Refusing to downgrade." + ) + + classes[target_idx]["level"] = new_level + + hp = data.setdefault("state", {}).setdefault( + "hp", {"max": 0, "current": 0, "temp": 0} + ) + hp["max"] = hp.get("max", 0) + hp_gain + hp["current"] = hp.get("current", 0) + hp_gain + + if features is not None: + data["features"] = features + + if time_anchor: + data["level_since"] = time_anchor + + if "advancement_ready" in data: + data["advancement_ready"] = None + + save_character(player_id, data, base_path) + + parts = [ + f"Level up! {class_name} {old_level} → {new_level}.", + f"+{hp_gain} HP (max {hp['max']}, current {hp['current']}).", + ] + if features is not None: + parts.append(f"Features updated ({len(features)} entries).") + if time_anchor: + parts.append(f"level_since = {time_anchor}.") + return " ".join(parts) + + def add_note( player_id: str, text: str, diff --git a/src/storied/cli.py b/src/storied/cli.py index 09ed43c..92a9e1e 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -431,6 +431,7 @@ def cmd_play(args: argparse.Namespace) -> int: # Build section list from all context parts sections: list[tuple[str, int, str]] = [ ("DM Instructions", stats["system_prompt"], "bright_blue"), + ("Tool Schemas", stats.get("tool_surface", 0), "bright_magenta"), ] for key, tokens in stats["context_parts"].items(): if key.startswith("Entity:") or key.startswith("Linked:"): @@ -515,11 +516,22 @@ def cmd_play(args: argparse.Namespace) -> int: console.print() continue - # Handle /save command (asks DM to save without quitting) + # Handle /save command — everything is already durably written + # on each set_scene; this just confirms state without a round-trip. if action.strip().lower() == "/save": + from storied.session import load_session console.print() - console.print("[dim]Saving session...[/dim]") - 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.]" + game_time = engine.get_current_time() + session = load_session(player_id, base_path=base_path) + location = (session or {}).get("location", "unknown") + console.print( + f"[green]Saved.[/green] [dim]{game_time} · {location}[/dim]" + ) + console.print( + "[dim]Session state is written after every turn — " + "quit any time.[/dim]" + ) + continue # Handle /dm command (out-of-character message to the DM) if action.strip().lower().startswith("/dm"): @@ -534,13 +546,27 @@ def cmd_play(args: argparse.Namespace) -> int: f"{ooc_msg}]" ) - # Handle /note command (add a note to the player's notes.md) + # Handle /note command — append directly to the player's notes.md + # without a DM round-trip. The DM still sees the latest notes on + # the next turn via its character context. if action.strip().lower().startswith("/note"): note_msg = action.strip()[5:].strip() if not note_msg: console.print("[dim]Usage: /note [/dim]") continue - action = f"[System: The player wants to remember something. Call add_note with this exact text: {note_msg}]" + from storied.character import add_note as char_add_note + time_anchor = engine._campaign_log.get_current_time().to_anchor() + char_add_note( + player_id, + note_msg, + time_anchor=time_anchor, + base_path=base_path, + ) + console.print() + console.print( + f"[dim]Noted ({time_anchor}): {note_msg}[/dim]" + ) + continue try: console.print(Rule(style="dim blue")) diff --git a/src/storied/engine.py b/src/storied/engine.py index e658f58..be7307f 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -152,6 +152,13 @@ class DMEngine: self._context_parts["Character"] = char_context parts.append(char_context) + # Flip level_up visibility based on whether the advancement + # evaluator has queued a pending level. The tool is hidden at + # compose time; the engine reveals it for the duration of any + # turn where `advancement_ready` is set on the sheet. + from storied.tools.character import refresh_advancement_visibility + refresh_advancement_visibility(character) + # 2. Campaign log log_context = self._campaign_log.format_for_context() if log_context: @@ -343,17 +350,58 @@ class DMEngine: """Rough token estimate (~4 chars per token).""" return len(text) // 4 + def _estimate_tool_surface_tokens(self) -> int: + """Approximate the token cost of the live MCP tool surface. + + The claude subprocess fetches `tools/list` from our in-process + FastMCP server and those definitions land in the model's context + every turn — they're a non-trivial slice of the window and the + old estimate missed them entirely. + + Walks the composed server's currently visible tools and sums + name + description + serialized JSON schema characters. The + ~4-chars-per-token heuristic is the same one used for prompts + and context parts; close enough for a usage indicator. + """ + import asyncio + import json + + async def _gather() -> int: + tools = await self._mcp.server.list_tools() + chars = 0 + for tool in tools: + name = getattr(tool, "name", "") or "" + description = getattr(tool, "description", "") or "" + parameters = getattr(tool, "parameters", None) or {} + try: + params_str = json.dumps(parameters) + except (TypeError, ValueError): + params_str = str(parameters) + chars += len(name) + len(description) + len(params_str) + return chars + + try: + chars = asyncio.run(_gather()) + except Exception: + return 0 + return chars // 4 + def get_context_stats(self) -> dict: """Get breakdown of context window usage.""" model_limit = 200_000 base_prompt_tokens = self._estimate_tokens(self._base_prompt) + tool_surface_tokens = self._estimate_tool_surface_tokens() context_breakdown = {} for name, content in self._context_parts.items(): context_breakdown[name] = self._estimate_tokens(content) - context_total = base_prompt_tokens + sum(context_breakdown.values()) + context_total = ( + base_prompt_tokens + + tool_surface_tokens + + sum(context_breakdown.values()) + ) # Usage from last result event last_input = 0 @@ -365,6 +413,7 @@ class DMEngine: return { "model_limit": model_limit, "system_prompt": base_prompt_tokens, + "tool_surface": tool_surface_tokens, "context_parts": context_breakdown, "context_total": context_total, "last_input": last_input, diff --git a/src/storied/log.py b/src/storied/log.py index 34485c3..d3fa9f3 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -96,12 +96,18 @@ class Duration: """Parse duration from text like '30 min', '2 hours', '3 days', '5 rounds'.""" text = text.strip().lower() - # Try common patterns + # Try common patterns. A 5e round is 6 seconds, so rounds convert + # to minutes via round(n * 6 / 60) with a 1-minute floor — ten + # rounds = one minute, twenty rounds = two minutes, and a short + # skirmish under a minute still advances the clock by one. patterns = [ (r"(\d+)\s*(?:day|days)", lambda m: int(m.group(1)) * 24 * 60), (r"(\d+)\s*(?:hour|hours|hr|hrs|h)", lambda m: int(m.group(1)) * 60), (r"(\d+)\s*(?:minute|minutes|min|mins|m)", lambda m: int(m.group(1))), - (r"(\d+)\s*(?:round|rounds|rnd|rnds|r)", lambda m: max(1, int(m.group(1)) // 10)), + ( + r"(\d+)\s*(?:round|rounds|rnd|rnds|r)", + lambda m: max(1, round(int(m.group(1)) * 6 / 60)), + ), (r"scene", lambda m: 5), # Scene change is ~5 min ] diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py index 306e565..2100691 100644 --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -103,12 +103,18 @@ async def _compose_server(role: str) -> FastMCP: if role == "dm": combat_keys: set[str] = set() + advancement_keys: set[str] = set() for tool in await server.list_tools(): if "combat" in tool.tags and "combat_control" not in tool.tags: combat_keys.add(tool.key) + if "advancement_available" in tool.tags: + advancement_keys.add(tool.key) if combat_keys: server.disable(keys=combat_keys) + if advancement_keys: + server.disable(keys=advancement_keys) combat.set_root(server, combat_keys) + character.set_root(server, advancement_keys) # Substitute {tool_signatures} placeholder in tool descriptions global _tool_signatures diff --git a/src/storied/session.py b/src/storied/session.py index 6e80e72..9ea3665 100644 --- a/src/storied/session.py +++ b/src/storied/session.py @@ -165,7 +165,9 @@ def extract_wiki_links(text: str) -> list[str]: # Priority order for wikilink resolution -ENTITY_TYPES = ["npcs", "locations", "items", "factions", "threads", "lore"] +ENTITY_TYPES = [ + "npcs", "locations", "items", "factions", "threads", "lore", "maps", +] def resolve_wiki_link( diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index 5a298c3..9f9f453 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -1,5 +1,7 @@ """Character management tools — bookkeeping primitives for the DM.""" +from __future__ import annotations + from pathlib import Path from typing import Literal @@ -13,6 +15,9 @@ from storied.character import ( from storied.character import ( add_effect as char_add_effect, ) +from storied.character import ( + break_concentration as char_break_concentration, +) from storied.character import ( add_item as char_add_item, ) @@ -31,6 +36,9 @@ from storied.character import ( from storied.character import ( heal as char_heal, ) +from storied.character import ( + level_up as char_level_up, +) from storied.character import ( load_character, ) @@ -71,6 +79,52 @@ from storied.tools._context import ( mcp = FastMCP("character") +# Dynamic visibility state for advancement-gated tools. +# +# `level_up` is tagged `advancement_available` and hidden at compose time. +# The engine calls `refresh_advancement_visibility()` at the top of each +# turn — when the character's sheet carries `advancement_ready`, the tool +# is enabled; otherwise it's hidden. This mirrors the combat tool flip in +# tools/combat.py. +_root: FastMCP | None = None +_advancement_keys: set[str] = set() + + +def set_root(root: FastMCP, advancement_keys: set[str]) -> None: + """Register the composed top-level FastMCP server for visibility flips. + + Called from mcp_server.start_server() after composition. + """ + global _root, _advancement_keys + _root = root + _advancement_keys = advancement_keys + + +def enable_advancement_tools() -> None: + """Show `level_up` on the parent server.""" + if _root is not None and _advancement_keys: + _root.enable(keys=_advancement_keys) + + +def disable_advancement_tools() -> None: + """Hide `level_up` on the parent server.""" + if _root is not None and _advancement_keys: + _root.disable(keys=_advancement_keys) + + +def refresh_advancement_visibility(character: dict | None) -> None: + """Flip `level_up` visibility based on whether advancement is pending. + + Called by the engine at the start of each turn. Safe to call when + level_up isn't in the current compose (e.g. non-DM roles) — the + _advancement_keys set is empty in that case and both branches no-op. + """ + if character and character.get("advancement_ready"): + enable_advancement_tools() + else: + disable_advancement_tools() + + # --- Universal field setter --- @@ -272,6 +326,7 @@ def add_effect( source: str, description: str, expires: str | None = None, + concentration: bool = False, player: str = Player(), root: Path = StorageRoot(), ) -> str: @@ -280,15 +335,42 @@ def add_effect( Use for spells, potions, environmental effects, narrative buffs/debuffs — anything that's temporarily affecting the character. + Set `concentration=True` for concentration-bound spells (Bless, Hold + Person, Hex, etc). Only one concentration effect can be active at a + time — adding a new one drops the old one automatically per 5e rules. + When the character takes damage while concentrating, `damage()` will + emit a concentration-save reminder with the correct DC. + Args: source: Where the effect comes from (e.g., "Potion of Heroism", "Bless from Cleric Aldric") description: What the effect does in narrative + mechanical terms expires: Optional game time anchor when the effect ends (e.g., "d28-1430") + concentration: True for concentration-bound effects. Defaults to False. Returns: Confirmation """ - return char_add_effect(player, source, description, expires=expires, base_path=root) + return char_add_effect( + player, source, description, + expires=expires, concentration=concentration, base_path=root, + ) + + +@mcp.tool(tags={"dm", "character"}) +def break_concentration( + player: str = Player(), + root: Path = StorageRoot(), +) -> str: + """Drop the character's current concentration effect. + + Call this when a concentration save fails, the character is + incapacitated, or they voluntarily drop concentration. No-op if the + character isn't currently concentrating on anything. + + Returns: + What was removed, or a message if nothing was concentrating. + """ + return char_break_concentration(player, base_path=root) @mcp.tool(tags={"dm", "character"}) @@ -484,6 +566,57 @@ def rest( return char_rest(player, type, base_path=root) +# --- Level advancement --- + + +@mcp.tool(tags={"dm", "character", "advancement_available"}) +def level_up( + class_name: str, + new_level: int, + hp_gain: int, + features: list[dict] | None = None, + timekeeper: CampaignLog = Timekeeper(), + player: str = Player(), + root: Path = StorageRoot(), +) -> str: + """Atomically level up the character. + + This tool is only visible when `advancement_ready` is set on the + character sheet — the advancement evaluator has already decided the + character has earned the next level. Call this at a narratively + appropriate moment (a rest, a triumph, a quiet reflection). Handles + in one call: the class level, max HP (and current HP by the same + delta), the `level_since` anchor for pacing, clearing + `advancement_ready`, and optionally replacing the features list. + + Before calling, use `recall` to look up the character's class features + for the new level so you can pass a complete `features` replacement. + + Args: + class_name: The class that's leveling up (e.g. "Rogue"). For + multiclass characters this picks which class. + new_level: The new level for that class (must be higher than current). + hp_gain: How much to add to max HP (and current HP). Usually the + class's hit die (or its average) plus the constitution modifier. + features: Optional full replacement for the features list. + Include all existing features plus any new ones — this replaces + wholesale. If omitted, features are left as-is. + + Returns: + Summary of what was updated. + """ + time_anchor = timekeeper.get_current_time().to_anchor() + return char_level_up( + player_id=player, + class_name=class_name, + new_level=new_level, + hp_gain=hp_gain, + features=features, + time_anchor=time_anchor, + base_path=root, + ) + + # --- Notes --- diff --git a/src/storied/tools/combat.py b/src/storied/tools/combat.py index 82e1099..7c5d753 100644 --- a/src/storied/tools/combat.py +++ b/src/storied/tools/combat.py @@ -7,7 +7,7 @@ right place there. The tools here only modify combat order and conditions. from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import Literal from fastmcp import FastMCP from pydantic import BaseModel, Field @@ -15,9 +15,6 @@ from pydantic import BaseModel, Field from storied.initiative import Combatant, InitiativeTracker from storied.tools._context import Combat -if TYPE_CHECKING: - from fastmcp import FastMCP as _FastMCP - mcp = FastMCP("combat") @@ -41,11 +38,11 @@ class CombatantInput(BaseModel): # The composed top-level server and the set of combat tool keys to hide # when leaving combat — both registered at start_server() time so the # enter/end_initiative tools can flip combat tag visibility on the parent. -_root: "_FastMCP | None" = None +_root: FastMCP | None = None _combat_keys_to_hide: set[str] = set() -def set_root(root: "_FastMCP", combat_keys_to_hide: set[str]) -> None: +def set_root(root: FastMCP, combat_keys_to_hide: set[str]) -> None: """Register the composed top-level FastMCP server. Called from mcp_server.start_server() after composition so that diff --git a/src/storied/tools/entities.py b/src/storied/tools/entities.py index aa1fdc7..f62726c 100644 --- a/src/storied/tools/entities.py +++ b/src/storied/tools/entities.py @@ -7,6 +7,7 @@ from typing import Literal import yaml from fastmcp import FastMCP +from storied.character import load_character from storied.log import CampaignLog from storied.search import VectorIndex from storied.session import name_to_slug @@ -23,8 +24,10 @@ from storied.tools._context import ( # Entity-type enums exposed to the LLM via JSON Schema. Each tool's set is # slightly different — only the kinds that make sense for that operation. -EstablishType = Literal["npcs", "locations", "items", "factions", "threads", "lore"] -MarkType = Literal["npcs", "locations", "items", "factions", "threads"] +EstablishType = Literal[ + "npcs", "locations", "items", "factions", "threads", "lore", "maps", +] +MarkType = Literal["npcs", "locations", "items", "factions", "threads", "maps"] DiscoveryType = Literal["npcs", "locations", "factions", "lore"] mcp = FastMCP("entities") @@ -210,6 +213,21 @@ def _do_establish( return f"{action} {entity_type.rstrip('s')} '{name}'" +def _normalize_anchor(when: str) -> str: + """Normalize a caller-supplied time anchor to the canonical `#dX-HHMM` form. + + Accepts `#d16-0845`, `d16-0845`, `#16-0845`, and a few close misses. + Returns the input unchanged if it can't be parsed — callers see the raw + text in the Was entry either way, and that's better than silently + dropping it. + """ + from storied.log import GameTime + try: + return GameTime.from_anchor(when).to_anchor() + except ValueError: + return when if when.startswith("#") else f"#{when}" + + def _do_mark( entity_type: str, name: str, @@ -220,6 +238,7 @@ def _do_mark( entity_index: EntityIndex, lore: VectorIndex, timekeeper: CampaignLog, + when: str | None = None, ) -> str: """Plain bookkeeping form of mark.""" file_path = entity_index.resolve(name) @@ -229,7 +248,10 @@ def _do_mark( if not file_path.exists(): return f"Error: Entity '{name}' not found in {entity_type}" - timestamp = timekeeper.get_current_time().to_anchor() + if when: + timestamp = _normalize_anchor(when) + else: + timestamp = timekeeper.get_current_time().to_anchor() lock = _get_file_lock(file_path) with lock: @@ -264,6 +286,61 @@ def _do_mark( return result +# Auto-mark cooldown: if an entity was auto-marked less than this many game +# minutes ago, the next auto-mark is skipped. This prevents Was-bloat on +# entities who are "present" through a whole scene of sequential set_scenes. +# The DM can still call `mark` directly for beats they want preserved. +_AUTO_MARK_COOLDOWN_MINUTES = 20 + +# set_scene events that represent session lifecycle rather than in-fiction +# beats. These should never land in an entity's history. +_OPERATIONAL_EVENT_PREFIXES = ( + "session resumed", + "session starting", + "session saved", + "session ended", + "autosave", + "[system", + "player requested", +) + + +def _is_operational_event(event: str) -> bool: + """True if `event` is session lifecycle chatter, not an in-fiction beat.""" + lower = event.strip().lower() + return any(lower.startswith(p) for p in _OPERATIONAL_EVENT_PREFIXES) + + +def _last_was_anchor(entity: dict) -> str | None: + """Return the time anchor of the most recent Was entry, if any.""" + was = entity.get("was") or [] + if not was: + return None + match = re.match(r"^(#d\d+-\d{2}\d{2})", was[-1]) + return match.group(1) if match else None + + +def _minutes_since( + anchor: str, now_hhmm_days: tuple[int, int, int], +) -> int | None: + """Minutes from `anchor` to the given (day, hour, minute) tuple. + + Returns None if the anchor is malformed or in the future (which can + happen with backdated marks or clock rewinds — in both cases we don't + want the cooldown to fire). + """ + from storied.log import GameTime + try: + then = GameTime.from_anchor(anchor) + except ValueError: + return None + day, hour, minute = now_hhmm_days + now_total = day * 24 * 60 + hour * 60 + minute + then_total = then.day * 24 * 60 + then.hour * 60 + then.minute + delta = now_total - then_total + return delta if delta >= 0 else None + + def _auto_mark_present( present: list[str], event: str, @@ -277,7 +354,18 @@ def _auto_mark_present( Called by set_scene; uses _do_mark directly so it doesn't have to go through dependency resolution again. + + Skip rules, in order: + 1. Operational events (session lifecycle) never land in entity history. + 2. If the entity was auto-marked less than _AUTO_MARK_COOLDOWN_MINUTES + ago in game time, skip — the scene is already represented. """ + if _is_operational_event(event): + return [] + + now = timekeeper.get_current_time() + now_tuple = (now.day, now.hour, now.minute) + marked: list[str] = [] for ref in present: link_match = re.search(r"\[\[([^\]]+)\]\]", ref) @@ -293,13 +381,25 @@ def _auto_mark_present( file_path = candidate break - if file_path and file_path.exists(): - entity_type = file_path.parent.name - _do_mark( - entity_type, name, event, None, - base_path, world_id, entity_index, lore, timekeeper, - ) - marked.append(name) + if not (file_path and file_path.exists()): + continue + + # Cooldown: if the last Was entry on this entity is within the + # cooldown window, skip. We use _load_entity rather than re-reading + # the file so it benefits from the index cache. + existing = _load_entity(file_path, entity_index) + last_anchor = _last_was_anchor(existing) + if last_anchor is not None: + delta = _minutes_since(last_anchor, now_tuple) + if delta is not None and delta < _AUTO_MARK_COOLDOWN_MINUTES: + continue + + entity_type = file_path.parent.name + _do_mark( + entity_type, name, event, None, + base_path, world_id, entity_index, lore, timekeeper, + ) + marked.append(name) return marked @@ -318,6 +418,7 @@ def establish( will: list[str] | None = None, root: Path = StorageRoot(), world: str = World(), + player: str = Player(), entities: EntityIndex = Entities(), lore: VectorIndex = Lore(), ) -> str: @@ -351,6 +452,16 @@ def establish( Returns: Confirmation with the file path """ + if entity_type == "npcs": + character = load_character(player, root) + if character: + pc_name = character.get("identity", {}).get("name", "") + if pc_name and pc_name == name: + return ( + f"Refused: '{name}' is the player character. " + f"The PC lives on the character sheet, not as an NPC entity." + ) + return _do_establish( entity_type, name, description, location, knows, wants, will, root, world, entities, lore, @@ -363,6 +474,7 @@ def mark( name: str, event: str, resolves: list[str] | None = None, + when: str | None = None, root: Path = StorageRoot(), world: str = World(), entities: EntityIndex = Entities(), @@ -383,16 +495,87 @@ def mark( name: Entity name (exact filename match) event: What happened - brief description for the Was section resolves: Optional list of Will items to remove if this event fired triggers + when: Optional time anchor (e.g. "d16-0845" or "#d16-0845") to backdate + the event. Use this when recording something that happened earlier + than the current clock — an off-screen event the world-tick agent + is catching up on, or a beat the DM narrated retroactively. + Defaults to the current game time. Do NOT prefix the event text + with a timestamp; pass it through `when` instead. Returns: Confirmation message """ return _do_mark( entity_type, name, event, resolves, - root, world, entities, lore, timekeeper, + root, world, entities, lore, timekeeper, when=when, ) +@mcp.tool(tags={"dm", "planner"}) +def amend_mark( + entity_type: MarkType, + name: str, + event: str, + root: Path = StorageRoot(), + world: str = World(), + entities: EntityIndex = Entities(), + lore: VectorIndex = Lore(), +) -> str: + """Replace the most recent Was entry on an entity with new text. + + Use this when you want to correct or extend the most recent beat you + recorded for an entity — a retcon, a typo fix, or additional detail. + The timestamp anchor of the entry is preserved; only the event text + changes. This avoids cluttering the history with "Retcon:" entries or + chained corrections. + + If you want to record something genuinely new, use `mark` instead. + + Args: + entity_type: Type of entity: npcs, locations, items, factions, threads, maps + name: Entity name (exact filename match) + event: The replacement event text. The anchor is reused from the + existing entry. + + Returns: + Confirmation of what was replaced, or an error if no Was entry exists. + """ + file_path = entities.resolve(name) + if file_path is None: + file_path = root / "worlds" / world / entity_type / f"{name}.md" + + if not file_path.exists(): + return f"Error: Entity '{name}' not found in {entity_type}" + + lock = _get_file_lock(file_path) + with lock: + existing = _load_entity(file_path, entities) + + was = existing.get("was") or [] + if not was: + return ( + f"Error: '{name}' has no history to amend. Use `mark` to " + f"create the first entry." + ) + + last_entry = was[-1] + match = re.match(r"^(#d\d+-\d{4})\s*\|\s*", last_entry) + anchor = match.group(1) if match else "" + was[-1] = f"{anchor} | {event}" if anchor else event + + data = { + "description": existing.get("description", ""), + "location": existing.get("location", ""), + "knows": existing.get("knows", []), + "wants": existing.get("wants", []), + "will": existing.get("will", []), + "was": was, + } + _write_entity(file_path, name, entity_type, data, entities, lore) + + return f"Amended most recent entry on '{name}': {event}" + + @mcp.tool(tags={"dm"}) def note_discovery( entity: str, diff --git a/tests/test_character.py b/tests/test_character.py index b183b1d..4493641 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -13,15 +13,20 @@ from storied.character import ( add_item, add_note, adjust_coins, + auto_fails_save, + break_concentration, create_character, damage, effective_hp, + exhaustion_penalty, format_character_context, format_sheet, format_status, + has_disadvantage_on_checks, has_expertise_in, heal, is_proficient_in, + level_up, load_character, load_character_prose, passive_score, @@ -388,6 +393,65 @@ class TestComputation: # 10 + perception modifier (+4) = 14 assert passive_score(mira, "perception") == 14 + def test_exhaustion_penalty_applied_to_skills(self, mira: dict): + mira["state"]["exhaustion"] = 2 + total, breakdown = skill_modifier(mira, "stealth") + # Without exhaustion: +8. With 2 levels: +4. + assert total == 4 + assert any("exhaustion" in b.lower() for b in breakdown) + + def test_exhaustion_penalty_applied_to_saves(self, mira: dict): + mira["state"]["exhaustion"] = 1 + total, breakdown = save_modifier(mira, "dexterity") + # Without exhaustion: +6. With 1 level: +4. + assert total == 4 + assert any("exhaustion" in b.lower() for b in breakdown) + + def test_exhaustion_zero_is_noop(self, mira: dict): + mira["state"]["exhaustion"] = 0 + total, breakdown = skill_modifier(mira, "stealth") + assert total == 8 + assert not any("exhaustion" in b.lower() for b in breakdown) + + def test_exhaustion_penalty_helper(self, mira: dict): + mira["state"]["exhaustion"] = 3 + assert exhaustion_penalty(mira) == -6 + + def test_poisoned_gives_disadvantage_on_checks(self, mira: dict): + mira["conditions"] = ["Poisoned"] + assert has_disadvantage_on_checks(mira) is True + + def test_frightened_gives_disadvantage_on_checks(self, mira: dict): + mira["conditions"] = ["frightened"] + assert has_disadvantage_on_checks(mira) is True + + def test_no_disadvantage_without_matching_condition(self, mira: dict): + mira["conditions"] = ["Prone"] + assert has_disadvantage_on_checks(mira) is False + + def test_passive_perception_drops_when_disadvantaged(self, mira: dict): + # Baseline: +4 perception → passive 14 + assert passive_score(mira, "perception") == 14 + mira["conditions"] = ["Poisoned"] + # With disadvantage: -5 → 9 + assert passive_score(mira, "perception") == 9 + + def test_paralyzed_auto_fails_str_and_dex_saves(self, mira: dict): + mira["conditions"] = ["Paralyzed"] + assert auto_fails_save(mira, "strength") is True + assert auto_fails_save(mira, "dexterity") is True + assert auto_fails_save(mira, "wisdom") is False + assert auto_fails_save(mira, "constitution") is False + + def test_restrained_auto_fails_dex_saves_only(self, mira: dict): + mira["conditions"] = ["restrained"] + assert auto_fails_save(mira, "dexterity") is True + assert auto_fails_save(mira, "strength") is False + + def test_no_auto_fail_without_condition(self, mira: dict): + assert auto_fails_save(mira, "dexterity") is False + assert auto_fails_save(mira, "strength") is False + def test_effective_hp_with_temp(self, player_dir: Path): save_character( "test-player", @@ -558,6 +622,30 @@ class TestDisplay: result = format_sheet(mira) assert "+5 temp" in result + def test_format_sheet_shows_disadvantage_legend_when_condition_applies( + self, mira: dict, + ): + mira["conditions"] = ["Poisoned"] + sheet = format_sheet(mira) + assert "disadvantage" in sheet.lower() + + def test_format_sheet_shows_inspiration_when_available(self, mira: dict): + mira["state"]["inspiration"] = True + sheet = format_sheet(mira) + assert "Inspiration" in sheet and "available" in sheet + + def test_format_sheet_shows_exhaustion_line_when_nonzero(self, mira: dict): + mira["state"]["exhaustion"] = 2 + sheet = format_sheet(mira) + assert "exhaustion 2" in sheet.lower() + assert "-4" in sheet + + def test_format_sheet_shows_auto_fail_marker(self, mira: dict): + mira["conditions"] = ["Paralyzed"] + sheet = format_sheet(mira) + assert "✗" in sheet + assert "auto-fail" in sheet.lower() + def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): mira["state"]["exhaustion"] = 2 result = format_sheet(mira) @@ -677,6 +765,379 @@ class TestDamageHeal: result = damage("test-player", 3, damage_type="fire", base_path=player_dir) assert "fire" in result + def test_damage_resistance_halves(self, mira: dict, player_dir: Path): + update_character( + "test-player", + {"defenses.resistances": [{"damage": "fire"}]}, + base_path=player_dir, + ) + result = damage("test-player", 10, damage_type="fire", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 19 + assert "resistance" in result + assert "10" in result and "5" in result + + def test_damage_resistance_only_applies_to_matched_type( + self, mira: dict, player_dir: Path, + ): + update_character( + "test-player", + {"defenses.resistances": [{"damage": "fire"}]}, + base_path=player_dir, + ) + damage("test-player", 10, damage_type="cold", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 14 + + def test_damage_vulnerability_doubles(self, mira: dict, player_dir: Path): + update_character( + "test-player", + {"defenses.vulnerabilities": [{"damage": "radiant"}]}, + base_path=player_dir, + ) + result = damage( + "test-player", 5, damage_type="radiant", base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 14 + assert "vulnerability" in result + + def test_damage_immunity_is_zero(self, mira: dict, player_dir: Path): + update_character( + "test-player", + {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, + base_path=player_dir, + ) + result = damage( + "test-player", 12, damage_type="poison", base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 24 + assert "Immune" in result + + def test_damage_resistance_and_vulnerability_cancel( + self, mira: dict, player_dir: Path, + ): + update_character( + "test-player", + { + "defenses.resistances": [{"damage": "cold"}], + "defenses.vulnerabilities": [{"damage": "cold"}], + }, + base_path=player_dir, + ) + damage("test-player", 8, damage_type="cold", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 16 + + def test_damage_resistance_tolerates_string_list( + self, mira: dict, player_dir: Path, + ): + # The LLM has been known to write resistances as a flat list of strings + # instead of a list of {"damage": ...} dicts. Handle both. + update_character( + "test-player", + {"defenses.resistances": ["fire"]}, + base_path=player_dir, + ) + damage("test-player", 10, damage_type="fire", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 19 + + def test_damage_resistance_is_case_insensitive( + self, mira: dict, player_dir: Path, + ): + update_character( + "test-player", + {"defenses.resistances": [{"damage": "Fire"}]}, + base_path=player_dir, + ) + damage("test-player", 10, damage_type="FIRE", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 19 + + def test_damage_resistance_with_temp_hp(self, mira: dict, player_dir: Path): + # Defenses apply before temp HP soaks. 10 fire → resisted to 5 → 5 temp + # absorbs all of it. + update_character( + "test-player", + { + "state.hp.temp": 5, + "defenses.resistances": [{"damage": "fire"}], + }, + base_path=player_dir, + ) + damage("test-player", 10, damage_type="fire", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["temp"] == 0 + assert data["state"]["hp"]["current"] == 24 + + +class TestLevelUp: + def test_level_up_increments_class_level( + self, mira: dict, player_dir: Path, + ): + result = level_up( + "test-player", + class_name="Rogue", + new_level=4, + hp_gain=6, + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["identity"]["classes"][0]["level"] == 4 + assert "3 → 4" in result + + def test_level_up_adds_hp_to_max_and_current( + self, mira: dict, player_dir: Path, + ): + # mira starts with 24/24 + level_up( + "test-player", "Rogue", + new_level=4, hp_gain=6, base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["max"] == 30 + assert data["state"]["hp"]["current"] == 30 + + def test_level_up_preserves_wounded_current_relative( + self, mira: dict, player_dir: Path, + ): + # Wound the character first + damage("test-player", 10, base_path=player_dir) + # HP is now 14/24 + level_up( + "test-player", "Rogue", + new_level=4, hp_gain=6, base_path=player_dir, + ) + data = load_character("test-player", player_dir) + # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) + assert data["state"]["hp"]["max"] == 30 + assert data["state"]["hp"]["current"] == 20 + + def test_level_up_sets_level_since( + self, mira: dict, player_dir: Path, + ): + level_up( + "test-player", "Rogue", + new_level=4, hp_gain=6, + time_anchor="#d12-1500", + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["level_since"] == "#d12-1500" + + def test_level_up_clears_advancement_ready( + self, mira: dict, player_dir: Path, + ): + update_character( + "test-player", + {"advancement_ready": 4}, + base_path=player_dir, + ) + level_up( + "test-player", "Rogue", + new_level=4, hp_gain=6, base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data.get("advancement_ready") is None + + def test_level_up_replaces_features_when_provided( + self, mira: dict, player_dir: Path, + ): + new_features = [ + {"name": "Sneak Attack", "text": "2d6"}, + {"name": "Uncanny Dodge", "text": "Reaction for half damage"}, + ] + level_up( + "test-player", "Rogue", + new_level=4, hp_gain=6, + features=new_features, + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert len(data["features"]) == 2 + assert data["features"][1]["name"] == "Uncanny Dodge" + + def test_level_up_preserves_features_when_omitted( + self, mira: dict, player_dir: Path, + ): + update_character( + "test-player", + {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, + base_path=player_dir, + ) + level_up( + "test-player", "Rogue", + new_level=4, hp_gain=6, base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] + + def test_level_up_rejects_downgrade( + self, mira: dict, player_dir: Path, + ): + result = level_up( + "test-player", "Rogue", + new_level=2, hp_gain=0, base_path=player_dir, + ) + assert "Refusing" in result + data = load_character("test-player", player_dir) + assert data["identity"]["classes"][0]["level"] == 3 # unchanged + + def test_level_up_rejects_unknown_class( + self, mira: dict, player_dir: Path, + ): + result = level_up( + "test-player", "Wizard", + new_level=4, hp_gain=4, base_path=player_dir, + ) + assert "No class matching" in result + data = load_character("test-player", player_dir) + assert data["identity"]["classes"][0]["level"] == 3 + + def test_level_up_multiclass_finds_correct_class( + self, mira: dict, player_dir: Path, + ): + # Add a Fighter level to make Mira multiclass + update_character( + "test-player", + {"identity.classes": [ + {"class": "Rogue", "subclass": "Thief", "level": 3}, + {"class": "Fighter", "subclass": None, "level": 1}, + ]}, + base_path=player_dir, + ) + level_up( + "test-player", "Fighter", + new_level=2, hp_gain=7, base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged + assert data["identity"]["classes"][1]["level"] == 2 # Fighter bumped + + +class TestConcentration: + def test_add_concentration_effect(self, mira: dict, player_dir: Path): + result = add_effect( + "test-player", "Bless", "+1d4 to attacks", + concentration=True, base_path=player_dir, + ) + assert "[Concentration]" in result + data = load_character("test-player", player_dir) + assert data["effects"][0]["concentration"] is True + + def test_adding_concentration_drops_previous( + self, mira: dict, player_dir: Path, + ): + add_effect( + "test-player", "Bless", "+1d4", + concentration=True, base_path=player_dir, + ) + result = add_effect( + "test-player", "Hold Person", "paralyzed", + concentration=True, base_path=player_dir, + ) + assert "lost concentration on Bless" in result + data = load_character("test-player", player_dir) + sources = [e["source"] for e in data["effects"]] + assert "Bless" not in sources + assert "Hold Person" in sources + + def test_adding_non_concentration_does_not_drop_existing( + self, mira: dict, player_dir: Path, + ): + add_effect( + "test-player", "Bless", "+1d4", + concentration=True, base_path=player_dir, + ) + add_effect( + "test-player", "Potion of Heroism", "+10 temp HP", + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + sources = [e["source"] for e in data["effects"]] + assert "Bless" in sources + assert "Potion of Heroism" in sources + + def test_damage_emits_concentration_save_hint( + self, mira: dict, player_dir: Path, + ): + add_effect( + "test-player", "Bless", "+1d4", + concentration=True, base_path=player_dir, + ) + result = damage("test-player", 6, base_path=player_dir) + # DC = max(10, 6 // 2) = 10 + assert "Concentration save DC 10" in result + assert "Bless" in result + + def test_damage_concentration_dc_scales_with_damage( + self, mira: dict, player_dir: Path, + ): + add_effect( + "test-player", "Hex", "extra 1d6 necrotic", + concentration=True, base_path=player_dir, + ) + # Need a caster with more HP, but raising max for this test + update_character( + "test-player", {"state.hp.max": 100, "state.hp.current": 100}, + base_path=player_dir, + ) + result = damage("test-player", 30, base_path=player_dir) + # DC = max(10, 30 // 2) = 15 + assert "Concentration save DC 15" in result + + def test_no_concentration_hint_without_concentration_effect( + self, mira: dict, player_dir: Path, + ): + add_effect( + "test-player", "Mage Armor", "+3 AC", # not concentration + base_path=player_dir, + ) + result = damage("test-player", 5, base_path=player_dir) + assert "Concentration save" not in result + + def test_no_concentration_hint_when_damage_absorbed( + self, mira: dict, player_dir: Path, + ): + # 5e 2024: no damage taken → no save needed. Test via immunity. + update_character( + "test-player", + {"defenses.immunities": {"damage": ["fire"], "conditions": []}}, + base_path=player_dir, + ) + add_effect( + "test-player", "Bless", "+1d4", + concentration=True, base_path=player_dir, + ) + result = damage("test-player", 10, damage_type="fire", base_path=player_dir) + assert "Concentration save" not in result + + def test_break_concentration_removes_effect( + self, mira: dict, player_dir: Path, + ): + add_effect( + "test-player", "Bless", "+1d4", + concentration=True, base_path=player_dir, + ) + add_effect( + "test-player", "Mage Armor", "+3 AC", # non-concentration + base_path=player_dir, + ) + result = break_concentration("test-player", base_path=player_dir) + assert "Bless" in result + data = load_character("test-player", player_dir) + sources = [e["source"] for e in data["effects"]] + assert "Bless" not in sources + assert "Mage Armor" in sources # non-concentration effects untouched + + def test_break_concentration_noop_when_nothing_concentrating( + self, mira: dict, player_dir: Path, + ): + result = break_concentration("test-player", base_path=player_dir) + assert "No concentration" in result + class TestEffects: def test_add_effect_appends(self, mira: dict, player_dir: Path): diff --git a/tests/test_entities.py b/tests/test_entities.py index 225a44a..7461ea5 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -10,6 +10,7 @@ from storied.session import ( resolve_wiki_link, ) from storied.tools import EntityIndex, ToolContext +from storied.tools.entities import amend_mark as _amend_mark from storied.tools.entities import establish as _establish from storied.tools.entities import mark as _mark @@ -28,6 +29,12 @@ def mark(**kwargs): return call_tool(_mark, **kwargs) +def amend_mark(**kwargs): + """Test shim: drop legacy `ctx` kwarg and resolve Dependency params.""" + kwargs.pop("ctx", None) + return call_tool(_amend_mark, **kwargs) + + class TestEstablish: """Tests for the establish tool.""" @@ -86,6 +93,16 @@ class TestEstablish: content = loc_file.read_text() assert "Hidden tunnel in cellar" in content + def test_establish_map(self, ctx: ToolContext): + establish( + entity_type="maps", + name="Ashenmere District Map", + ctx=ctx, + description="```map\n┌────┐\n│ A │\n└────┘\n```", + ) + map_file = ctx.base_path / "worlds/test-world/maps/Ashenmere District Map.md" + assert map_file.exists() + def test_establish_item(self, ctx: ToolContext): establish( entity_type="items", @@ -167,7 +184,74 @@ class TestEstablish: content = (ctx.base_path / "worlds/test-world/npcs/Garrick the Jailer.md").read_text() assert "**Location:** In the basement of [[Greyhaven City Jail]]" in content - assert "Heavyset man" in content + assert "Heavyset man in his fifties." in content + + def test_establish_refuses_player_character_as_npc(self, ctx: ToolContext): + from storied.character import create_character + + create_character( + player_id="default", + name="Mira", + race="Human", + char_class="Rogue", + level=3, + abilities={"strength": 10, "dexterity": 16, "constitution": 12, + "intelligence": 12, "wisdom": 12, "charisma": 14}, + hp_max=24, ac=16, + base_path=ctx.base_path, + ) + + result = establish( + entity_type="npcs", + name="Mira", + ctx=ctx, + description="The player character, should never be here.", + ) + + assert "Refused" in result + assert "player character" in result + assert not (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() + + def test_establish_allows_player_name_for_non_npc(self, ctx: ToolContext): + """The guard is NPC-scoped — an NPC can't share the PC's name, but + a location or thread happening to be named 'Mira' is fine.""" + from storied.character import create_character + + create_character( + player_id="default", + name="Mira", + race="Human", + char_class="Rogue", + level=1, + abilities={"strength": 10, "dexterity": 16, "constitution": 12, + "intelligence": 12, "wisdom": 12, "charisma": 14}, + hp_max=8, ac=14, + base_path=ctx.base_path, + ) + + result = establish( + entity_type="locations", + name="Mira", + ctx=ctx, + description="A hamlet by the lake.", + ) + + assert "Established" in result + assert (ctx.base_path / "worlds/test-world/locations/Mira.md").exists() + + def test_establish_allows_npc_matching_pc_name_with_no_character( + self, ctx: ToolContext, + ): + """Without a character sheet on disk, the guard should not fire.""" + result = establish( + entity_type="npcs", + name="Mira", + ctx=ctx, + description="Some other Mira — no PC exists yet.", + ) + + assert "Established" in result + assert (ctx.base_path / "worlds/test-world/npcs/Mira.md").exists() def test_establish_location_preserved_on_update(self, ctx: ToolContext): # Create with location @@ -335,6 +419,131 @@ class TestMark: assert "not found" in result.lower() + def test_mark_with_when_backdates_the_entry(self, ctx: ToolContext): + # Advance the clock to d5-1400 so the current time is clearly + # distinct from the backdated time we're about to pass. + ctx.campaign_log.append_entry("Clock advance", "5 days") + + establish( + entity_type="npcs", + name="Dortha Cray", + ctx=ctx, + description="Tanner.", + ) + + mark( + entity_type="npcs", + name="Dortha Cray", + event="Did something earlier today, off-screen", + when="d1-0900", + ctx=ctx, + ) + + content = (ctx.base_path / "worlds/test-world/npcs/Dortha Cray.md").read_text() + # The timestamp on the Was entry should be the backdated one, + # not the current clock time. + assert "#d1-0900" in content + # And crucially there is NO double-timestamp prefix. + assert "#d1-0900 | #" not in content + + def test_mark_with_when_accepts_hash_prefix(self, ctx: ToolContext): + establish(entity_type="npcs", name="Somebody", ctx=ctx, description="x") + mark( + entity_type="npcs", + name="Somebody", + event="thing", + when="#d2-1430", + ctx=ctx, + ) + content = (ctx.base_path / "worlds/test-world/npcs/Somebody.md").read_text() + assert "#d2-1430" in content + + def test_mark_with_invalid_when_falls_back_gracefully( + self, ctx: ToolContext, + ): + # A garbage `when` value should not crash — the worst case is the + # literal text landing in the Was prefix. Callers can tell they + # fat-fingered it and retry. + establish(entity_type="npcs", name="Nobody2", ctx=ctx, description="x") + result = mark( + entity_type="npcs", + name="Nobody2", + event="thing", + when="not-a-timestamp", + ctx=ctx, + ) + assert "Marked" in result + + +class TestAmendMark: + """Tests for the amend_mark tool — replaces the most recent Was entry.""" + + def test_amend_replaces_most_recent_entry(self, ctx: ToolContext): + establish(entity_type="npcs", name="Vera", ctx=ctx, description="x") + mark( + entity_type="npcs", name="Vera", + event="Told Mira a half-truth", ctx=ctx, + ) + + result = amend_mark( + entity_type="npcs", name="Vera", + event="Told Mira the full truth about the smuggling ring", + ctx=ctx, + ) + + assert "Amended" in result + content = (ctx.base_path / "worlds/test-world/npcs/Vera.md").read_text() + assert "full truth" in content + assert "half-truth" not in content + + def test_amend_preserves_anchor(self, ctx: ToolContext): + establish(entity_type="npcs", name="Tam", ctx=ctx, description="x") + mark( + entity_type="npcs", name="Tam", + event="Original beat", when="d5-1200", ctx=ctx, + ) + + amend_mark( + entity_type="npcs", name="Tam", + event="Corrected beat", ctx=ctx, + ) + + content = (ctx.base_path / "worlds/test-world/npcs/Tam.md").read_text() + assert "#d5-1200 | Corrected beat" in content + + def test_amend_leaves_older_entries_untouched(self, ctx: ToolContext): + establish(entity_type="npcs", name="Oben", ctx=ctx, description="x") + mark(entity_type="npcs", name="Oben", event="First beat", ctx=ctx) + ctx.campaign_log.append_entry("advance", "1 hour") + mark(entity_type="npcs", name="Oben", event="Second beat", ctx=ctx) + + amend_mark( + entity_type="npcs", name="Oben", + event="Second beat, corrected", ctx=ctx, + ) + + content = (ctx.base_path / "worlds/test-world/npcs/Oben.md").read_text() + assert "First beat" in content + assert "Second beat, corrected" in content + assert "- Second beat\n" not in content # old unamended line gone + + def test_amend_on_entity_with_no_history_returns_error( + self, ctx: ToolContext, + ): + establish(entity_type="npcs", name="Fresh", ctx=ctx, description="x") + result = amend_mark( + entity_type="npcs", name="Fresh", + event="Something", ctx=ctx, + ) + assert "no history" in result.lower() + + def test_amend_on_nonexistent_entity_returns_error(self, ctx: ToolContext): + result = amend_mark( + entity_type="npcs", name="Ghost", + event="Something", ctx=ctx, + ) + assert "not found" in result.lower() + class TestWikilinkResolution: """Tests for wikilink extraction and resolution.""" diff --git a/tests/test_execute_tool.py b/tests/test_execute_tool.py index 8601866..24c07e1 100644 --- a/tests/test_execute_tool.py +++ b/tests/test_execute_tool.py @@ -617,6 +617,78 @@ class TestSceneToolWrappers: }) assert "Auto-marked: Vera" in result + def test_auto_mark_cooldown_suppresses_near_repeats( + self, ctx: ToolContext, + ): + """A second set_scene with the same present entity within the + cooldown window should NOT append another Was entry.""" + call("establish", { + "entity_type": "npcs", "name": "Margit", + "description": "Chandler.", + }) + + call("set_scene", { + "event": "Met Mira at the candle shop", + "duration": "10 min", + "present": ["[[Margit]]"], + }) + result2 = call("set_scene", { + "event": "Walked together to dinner", + "duration": "10 min", + "present": ["[[Margit]]"], + }) + + assert "Auto-marked" not in result2 + content = ( + ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Margit.md" + ).read_text() + assert content.count("Met Mira at the candle shop") == 1 + assert "Walked together to dinner" not in content + + def test_auto_mark_cooldown_clears_after_window(self, ctx: ToolContext): + """After more than cooldown minutes have passed in-game, the next + auto-mark for the same entity fires again.""" + call("establish", { + "entity_type": "npcs", "name": "Aldric", + "description": "Bookseller.", + }) + + call("set_scene", { + "event": "Briefed Aldric on the investigation", + "duration": "30 min", # advances the clock past the cooldown + "present": ["[[Aldric]]"], + }) + call("set_scene", { + "event": "Walked away to get lunch", + "duration": "30 min", + }) + result3 = call("set_scene", { + "event": "Returned and shared a new lead with Aldric", + "duration": "20 min", + "present": ["[[Aldric]]"], + }) + + assert "Auto-marked: Aldric" in result3 + + def test_auto_mark_skips_operational_events(self, ctx: ToolContext): + """Session-lifecycle events (Session resumed, etc) must never land + in an entity's history.""" + call("establish", { + "entity_type": "npcs", "name": "Dortha", + "description": "Tanner.", + }) + result = call("set_scene", { + "event": "Session resumed. Mira at Dortha's shop.", + "duration": "0 min", + "present": ["[[Dortha]]"], + }) + + assert "Auto-marked" not in result + content = ( + ctx.base_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md" + ).read_text() + assert "Session resumed" not in content + def test_set_scene_with_threads(self, ctx: ToolContext): result = call("set_scene", { "event": "Got a lead", diff --git a/tests/test_log.py b/tests/test_log.py index ea74242..6e3e047 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -98,9 +98,21 @@ class TestDuration: d = Duration.parse("3 days") assert d.minutes == 3 * 24 * 60 - def test_parse_rounds(self): - d = Duration.parse("5 rounds") - assert d.minutes >= 0 # Rounds are very short + def test_parse_rounds_short_fight_floors_to_one_minute(self): + # 5 rounds = 30 seconds, but the clock's minimum precision is + # one minute, so a sub-minute combat still advances the clock. + assert Duration.parse("5 rounds").minutes == 1 + + def test_parse_rounds_ten_rounds_is_one_minute(self): + # 10 rounds = 60 seconds — the one natural minute boundary. + assert Duration.parse("10 rounds").minutes == 1 + + def test_parse_rounds_twenty_rounds_is_two_minutes(self): + assert Duration.parse("20 rounds").minutes == 2 + + def test_parse_rounds_long_combat(self): + # 100 rounds = 10 minutes. + assert Duration.parse("100 rounds").minutes == 10 def test_parse_scene(self): d = Duration.parse("scene") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b7f4636..b6772aa 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -14,6 +14,7 @@ import pytest from storied.initiative import Combatant from storied.mcp_server import _compose_server from storied.tools import ToolContext, _context +from storied.tools.character import refresh_advancement_visibility from storied.tools.combat import _flip_into_combat, _flip_out_of_combat @@ -62,7 +63,9 @@ class TestPerRoleComposition: assert "end_initiative" in names def test_planner_only_has_its_tools(self): - assert _names("planner") == {"establish", "mark", "notify_dm", "recall"} + assert _names("planner") == { + "establish", "mark", "amend_mark", "notify_dm", "recall", + } def test_seeder_only_has_its_tools(self): assert _names("seeder") == {"establish", "set_scene"} @@ -131,9 +134,10 @@ class TestToolSchemas: ("set_item_status", "status", {"attuned", "equipped", "carried"}), ("recall", "scope", {"rules", "world", "all"}), ("establish", "entity_type", - {"npcs", "locations", "items", "factions", "threads", "lore"}), + {"npcs", "locations", "items", "factions", "threads", "lore", + "maps"}), ("mark", "entity_type", - {"npcs", "locations", "items", "factions", "threads"}), + {"npcs", "locations", "items", "factions", "threads", "maps"}), ("note_discovery", "content_type", {"npcs", "locations", "factions", "lore"}), ], @@ -219,6 +223,58 @@ class TestCombatTagFlip: assert "end_initiative" in state +class TestAdvancementVisibility: + """level_up is hidden until the character has advancement_ready set.""" + + def test_level_up_hidden_at_compose_time(self, ctx: ToolContext): + """Fresh compose should not expose level_up — nothing has granted it yet.""" + async def _gather() -> set[str]: + server = await _compose_server("dm") + return {t.name for t in await server.list_tools()} + + names = asyncio.run(_gather()) + assert "level_up" not in names + + def test_level_up_revealed_when_advancement_ready(self, ctx: ToolContext): + async def _run() -> set[str]: + server = await _compose_server("dm") + refresh_advancement_visibility({"advancement_ready": 4}) + return {t.name for t in await server.list_tools()} + + names = asyncio.run(_run()) + assert "level_up" in names + refresh_advancement_visibility(None) # cleanup + + def test_level_up_hidden_again_when_flag_cleared(self, ctx: ToolContext): + async def _run() -> set[str]: + server = await _compose_server("dm") + refresh_advancement_visibility({"advancement_ready": 4}) + refresh_advancement_visibility({"advancement_ready": None}) + return {t.name for t in await server.list_tools()} + + names = asyncio.run(_run()) + assert "level_up" not in names + + def test_level_up_not_in_planner_compose(self, ctx: ToolContext): + """Only the DM role cares about advancement visibility. Other roles + don't have level_up at all, so the flip is a no-op for them.""" + async def _run() -> set[str]: + server = await _compose_server("planner") + return {t.name for t in await server.list_tools()} + + names = asyncio.run(_run()) + assert "level_up" not in names + + def test_refresh_with_none_character_is_safe(self, ctx: ToolContext): + """A character sheet that doesn't exist yet (pre-creation) should + not crash the visibility flip.""" + async def _run() -> None: + await _compose_server("dm") + refresh_advancement_visibility(None) + + asyncio.run(_run()) + + class TestPopulateIndex: """Cover the SRD-seeding helper without launching a real server."""