diff --git a/prompts/character-creation.md b/prompts/character-creation.md index 4736ea1..81cda8a 100644 --- a/prompts/character-creation.md +++ b/prompts/character-creation.md @@ -40,7 +40,9 @@ Get the details right - this character will be with them for a while. ## Finalizing -Once you have everything, call `create_character` with the full character data. This saves their character sheet. +Once you have everything, call `create_character` with the full character data. This writes `character.yaml` (structured stats) and `character.md` (the prose backstory you pass via the `backstory` argument). + +After the character exists, you can call `update_character` to add proficiencies (skills/saves/tools), `add_item` to populate starting equipment, and `update_character({"features": [...]})` to add class features. Equipment goes into location subsections like `on_person`, `worn`, or whatever the player describes. **IMPORTANT**: After calling `create_character`, give a brief summary of the character (a "quick reference" with key abilities, spells if any, and notable features) but do NOT start the adventure. The session will end and the player will start fresh with `storied play` using the full DM system. diff --git a/prompts/dm-system.md b/prompts/dm-system.md index 613375e..f37291b 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -4,34 +4,65 @@ This is collaborative storytelling in a fantasy game. Players may explore morall ## Available Tools -### Always Available +The character sheet system is a **bookkeeping toolkit**. The system tracks state and computes display values (skill modifiers, AC, passive perception). You handle the rules adjudication. Use the dedicated tools below — they prevent arithmetic errors and let you focus on narration. + +### General | Tool | Purpose | |------|---------| | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | | `recall` | Look up rules or world content | -| `update_character` | Modify character stats (HP, equipment, features) | -| `adjust_coins` | Spend or gain coins by relative amounts (preferred for all coin changes) | +| `run_code` | Run Python in a sandbox — all your tools available as functions | + +### Character — HP and damage +| Tool | Purpose | +|------|---------| +| `damage` | Apply damage to the character (handles temp HP). `damage(7)` or `damage(7, type="fire")`. **Don't do `update_character({"state.hp.current": ...})` math yourself.** | +| `heal` | Heal the character. `heal(5)` | +| `add_condition` | Mark a condition (Poisoned, Prone, Frightened, etc.) | +| `remove_condition` | Remove a condition | + +### Character — effects (temporary buffs/debuffs) +| Tool | Purpose | +|------|---------| +| `add_effect` | Track a temporary effect with optional expiry. Use for spells, potions, environmental buffs/debuffs. | +| `remove_effect` | Remove an effect by source name | + +### Character — coins, items, resources +| Tool | Purpose | +|------|---------| +| `adjust_coins` | Spend/gain coins. Always use this — never set coins directly. | +| `add_item` | Add a mundane item to a location subsection (`add_item("Lockpicks", location="on_person")`) | +| `remove_item` | Remove a mundane item by name (substring match) | +| `set_item_status` | Move a magic item between attuned/equipped/carried. Magic items live as world entities; establish them first. | +| `use_resource` | Decrement a resource pool (rage uses, ki points, magic item charges, hit dice). Generic — works for anything. | +| `restore_resource` | Restore points to a resource (rare; usually use `rest`). | +| `rest` | Take a `short` or `long` rest. Refreshes resources by refresh type. Long rest also clears death saves, removes 1 exhaustion, restores HP. | +| `add_note` | Append a timestamped note to the player's journal | + +### Character — meta updates +| Tool | Purpose | +|------|---------| +| `update_character` | Universal field setter for anything without a dedicated tool (level-up, feature lists, exhaustion level, etc.). Use dot notation: `{"state.exhaustion": 1}`, `{"identity.classes.0.level": 4}` | +| `create_character` | Create a new character (character creation flow) | -### Narrative Mode (outside initiative) +### World and scene | Tool | Purpose | |------|---------| | `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | | `establish` | Create or update entities (NPCs, locations, items, threads) | | `mark` | Record what happened to an entity | | `note_discovery` | Record what the player learned | -| `create_character` | Create a new character | | `tune` | Update your style/personality tuning based on player feedback | | `end_session` | Gracefully end the session | | `enter_initiative` | Enter initiative mode for combat or turn-based encounters | -| `run_code` | Run Python in a sandbox — all your tools available as functions | -### Initiative Mode (during initiative) +### Initiative Mode (during initiative — overrides `damage`/`heal`) | Tool | Purpose | |------|---------| | `next_turn` | Advance to the next combatant's turn | -| `damage` | Deal damage to a combatant (tracks defeat at 0 HP) | -| `heal` | Heal a combatant (clamped to max HP) | -| `condition` | Add or remove a condition (Prone, Stunned, etc.) | +| `damage` | Deal damage to a combatant — `damage(target="Goblin", amount=7)` | +| `heal` | Heal a combatant — `heal(target="Mira", amount=5)` | +| `condition` | Add or remove a condition on a combatant | | `add_combatant` | Add reinforcements or late arrivals | | `remove_combatant` | Remove a combatant who fled or was banished | | `end_initiative` | End initiative and return to narrative mode | @@ -293,20 +324,61 @@ The player chose to play a murderer. Now run that story. Make the investigation ## Character Management -The player's character sheet is provided below. Persist changes immediately — don't wait until the end of the session. +The character sheet is split across three files: +- **character.yaml** — structured data (stats, equipment, resources, effects). The DM tools mostly read and write this. +- **character.md** — free-text prose: backstory, personality, aliases, voice. Read it for roleplay reference. +- **notes.md** — appending journal of player observations, leads, and decisions. Use `add_note` to add entries. + +The character sheet is provided in your context every turn with all derived values **already computed** — skill modifiers, save modifiers, AC, passive perception, effective HP including temp, magic item charges, active effect summaries. **You should never type a derived value or do arithmetic.** When you need to make a check, the modifier is right there. When you damage the character, the system handles the math. + +### When something happens, use the dedicated tool + +Each of these is one verb. Don't compose them into `update_character` calls. + +**Damage and healing** (replaces ad-hoc HP math): +- `damage(amount)` — apply damage; temp HP soaks first +- `damage(amount, type="fire")` — with damage type for narration +- `heal(amount)` — clamped to max HP +- Inside initiative, use the targeted forms: `damage(target="Goblin", amount=7)` + +**Coins** (always relative, never absolute): +- `adjust_coins({"gp": -5})` — spending +- `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` — looting + +**Conditions and effects**: +- `add_condition("Poisoned")` / `remove_condition("Poisoned")` — for 5e named conditions +- `add_effect(source="Bless", description="+1d4 to attacks and saves", expires="d28-1430")` — for spell effects, potions, blessings, anything temporary. The system removes effects whose expiry has passed. +- `remove_effect("Bless")` — substring match on source + +**Inventory**: +- `add_item("Lockpicks", location="on_person")` — mundane item to a subsection +- `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): +- `use_resource("rage")` — decrement by 1 +- `use_resource("hit_dice_d8", amount=2)` — decrement by 2 +- `rest("short")` or `rest("long")` — refreshes resources by their refresh type. Long rest also clears death saves, removes one exhaustion level, and restores HP. + +**Notes**: +- `add_note("The miller mentioned strange lights at the old mill")` — appends to notes.md with current game time + +**Anything else** (level-up, exhaustion, death saves, ability score changes, proficiency changes): +- `update_character({"identity.classes.0.level": 4, "state.hp.max": 30})` — universal field setter +- `update_character({"state.exhaustion": 1})` — set exhaustion level +- `update_character({"state.death_saves.successes": 2})` — track death saves +- `update_character({"features": [...new full list...]})` — replace features at level-up + +### Important: never do arithmetic -Use `adjust_coins` for all coin changes (spending, earning, looting): -- **Spending coins**: `adjust_coins({"gp": -5})` — negative to spend -- **Gaining coins**: `adjust_coins({"gp": 10, "sp": 5})` — positive to gain -- **Looting mixed coins**: `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` +❌ Don't: `update_character({"state.hp.current": 19})` ← you computed 24 - 5 yourself +✅ Do: `damage(5)` ← the system computes it -Coins are clamped to 0 — the tool will tell you if the character was short. +❌ Don't: `update_character({"state.purse.gp": 38})` ← you computed 43 - 5 yourself +✅ Do: `adjust_coins({"gp": -5})` ← the system computes it -Use `update_character` for everything else: -- **After damage/healing**: `{"hp.current": 5}` -- **After using abilities**: `{"features.0.uses": 0}` (e.g., Second Wind) -- **After gaining/losing equipment**: `{"section.Equipment": "- Longsword\n- New shield"}` -- **After leveling up**: `{"level": 2, "hp.max": 20, "level_since": "#d5-1430", "advancement_ready": null}` +❌ Don't: think "her Stealth is +8 because dex +4 and expertise +4" +✅ Do: read "Stealth +8 ★★" from the character context and use that number ## Level Advancement @@ -316,7 +388,7 @@ A background system evaluates whether the character has earned a level-up. When 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, updated HP, and any new features. Clear the flag: `{"level": , "hp.max": , "level_since": "", "advancement_ready": null}` +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"]` 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. diff --git a/src/storied/character.py b/src/storied/character.py deleted file mode 100644 index 1c7558e..0000000 --- a/src/storied/character.py +++ /dev/null @@ -1,460 +0,0 @@ -"""Player character loading and saving.""" - -import re -from pathlib import Path - -import yaml - - -def load_character(player_id: str, base_path: Path | None = None) -> dict | None: - """Load a character file, returning frontmatter and body. - - Args: - player_id: Player identifier (directory name under players/) - base_path: Base path for players directory (defaults to cwd) - - Returns: - Dict with frontmatter fields plus 'body' key, or None if not found - """ - if base_path is None: - base_path = Path.cwd() - - char_path = base_path / "players" / player_id / "character.md" - if not char_path.exists(): - return None - - content = char_path.read_text() - return parse_character(content) - - -def parse_character(content: str) -> dict: - """Parse character markdown with YAML frontmatter.""" - if content.startswith("---"): - # Find the closing --- - end_match = re.search(r"\n---\s*\n", content[3:]) - if end_match: - frontmatter_end = end_match.start() + 3 - frontmatter_str = content[3:frontmatter_end] - body = content[frontmatter_end + end_match.end() - end_match.start() :] - - result = yaml.safe_load(frontmatter_str) or {} - result["body"] = body.strip() - return result - - # No frontmatter, just body - return {"body": content.strip()} - - -def save_character( - player_id: str, data: dict, base_path: Path | None = None -) -> None: - """Save character data back to file. - - Args: - player_id: Player identifier - data: Character data with frontmatter fields and 'body' - base_path: Base path for players directory - """ - if base_path is None: - base_path = Path.cwd() - - char_path = base_path / "players" / player_id / "character.md" - char_path.parent.mkdir(parents=True, exist_ok=True) - - # Separate body from frontmatter fields - body = data.pop("body", "") - frontmatter = data - - # Build the file content - content = "---\n" - content += yaml.dump(frontmatter, sort_keys=False, allow_unicode=True) - content += "---\n\n" - content += body - - char_path.write_text(content) - - # Put body back in data dict - data["body"] = body - - -def update_character( - player_id: str, - updates: dict, - base_path: Path | None = None, -) -> str: - """Update specific fields in the character sheet. - - Args: - player_id: Player identifier - updates: Dict of updates. Use dot notation for nested fields: - {"hp.current": 5, "gold": 25} - Use "section.Name" for markdown sections: - {"section.Equipment": "- Sword\\n- Shield"} - base_path: Base path for players directory - - Returns: - Confirmation message - """ - data = load_character(player_id, base_path) - if data is None: - return f"No character found for player '{player_id}'" - - changes = [] - - for key, value in updates.items(): - if key.startswith("section."): - # Update a markdown section - section_name = key[8:] # Remove "section." prefix - data["body"] = _update_section(data.get("body", ""), section_name, value) - changes.append(f"Updated {section_name} section") - elif "." in key: - # Nested frontmatter field (e.g., "hp.current") - _set_nested(data, key, value) - changes.append(f"{key} = {value}") - else: - # Top-level frontmatter field - data[key] = value - changes.append(f"{key} = {value}") - - # Clamp HP to valid 5e range: 0 to max (no negative HP in 5e) - hp = data.get("hp", {}) - if isinstance(hp, dict) and "current" in hp: - hp_max = hp.get("max", hp["current"]) - original = hp["current"] - hp["current"] = max(0, min(hp["current"], hp_max)) - if hp["current"] != original: - changes.append(f"(HP clamped to {hp['current']})") - - # Clamp purse: no negative coins - purse = data.get("purse", {}) - if isinstance(purse, dict): - for denom in ("cp", "sp", "ep", "gp", "pp"): - if denom in purse and purse[denom] < 0: - purse[denom] = 0 - changes.append(f"({denom} clamped to 0)") - - save_character(player_id, data, base_path) - return "Character updated: " + ", ".join(changes) - - -def adjust_coins( - player_id: str, - deltas: dict[str, int], - base_path: Path | None = None, -) -> str: - """Apply relative coin changes (positive=gain, negative=spend). - - Each denomination is clamped to 0 minimum. Returns the new purse state. - """ - data = load_character(player_id, base_path) - if data is None: - return f"No character found for player '{player_id}'" - - purse = data.get("purse", {}) - if not isinstance(purse, dict): - purse = {} - data["purse"] = purse - - changes = [] - for denom, delta in deltas.items(): - if denom not in ("cp", "sp", "ep", "gp", "pp"): - continue - old = purse.get(denom, 0) - new = old + delta - if new < 0: - changes.append(f"{denom} {old} → 0 (short {-new} {denom})") - purse[denom] = 0 - else: - changes.append(f"{denom} {old} → {new}") - purse[denom] = new - - save_character(player_id, data, base_path) - - # Format the current purse for the result - coins = [] - for denom in ("pp", "gp", "ep", "sp", "cp"): - amount = purse.get(denom, 0) - if amount: - coins.append(f"{amount} {denom}") - purse_str = ", ".join(coins) if coins else "empty" - - return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" - - -def _set_nested(data: dict, key: str, value) -> None: - """Set a nested value using dot notation.""" - parts = key.split(".") - current = data - - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - - current[parts[-1]] = value - - -def _update_section(body: str, section_name: str, new_content: str) -> str: - """Update a markdown section by name.""" - # Pattern to find ## Section Name and capture until next ## or end - pattern = rf"(## {re.escape(section_name)}\n)(.*?)(?=\n## |\Z)" - - def replacer(match: re.Match) -> str: - return match.group(1) + new_content.strip() + "\n" - - new_body, count = re.subn(pattern, replacer, body, flags=re.DOTALL) - - if count == 0: - # Section doesn't exist, append it - new_body = body.rstrip() + f"\n\n## {section_name}\n{new_content.strip()}\n" - - return new_body - - -def create_character( - player_id: str, - name: str, - race: str, - char_class: str, - level: int, - abilities: dict[str, int], - hp_max: int, - ac: int, - background: str | None = None, - speed: int = 30, - purse: dict[str, int] | None = None, - equipment: list[str] | None = None, - features: list[str] | None = None, - proficiencies: str | None = None, - backstory: str | None = None, - base_path: Path | None = None, -) -> str: - """Create a new character from scratch. - - Args: - player_id: Player identifier - name: Character name - race: Character race (e.g., "Human", "Elf") - char_class: Character class (e.g., "Fighter", "Wizard") - level: Starting level (usually 1) - abilities: Dict of ability scores {"strength": 15, "dexterity": 14, ...} - hp_max: Maximum HP - ac: Armor class - background: Background (e.g., "Soldier", "Acolyte") - speed: Movement speed in feet - purse: Starting coins as {cp, sp, ep, gp, pp} (defaults to all zeros) - equipment: List of equipment items - features: List of class/racial features - proficiencies: Proficiency description - backstory: Character backstory - base_path: Base path for players directory - - Returns: - Confirmation message - """ - # Build frontmatter - data = { - "name": name, - "race": race, - "class": char_class, - "level": level, - "background": background, - "hp": {"current": hp_max, "max": hp_max}, - "ac": ac, - "speed": speed, - "abilities": abilities, - "purse": purse or {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, - } - - # Build body sections - body_parts = [] - - if proficiencies: - body_parts.append(f"## Proficiencies\n{proficiencies}") - - if features: - body_parts.append("## Features\n" + "\n".join(f"- {f}" for f in features)) - - if equipment: - body_parts.append("## Equipment\n" + "\n".join(f"- {e}" for e in equipment)) - - if backstory: - body_parts.append(f"## Backstory\n{backstory}") - - data["body"] = "\n\n".join(body_parts) - - save_character(player_id, data, base_path) - return f"Created character '{name}' - a level {level} {race} {char_class}!" - - -_ABILITY_ORDER = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] - - -def _ability_line(abilities: dict[str, int]) -> str: - """Format abilities as a single pipe-separated line.""" - parts = [] - for ability in _ABILITY_ORDER: - score = abilities.get(ability, 10) - mod = (score - 10) // 2 - mod_str = f"+{mod}" if mod >= 0 else str(mod) - parts.append(f"{ability[:3].upper()} {score} ({mod_str})") - return " | ".join(parts) - - -def _purse_line(data: dict) -> str | None: - """Format purse as a string, or None if empty/missing.""" - purse = data.get("purse") - if purse and isinstance(purse, dict): - coins = [] - for denom in ["pp", "gp", "ep", "sp", "cp"]: - amount = purse.get(denom, 0) - if amount: - coins.append(f"{amount} {denom}") - return ", ".join(coins) if coins else "empty" - if (gold := data.get("gold")) is not None: - return f"{gold} gp" - return None - - -def _parse_sections(body: str) -> list[tuple[str, str]]: - """Split markdown body into (section_name, content) pairs.""" - sections: list[tuple[str, str]] = [] - current_name: str | None = None - current_lines: list[str] = [] - - for line in body.splitlines(): - if line.startswith("## "): - if current_name is not None: - sections.append((current_name, "\n".join(current_lines).strip())) - current_name = line[3:].strip() - current_lines = [] - elif current_name is not None: - current_lines.append(line) - - if current_name is not None: - sections.append((current_name, "\n".join(current_lines).strip())) - - return sections - - -def format_status(data: dict, *, include_equipment: bool = True) -> str: - """Compact character status for /status — renders instantly via Rich Markdown.""" - name = data.get("name", "Unknown") - race = data.get("race", "") - char_class = data.get("class", "") - level = data.get("level", 1) - background = data.get("background") - - identity = f"**{name}** — {race} {char_class} {level}" - if background: - identity += f" ({background})" - - hp = data.get("hp", {}) - if isinstance(hp, dict): - hp_str = f"{hp.get('current', '?')}/{hp.get('max', '?')}" - else: - hp_str = str(hp) - - ac = data.get("ac", "?") - speed = data.get("speed", 30) - vitals = f"HP {hp_str} | AC {ac} | Speed {speed} ft" - - lines = [identity, vitals] - - abilities = data.get("abilities", {}) - if abilities: - lines.append("") - lines.append(_ability_line(abilities)) - - purse = _purse_line(data) - if purse: - lines.append("") - lines.append(f"**Purse:** {purse}") - - if include_equipment: - sections = _parse_sections(data.get("body", "")) - for section_name, content in sections: - if section_name.lower() == "equipment": - items = [line.lstrip("- ").strip() for line in content.splitlines() if line.strip()] - if items: - lines.append(f"**Equipment:** {', '.join(items)}") - break - - return "\n".join(lines) - - -def format_sheet(data: dict) -> str: - """Full character sheet for /me — renders instantly via Rich Markdown.""" - lines = [format_status(data, include_equipment=False)] - - sections = _parse_sections(data.get("body", "")) - for section_name, content in sections: - if not content: - continue - lines.append("") - lines.append(f"**{section_name}**") - lines.append(content) - - return "\n".join(lines) - - -def format_character_context(data: dict) -> str: - """Format character data for inclusion in the system prompt. - - Returns the full character sheet as readable text. - """ - lines = ["## Player Character\n"] - - # Format frontmatter as readable stats - name = data.get("name", "Unknown") - race = data.get("race", "") - char_class = data.get("class", "") - level = data.get("level", 1) - - lines.append(f"**{name}** - Level {level} {race} {char_class}\n") - - # HP and AC - hp = data.get("hp", {}) - if isinstance(hp, dict): - lines.append(f"**HP:** {hp.get('current', '?')}/{hp.get('max', '?')}") - else: - lines.append(f"**HP:** {hp}") - - ac = data.get("ac", "?") - speed = data.get("speed", 30) - lines.append(f"**AC:** {ac} | **Speed:** {speed} ft\n") - - # Abilities - abilities = data.get("abilities", {}) - if abilities: - ability_strs = [] - for ability in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]: - score = abilities.get(ability, 10) - mod = (score - 10) // 2 - mod_str = f"+{mod}" if mod >= 0 else str(mod) - ability_strs.append(f"{ability[:3].upper()} {score} ({mod_str})") - lines.append(" | ".join(ability_strs) + "\n") - - # Purse (supports legacy "gold" field) - purse = data.get("purse") - if purse and isinstance(purse, dict): - coins = [] - for denom in ["pp", "gp", "ep", "sp", "cp"]: - amount = purse.get(denom, 0) - if amount: - coins.append(f"{amount} {denom}") - lines.append(f"**Purse:** {', '.join(coins) if coins else 'empty'}\n") - elif (gold := data.get("gold")) is not None: - lines.append(f"**Purse:** {gold} gp\n") - - # Advancement readiness flag (set by background evaluator) - advancement_ready = data.get("advancement_ready") - if advancement_ready: - lines.append(f"**⚡ Advancement Ready: Level {advancement_ready}**\n") - - # Include the markdown body (equipment, features, notes, etc.) - body = data.get("body", "") - if body: - lines.append(body) - - return "\n".join(lines) diff --git a/src/storied/character/__init__.py b/src/storied/character/__init__.py new file mode 100644 index 0000000..36e9ecd --- /dev/null +++ b/src/storied/character/__init__.py @@ -0,0 +1,93 @@ +"""Character module — structured character data, computation, and operations.""" + +from storied.character.compute import ( + ABILITIES, + ALL_SKILLS, + SKILL_TO_ABILITY, + ability_modifier, + class_summary, + effective_hp, + has_expertise_in, + initiative_modifier, + is_proficient_in, + passive_score, + proficiency_bonus, + save_modifier, + skill_modifier, + total_level, +) +from storied.character.data import ( + DEFAULT_SCHEMA, + create_character, + load_character, + load_character_prose, + save_character, + save_character_prose, + update_character, +) +from storied.character.display import ( + format_character_context, + format_sheet, + format_status, +) +from storied.character.operations import ( + add_condition, + add_effect, + add_item, + add_note, + adjust_coins, + damage, + heal, + remove_condition, + remove_effect, + remove_item, + rest, + restore_resource, + set_item_status, + use_resource, +) + +__all__ = [ + # Data + "DEFAULT_SCHEMA", + "create_character", + "load_character", + "load_character_prose", + "save_character", + "save_character_prose", + "update_character", + # Compute + "ABILITIES", + "ALL_SKILLS", + "SKILL_TO_ABILITY", + "ability_modifier", + "class_summary", + "effective_hp", + "has_expertise_in", + "initiative_modifier", + "is_proficient_in", + "passive_score", + "proficiency_bonus", + "save_modifier", + "skill_modifier", + "total_level", + # Display + "format_character_context", + "format_sheet", + "format_status", + # Operations + "add_condition", + "add_effect", + "add_item", + "add_note", + "adjust_coins", + "damage", + "heal", + "remove_condition", + "remove_effect", + "remove_item", + "rest", + "restore_resource", + "set_item_status", + "use_resource", +] diff --git a/src/storied/character/compute.py b/src/storied/character/compute.py new file mode 100644 index 0000000..6daf014 --- /dev/null +++ b/src/storied/character/compute.py @@ -0,0 +1,151 @@ +"""Pure computation functions for derived character values. + +Everything here is best-effort 5e-flavored display computation. The DM +can override any value via update_character. These functions are for the +DM's convenience, not for enforcing rules. +""" + + +# Maps each skill to its governing ability +SKILL_TO_ABILITY: dict[str, str] = { + "acrobatics": "dexterity", + "animal_handling": "wisdom", + "arcana": "intelligence", + "athletics": "strength", + "deception": "charisma", + "history": "intelligence", + "insight": "wisdom", + "intimidation": "charisma", + "investigation": "intelligence", + "medicine": "wisdom", + "nature": "intelligence", + "perception": "wisdom", + "performance": "charisma", + "persuasion": "charisma", + "religion": "intelligence", + "sleight_of_hand": "dexterity", + "stealth": "dexterity", + "survival": "wisdom", +} + +ALL_SKILLS = list(SKILL_TO_ABILITY.keys()) + +ABILITIES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] + + +def ability_modifier(score: int) -> int: + """Standard 5e ability modifier: (score - 10) // 2.""" + return (score - 10) // 2 + + +def total_level(char: dict) -> int: + """Sum levels across all classes (multiclass-aware).""" + classes = char.get("identity", {}).get("classes", []) + return sum(c.get("level", 0) for c in classes) + + +def proficiency_bonus(char: dict) -> int: + """5e proficiency bonus from total character level.""" + level = max(1, total_level(char)) + return 2 + (level - 1) // 4 + + +def skill_modifier(char: dict, skill: str) -> tuple[int, list[str]]: + """Compute a skill modifier and the breakdown of contributions. + + Returns (total, breakdown_lines). + """ + ability = SKILL_TO_ABILITY.get(skill) + if ability is None: + return 0, [f"Unknown skill: {skill}"] + + abilities = char.get("abilities", {}) + ability_score = abilities.get(ability, 10) + ability_mod = ability_modifier(ability_score) + + breakdown: list[str] = [] + breakdown.append(f"{ability_mod:+d} {ability[:3].upper()}") + + skills = char.get("proficiencies", {}).get("skills", {}) + prof_level = skills.get(skill) + pb = proficiency_bonus(char) + prof_bonus = 0 + if prof_level == "expertise": + prof_bonus = pb * 2 + breakdown.append(f"+{prof_bonus} expertise") + elif prof_level == "proficient": + prof_bonus = pb + breakdown.append(f"+{prof_bonus} proficient") + + total = ability_mod + prof_bonus + return total, breakdown + + +def save_modifier(char: dict, ability: str) -> tuple[int, list[str]]: + """Compute a saving throw modifier and breakdown.""" + abilities = char.get("abilities", {}) + ability_score = abilities.get(ability, 10) + mod = ability_modifier(ability_score) + + breakdown: list[str] = [f"{mod:+d} {ability[:3].upper()}"] + + saves = char.get("proficiencies", {}).get("saves", []) + pb = proficiency_bonus(char) + if ability in saves: + mod += pb + breakdown.append(f"+{pb} proficient") + + return mod, breakdown + + +def passive_score(char: dict, skill: str = "perception") -> int: + """Passive score for a skill: 10 + skill modifier.""" + mod, _ = skill_modifier(char, skill) + return 10 + mod + + +def initiative_modifier(char: dict) -> int: + """Initiative is just the dex modifier (no proficiency by default in 5e).""" + return ability_modifier(char.get("abilities", {}).get("dexterity", 10)) + + +def effective_hp(char: dict) -> dict: + """Return current/max/temp/total HP info.""" + hp = char.get("state", {}).get("hp", {}) + current = hp.get("current", 0) + max_hp = hp.get("max", 0) + temp = hp.get("temp", 0) + return { + "current": current, + "max": max_hp, + "temp": temp, + "effective": current + temp, + } + + +def is_proficient_in(char: dict, skill: str) -> bool: + skills = char.get("proficiencies", {}).get("skills", {}) + return skills.get(skill) in ("proficient", "expertise") + + +def has_expertise_in(char: dict, skill: str) -> bool: + skills = char.get("proficiencies", {}).get("skills", {}) + return skills.get(skill) == "expertise" + + +def class_summary(char: dict) -> str: + """Format identity for display: 'Level 3 Rogue (Thief)' or multiclass.""" + classes = char.get("identity", {}).get("classes", []) + if not classes: + return "Unknown" + + parts = [] + for c in classes: + cls = c.get("class", "?") + sub = c.get("subclass") + lvl = c.get("level", 0) + if sub: + parts.append(f"Level {lvl} {cls} ({sub})") + else: + parts.append(f"Level {lvl} {cls}") + return " / ".join(parts) diff --git a/src/storied/character/data.py b/src/storied/character/data.py new file mode 100644 index 0000000..83f171b --- /dev/null +++ b/src/storied/character/data.py @@ -0,0 +1,248 @@ +"""Character data persistence: load and save character.yaml + character.md.""" + +from copy import deepcopy +from pathlib import Path + +import yaml + + +# Default character schema — used when creating a new character +DEFAULT_SCHEMA: dict = { + "identity": { + "name": "", + "race": "", + "subrace": None, + "classes": [], + "background": None, + }, + "abilities": { + "strength": 10, + "dexterity": 10, + "constitution": 10, + "intelligence": 10, + "wisdom": 10, + "charisma": 10, + }, + "proficiencies": { + "saves": [], + "skills": {}, + "tools": {}, + "weapons": [], + "armor": [], + "languages": [], + }, + "state": { + "hp": {"max": 1, "current": 1, "temp": 0}, + "ac": 10, + "speed": 30, + "movement_modes": {}, + "senses": {}, + "purse": {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, + "inspiration": False, + "exhaustion": 0, + "death_saves": {"successes": 0, "failures": 0}, + }, + "defenses": { + "resistances": [], + "vulnerabilities": [], + "immunities": {"damage": [], "conditions": []}, + }, + "conditions": [], + "features": [], + "resources": {}, + "equipment": {}, + "magic_items": {"attuned": [], "equipped": [], "carried": []}, + "effects": [], + "spellcasting": None, # populated only for spellcasters +} + + +def _character_yaml_path(player_id: str, base_path: Path | None = None) -> Path: + if base_path is None: + base_path = Path.cwd() + return base_path / "players" / player_id / "character.yaml" + + +def _character_md_path(player_id: str, base_path: Path | None = None) -> Path: + if base_path is None: + base_path = Path.cwd() + return base_path / "players" / player_id / "character.md" + + +def load_character(player_id: str, base_path: Path | None = None) -> dict | None: + """Load a character's structured data from character.yaml. + + Returns None if no character exists. Returns the parsed YAML dict, with + missing fields filled in from DEFAULT_SCHEMA so callers can rely on the + full structure being present. + """ + yaml_path = _character_yaml_path(player_id, base_path) + if not yaml_path.exists(): + return None + + raw = yaml.safe_load(yaml_path.read_text()) or {} + return _merge_defaults(raw) + + +def load_character_prose(player_id: str, base_path: Path | None = None) -> str: + """Load the character's free-text prose from character.md. + + Returns empty string if no prose file exists. + """ + md_path = _character_md_path(player_id, base_path) + if not md_path.exists(): + return "" + return md_path.read_text() + + +def save_character( + player_id: str, data: dict, base_path: Path | None = None +) -> None: + """Save character data back to character.yaml.""" + yaml_path = _character_yaml_path(player_id, base_path) + yaml_path.parent.mkdir(parents=True, exist_ok=True) + yaml_path.write_text( + yaml.dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False) + ) + + +def save_character_prose( + player_id: str, prose: str, base_path: Path | None = None +) -> None: + """Save the character's free-text prose to character.md.""" + md_path = _character_md_path(player_id, base_path) + md_path.parent.mkdir(parents=True, exist_ok=True) + md_path.write_text(prose) + + +def _merge_defaults(data: dict) -> dict: + """Recursively merge user data over DEFAULT_SCHEMA so missing fields are filled.""" + result = deepcopy(DEFAULT_SCHEMA) + _deep_update(result, data) + return result + + +def _deep_update(target: dict, source: dict) -> None: + """Recursively update target with values from source.""" + for key, value in source.items(): + if ( + key in target + and isinstance(target[key], dict) + and isinstance(value, dict) + ): + _deep_update(target[key], value) + else: + target[key] = value + + +def update_character( + player_id: str, + updates: dict, + base_path: Path | None = None, +) -> str: + """Update fields in character.yaml using dot notation. + + Example: {"state.hp.current": 5, "identity.classes.0.level": 4} + """ + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + changes = [] + for key, value in updates.items(): + _set_nested(data, key, value) + changes.append(f"{key} = {value}") + + # Clamp HP to 0..max + hp = data.get("state", {}).get("hp", {}) + if isinstance(hp, dict) and "current" in hp: + hp_max = hp.get("max", hp["current"]) + original = hp["current"] + hp["current"] = max(0, min(hp["current"], hp_max)) + if hp["current"] != original: + changes.append(f"(HP clamped to {hp['current']})") + + # Clamp purse: no negative coins + purse = data.get("state", {}).get("purse", {}) + if isinstance(purse, dict): + for denom in ("cp", "sp", "ep", "gp", "pp"): + if denom in purse and purse[denom] < 0: + purse[denom] = 0 + changes.append(f"({denom} clamped to 0)") + + save_character(player_id, data, base_path) + return "Character updated: " + ", ".join(changes) + + +def _set_nested(data: dict, key: str, value) -> None: + """Set a nested value using dot notation. Supports list indices like 'classes.0.level'.""" + parts = key.split(".") + current = data + + for part in parts[:-1]: + # Try as integer for list index + if part.isdigit() and isinstance(current, list): + current = current[int(part)] + continue + if part not in current: + current[part] = {} + current = current[part] + + last = parts[-1] + if last.isdigit() and isinstance(current, list): + current[int(last)] = value + else: + current[last] = value + + +def create_character( + player_id: str, + name: str, + race: str, + char_class: str, + level: int, + abilities: dict[str, int], + hp_max: int, + ac: int, + background: str | None = None, + speed: int = 30, + purse: dict[str, int] | None = None, + subclass: str | None = None, + subrace: str | None = None, + proficiencies: dict | None = None, + features: list[dict] | None = None, + equipment: dict | None = None, + backstory: str | None = None, + base_path: Path | None = None, +) -> str: + """Create a new character with the new schema. + + Writes character.yaml (structured) and character.md (prose). + """ + data = deepcopy(DEFAULT_SCHEMA) + data["identity"] = { + "name": name, + "race": race, + "subrace": subrace, + "classes": [{"class": char_class, "subclass": subclass, "level": level}], + "background": background, + } + data["abilities"] = abilities + data["state"]["hp"] = {"max": hp_max, "current": hp_max, "temp": 0} + data["state"]["ac"] = ac + data["state"]["speed"] = speed + if purse: + data["state"]["purse"].update(purse) + if proficiencies: + data["proficiencies"].update(proficiencies) + if features: + data["features"] = features + if equipment: + data["equipment"] = equipment + + save_character(player_id, data, base_path) + + if backstory: + save_character_prose(player_id, f"# {name}\n\n{backstory}\n", base_path) + + return f"Created character '{name}' - a level {level} {race} {char_class}!" diff --git a/src/storied/character/display.py b/src/storied/character/display.py new file mode 100644 index 0000000..b4c1cdc --- /dev/null +++ b/src/storied/character/display.py @@ -0,0 +1,271 @@ +"""Display formatting for character data — used by /me, /status, and the DM context.""" + +from storied.character.compute import ( + ABILITIES, + ALL_SKILLS, + SKILL_TO_ABILITY, + ability_modifier, + class_summary, + effective_hp, + has_expertise_in, + initiative_modifier, + is_proficient_in, + passive_score, + proficiency_bonus, + save_modifier, + skill_modifier, + total_level, +) + + +def _purse_line(state: dict) -> str | None: + purse = state.get("purse", {}) or {} + coins = [] + for denom in ("pp", "gp", "ep", "sp", "cp"): + amount = purse.get(denom, 0) + if amount: + coins.append(f"{amount} {denom}") + if not coins: + return None + return ", ".join(coins) + + +def _format_skill_name(skill: str) -> str: + """Convert 'sleight_of_hand' to 'Sleight of Hand'.""" + return " ".join(word.capitalize() for word in skill.split("_")) + + +def _save_line(char: dict) -> str: + parts = [] + 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}") + return " ".join(parts) + + +def _skill_lines(char: dict) -> list[str]: + """Render skills sorted alphabetically with proficiency markers.""" + lines: list[str] = [] + for skill in sorted(ALL_SKILLS): + mod, _ = skill_modifier(char, skill) + marker = "" + if has_expertise_in(char, skill): + marker = " ★★" + elif is_proficient_in(char, skill): + marker = " ★" + name = _format_skill_name(skill) + lines.append(f" {name:<18} {mod:+d}{marker}") + return lines + + +def _ability_line(char: dict) -> str: + abilities = char.get("abilities", {}) + parts = [] + for a in ABILITIES: + score = abilities.get(a, 10) + mod = ability_modifier(score) + parts.append(f"{a[:3].upper()} {score} ({mod:+d})") + return " | ".join(parts) + + +def _format_effects(char: dict) -> list[str]: + effects = char.get("effects", []) or [] + if not effects: + return [] + lines = ["**Active Effects:**"] + for e in effects: + source = e.get("source", "(unknown)") + desc = e.get("description", "") + expires = e.get("expires") + suffix = f" (until {expires})" if expires else "" + lines.append(f" • {source} — {desc}{suffix}") + return lines + + +def _format_resources(char: dict) -> list[str]: + resources = char.get("resources", {}) or {} + if not resources: + return [] + lines = ["**Resources:**"] + for name, pool in resources.items(): + current = pool.get("current", 0) + maximum = pool.get("max", 0) + notes = pool.get("notes", name) + die = pool.get("die") + die_str = f" {die}" if die else "" + lines.append(f" • {notes}: {current}/{maximum}{die_str}") + return lines + + +def _format_magic_items(char: dict) -> list[str]: + mi = char.get("magic_items", {}) or {} + if not (mi.get("attuned") or mi.get("equipped") or mi.get("carried")): + return [] + lines = ["**Magic Items:**"] + if mi.get("attuned"): + lines.append(f" Attuned: {', '.join(mi['attuned'])}") + if mi.get("equipped"): + lines.append(f" Equipped: {', '.join(mi['equipped'])}") + if mi.get("carried"): + lines.append(f" Carried: {', '.join(mi['carried'])}") + return lines + + +def _format_features(char: dict) -> list[str]: + features = char.get("features", []) or [] + if not features: + return [] + lines = ["**Features:**"] + for f in features: + name = f.get("name", "?") + text = f.get("text", "") + source = f.get("source", "") + src_str = f" ({source})" if source else "" + lines.append(f" • **{name}**{src_str} — {text}") + return lines + + +def _format_equipment(char: dict) -> list[str]: + equipment = char.get("equipment", {}) or {} + if not equipment: + return [] + lines = ["**Equipment:**"] + for location, items in equipment.items(): + if not items: + continue + loc_label = _format_skill_name(location) + lines.append(f" *{loc_label}:*") + for item in items: + lines.append(f" - {item}") + return lines + + +def _format_conditions(char: dict) -> list[str]: + conditions = char.get("conditions", []) or [] + if not conditions: + return [] + return [f"**Conditions:** {', '.join(conditions)}"] + + +def _format_defenses(char: dict) -> list[str]: + defenses = char.get("defenses", {}) or {} + lines = [] + if defenses.get("resistances"): + types = [r.get("damage", "?") for r in defenses["resistances"]] + lines.append(f"**Resistances:** {', '.join(types)}") + if defenses.get("vulnerabilities"): + types = [r.get("damage", "?") for r in defenses["vulnerabilities"]] + lines.append(f"**Vulnerabilities:** {', '.join(types)}") + immunities = defenses.get("immunities", {}) or {} + if immunities.get("damage"): + lines.append(f"**Damage Immunities:** {', '.join(immunities['damage'])}") + if immunities.get("conditions"): + lines.append(f"**Condition Immunities:** {', '.join(immunities['conditions'])}") + return lines + + +def _vital_line(char: dict) -> str: + state = char.get("state", {}) + hp = effective_hp(char) + hp_str = f"{hp['current']}/{hp['max']}" + if hp["temp"]: + hp_str += f" (+{hp['temp']} temp)" + ac = state.get("ac", 10) + speed = state.get("speed", 30) + init = initiative_modifier(char) + pb = proficiency_bonus(char) + parts = [f"HP {hp_str}", f"AC {ac}", f"Speed {speed}", f"Init {init:+d}", f"PB +{pb}"] + exhaustion = state.get("exhaustion", 0) + if exhaustion: + parts.append(f"Exhaustion {exhaustion}") + return " · ".join(parts) + + +def format_status(data: dict, *, include_equipment: bool = True) -> str: + """Compact status block for /status command.""" + name = data.get("identity", {}).get("name", "Unknown") + summary = class_summary(data) + + lines = [f"**{name}** — {summary}", _vital_line(data), ""] + lines.append(_ability_line(data)) + + purse = _purse_line(data.get("state", {})) + if purse: + lines.append("") + lines.append(f"**Purse:** {purse}") + + if include_equipment: + equipment = data.get("equipment", {}) or {} + if equipment: + items_flat = [] + for items in equipment.values(): + items_flat.extend(items) + if items_flat: + lines.append("") + lines.append(f"**Equipment:** {', '.join(items_flat[:8])}") + if len(items_flat) > 8: + lines.append(f" ... and {len(items_flat) - 8} more") + + return "\n".join(lines) + + +def format_sheet(data: dict) -> str: + """Full character sheet for /me command.""" + lines = [format_status(data, include_equipment=False), ""] + + # Saves + lines.append("**Saving Throws:**") + lines.append(f" {_save_line(data)}") + lines.append(" ★ proficient") + lines.append("") + + # Skills + lines.append("**Skills:**") + lines.extend(_skill_lines(data)) + lines.append(f" Passive Perception: {passive_score(data, 'perception')}") + lines.append(" ★ proficient · ★★ expertise") + lines.append("") + + # Conditions, defenses + cond_lines = _format_conditions(data) + if cond_lines: + lines.extend(cond_lines) + lines.append("") + + def_lines = _format_defenses(data) + if def_lines: + lines.extend(def_lines) + lines.append("") + + # Effects, resources, magic items, features + for section_func in (_format_effects, _format_resources, _format_magic_items, _format_features): + section = section_func(data) + if section: + lines.extend(section) + lines.append("") + + # Equipment + equipment_lines = _format_equipment(data) + if equipment_lines: + lines.extend(equipment_lines) + lines.append("") + + return "\n".join(lines).rstrip() + + +def format_character_context(data: dict) -> str: + """Format character data for inclusion in the DM's system prompt. + + Same as format_sheet but with additional context like advancement_ready + and a header. + """ + lines = ["## Player Character", ""] + lines.append(format_sheet(data)) + + advancement = data.get("advancement_ready") + if advancement: + lines.append("") + lines.append(f"**⚡ Advancement Ready: Level {advancement}**") + + return "\n".join(lines) diff --git a/src/storied/character/operations.py b/src/storied/character/operations.py new file mode 100644 index 0000000..7bb8d8e --- /dev/null +++ b/src/storied/character/operations.py @@ -0,0 +1,485 @@ +"""Action functions that modify character state. + +Each function loads the character, applies a change, saves, and returns +a result message describing what happened. These are the underlying +implementations behind the DM's bookkeeping tools. +""" + +from pathlib import Path + +from storied.character.data import ( + load_character, + save_character, +) + + +# --- HP operations --- + + +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.""" + 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" + + hp = data["state"]["hp"] + remaining = amount + + # Temp HP soaks damage first + temp_used = 0 + if hp.get("temp", 0) > 0: + temp_used = min(hp["temp"], remaining) + hp["temp"] -= temp_used + remaining -= temp_used + + # Then current HP + hp_before = hp["current"] + hp["current"] = max(0, hp["current"] - remaining) + 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 temp_used: + parts.append(f"absorbed {temp_used} with temp HP") + parts.append(f"HP: {hp['current']}/{hp['max']}") + if hp.get("temp", 0): + parts.append(f"({hp['temp']} temp remaining)") + if hp["current"] == 0: + parts.append("**(at 0 HP — death saves!)**") + return ". ".join(parts) + + +def heal( + player_id: str, + amount: int, + base_path: Path | None = None, +) -> str: + """Heal the character, clamped to max 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 "Heal amount must be non-negative" + + hp = data["state"]["hp"] + before = hp["current"] + hp["current"] = min(hp["max"], hp["current"] + amount) + actual = hp["current"] - before + + save_character(player_id, data, base_path) + return f"Healed {actual} HP. HP: {hp['current']}/{hp['max']}" + + +# --- Effect operations --- + + +def add_effect( + player_id: str, + source: str, + description: str, + expires: str | None = None, + base_path: Path | None = None, +) -> str: + """Add a temporary effect to the character.""" + 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, + } + if expires: + effect["expires"] = expires + 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}" + + +def remove_effect( + player_id: str, + source: str, + base_path: Path | None = None, +) -> str: + """Remove an effect by source name (case-insensitive substring match).""" + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + effects = data.get("effects", []) + needle = source.lower() + + for i, e in enumerate(effects): + if needle in e.get("source", "").lower(): + removed = effects.pop(i) + save_character(player_id, data, base_path) + return f"Effect removed: {removed.get('source', '?')}" + + return f"No effect matching '{source}' found" + + +# --- Condition operations --- + + +def add_condition( + player_id: str, + name: str, + base_path: Path | None = None, +) -> str: + """Add a condition to the character (no duplicates).""" + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + conditions = data.setdefault("conditions", []) + name_lower = name.lower() + if name_lower in [c.lower() for c in conditions]: + return f"Already has condition: {name}" + conditions.append(name) + + save_character(player_id, data, base_path) + return f"Condition added: {name}" + + +def remove_condition( + player_id: str, + name: str, + base_path: Path | None = None, +) -> str: + """Remove a condition (case-insensitive).""" + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + conditions = data.get("conditions", []) + needle = name.lower() + for i, c in enumerate(conditions): + if c.lower() == needle: + removed = conditions.pop(i) + save_character(player_id, data, base_path) + return f"Condition removed: {removed}" + + return f"No condition matching '{name}' found" + + +# --- Inventory operations --- + + +def add_item( + player_id: str, + item: str, + location: str | None = None, + base_path: Path | None = None, +) -> str: + """Add an item to a location in the equipment dict. + + Substring match on existing location keys. Creates the location if it + doesn't exist (using the given name as-is). If location is omitted, + uses the first existing location, or 'on_person' as a default. + """ + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + equipment = data.setdefault("equipment", {}) + + target_key = None + if location: + # Try substring match against existing keys + needle = location.lower().replace(" ", "_") + for key in equipment: + if needle in key.lower() or key.lower() in needle: + target_key = key + break + if target_key is None: + # Create new location with the user-provided name (normalized) + target_key = location.lower().replace(" ", "_") + equipment[target_key] = [] + else: + if equipment: + target_key = next(iter(equipment)) + else: + target_key = "on_person" + equipment[target_key] = [] + + equipment[target_key].append(item) + save_character(player_id, data, base_path) + return f"Added '{item}' to {target_key}" + + +def remove_item( + player_id: str, + item: str, + base_path: Path | None = None, +) -> str: + """Remove an item by case-insensitive substring match across all locations.""" + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + equipment = data.get("equipment", {}) + needle = item.lower() + + for location, items in equipment.items(): + for i, existing in enumerate(items): + if needle in existing.lower(): + removed = items.pop(i) + save_character(player_id, data, base_path) + return f"Removed '{removed}' from {location}" + + return f"No item matching '{item}' found" + + +def set_item_status( + player_id: str, + item: str, + status: str, + base_path: Path | None = None, +) -> str: + """Set a magic item's status (attuned, equipped, carried). + + The item is referenced by its world entity name. The function manages + the wikilinks in the character's magic_items dict. + """ + valid_statuses = ("attuned", "equipped", "carried") + if status not in valid_statuses: + return f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}" + + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + magic_items = data.setdefault( + "magic_items", {"attuned": [], "equipped": [], "carried": []} + ) + + # Normalize to wikilink format + if not item.startswith("[["): + wikilink = f"[[{item}]]" + else: + wikilink = item + + # Remove from all current statuses + for s in valid_statuses: + lst = magic_items.setdefault(s, []) + if wikilink in lst: + lst.remove(wikilink) + + # Add to the new status + magic_items[status].append(wikilink) + + save_character(player_id, data, base_path) + return f"{item} is now {status}" + + +# --- Resource operations --- + + +def use_resource( + player_id: str, + name: str, + amount: int = 1, + base_path: Path | None = None, +) -> str: + """Decrement a resource pool. Substring match on resource name.""" + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + resources = data.get("resources", {}) + needle = name.lower() + + target = None + for key in resources: + if needle in key.lower() or needle in resources[key].get("notes", "").lower(): + target = key + break + + if target is None: + return f"No resource matching '{name}' found" + + pool = resources[target] + before = pool.get("current", 0) + pool["current"] = max(0, before - amount) + actual = before - pool["current"] + short = amount - actual + + save_character(player_id, data, base_path) + + notes = pool.get("notes", target) + msg = f"Used {actual} {notes} ({pool['current']}/{pool.get('max', 0)} remaining)" + if short > 0: + msg += f" — short {short}" + return msg + + +def restore_resource( + player_id: str, + name: str, + amount: int, + base_path: Path | None = None, +) -> str: + """Restore points to a resource pool, clamped to max.""" + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + resources = data.get("resources", {}) + needle = name.lower() + + target = None + for key in resources: + if needle in key.lower() or needle in resources[key].get("notes", "").lower(): + target = key + break + + if target is None: + return f"No resource matching '{name}' found" + + pool = resources[target] + before = pool.get("current", 0) + maximum = pool.get("max", 0) + pool["current"] = min(maximum, before + amount) + actual = pool["current"] - before + + save_character(player_id, data, base_path) + notes = pool.get("notes", target) + return f"Restored {actual} {notes} ({pool['current']}/{maximum})" + + +def rest( + player_id: str, + rest_type: str, + base_path: Path | None = None, +) -> str: + """Take a short or long rest. Refreshes resources by refresh type. + + short rest: refreshes resources with refresh: short_rest + long rest: refreshes short_rest AND long_rest resources, clears all + death saves, removes one level of exhaustion, and clears expired effects + """ + if rest_type not in ("short", "long"): + return f"Invalid rest type '{rest_type}'. Must be 'short' or 'long'." + + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + refreshed = [] + resources = data.get("resources", {}) + for key, pool in resources.items(): + refresh = pool.get("refresh") + should_refresh = refresh == "short_rest" or ( + rest_type == "long" and refresh in ("long_rest", "short_rest") + ) + if should_refresh and pool.get("current", 0) < pool.get("max", 0): + pool["current"] = pool["max"] + refreshed.append(pool.get("notes", key)) + + msg_parts = [f"Took a {rest_type} rest."] + if refreshed: + msg_parts.append(f"Refreshed: {', '.join(refreshed)}.") + + if rest_type == "long": + # Reset death saves + ds = data["state"].get("death_saves", {}) + if ds.get("successes", 0) or ds.get("failures", 0): + ds["successes"] = 0 + ds["failures"] = 0 + msg_parts.append("Death saves reset.") + + # Reduce exhaustion by 1 + exhaustion = data["state"].get("exhaustion", 0) + if exhaustion > 0: + data["state"]["exhaustion"] = exhaustion - 1 + msg_parts.append(f"Exhaustion: {exhaustion} → {exhaustion - 1}.") + + # Restore HP to max + hp = data["state"]["hp"] + if hp["current"] < hp["max"]: + hp["current"] = hp["max"] + msg_parts.append(f"HP restored to {hp['max']}.") + + save_character(player_id, data, base_path) + return " ".join(msg_parts) + + +# --- Coin and notes operations --- + + +def adjust_coins( + player_id: str, + deltas: dict[str, int], + base_path: Path | None = None, +) -> str: + """Apply relative coin changes (positive=gain, negative=spend). + + Each denomination is clamped to 0 minimum. + """ + data = load_character(player_id, base_path) + if data is None: + return f"No character found for player '{player_id}'" + + purse = data["state"].setdefault( + "purse", {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0} + ) + + changes = [] + for denom, delta in deltas.items(): + if denom not in ("cp", "sp", "ep", "gp", "pp"): + continue + old = purse.get(denom, 0) + new = old + delta + if new < 0: + changes.append(f"{denom} {old} → 0 (short {-new} {denom})") + purse[denom] = 0 + else: + changes.append(f"{denom} {old} → {new}") + purse[denom] = new + + save_character(player_id, data, base_path) + + coins = [] + for denom in ("pp", "gp", "ep", "sp", "cp"): + amount = purse.get(denom, 0) + if amount: + coins.append(f"{amount} {denom}") + purse_str = ", ".join(coins) if coins else "empty" + + return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" + + +def add_note( + player_id: str, + text: str, + time_anchor: str | None = None, + base_path: Path | None = None, +) -> str: + """Append a note to the player's notes.md file.""" + if base_path is None: + base_path = Path.cwd() + notes_path = base_path / "players" / player_id / "notes.md" + notes_path.parent.mkdir(parents=True, exist_ok=True) + + prefix = f"{time_anchor} | " if time_anchor else "" + line = f"- {prefix}{text}\n" + + if not notes_path.exists(): + notes_path.write_text("# Notes\n\n") + with notes_path.open("a") as f: + f.write(line) + + return f"Note added: {text}" diff --git a/src/storied/cli.py b/src/storied/cli.py index 998cc50..8fb1b07 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -519,13 +519,13 @@ def cmd_play(args: argparse.Namespace) -> int: f"{ooc_msg}]" ) - # Handle /note command (add a note to the character sheet) + # Handle /note command (add a note to the player's notes.md) 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 add a note to their character sheet. Use update_character with section.Notes to append this note, preserving any existing notes. Note to add: {note_msg}]" + action = f"[System: The player wants to remember something. Call add_note with this exact text: {note_msg}]" try: console.print(Rule(style="dim blue")) diff --git a/src/storied/engine.py b/src/storied/engine.py index b848b47..2dcb90f 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -10,6 +10,12 @@ import yaml from storied import notifications from storied.character import format_character_context, load_character +from storied.notification_formatters import ( + DEFERRED_FORMATTERS, + TOOL_LABELS, + _extract_json_field, + _extract_roll_reason, +) from storied.claude import ( Result, TextDelta, @@ -37,77 +43,13 @@ def load_prompt(name: str, prompts_path: Path | None = None) -> str: return path.read_text() -def _extract_json_field(tool_json: str, field: str) -> str | None: - """Extract a named field from accumulated tool JSON.""" - try: - args = json.loads(tool_json) - return args.get(field) - except (json.JSONDecodeError, AttributeError): - return None - - -def _extract_roll_reason(tool_json: str) -> str | None: - """Extract the reason field from accumulated roll tool JSON.""" - return _extract_json_field(tool_json, "reason") - - -def _format_coin_notification(tool_json: str) -> str: - """Format an adjust_coins call as a friendly notification.""" - try: - args = json.loads(tool_json) - deltas = args.get("deltas", {}) - except (json.JSONDecodeError, AttributeError): - return "Adjusting coins" - - spending = [] - receiving = [] - denom_names = {"cp": "copper", "sp": "silver", "ep": "electrum", "gp": "gold", "pp": "platinum"} - for denom in ("pp", "gp", "ep", "sp", "cp"): - amount = deltas.get(denom, 0) - if amount < 0: - spending.append(f"{-amount} {denom_names.get(denom, denom)}") - elif amount > 0: - receiving.append(f"{amount} {denom_names.get(denom, denom)}") - - if spending and receiving: - return f"Spending {', '.join(spending)}; receiving {', '.join(receiving)}" - elif spending: - return f"Spending {', '.join(spending)}" - elif receiving: - return f"Receiving {', '.join(receiving)}" - return "Adjusting coins" - - def _tool_notification(name: str) -> str: """Build a friendly tool notification string from an MCP tool name. MCP tool names are prefixed as mcp__storied__ by Claude Code. """ short = name.rsplit("__", 1)[-1] if "__" in name else name - - labels = { - "roll": "Rolling", - "recall": "Recalling", - "update_character": "Updating character sheet", - "adjust_coins": "Adjusting coins", - "create_character": "Creating character", - "set_scene": "Setting scene", - "establish": "Establishing", - "mark": "Recording", - "note_discovery": "Noting discovery", - "tune": "Tuning style", - "end_session": "Saving session", - "enter_initiative": "Entering initiative", - "next_turn": "Next turn", - "add_combatant": "Adding combatant", - "remove_combatant": "Removing combatant", - "damage": "Applying damage", - "heal": "Healing", - "condition": "Updating condition", - "end_initiative": "Ending initiative", - "run_code": "Running code", - } - label = labels.get(short, short) + label = TOOL_LABELS.get(short, short) return f"[{label}...]" @@ -480,10 +422,14 @@ class DMEngine: if short == "end_initiative": self.combat_ended = True - if short in ("roll", "run_code", "adjust_coins") and not self.debug: + is_deferred = ( + short in ("roll", "run_code") + or short in DEFERRED_FORMATTERS + ) + if is_deferred and not self.debug: # Signal the CLI to flush the renderer before we # wait for tool input (the notification comes at - # ToolStop once we know the reason/description). + # ToolStop once we know the arguments). yield "" deferred_notification = True elif self.debug: @@ -505,8 +451,9 @@ class DMEngine: desc = _extract_json_field(current_tool_json, "description") label = desc if desc else "Running code" yield f"[{label}...]" - elif deferred_notification and current_tool_name == "adjust_coins": - yield f"[{_format_coin_notification(current_tool_json)}...]" + elif deferred_notification and current_tool_name in DEFERRED_FORMATTERS: + formatter = DEFERRED_FORMATTERS[current_tool_name] + yield f"[{formatter(current_tool_json)}...]" if self.debug and current_tool_json: truncated = current_tool_json[:200] diff --git a/src/storied/notification_formatters.py b/src/storied/notification_formatters.py new file mode 100644 index 0000000..474fa95 --- /dev/null +++ b/src/storied/notification_formatters.py @@ -0,0 +1,226 @@ +"""Friendly TUI notification formatters for deferred tool calls. + +Each formatter takes the accumulated tool input JSON and returns a +human-readable label like "Taking 7 fire damage" or "Adding effect: Bless". +""" + +import json +from collections.abc import Callable + + +def _parse_tool_args(tool_json: str) -> dict: + """Safely parse accumulated tool JSON, returning {} on failure.""" + try: + return json.loads(tool_json) or {} + except (json.JSONDecodeError, AttributeError): + return {} + + +def _extract_json_field(tool_json: str, field: str) -> str | None: + """Extract a named field from accumulated tool JSON.""" + return _parse_tool_args(tool_json).get(field) + + +def _extract_roll_reason(tool_json: str) -> str | None: + """Extract the reason field from accumulated roll tool JSON.""" + return _extract_json_field(tool_json, "reason") + + +_DENOM_NAMES = { + "cp": "copper", + "sp": "silver", + "ep": "electrum", + "gp": "gold", + "pp": "platinum", +} + + +def _format_coin_notification(tool_json: str) -> str: + """Format an adjust_coins call as a friendly notification.""" + args = _parse_tool_args(tool_json) + deltas = args.get("deltas", {}) + + spending = [] + receiving = [] + for denom in ("pp", "gp", "ep", "sp", "cp"): + amount = deltas.get(denom, 0) + if amount < 0: + spending.append(f"{-amount} {_DENOM_NAMES.get(denom, denom)}") + elif amount > 0: + receiving.append(f"{amount} {_DENOM_NAMES.get(denom, denom)}") + + if spending and receiving: + return f"Spending {', '.join(spending)}; receiving {', '.join(receiving)}" + elif spending: + return f"Spending {', '.join(spending)}" + elif receiving: + return f"Receiving {', '.join(receiving)}" + return "Adjusting coins" + + +def _format_damage_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + amount = args.get("amount", "?") + dtype = args.get("type") + target = args.get("target") + if target: + return f"{target} takes {amount} damage" + if dtype: + return f"Taking {amount} {dtype} damage" + return f"Taking {amount} damage" + + +def _format_heal_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + amount = args.get("amount", "?") + target = args.get("target") + if target: + return f"Healing {target} for {amount}" + return f"Healing {amount} HP" + + +def _format_effect_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + source = args.get("source", "effect") + expires = args.get("expires") + if expires: + return f"Adding effect: {source} (until {expires})" + return f"Adding effect: {source}" + + +def _format_remove_effect_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + source = args.get("source", "effect") + return f"Removing effect: {source}" + + +def _format_condition_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + name = args.get("name", "condition") + return f"Becoming {name}" + + +def _format_remove_condition_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + name = args.get("name", "condition") + return f"Recovering from {name}" + + +def _format_add_item_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + item = args.get("item", "item") + location = args.get("location") + if location: + return f"Adding '{item}' to {location}" + return f"Picking up '{item}'" + + +def _format_remove_item_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + item = args.get("item", "item") + return f"Removing '{item}'" + + +def _format_set_item_status_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + item = args.get("item", "item") + status = args.get("status", "status") + verbs = {"attuned": "Attuning to", "equipped": "Equipping", "carried": "Stowing"} + verb = verbs.get(status, "Setting status of") + return f"{verb} {item}" + + +def _format_use_resource_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + name = args.get("name", "resource") + amount = args.get("amount", 1) + if amount == 1: + return f"Using {name}" + return f"Using {amount} of {name}" + + +def _format_restore_resource_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + name = args.get("name", "resource") + amount = args.get("amount", "?") + return f"Restoring {amount} of {name}" + + +def _format_rest_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + rest_type = args.get("type", "short") + return f"Taking a {rest_type} rest" + + +def _format_add_note_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + text = args.get("text", "") + snippet = text[:50] + "..." if len(text) > 50 else text + return f"Noting: {snippet}" + + +def _format_update_character_notification(tool_json: str) -> str: + args = _parse_tool_args(tool_json) + updates = args.get("updates", {}) + if not updates: + return "Updating character" + keys = list(updates.keys())[:3] + if len(updates) > 3: + return f"Updating: {', '.join(keys)}, ..." + return f"Updating: {', '.join(keys)}" + + +# Maps tool name → formatter for deferred notifications +DEFERRED_FORMATTERS: dict[str, Callable[[str], str]] = { + "adjust_coins": _format_coin_notification, + "damage": _format_damage_notification, + "heal": _format_heal_notification, + "add_effect": _format_effect_notification, + "remove_effect": _format_remove_effect_notification, + "add_condition": _format_condition_notification, + "remove_condition": _format_remove_condition_notification, + "add_item": _format_add_item_notification, + "remove_item": _format_remove_item_notification, + "set_item_status": _format_set_item_status_notification, + "use_resource": _format_use_resource_notification, + "restore_resource": _format_restore_resource_notification, + "rest": _format_rest_notification, + "add_note": _format_add_note_notification, + "update_character": _format_update_character_notification, +} + + +TOOL_LABELS = { + "roll": "Rolling", + "recall": "Recalling", + "update_character": "Updating character sheet", + "adjust_coins": "Adjusting coins", + "create_character": "Creating character", + "set_scene": "Setting scene", + "establish": "Establishing", + "mark": "Recording", + "note_discovery": "Noting discovery", + "tune": "Tuning style", + "end_session": "Saving session", + "enter_initiative": "Entering initiative", + "next_turn": "Next turn", + "add_combatant": "Adding combatant", + "remove_combatant": "Removing combatant", + "damage": "Taking damage", + "heal": "Healing", + "condition": "Updating condition", + "add_condition": "Becoming conditioned", + "remove_condition": "Recovering", + "add_effect": "Adding effect", + "remove_effect": "Removing effect", + "add_item": "Picking up item", + "remove_item": "Removing item", + "set_item_status": "Updating magic item", + "use_resource": "Using resource", + "restore_resource": "Restoring resource", + "rest": "Resting", + "add_note": "Taking a note", + "end_initiative": "Ending initiative", + "run_code": "Running code", + "notify_dm": "Sending notification", +} diff --git a/src/storied/sandbox.py b/src/storied/sandbox.py index 9dee494..7244cb4 100644 --- a/src/storied/sandbox.py +++ b/src/storied/sandbox.py @@ -81,20 +81,30 @@ def build_tool_signatures() -> str: - all other tools go through execute_tool (return str) """ from storied.tools import ( - adjust_coins, recall, establish, mark, note_discovery, - set_scene, update_character, create_character, tune, end_session, + add_condition, add_effect, add_item, add_note, + adjust_coins, create_character, damage, heal, + recall, establish, mark, note_discovery, + remove_condition, remove_effect, remove_item, + rest, restore_resource, set_item_status, set_scene, + tune, end_session, update_character, use_resource, ) from storied.initiative import ( enter_initiative, next_turn, add_combatant, remove_combatant, - damage, heal, condition, end_initiative, + condition, end_initiative, ) ctx_params = {"ctx", "tracker"} str_fns = [ - adjust_coins, recall, establish, mark, note_discovery, set_scene, - update_character, create_character, tune, end_session, + # Character/state operations + damage, heal, adjust_coins, update_character, create_character, + add_effect, remove_effect, add_condition, remove_condition, + add_item, remove_item, set_item_status, + use_resource, restore_resource, rest, add_note, + # World operations + recall, establish, mark, note_discovery, set_scene, tune, end_session, + # Initiative enter_initiative, next_turn, add_combatant, remove_combatant, - damage, heal, condition, end_initiative, + condition, end_initiative, ] lines: list[str] = [_sig(_roll_host, set(), "dict").replace("_roll_host", "roll")] diff --git a/src/storied/tools/__init__.py b/src/storied/tools/__init__.py index 46502a2..6bb4527 100644 --- a/src/storied/tools/__init__.py +++ b/src/storied/tools/__init__.py @@ -16,9 +16,22 @@ from storied.tools._context import ( _sync_player_hp, ) from storied.tools.character import ( + add_condition, + add_effect, + add_item, + add_note, adjust_coins, create_character, + damage, + heal, + remove_condition, + remove_effect, + remove_item, + rest, + restore_resource, + set_item_status, update_character, + use_resource, ) from storied.tools.entities import ( _auto_mark_present, @@ -51,12 +64,18 @@ TOOL_DEFINITIONS: list[dict] = ( def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: """Execute a tool by name with the given input.""" + # Initiative-only tools (enter_initiative, next_turn, end_initiative, + # add/remove_combatant, condition) always route to the initiative module. + # damage/heal route to initiative ONLY when called with a target — without + # one they apply to the player character via the new operations module. if tool_name in ALL_INITIATIVE_TOOL_NAMES: - result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) - if result is not None: - if tool_name in ("damage", "heal"): - result = _sync_player_hp(tool_input["target"], ctx, result) - return result + is_player_op = tool_name in ("damage", "heal") and "target" not in tool_input + if not is_player_op: + result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) + if result is not None: + if tool_name in ("damage", "heal"): + result = _sync_player_hp(tool_input["target"], ctx, result) + return result if tool_name == "roll": result = roll(tool_input["notation"]) @@ -95,12 +114,54 @@ def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: background=tool_input.get("background"), speed=tool_input.get("speed", 30), purse=tool_input.get("purse"), - equipment=tool_input.get("equipment"), - features=tool_input.get("features"), - proficiencies=tool_input.get("proficiencies"), + subclass=tool_input.get("subclass"), backstory=tool_input.get("backstory"), ) + elif tool_name == "damage": + return damage(tool_input["amount"], ctx, type=tool_input.get("type")) + + elif tool_name == "heal": + return heal(tool_input["amount"], ctx) + + elif tool_name == "add_effect": + return add_effect( + source=tool_input["source"], + description=tool_input["description"], + ctx=ctx, + expires=tool_input.get("expires"), + ) + + elif tool_name == "remove_effect": + return remove_effect(tool_input["source"], ctx) + + elif tool_name == "add_condition": + return add_condition(tool_input["name"], ctx) + + elif tool_name == "remove_condition": + return remove_condition(tool_input["name"], ctx) + + elif tool_name == "add_item": + return add_item(tool_input["item"], ctx, location=tool_input.get("location")) + + elif tool_name == "remove_item": + return remove_item(tool_input["item"], ctx) + + elif tool_name == "set_item_status": + return set_item_status(tool_input["item"], tool_input["status"], ctx) + + elif tool_name == "use_resource": + return use_resource(tool_input["name"], ctx, amount=tool_input.get("amount", 1)) + + elif tool_name == "restore_resource": + return restore_resource(tool_input["name"], tool_input["amount"], ctx) + + elif tool_name == "rest": + return rest(tool_input["type"], ctx) + + elif tool_name == "add_note": + return add_note(tool_input["text"], ctx) + elif tool_name == "set_scene": return set_scene( ctx=ctx, diff --git a/src/storied/tools/_context.py b/src/storied/tools/_context.py index b9640dc..a468a75 100644 --- a/src/storied/tools/_context.py +++ b/src/storied/tools/_context.py @@ -77,6 +77,6 @@ def _sync_player_hp(target: str, ctx: ToolContext, result: str) -> str: """Auto-sync player character sheet when damage/heal targets a player.""" combatant = ctx.initiative._find(target) if combatant and combatant.is_player: - char_update(ctx.player_id, {"hp.current": combatant.hp}, ctx.base_path) + char_update(ctx.player_id, {"state.hp.current": combatant.hp}, ctx.base_path) result += f" (character sheet synced to {combatant.hp} HP)" return result diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index 5cff1ff..315d917 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -1,28 +1,76 @@ -"""Character management tools.""" +"""Character management tools — bookkeeping primitives for the DM.""" -from storied.character import adjust_coins as char_adjust_coins -from storied.character import create_character as char_create -from storied.character import update_character as char_update +from storied.character import ( + add_condition as char_add_condition, +) +from storied.tools.character_schemas import SCHEMAS +from storied.character import ( + add_effect as char_add_effect, +) +from storied.character import ( + add_item as char_add_item, +) +from storied.character import ( + add_note as char_add_note, +) +from storied.character import ( + adjust_coins as char_adjust_coins, +) +from storied.character import ( + create_character as char_create, +) +from storied.character import ( + damage as char_damage, +) +from storied.character import ( + heal as char_heal, +) +from storied.character import ( + remove_condition as char_remove_condition, +) +from storied.character import ( + remove_effect as char_remove_effect, +) +from storied.character import ( + remove_item as char_remove_item, +) +from storied.character import ( + rest as char_rest, +) +from storied.character import ( + restore_resource as char_restore_resource, +) +from storied.character import ( + set_item_status as char_set_item_status, +) +from storied.character import ( + update_character as char_update, +) +from storied.character import ( + use_resource as char_use_resource, +) from storied.tools._context import ToolContext +# --- Universal field setter --- + + def update_character(updates: dict, ctx: ToolContext) -> str: - """Update the player's character sheet to persist changes. + """Update arbitrary fields on the character sheet via dot notation. - Call this after HP changes, equipment gained/lost, coins spent, level ups, etc. - This ensures progress is saved and survives between sessions. + Use this for any change that doesn't have a more specific tool. For HP, + coins, items, effects, etc., prefer the dedicated tools (damage, heal, + adjust_coins, add_item, add_effect, ...) — they prevent arithmetic errors. Args: - updates: Fields to update. Use dot notation for nested fields. - Examples: - - {"hp.current": 5} - set current HP to 5 - - {"purse.gp": 25} - set gold to 25 - - {"purse.sp": 10, "purse.cp": 50} - set silver and copper - - {"level": 2, "hp.max": 20} - level up - For markdown sections, use "section.Name": - - {"section.Equipment": "- Longsword\\n- New shield"} - player_id: Player identifier (usually "default") - base_path: Base path for players directory + updates: Fields to update by dot path. Examples: + - {"identity.classes.0.level": 4} — level up + - {"state.hp.max": 30} — increase max HP + - {"state.exhaustion": 1} — set exhaustion level + - {"state.death_saves.successes": 2} — record a death save + - {"proficiencies.skills.persuasion": "proficient"} — add proficiency + - {"advancement_ready": null} — clear advancement flag + - {"features": [...]} — replace features list Returns: Confirmation of what was updated @@ -42,36 +90,26 @@ def create_character( background: str | None = None, speed: int = 30, purse: dict[str, int] | None = None, - equipment: list[str] | None = None, - features: list[str] | None = None, - proficiencies: str | None = None, + subclass: str | None = None, backstory: str | None = None, ) -> str: - """Create a new player character and save to disk. + """Create a new player character with the new structured schema. - Call this when character creation is complete. Include all the mechanical - details needed to play: abilities, HP, AC, equipment, and features. + Writes character.yaml (structured data) and character.md (prose backstory). Args: name: Character name - race: Race (e.g., "Human", "High Elf", "Hill Dwarf") - char_class: Class (e.g., "Fighter", "Wizard", "Rogue") + race: Race (e.g., "Human", "High Elf") + char_class: Class (e.g., "Fighter", "Wizard") level: Starting level (usually 1) - abilities: All six ability scores as a dict: - {"strength": 15, "dexterity": 14, "constitution": 13, - "intelligence": 12, "wisdom": 10, "charisma": 8} + abilities: All six ability scores hp_max: Maximum hit points ac: Armor class - background: Background (e.g., "Soldier", "Sage", "Criminal") - speed: Movement speed in feet (default 30) - purse: Starting coins by denomination: - {"cp": 0, "sp": 0, "ep": 0, "gp": 15, "pp": 0} - Denominations: cp (copper), sp (silver), ep (electrum), - gp (gold), pp (platinum). Omit denominations for 0. - equipment: List of equipment items - features: List of racial and class features - proficiencies: Description of proficiencies (armor, weapons, tools, saves, skills) - backstory: Character backstory and personality + background: Background (e.g., "Soldier", "Criminal") + speed: Movement speed in feet + purse: Starting coins {cp, sp, ep, gp, pp} + subclass: Subclass if chosen + backstory: Character backstory and personality (goes in character.md) Returns: Confirmation message @@ -88,25 +126,51 @@ def create_character( background=background, speed=speed, purse=purse, - equipment=equipment, - features=features, - proficiencies=proficiencies, + subclass=subclass, backstory=backstory, base_path=ctx.base_path, ) +# --- HP operations --- + + +def damage(amount: int, ctx: ToolContext, type: str | None = None) -> str: + """Apply damage to the character. Temp HP absorbs first, then current HP. + + Args: + amount: Damage amount (non-negative) + type: Optional damage type (fire, cold, slashing, etc.) for narration + + Returns: + Damage taken and remaining HP + """ + return char_damage(ctx.player_id, amount, damage_type=type, base_path=ctx.base_path) + + +def heal(amount: int, ctx: ToolContext) -> str: + """Heal the character. Clamped to max HP. + + Args: + amount: HP to restore (non-negative) + + Returns: + Healing applied and current HP + """ + return char_heal(ctx.player_id, amount, base_path=ctx.base_path) + + +# --- Coin operations --- + + def adjust_coins(deltas: dict[str, int], ctx: ToolContext) -> str: """Adjust the player's coins by relative amounts. - Use negative values to spend, positive to gain. Coins are clamped to 0 - (the character can't go into debt). Returns the new purse balance. + Use negative values to spend, positive to gain. Coins are clamped to 0. Args: - deltas: Coin changes by denomination. Negative to spend, positive to gain. - Examples: {"gp": -5} to spend 5 gold, - {"gp": 10, "sp": 5} to gain 10 gold and 5 silver. - Keys: cp (copper), sp (silver), ep (electrum), gp (gold), pp (platinum). + deltas: Coin changes by denomination (cp/sp/ep/gp/pp). + Examples: {"gp": -5}, {"gp": 10, "sp": 5} Returns: Summary of changes and new purse balance @@ -114,85 +178,232 @@ def adjust_coins(deltas: dict[str, int], ctx: ToolContext) -> str: return char_adjust_coins(ctx.player_id, deltas, ctx.base_path) +# --- Effect operations --- + + +def add_effect( + source: str, + description: str, + ctx: ToolContext, + expires: str | None = None, +) -> str: + """Track a temporary effect on the character. + + Use for spells, potions, environmental effects, narrative buffs/debuffs — + anything that's temporarily affecting the character. + + 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") + + Returns: + Confirmation + """ + return char_add_effect( + ctx.player_id, source, description, expires=expires, base_path=ctx.base_path + ) + + +def remove_effect(source: str, ctx: ToolContext) -> str: + """Remove an effect by source name (case-insensitive substring match). + + Args: + source: The effect's source name to remove (e.g., "Potion of Heroism") + + Returns: + Confirmation of what was removed + """ + return char_remove_effect(ctx.player_id, source, base_path=ctx.base_path) + + +# --- Condition operations --- + + +def add_condition(name: str, ctx: ToolContext) -> str: + """Mark a condition on the character (5e conditions or any custom name). + + Args: + name: Condition name (e.g., "Poisoned", "Prone", "Frightened") + + Returns: + Confirmation + """ + return char_add_condition(ctx.player_id, name, base_path=ctx.base_path) + + +def remove_condition(name: str, ctx: ToolContext) -> str: + """Remove a condition from the character. + + Args: + name: Condition name to remove + + Returns: + Confirmation + """ + return char_remove_condition(ctx.player_id, name, base_path=ctx.base_path) + + +# --- Inventory operations --- + + +def add_item(item: str, ctx: ToolContext, location: str | None = None) -> str: + """Add a mundane item to the character's equipment. + + Items are organized by location subsection (e.g., "on_person", "stashed_at_inn"). + If location is omitted, uses the first existing location, or 'on_person' as default. + Substring match against existing locations, so 'On Person' matches 'on_person'. + + For magic items, use set_item_status instead (after establishing the item as a + world entity). + + Args: + item: Item description (e.g., "Lockpicks", "Healing potion (2d4+2)") + location: Optional subsection name (e.g., "on_person", "backpack") + + Returns: + Confirmation with the location it was added to + """ + return char_add_item(ctx.player_id, item, location=location, base_path=ctx.base_path) + + +def remove_item(item: str, ctx: ToolContext) -> str: + """Remove an item from the character's equipment by name. + + Uses case-insensitive substring matching across all equipment subsections. + Removes the first match. + + Args: + item: Item name to remove (e.g., "Lockpicks", "knife") + + Returns: + Confirmation with the item that was removed + """ + return char_remove_item(ctx.player_id, item, base_path=ctx.base_path) + + +def set_item_status(item: str, status: str, ctx: ToolContext) -> str: + """Set a magic item's status (attuned, equipped, or carried). + + The item should already exist as a world entity in worlds/{world}/items/. + This function manages where the wikilink lives in the character's + magic_items dict. + + Args: + item: Magic item name (matches the world entity name) + status: One of "attuned", "equipped", or "carried" + + Returns: + Confirmation + """ + return char_set_item_status( + ctx.player_id, item, status, base_path=ctx.base_path + ) + + +# --- Resource operations --- + + +def use_resource(name: str, ctx: ToolContext, amount: int = 1) -> str: + """Decrement a resource pool (rage uses, ki points, hit dice, magic item charges, etc.). + + Substring match on the resource name. Resources are clamped to 0. + + Args: + name: Resource name (e.g., "rage", "hit_dice_d8", "bracer") + amount: How many to use (default 1) + + Returns: + Confirmation with remaining count + """ + return char_use_resource( + ctx.player_id, name, amount=amount, base_path=ctx.base_path + ) + + +def restore_resource(name: str, amount: int, ctx: ToolContext) -> str: + """Restore points to a resource pool, clamped to max. + + Usually you'll use `rest` instead, which refreshes resources by their + refresh type. Use this for one-off restorations. + + Args: + name: Resource name (substring match) + amount: How many to restore + + Returns: + Confirmation + """ + return char_restore_resource( + ctx.player_id, name, amount, base_path=ctx.base_path + ) + + +def rest(type: str, ctx: ToolContext) -> str: + """Take a short or long rest. + + Refreshes resources by refresh type (long rest also refreshes short_rest + resources). Long rest also clears death saves, removes one exhaustion + level, and restores HP to max. + + Args: + type: "short" or "long" + + Returns: + Summary of what was refreshed + """ + return char_rest(ctx.player_id, type, base_path=ctx.base_path) + + +# --- Notes --- + + +def add_note(text: str, ctx: ToolContext) -> str: + """Append a note to the player's notes.md journal. + + Notes accumulate over time and are stamped with the current game time. + Use this for player observations, leads, decisions, things to remember. + + Args: + text: The note text + + Returns: + Confirmation + """ + time_anchor = ctx.campaign_log.get_current_time().to_anchor() + return char_add_note( + ctx.player_id, text, time_anchor=time_anchor, base_path=ctx.base_path + ) + + + +# --- Tool definitions for the API --- +# Wrapper docstrings + schemas from character_schemas.py + +_WRAPPERS = { + "update_character": update_character, + "create_character": create_character, + "damage": damage, + "heal": heal, + "adjust_coins": adjust_coins, + "add_effect": add_effect, + "remove_effect": remove_effect, + "add_condition": add_condition, + "remove_condition": remove_condition, + "add_item": add_item, + "remove_item": remove_item, + "set_item_status": set_item_status, + "use_resource": use_resource, + "restore_resource": restore_resource, + "rest": rest, + "add_note": add_note, +} + DEFINITIONS: list[dict] = [ { - "name": "update_character", - "description": update_character.__doc__, - "input_schema": { - "type": "object", - "properties": { - "updates": { - "type": "object", - "description": "Fields to update. Use dot notation for nested (e.g., 'hp.current': 5). Use 'section.Name' for markdown sections.", - }, - }, - "required": ["updates"], - }, - }, - { - "name": "create_character", - "description": create_character.__doc__, - "input_schema": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Character name"}, - "race": {"type": "string", "description": "Race (e.g., 'Human', 'High Elf')"}, - "char_class": {"type": "string", "description": "Class (e.g., 'Fighter', 'Wizard')"}, - "level": {"type": "integer", "description": "Starting level (usually 1)"}, - "abilities": { - "type": "object", - "description": "All six ability scores: strength, dexterity, constitution, intelligence, wisdom, charisma", - }, - "hp_max": {"type": "integer", "description": "Maximum hit points"}, - "ac": {"type": "integer", "description": "Armor class"}, - "background": {"type": "string", "description": "Background (e.g., 'Soldier', 'Sage')"}, - "speed": {"type": "integer", "description": "Movement speed in feet"}, - "purse": { - "type": "object", - "description": "Starting coins: {cp, sp, ep, gp, pp}. Omit denominations for 0.", - "properties": { - "cp": {"type": "integer", "description": "Copper pieces"}, - "sp": {"type": "integer", "description": "Silver pieces"}, - "ep": {"type": "integer", "description": "Electrum pieces"}, - "gp": {"type": "integer", "description": "Gold pieces"}, - "pp": {"type": "integer", "description": "Platinum pieces"}, - }, - }, - "equipment": { - "type": "array", - "items": {"type": "string"}, - "description": "List of equipment items", - }, - "features": { - "type": "array", - "items": {"type": "string"}, - "description": "List of racial and class features", - }, - "proficiencies": {"type": "string", "description": "Proficiency description"}, - "backstory": {"type": "string", "description": "Character backstory and personality"}, - }, - "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], - }, - }, - { - "name": "adjust_coins", - "description": adjust_coins.__doc__, - "input_schema": { - "type": "object", - "properties": { - "deltas": { - "type": "object", - "description": "Coin changes by denomination. Negative to spend, positive to gain.", - "properties": { - "cp": {"type": "integer", "description": "Copper pieces"}, - "sp": {"type": "integer", "description": "Silver pieces"}, - "ep": {"type": "integer", "description": "Electrum pieces"}, - "gp": {"type": "integer", "description": "Gold pieces"}, - "pp": {"type": "integer", "description": "Platinum pieces"}, - }, - }, - }, - "required": ["deltas"], - }, - }, + "name": s["name"], + "description": _WRAPPERS[s["name"]].__doc__, + "input_schema": s["input_schema"], + } + for s in SCHEMAS ] diff --git a/src/storied/tools/character_schemas.py b/src/storied/tools/character_schemas.py new file mode 100644 index 0000000..48f376b --- /dev/null +++ b/src/storied/tools/character_schemas.py @@ -0,0 +1,214 @@ +"""JSON schemas for character tool definitions. + +Imported by tools/character.py to construct the DEFINITIONS list. +The descriptions come from the wrapper function docstrings, so this +file holds only the input_schema portion plus the tool name. +""" + + +SCHEMAS: list[dict] = [ + { + "name": "update_character", + "input_schema": { + "type": "object", + "properties": { + "updates": { + "type": "object", + "description": "Fields to update by dot path (e.g., 'state.hp.max', 'identity.classes.0.level')", + }, + }, + "required": ["updates"], + }, + }, + { + "name": "create_character", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "race": {"type": "string"}, + "char_class": {"type": "string"}, + "level": {"type": "integer"}, + "abilities": {"type": "object"}, + "hp_max": {"type": "integer"}, + "ac": {"type": "integer"}, + "background": {"type": "string"}, + "speed": {"type": "integer"}, + "subclass": {"type": "string"}, + "purse": { + "type": "object", + "properties": { + "cp": {"type": "integer"}, "sp": {"type": "integer"}, + "ep": {"type": "integer"}, "gp": {"type": "integer"}, + "pp": {"type": "integer"}, + }, + }, + "backstory": {"type": "string"}, + }, + "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], + }, + }, + { + "name": "damage", + "input_schema": { + "type": "object", + "properties": { + "amount": {"type": "integer", "description": "Damage amount (non-negative)"}, + "type": {"type": "string", "description": "Damage type (fire, cold, slashing, etc.)"}, + }, + "required": ["amount"], + }, + }, + { + "name": "heal", + "input_schema": { + "type": "object", + "properties": { + "amount": {"type": "integer", "description": "HP to restore"}, + }, + "required": ["amount"], + }, + }, + { + "name": "adjust_coins", + "input_schema": { + "type": "object", + "properties": { + "deltas": { + "type": "object", + "description": "Coin deltas: negative=spend, positive=gain", + "properties": { + "cp": {"type": "integer"}, "sp": {"type": "integer"}, + "ep": {"type": "integer"}, "gp": {"type": "integer"}, + "pp": {"type": "integer"}, + }, + }, + }, + "required": ["deltas"], + }, + }, + { + "name": "add_effect", + "input_schema": { + "type": "object", + "properties": { + "source": {"type": "string", "description": "Where the effect comes from"}, + "description": {"type": "string", "description": "What the effect does"}, + "expires": {"type": "string", "description": "Optional game time anchor when the effect ends"}, + }, + "required": ["source", "description"], + }, + }, + { + "name": "remove_effect", + "input_schema": { + "type": "object", + "properties": { + "source": {"type": "string", "description": "Effect source name (substring match)"}, + }, + "required": ["source"], + }, + }, + { + "name": "add_condition", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Condition name"}, + }, + "required": ["name"], + }, + }, + { + "name": "remove_condition", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Condition name"}, + }, + "required": ["name"], + }, + }, + { + "name": "add_item", + "input_schema": { + "type": "object", + "properties": { + "item": {"type": "string", "description": "Item description"}, + "location": {"type": "string", "description": "Optional location subsection"}, + }, + "required": ["item"], + }, + }, + { + "name": "remove_item", + "input_schema": { + "type": "object", + "properties": { + "item": {"type": "string", "description": "Item name (substring match)"}, + }, + "required": ["item"], + }, + }, + { + "name": "set_item_status", + "input_schema": { + "type": "object", + "properties": { + "item": {"type": "string", "description": "Magic item entity name"}, + "status": { + "type": "string", + "enum": ["attuned", "equipped", "carried"], + "description": "New status for the item", + }, + }, + "required": ["item", "status"], + }, + }, + { + "name": "use_resource", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Resource name (substring match)"}, + "amount": {"type": "integer", "description": "How many to use (default 1)"}, + }, + "required": ["name"], + }, + }, + { + "name": "restore_resource", + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Resource name"}, + "amount": {"type": "integer", "description": "How many to restore"}, + }, + "required": ["name", "amount"], + }, + }, + { + "name": "rest", + "input_schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["short", "long"], + "description": "Rest type", + }, + }, + "required": ["type"], + }, + }, + { + "name": "add_note", + "input_schema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Note text"}, + }, + "required": ["text"], + }, + }, +] diff --git a/tests/test_advancement.py b/tests/test_advancement.py index a4294a2..9349e41 100644 --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -26,13 +26,12 @@ from storied.tools.scene import notify_dm def character(ctx: ToolContext) -> dict: """Create a level 3 rogue character.""" data = { - "name": "Kira", - "race": "Human", - "class": "Rogue", - "level": 3, - "hp": {"current": 24, "max": 24}, - "ac": 14, - "speed": 30, + "identity": { + "name": "Kira", + "race": "Human", + "classes": [{"class": "Rogue", "subclass": "Thief", "level": 3}], + "background": "Criminal", + }, "abilities": { "strength": 10, "dexterity": 16, @@ -41,7 +40,15 @@ def character(ctx: ToolContext) -> dict: "wisdom": 10, "charisma": 14, }, - "body": "## Features\n- Sneak Attack 2d6\n- Cunning Action\n- Thief subclass", + "state": { + "hp": {"current": 24, "max": 24, "temp": 0}, + "ac": 14, + "speed": 30, + }, + "features": [ + {"source": "Rogue Lv1", "name": "Sneak Attack", "text": "2d6"}, + {"source": "Rogue Lv2", "name": "Cunning Action", "text": ""}, + ], } save_character("default", data, ctx.base_path) return data diff --git a/tests/test_character.py b/tests/test_character.py index fcaecf2..1c712b2 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -1,464 +1,661 @@ -"""Tests for character loading, saving, updates, and display formatting.""" +"""Tests for the character system: data, computation, display, and operations.""" + +from pathlib import Path import pytest from storied.character import ( + ABILITIES, + SKILL_TO_ABILITY, + ability_modifier, + add_condition, + add_effect, + add_item, + add_note, + adjust_coins, + create_character, + damage, + effective_hp, format_character_context, format_sheet, format_status, + has_expertise_in, + heal, + is_proficient_in, load_character, - parse_character, + load_character_prose, + passive_score, + proficiency_bonus, + remove_condition, + remove_effect, + remove_item, + rest, + restore_resource, + save_character, + save_modifier, + set_item_status, + skill_modifier, + total_level, + update_character, + use_resource, ) -from storied.tools import ToolContext, create_character, update_character +from storied.tools import ToolContext + + +# --- Fixtures --- @pytest.fixture -def char_ctx(ctx: ToolContext) -> ToolContext: - """ToolContext configured for character tests with players dir.""" - ctx.player_id = "test-player" - (ctx.base_path / "players" / "test-player").mkdir(parents=True) - return ctx +def player_dir(tmp_path: Path) -> Path: + (tmp_path / "players" / "test-player").mkdir(parents=True) + return tmp_path @pytest.fixture -def basic_character(char_ctx: ToolContext) -> dict: - """Create a basic character for testing.""" +def mira(player_dir: Path) -> dict: + """A level 3 Rogue/Thief, modeled after Mira Ashvale.""" create_character( - name="Test Hero", + player_id="test-player", + name="Mira", race="Human", - char_class="Fighter", - level=1, + char_class="Rogue", + subclass="Thief", + level=3, abilities={ - "strength": 16, - "dexterity": 14, - "constitution": 15, - "intelligence": 10, - "wisdom": 12, - "charisma": 8, + "strength": 11, + "dexterity": 18, + "constitution": 14, + "intelligence": 15, + "wisdom": 14, + "charisma": 18, }, - hp_max=12, + hp_max=24, ac=16, - purse={"cp": 0, "sp": 0, "ep": 0, "gp": 50, "pp": 0}, - ctx=char_ctx, + background="Criminal", + purse={"gp": 43, "cp": 66}, + base_path=player_dir, ) - return load_character("test-player", char_ctx.base_path) - - -class TestParseCharacter: - """Tests for parsing character markdown.""" - - def test_parse_with_frontmatter(self): - content = """--- -name: Test -hp: - current: 10 - max: 10 ---- + # Add proficiencies and resources via update_character + update_character( + "test-player", + { + "proficiencies.saves": ["dexterity", "intelligence"], + "proficiencies.skills.stealth": "expertise", + "proficiencies.skills.sleight_of_hand": "expertise", + "proficiencies.skills.acrobatics": "proficient", + "proficiencies.skills.perception": "proficient", + "proficiencies.skills.deception": "proficient", + "proficiencies.skills.insight": "proficient", + }, + base_path=player_dir, + ) + update_character( + "test-player", + { + "resources.hit_dice_d8": { + "current": 3, "max": 3, "refresh": "long_rest", + "notes": "Hit Dice (d8)", + }, + }, + base_path=player_dir, + ) + return load_character("test-player", player_dir) -## Equipment -- Sword -""" - result = parse_character(content) - assert result["name"] == "Test" - assert result["hp"]["current"] == 10 - assert "Equipment" in result["body"] - def test_parse_without_frontmatter(self): - content = "Just a body" - result = parse_character(content) - assert result["body"] == "Just a body" +# --- Data layer tests --- -class TestCreateCharacter: - """Tests for character creation.""" +class TestDataLayer: + def test_load_returns_none_for_missing(self, player_dir: Path): + assert load_character("test-player", player_dir) is None - def test_create_basic_character(self, char_ctx: ToolContext): - result = create_character( + def test_create_and_load(self, player_dir: Path): + create_character( + player_id="test-player", name="Conan", race="Human", char_class="Barbarian", level=1, - abilities={ - "strength": 18, - "dexterity": 14, - "constitution": 16, - "intelligence": 8, - "wisdom": 10, - "charisma": 12, - }, + abilities={"strength": 18, "dexterity": 14, "constitution": 16, + "intelligence": 8, "wisdom": 10, "charisma": 12}, hp_max=15, ac=14, - ctx=char_ctx, + base_path=player_dir, ) - - assert "Created character 'Conan'" in result - char_file = char_ctx.base_path / "players/test-player/character.md" - assert char_file.exists() - - def test_created_character_has_full_hp(self, char_ctx: ToolContext): + data = load_character("test-player", player_dir) + assert data["identity"]["name"] == "Conan" + assert data["identity"]["classes"][0]["class"] == "Barbarian" + assert data["identity"]["classes"][0]["level"] == 1 + assert data["abilities"]["strength"] == 18 + assert data["state"]["hp"]["max"] == 15 + + def test_load_fills_defaults(self, player_dir: Path): + # Save a sparse character + save_character("test-player", {"identity": {"name": "Sparse"}}, player_dir) + data = load_character("test-player", player_dir) + # Default schema should be merged in + assert "abilities" in data + assert "state" in data + assert data["abilities"]["strength"] == 10 + + def test_create_writes_backstory(self, player_dir: Path): create_character( - name="Test", - race="Human", - char_class="Fighter", + player_id="test-player", + name="Storyteller", + race="Half-Elf", + char_class="Bard", level=1, - abilities={"strength": 10, "dexterity": 10, "constitution": 10, - "intelligence": 10, "wisdom": 10, "charisma": 10}, - hp_max=10, - ac=10, - ctx=char_ctx, + abilities={"strength": 8, "dexterity": 14, "constitution": 12, + "intelligence": 13, "wisdom": 10, "charisma": 16}, + hp_max=9, + ac=12, + backstory="A wandering minstrel with secrets.", + base_path=player_dir, ) - - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 10 - assert char["hp"]["max"] == 10 + prose = load_character_prose("test-player", player_dir) + assert "wandering minstrel" in prose class TestUpdateCharacter: - """Tests for character updates.""" + def test_update_simple_field(self, mira: dict, player_dir: Path): + update_character("test-player", {"state.ac": 17}, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["ac"] == 17 - def test_update_purse(self, char_ctx: ToolContext, basic_character: dict): - result = update_character( - updates={"purse.gp": 100, "purse.sp": 25}, - ctx=char_ctx, + def test_update_nested_via_dot(self, mira: dict, player_dir: Path): + update_character( + "test-player", {"state.hp.max": 30}, base_path=player_dir ) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["max"] == 30 - assert "purse.gp = 100" in result - char = load_character("test-player", char_ctx.base_path) - assert char["purse"]["gp"] == 100 - assert char["purse"]["sp"] == 25 - - def test_update_hp_current(self, char_ctx: ToolContext, basic_character: dict): - result = update_character( - updates={"hp.current": 5}, - ctx=char_ctx, + def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): + update_character( + "test-player", {"state.hp.current": -5}, base_path=player_dir ) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 0 - assert "hp.current = 5" in result - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 5 + def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): + update_character( + "test-player", {"state.hp.current": 100}, base_path=player_dir + ) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 24 - def test_update_section(self, char_ctx: ToolContext, basic_character: dict): + def test_negative_coins_clamped(self, mira: dict, player_dir: Path): update_character( - updates={"section.Equipment": "- Longsword\n- Shield"}, - ctx=char_ctx, + "test-player", {"state.purse.sp": -20}, base_path=player_dir ) + data = load_character("test-player", player_dir) + assert data["state"]["purse"]["sp"] == 0 - char = load_character("test-player", char_ctx.base_path) - assert "Longsword" in char["body"] - assert "Shield" in char["body"] + def test_no_character_returns_error(self, player_dir: Path): + result = update_character("missing", {"foo": "bar"}, base_path=player_dir) + assert "no character" in result.lower() -class TestHPClamping: - """Tests for HP clamping to valid 5e range.""" +# --- Computation tests --- - def test_hp_cannot_go_negative(self, char_ctx: ToolContext, basic_character: dict): - """In 5e, HP minimum is 0 (no negative HP).""" - update_character( - updates={"hp.current": -5}, - ctx=char_ctx, - ) - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 0 +class TestComputation: + def test_ability_modifier(self): + assert ability_modifier(10) == 0 + assert ability_modifier(11) == 0 + assert ability_modifier(12) == 1 + assert ability_modifier(18) == 4 + assert ability_modifier(8) == -1 + assert ability_modifier(20) == 5 + + def test_total_level_single_class(self, mira: dict): + assert total_level(mira) == 3 - def test_hp_clamped_message(self, char_ctx: ToolContext, basic_character: dict): - """Update result should indicate HP was clamped.""" - result = update_character( - updates={"hp.current": -10}, - ctx=char_ctx, + def test_total_level_multiclass(self, player_dir: Path): + save_character( + "test-player", + {"identity": {"classes": [ + {"class": "Fighter", "level": 3}, + {"class": "Wizard", "level": 2}, + ]}}, + player_dir, ) + data = load_character("test-player", player_dir) + assert total_level(data) == 5 + + def test_proficiency_bonus_scaling(self, player_dir: Path): + for level, expected in [(1, 2), (4, 2), (5, 3), (8, 3), (9, 4), + (12, 4), (13, 5), (16, 5), (17, 6), (20, 6)]: + save_character( + "test-player", + {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, + player_dir, + ) + data = load_character("test-player", player_dir) + assert proficiency_bonus(data) == expected, f"level {level}" + + def test_skill_modifier_with_expertise(self, mira: dict): + total, breakdown = skill_modifier(mira, "stealth") + # +4 dex, +4 expertise (2 prof bonus * 2) + assert total == 8 + assert any("dex" in b.lower() for b in breakdown) + assert any("expertise" in b.lower() for b in breakdown) + + def test_skill_modifier_proficient(self, mira: dict): + total, breakdown = skill_modifier(mira, "perception") + # +2 wis, +2 prof + assert total == 4 + assert any("proficient" in b.lower() for b in breakdown) + + def test_skill_modifier_no_proficiency(self, mira: dict): + total, _ = skill_modifier(mira, "athletics") + # +0 str, no prof + assert total == 0 + + def test_save_modifier_proficient(self, mira: dict): + total, breakdown = save_modifier(mira, "dexterity") + # +4 dex, +2 prof + assert total == 6 + assert any("proficient" in b.lower() for b in breakdown) + + def test_save_modifier_not_proficient(self, mira: dict): + total, _ = save_modifier(mira, "wisdom") + # +2 wis only + assert total == 2 + + def test_passive_perception(self, mira: dict): + # 10 + perception modifier (+4) = 14 + assert passive_score(mira, "perception") == 14 + + def test_effective_hp_with_temp(self, player_dir: Path): + save_character( + "test-player", + {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}}, + player_dir, + ) + data = load_character("test-player", player_dir) + hp = effective_hp(data) + assert hp["effective"] == 25 + assert hp["current"] == 20 + assert hp["temp"] == 5 + + def test_is_proficient_in(self, mira: dict): + assert is_proficient_in(mira, "stealth") + assert is_proficient_in(mira, "perception") + assert not is_proficient_in(mira, "athletics") + + def test_has_expertise_in(self, mira: dict): + assert has_expertise_in(mira, "stealth") + assert not has_expertise_in(mira, "perception") + assert not has_expertise_in(mira, "athletics") + + def test_skill_to_ability_complete(self): + # Every skill mapped + assert "acrobatics" in SKILL_TO_ABILITY + assert "stealth" in SKILL_TO_ABILITY + assert "investigation" in SKILL_TO_ABILITY + assert len(SKILL_TO_ABILITY) == 18 - assert "clamped to 0" in result + def test_abilities_constant(self): + assert len(ABILITIES) == 6 - def test_hp_cannot_exceed_max(self, char_ctx: ToolContext, basic_character: dict): - """HP cannot exceed maximum.""" + +# --- Display tests --- + + +class TestDisplay: + def test_format_status_includes_name_and_class(self, mira: dict): + result = format_status(mira) + assert "Mira" in result + assert "Rogue" in result + assert "Thief" in result + + def test_format_status_includes_hp(self, mira: dict): + result = format_status(mira) + assert "24/24" in result + + def test_format_status_includes_purse(self, mira: dict): + result = format_status(mira) + assert "43 gp" in result + + def test_format_sheet_includes_skills(self, mira: dict): + result = format_sheet(mira) + assert "Stealth" in result + assert "+8" in result + + def test_format_sheet_shows_expertise_marker(self, mira: dict): + result = format_sheet(mira) + # Stealth has expertise, marked with ★★ + assert "★★" in result + + def test_format_sheet_includes_passive_perception(self, mira: dict): + result = format_sheet(mira) + assert "Passive Perception" in result + assert "14" in result + + def test_format_character_context_includes_advancement( + self, mira: dict, player_dir: Path + ): update_character( - updates={"hp.current": 100}, - ctx=char_ctx, + "test-player", {"advancement_ready": 4}, base_path=player_dir ) + data = load_character("test-player", player_dir) + result = format_character_context(data) + assert "Advancement Ready" in result + assert "Level 4" in result - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 12 # max HP is 12 - def test_hp_exceeds_max_clamped_message(self, char_ctx: ToolContext, basic_character: dict): - """Update result should indicate HP was clamped to max.""" - result = update_character( - updates={"hp.current": 999}, - ctx=char_ctx, - ) +# --- Operations tests --- - assert "clamped to 12" in result - def test_valid_hp_not_clamped(self, char_ctx: ToolContext, basic_character: dict): - """Valid HP values should not be modified.""" - result = update_character( - updates={"hp.current": 6}, - ctx=char_ctx, - ) +class TestDamageHeal: + def test_damage_subtracts_hp(self, mira: dict, player_dir: Path): + damage("test-player", 5, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 19 - assert "clamped" not in result - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 6 + def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): + update_character( + "test-player", {"state.hp.temp": 5}, base_path=player_dir + ) + damage("test-player", 3, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["temp"] == 2 + assert data["state"]["hp"]["current"] == 24 - def test_hp_zero_is_valid(self, char_ctx: ToolContext, basic_character: dict): - """Setting HP to exactly 0 is valid (unconscious).""" - result = update_character( - updates={"hp.current": 0}, - ctx=char_ctx, + def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): + update_character( + "test-player", {"state.hp.temp": 5}, base_path=player_dir ) + damage("test-player", 8, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["temp"] == 0 + assert data["state"]["hp"]["current"] == 21 + + def test_damage_clamps_to_zero(self, mira: dict, player_dir: Path): + damage("test-player", 100, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 0 + + def test_damage_at_zero_mentions_death_saves( + self, mira: dict, player_dir: Path + ): + result = damage("test-player", 100, base_path=player_dir) + assert "death save" in result.lower() + + def test_heal_restores_hp(self, mira: dict, player_dir: Path): + damage("test-player", 10, base_path=player_dir) + heal("test-player", 5, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 19 + + def test_heal_clamped_to_max(self, mira: dict, player_dir: Path): + heal("test-player", 100, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 24 + + def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): + result = damage("test-player", 3, damage_type="fire", base_path=player_dir) + assert "fire" in result + + +class TestEffects: + def test_add_effect_appends(self, mira: dict, player_dir: Path): + add_effect("test-player", "Bless", "+1d4 to attacks", base_path=player_dir) + data = load_character("test-player", player_dir) + assert len(data["effects"]) == 1 + assert data["effects"][0]["source"] == "Bless" + + def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): + add_effect( + "test-player", "Potion", "+10 temp HP", + expires="d1-1430", base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert data["effects"][0]["expires"] == "d1-1430" + + def test_remove_effect_by_source(self, mira: dict, player_dir: Path): + add_effect("test-player", "Bless", "+1d4", base_path=player_dir) + result = remove_effect("test-player", "bless", base_path=player_dir) + data = load_character("test-player", player_dir) + assert len(data["effects"]) == 0 + assert "Bless" in result + + def test_remove_effect_substring_match(self, mira: dict, player_dir: Path): + add_effect("test-player", "Potion of Heroism", "+10 temp HP", base_path=player_dir) + remove_effect("test-player", "Heroism", base_path=player_dir) + data = load_character("test-player", player_dir) + assert len(data["effects"]) == 0 + + def test_remove_effect_not_found(self, mira: dict, player_dir: Path): + result = remove_effect("test-player", "Nonexistent", base_path=player_dir) + assert "no effect matching" in result.lower() + + +class TestConditions: + def test_add_condition(self, mira: dict, player_dir: Path): + add_condition("test-player", "Poisoned", base_path=player_dir) + data = load_character("test-player", player_dir) + assert "Poisoned" in data["conditions"] + + def test_add_condition_no_duplicate(self, mira: dict, player_dir: Path): + add_condition("test-player", "Prone", base_path=player_dir) + result = add_condition("test-player", "prone", base_path=player_dir) + data = load_character("test-player", player_dir) + assert len(data["conditions"]) == 1 + assert "already" in result.lower() + + def test_remove_condition(self, mira: dict, player_dir: Path): + add_condition("test-player", "Frightened", base_path=player_dir) + remove_condition("test-player", "Frightened", base_path=player_dir) + data = load_character("test-player", player_dir) + assert "Frightened" not in data["conditions"] + + +class TestInventory: + def test_add_item_to_default_location(self, mira: dict, player_dir: Path): + add_item("test-player", "Lockpicks", base_path=player_dir) + data = load_character("test-player", player_dir) + # Should create on_person if no equipment exists + all_items = [] + for items in data["equipment"].values(): + all_items.extend(items) + assert "Lockpicks" in all_items + + def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): + add_item( + "test-player", "Rope (50ft)", location="backpack", + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert "Rope (50ft)" in data["equipment"]["backpack"] + + def test_add_item_substring_location_match(self, mira: dict, player_dir: Path): + add_item("test-player", "First", location="on_person", base_path=player_dir) + add_item("test-player", "Second", location="On Person", base_path=player_dir) + data = load_character("test-player", player_dir) + # Both should land in the same location + assert "First" in data["equipment"]["on_person"] + assert "Second" in data["equipment"]["on_person"] + + def test_remove_item_substring(self, mira: dict, player_dir: Path): + add_item("test-player", "Boots of Elvenkind (worn)", base_path=player_dir) + result = remove_item("test-player", "Boots", base_path=player_dir) + assert "Boots of Elvenkind" in result + + def test_remove_item_not_found(self, mira: dict, player_dir: Path): + result = remove_item("test-player", "Nonexistent", base_path=player_dir) + assert "no item matching" in result.lower() + + +class TestMagicItems: + def test_set_item_status_attuned(self, mira: dict, player_dir: Path): + set_item_status( + "test-player", "Bracer of the Unseen Step", "attuned", + base_path=player_dir, + ) + data = load_character("test-player", player_dir) + assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] - assert "clamped" not in result - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 0 + def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): + set_item_status( + "test-player", "Cloak", "carried", base_path=player_dir + ) + set_item_status( + "test-player", "Cloak", "equipped", base_path=player_dir + ) + data = load_character("test-player", player_dir) + assert "[[Cloak]]" not in data["magic_items"]["carried"] + assert "[[Cloak]]" in data["magic_items"]["equipped"] - def test_hp_max_is_valid(self, char_ctx: ToolContext, basic_character: dict): - """Setting HP to exactly max is valid.""" - result = update_character( - updates={"hp.current": 12}, - ctx=char_ctx, + def test_set_item_status_invalid(self, mira: dict, player_dir: Path): + result = set_item_status( + "test-player", "Cloak", "invalid", base_path=player_dir ) + assert "invalid status" in result.lower() - assert "clamped" not in result - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 12 - def test_damage_calculation_example(self, char_ctx: ToolContext, basic_character: dict): - """Simulate taking 20 damage when at 12 HP - should clamp to 0.""" - # Character starts at 12/12 HP - # Takes 20 damage, DM sets hp.current = -8 - # Should be clamped to 0 +class TestResources: + def test_use_resource_decrements(self, mira: dict, player_dir: Path): + use_resource("test-player", "hit_dice", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["resources"]["hit_dice_d8"]["current"] == 2 - update_character( - updates={"hp.current": -8}, - ctx=char_ctx, + def test_use_resource_amount(self, mira: dict, player_dir: Path): + use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["resources"]["hit_dice_d8"]["current"] == 1 + + def test_use_resource_clamped_to_zero(self, mira: dict, player_dir: Path): + result = use_resource( + "test-player", "hit_dice", amount=10, base_path=player_dir ) + data = load_character("test-player", player_dir) + assert data["resources"]["hit_dice_d8"]["current"] == 0 + assert "short" in result.lower() + + def test_use_resource_not_found(self, mira: dict, player_dir: Path): + result = use_resource("test-player", "nonexistent", base_path=player_dir) + assert "no resource matching" in result.lower() - char = load_character("test-player", char_ctx.base_path) - assert char["hp"]["current"] == 0 + def test_restore_resource_clamped_to_max(self, mira: dict, player_dir: Path): + use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) + restore_resource( + "test-player", "hit_dice", 100, base_path=player_dir + ) + data = load_character("test-player", player_dir) + assert data["resources"]["hit_dice_d8"]["current"] == 3 -# ── Display formatting ─────────────────────────────────────────────────── +class TestRest: + def test_long_rest_refreshes_long_rest_resources(self, mira: dict, player_dir: Path): + use_resource("test-player", "hit_dice", amount=3, base_path=player_dir) + rest("test-player", "long", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["resources"]["hit_dice_d8"]["current"] == 3 + def test_long_rest_restores_hp(self, mira: dict, player_dir: Path): + damage("test-player", 10, base_path=player_dir) + rest("test-player", "long", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["hp"]["current"] == 24 -@pytest.fixture -def rich_character() -> dict: - """A character dict with all fields populated for display tests.""" - return { - "name": "Mira Ashvale", - "race": "Human", - "class": "Rogue", - "level": 3, - "background": "Criminal", - "hp": {"current": 20, "max": 24}, - "ac": 16, - "speed": 30, - "abilities": { - "strength": 11, - "dexterity": 18, - "constitution": 14, - "intelligence": 15, - "wisdom": 14, - "charisma": 18, - }, - "purse": {"cp": 40, "sp": 20, "ep": 0, "gp": 93, "pp": 0}, - "body": ( - "## Proficiencies\n" - "Armor: Light.\n\n" - "## Features\n" - "- Sneak Attack 2d6\n" - "- Cunning Action\n\n" - "## Equipment\n" - "- Thieves' tools\n" - "- Dagger\n\n" - "## Backstory\n" - "A sharp-tongued grifter." - ), - } - - -class TestFormatStatus: - """Tests for compact /status display.""" - - def test_identity_line(self, rich_character: dict): - result = format_status(rich_character) - assert "**Mira Ashvale**" in result - assert "Human Rogue 3" in result - assert "(Criminal)" in result - - def test_vitals(self, rich_character: dict): - result = format_status(rich_character) - assert "HP 20/24" in result - assert "AC 16" in result - assert "Speed 30 ft" in result - - def test_abilities(self, rich_character: dict): - result = format_status(rich_character) - assert "STR 11 (+0)" in result - assert "DEX 18 (+4)" in result - assert "CHA 18 (+4)" in result - - def test_purse_nonzero_only(self, rich_character: dict): - result = format_status(rich_character) - assert "93 gp" in result - assert "20 sp" in result - assert "40 cp" in result - assert "ep" not in result - assert "pp" not in result - - def test_equipment_one_liner(self, rich_character: dict): - result = format_status(rich_character) - assert "Thieves' tools" in result - assert "Dagger" in result - - def test_equipment_excluded(self, rich_character: dict): - result = format_status(rich_character, include_equipment=False) - assert "Thieves' tools" not in result - - def test_no_background(self): - data = { - "name": "Test", "race": "Elf", "class": "Wizard", "level": 1, - "hp": {"current": 6, "max": 6}, "ac": 12, "speed": 30, - "abilities": {}, "body": "", - } - result = format_status(data) - assert "(" not in result - - def test_legacy_gold_field(self): - data = { - "name": "Old", "race": "Human", "class": "Fighter", "level": 1, - "hp": 10, "ac": 14, "speed": 30, "gold": 50, "body": "", - } - result = format_status(data) - assert "50 gp" in result - - def test_empty_purse(self): - data = { - "name": "Broke", "race": "Human", "class": "Rogue", "level": 1, - "hp": {"current": 5, "max": 5}, "ac": 10, "speed": 30, - "purse": {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, - "body": "", - } - result = format_status(data) - assert "empty" in result - - -class TestFormatSheet: - """Tests for full /me display.""" - - def test_includes_status_header(self, rich_character: dict): - result = format_sheet(rich_character) - assert "**Mira Ashvale**" in result - assert "HP 20/24" in result - - def test_includes_all_sections(self, rich_character: dict): - result = format_sheet(rich_character) - assert "**Proficiencies**" in result - assert "**Features**" in result - assert "**Equipment**" in result - assert "**Backstory**" in result - - def test_equipment_as_list(self, rich_character: dict): - result = format_sheet(rich_character) - assert "- Thieves' tools" in result - assert "- Dagger" in result - - def test_skips_empty_sections(self): - data = { - "name": "Sparse", "race": "Human", "class": "Fighter", "level": 1, - "hp": {"current": 10, "max": 10}, "ac": 14, "speed": 30, - "body": "## Features\n- Tough\n\n## Notes\n", - } - result = format_sheet(data) - assert "**Features**" in result - assert "**Notes**" not in result - - def test_no_equipment_one_liner(self, rich_character: dict): - result = format_sheet(rich_character) - equipment_one_liners = [ - l for l in result.splitlines() if l.startswith("**Equipment:**") - ] - assert len(equipment_one_liners) == 0 - - -class TestFormatCharacterContext: - """Tests for system prompt character context.""" - - def test_includes_name_and_class(self, rich_character: dict): - result = format_character_context(rich_character) - assert "Mira Ashvale" in result - assert "Rogue" in result + def test_long_rest_clears_death_saves(self, mira: dict, player_dir: Path): + update_character( + "test-player", + {"state.death_saves.successes": 2, "state.death_saves.failures": 1}, + base_path=player_dir, + ) + rest("test-player", "long", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["death_saves"]["successes"] == 0 + assert data["state"]["death_saves"]["failures"] == 0 - def test_includes_abilities_with_modifiers(self, rich_character: dict): - result = format_character_context(rich_character) - assert "STR 11 (+0)" in result - assert "DEX 18 (+4)" in result - - def test_includes_purse(self, rich_character: dict): - result = format_character_context(rich_character) - assert "93 gp" in result - - def test_includes_body(self, rich_character: dict): - result = format_character_context(rich_character) - assert "Sneak Attack" in result - - -class TestPurseClamping: - """Tests for purse validation in update_character.""" - - def test_negative_coins_clamped_to_zero(self, basic_character: dict, char_ctx: ToolContext): - result = update_character({"purse.sp": -20}, char_ctx) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["sp"] == 0 - assert "clamped" in result.lower() - - def test_valid_coins_not_clamped(self, basic_character: dict, char_ctx: ToolContext): - result = update_character({"purse.gp": 25}, char_ctx) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["gp"] == 25 - assert "clamped" not in result.lower() - - def test_zero_coins_not_clamped(self, basic_character: dict, char_ctx: ToolContext): - update_character({"purse.gp": 0}, char_ctx) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["gp"] == 0 - - -class TestAdjustCoins: - """Tests for the adjust_coins function.""" - - def test_spend_coins(self, basic_character: dict, char_ctx: ToolContext): - from storied.character import adjust_coins - result = adjust_coins("test-player", {"gp": -5}, char_ctx.base_path) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["gp"] == 45 - assert "45" in result - - def test_gain_coins(self, basic_character: dict, char_ctx: ToolContext): - from storied.character import adjust_coins - adjust_coins("test-player", {"gp": 10}, char_ctx.base_path) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["gp"] == 60 - - def test_spend_more_than_have(self, basic_character: dict, char_ctx: ToolContext): - from storied.character import adjust_coins - result = adjust_coins("test-player", {"gp": -100}, char_ctx.base_path) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["gp"] == 0 + def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): + update_character( + "test-player", {"state.exhaustion": 3}, base_path=player_dir + ) + rest("test-player", "long", base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["exhaustion"] == 2 + + def test_short_rest_doesnt_refresh_long_rest_resources( + self, mira: dict, player_dir: Path + ): + use_resource("test-player", "hit_dice", amount=2, base_path=player_dir) + rest("test-player", "short", base_path=player_dir) + data = load_character("test-player", player_dir) + # hit_dice has refresh: long_rest, so short rest shouldn't refresh it + assert data["resources"]["hit_dice_d8"]["current"] == 1 + + def test_invalid_rest_type(self, mira: dict, player_dir: Path): + result = rest("test-player", "epic", base_path=player_dir) + assert "invalid" in result.lower() + + +class TestCoins: + def test_adjust_coins_spending(self, mira: dict, player_dir: Path): + adjust_coins("test-player", {"gp": -5}, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["purse"]["gp"] == 38 + + def test_adjust_coins_gaining(self, mira: dict, player_dir: Path): + adjust_coins("test-player", {"gp": 10, "sp": 5}, base_path=player_dir) + data = load_character("test-player", player_dir) + assert data["state"]["purse"]["gp"] == 53 + assert data["state"]["purse"]["sp"] == 5 + + def test_adjust_coins_clamped_to_zero(self, mira: dict, player_dir: Path): + result = adjust_coins( + "test-player", {"gp": -100}, base_path=player_dir + ) + data = load_character("test-player", player_dir) + assert data["state"]["purse"]["gp"] == 0 assert "short" in result.lower() - def test_multi_denomination(self, basic_character: dict, char_ctx: ToolContext): - from storied.character import adjust_coins - adjust_coins("test-player", {"gp": -5, "sp": 10}, char_ctx.base_path) - data = load_character("test-player", char_ctx.base_path) - assert data["purse"]["gp"] == 45 - assert data["purse"]["sp"] == 10 - - def test_returns_new_balance(self, basic_character: dict, char_ctx: ToolContext): - from storied.character import adjust_coins - result = adjust_coins("test-player", {"gp": -5}, char_ctx.base_path) - assert "gp" in result.lower() - - def test_no_character(self, char_ctx: ToolContext): - from storied.character import adjust_coins - result = adjust_coins("nonexistent", {"gp": 5}, char_ctx.base_path) - assert "no character" in result.lower() + +class TestNotes: + def test_add_note_creates_file(self, mira: dict, player_dir: Path): + add_note("test-player", "Found a secret door", base_path=player_dir) + notes_path = player_dir / "players" / "test-player" / "notes.md" + assert notes_path.exists() + assert "secret door" in notes_path.read_text() + + def test_add_note_appends(self, mira: dict, player_dir: Path): + add_note("test-player", "First note", base_path=player_dir) + add_note("test-player", "Second note", base_path=player_dir) + notes_path = player_dir / "players" / "test-player" / "notes.md" + content = notes_path.read_text() + assert "First note" in content + assert "Second note" in content + + def test_add_note_with_anchor(self, mira: dict, player_dir: Path): + add_note( + "test-player", "Witnessed the heist", + time_anchor="d28-1330", base_path=player_dir, + ) + notes_path = player_dir / "players" / "test-player" / "notes.md" + assert "d28-1330" in notes_path.read_text() + + +# --- Edge cases --- + + +class TestEdgeCases: + def test_no_character_returns_error_for_each_op(self, player_dir: Path): + for fn, args in [ + (damage, (5,)), + (heal, (5,)), + (add_effect, ("Source", "Desc")), + (remove_effect, ("Source",)), + (add_condition, ("Poisoned",)), + (remove_condition, ("Poisoned",)), + (add_item, ("Item",)), + (remove_item, ("Item",)), + (set_item_status, ("Item", "attuned")), + (use_resource, ("res",)), + (restore_resource, ("res", 1)), + (rest, ("short",)), + (adjust_coins, ({"gp": 5},)), + ]: + result = fn("missing-player", *args, base_path=player_dir) + assert "no character" in result.lower(), f"{fn.__name__} failed" diff --git a/tests/test_execute_tool.py b/tests/test_execute_tool.py index 5ee90ef..5a0a719 100644 --- a/tests/test_execute_tool.py +++ b/tests/test_execute_tool.py @@ -45,11 +45,18 @@ class TestExecuteToolDispatch: }, ctx) result = execute_tool( - "update_character", {"updates": {"hp.current": 8}}, ctx, + "update_character", {"updates": {"state.hp.current": 8}}, ctx, ) - assert "updated" in result.lower() + # Verify the value actually landed in the right place in the new schema + from storied.character import load_character + data = load_character(ctx.player_id, ctx.base_path) + assert data["state"]["hp"]["current"] == 8, ( + "update_character should write to state.hp.current with the new schema, " + f"but state.hp.current is {data['state']['hp']['current']}" + ) + def test_set_scene(self, ctx: ToolContext): result = execute_tool("set_scene", { "event": "Spoke with guards", "duration": "10 min", @@ -277,7 +284,10 @@ class TestInitiativeViaExecuteTool: assert "synced" in result from storied.character import load_character char = load_character(ctx.player_id, ctx.base_path) - assert char["hp"]["current"] == 18 + # Must land in the new nested schema location, not flat hp.current + assert char["state"]["hp"]["current"] == 18, ( + "_sync_player_hp must write to state.hp.current with the new schema" + ) def test_heal_syncs_player_hp(self, ctx: ToolContext): execute_tool("create_character", { @@ -288,6 +298,10 @@ class TestInitiativeViaExecuteTool: }, "hp_max": 25, "ac": 16, }, ctx) + # Set the character's HP to 20 first (matching the combatant) + execute_tool( + "update_character", {"updates": {"state.hp.current": 20}}, ctx, + ) ctx.initiative.begin([ Combatant(name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True), ]) @@ -297,7 +311,10 @@ class TestInitiativeViaExecuteTool: assert "synced" in result from storied.character import load_character char = load_character(ctx.player_id, ctx.base_path) - assert char["hp"]["current"] == 23 + # Must land in the new nested schema location + assert char["state"]["hp"]["current"] == 23, ( + "_sync_player_hp must write to state.hp.current with the new schema" + ) def test_damage_no_sync_for_non_player(self, ctx: ToolContext): ctx.initiative.begin([ diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d1c6e0a..9481ced 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -61,8 +61,10 @@ class TestDynamicDmTools: names = {d["name"] for d in defs} assert "next_turn" not in names - assert "damage" not in names assert "end_initiative" not in names + # Note: damage/heal exist as character tools out of combat (no target), + # and as initiative tools in combat (with target). They share names. + assert "damage" in names # the character version def test_narrative_mode_count(self, ctx: ToolContext): defs = _dm_tool_definitions(ctx) diff --git a/tests/test_seeder.py b/tests/test_seeder.py index e5dbd6a..c174c0e 100644 --- a/tests/test_seeder.py +++ b/tests/test_seeder.py @@ -76,7 +76,7 @@ def character_world(tmp_path: Path) -> Path: ac=16, background="Soldier", purse={"gp": 50}, - equipment=["Longsword", "Chain mail", "Shield"], + equipment={"on_person": ["Longsword", "Chain mail", "Shield"]}, backstory="A former soldier haunted by a battle gone wrong.", base_path=tmp_path, )