diff --git a/.gitignore b/.gitignore index 6ee64a3..51f279c 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ players/* # Search indexes (build artifacts) search.db + +# loq cache +.loq_cache diff --git a/.loq_cache b/.loq_cache deleted file mode 100644 index 861acd4..0000000 --- a/.loq_cache +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"config_hash":4557771575092473650,"entries":{"src/storied/tools/_context.py":{"mtime_secs":1775582352,"mtime_nanos":694759924,"lines":82},"src/storied/display.py":{"mtime_secs":1774782927,"mtime_nanos":98901860,"lines":355},"src/storied/dice.py":{"mtime_secs":1767047766,"mtime_nanos":398050421,"lines":180},"src/storied/initiative.py":{"mtime_secs":1774916278,"mtime_nanos":380939471,"lines":574},"src/storied/advancement.py":{"mtime_secs":1775582572,"mtime_nanos":681291189,"lines":199},"src/storied/srd/clean.py":{"mtime_secs":1766869132,"mtime_nanos":684969729,"lines":101},"src/storied/sandbox.py":{"mtime_secs":1774959788,"mtime_nanos":500556437,"lines":147},"src/storied/content.py":{"mtime_secs":1774709737,"mtime_nanos":677509525,"lines":115},"src/storied/cli.py":{"mtime_secs":1775582667,"mtime_nanos":84277922,"lines":1090},"src/storied/tools.py":{"mtime_secs":1775581781,"mtime_nanos":567165396,"lines":1203},"src/storied/__init__.py":{"mtime_secs":1766844771,"mtime_nanos":901498410,"lines":3},"src/storied/tools/mechanics.py":{"mtime_secs":1775582379,"mtime_nanos":322091028,"lines":143},"src/storied/notifications.py":{"mtime_secs":1775581593,"mtime_nanos":188431521,"lines":49},"src/storied/planner.py":{"mtime_secs":1775582662,"mtime_nanos":80226590,"lines":519},"src/storied/tools/character.py":{"mtime_secs":1775582513,"mtime_nanos":318646536,"lines":158},"src/storied/session.py":{"mtime_secs":1768056565,"mtime_nanos":259214128,"lines":264},"src/storied/search.py":{"mtime_secs":1774713471,"mtime_nanos":353186743,"lines":408},"src/storied/tools/entities.py":{"mtime_secs":1775582467,"mtime_nanos":786135738,"lines":466},"src/storied/log.py":{"mtime_secs":1775581843,"mtime_nanos":65667046,"lines":540},"src/storied/engine.py":{"mtime_secs":1775581998,"mtime_nanos":312921199,"lines":523},"src/storied/srd/split.py":{"mtime_secs":1766849074,"mtime_nanos":657046597,"lines":336},"src/storied/srd/download.py":{"mtime_secs":1766845896,"mtime_nanos":726572037,"lines":49},"src/storied/tools/scene.py":{"mtime_secs":1775582498,"mtime_nanos":739484677,"lines":215},"src/storied/tools/__init__.py":{"mtime_secs":1775582704,"mtime_nanos":806661949,"lines":197},"src/storied/character.py":{"mtime_secs":1774487537,"mtime_nanos":474285279,"lines":403},"src/storied/claude.py":{"mtime_secs":1774458748,"mtime_nanos":52161247,"lines":377},"src/storied/srd/extract.py":{"mtime_secs":1766849840,"mtime_nanos":197357744,"lines":63},"src/storied/srd/__init__.py":{"mtime_secs":1766869155,"mtime_nanos":624121322,"lines":15},"src/storied/mcp_server.py":{"mtime_secs":1775581792,"mtime_nanos":695257450,"lines":198}}} \ No newline at end of file diff --git a/prompts/dm-system.md b/prompts/dm-system.md index 64ada4b..613375e 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -9,7 +9,8 @@ This is collaborative storytelling in a fantasy game. Players may explore morall |------|---------| | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | | `recall` | Look up rules or world content | -| `update_character` | Modify character stats (HP, gold, equipment) | +| `update_character` | Modify character stats (HP, equipment, features) | +| `adjust_coins` | Spend or gain coins by relative amounts (preferred for all coin changes) | ### Narrative Mode (outside initiative) | Tool | Purpose | @@ -292,16 +293,21 @@ The player chose to play a murderer. Now run that story. Make the investigation ## Character Management -The player's character sheet is provided below. Use update_character to persist changes so progress is saved between sessions: +The player's character sheet is provided below. Persist changes immediately — don't wait until the end of the session. +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})` + +Coins are clamped to 0 — the tool will tell you if the character was short. + +Use `update_character` for everything else: - **After damage/healing**: `{"hp.current": 5}` -- **After spending/gaining coins**: `{"purse.gp": 25}` or `{"purse.sp": 10, "purse.cp": 50}` - **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}` -Call update_character immediately when these changes happen, not at the end of the session. - ## Level Advancement A background system evaluates whether the character has earned a level-up. When it decides they have, you'll see an `advancement_ready` field on the character sheet and a notification in "Recent World Changes." diff --git a/src/storied/character.py b/src/storied/character.py index 5c91e8d..1c7558e 100644 --- a/src/storied/character.py +++ b/src/storied/character.py @@ -125,10 +125,62 @@ def update_character( 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(".") diff --git a/src/storied/engine.py b/src/storied/engine.py index cae8532..b848b47 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -51,6 +51,33 @@ def _extract_roll_reason(tool_json: str) -> str | None: 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. @@ -62,6 +89,7 @@ def _tool_notification(name: str) -> str: "roll": "Rolling", "recall": "Recalling", "update_character": "Updating character sheet", + "adjust_coins": "Adjusting coins", "create_character": "Creating character", "set_scene": "Setting scene", "establish": "Establishing", @@ -452,7 +480,7 @@ class DMEngine: if short == "end_initiative": self.combat_ended = True - if short in ("roll", "run_code") and not self.debug: + if short in ("roll", "run_code", "adjust_coins") 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). @@ -477,6 +505,8 @@ 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)}...]" if self.debug and current_tool_json: truncated = current_tool_json[:200] diff --git a/src/storied/sandbox.py b/src/storied/sandbox.py index aaff819..9dee494 100644 --- a/src/storied/sandbox.py +++ b/src/storied/sandbox.py @@ -81,7 +81,7 @@ def build_tool_signatures() -> str: - all other tools go through execute_tool (return str) """ from storied.tools import ( - recall, establish, mark, note_discovery, + adjust_coins, recall, establish, mark, note_discovery, set_scene, update_character, create_character, tune, end_session, ) from storied.initiative import ( @@ -91,7 +91,7 @@ def build_tool_signatures() -> str: ctx_params = {"ctx", "tracker"} str_fns = [ - recall, establish, mark, note_discovery, set_scene, + adjust_coins, recall, establish, mark, note_discovery, set_scene, update_character, create_character, tune, end_session, enter_initiative, next_turn, add_combatant, remove_combatant, damage, heal, condition, end_initiative, diff --git a/src/storied/tools/__init__.py b/src/storied/tools/__init__.py index 1d90f41..46502a2 100644 --- a/src/storied/tools/__init__.py +++ b/src/storied/tools/__init__.py @@ -16,6 +16,7 @@ from storied.tools._context import ( _sync_player_hp, ) from storied.tools.character import ( + adjust_coins, create_character, update_character, ) @@ -78,6 +79,9 @@ def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: elif tool_name == "update_character": return update_character(tool_input["updates"], ctx) + elif tool_name == "adjust_coins": + return adjust_coins(tool_input["deltas"], ctx) + elif tool_name == "create_character": return create_character( name=tool_input["name"], diff --git a/src/storied/tools/character.py b/src/storied/tools/character.py index c952893..5cff1ff 100644 --- a/src/storied/tools/character.py +++ b/src/storied/tools/character.py @@ -1,5 +1,6 @@ """Character management tools.""" +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.tools._context import ToolContext @@ -95,6 +96,24 @@ def create_character( ) +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. + + 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). + + Returns: + Summary of changes and new purse balance + """ + return char_adjust_coins(ctx.player_id, deltas, ctx.base_path) + + DEFINITIONS: list[dict] = [ { "name": "update_character", @@ -155,4 +174,25 @@ DEFINITIONS: list[dict] = [ "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"], + }, + }, ] diff --git a/tests/test_character.py b/tests/test_character.py index eb3923d..fcaecf2 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -400,3 +400,65 @@ class TestFormatCharacterContext: 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 + 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()