A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Add adjust_coins tool and purse clamping

The DM was doing absolute-value coin updates and getting the math wrong,
which led to -20 silver on the character sheet. Now there's an
`adjust_coins` tool that takes relative deltas (negative to spend,
positive to gain) so the DM doesn't need to do mental arithmetic. Coins
are clamped to 0 as a backstop in update_character too.

The TUI shows friendly notifications like "Spending 13 gold, 10 silver"
using the same deferred-notification pattern as dice rolls.

Also gitignored .loq_cache which snuck into the repo.

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

+205 -9
+3
.gitignore
··· 63 63 64 64 # Search indexes (build artifacts) 65 65 search.db 66 + 67 + # loq cache 68 + .loq_cache
-1
.loq_cache
··· 1 - {"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}}}
+11 -5
prompts/dm-system.md
··· 9 9 |------|---------| 10 10 | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 11 11 | `recall` | Look up rules or world content | 12 - | `update_character` | Modify character stats (HP, gold, equipment) | 12 + | `update_character` | Modify character stats (HP, equipment, features) | 13 + | `adjust_coins` | Spend or gain coins by relative amounts (preferred for all coin changes) | 13 14 14 15 ### Narrative Mode (outside initiative) 15 16 | Tool | Purpose | ··· 292 293 293 294 ## Character Management 294 295 295 - The player's character sheet is provided below. Use update_character to persist changes so progress is saved between sessions: 296 + The player's character sheet is provided below. Persist changes immediately — don't wait until the end of the session. 297 + 298 + Use `adjust_coins` for all coin changes (spending, earning, looting): 299 + - **Spending coins**: `adjust_coins({"gp": -5})` — negative to spend 300 + - **Gaining coins**: `adjust_coins({"gp": 10, "sp": 5})` — positive to gain 301 + - **Looting mixed coins**: `adjust_coins({"gp": 3, "sp": 12, "cp": 45})` 302 + 303 + Coins are clamped to 0 — the tool will tell you if the character was short. 296 304 305 + Use `update_character` for everything else: 297 306 - **After damage/healing**: `{"hp.current": 5}` 298 - - **After spending/gaining coins**: `{"purse.gp": 25}` or `{"purse.sp": 10, "purse.cp": 50}` 299 307 - **After using abilities**: `{"features.0.uses": 0}` (e.g., Second Wind) 300 308 - **After gaining/losing equipment**: `{"section.Equipment": "- Longsword\n- New shield"}` 301 309 - **After leveling up**: `{"level": 2, "hp.max": 20, "level_since": "#d5-1430", "advancement_ready": null}` 302 - 303 - Call update_character immediately when these changes happen, not at the end of the session. 304 310 305 311 ## Level Advancement 306 312
+52
src/storied/character.py
··· 125 125 if hp["current"] != original: 126 126 changes.append(f"(HP clamped to {hp['current']})") 127 127 128 + # Clamp purse: no negative coins 129 + purse = data.get("purse", {}) 130 + if isinstance(purse, dict): 131 + for denom in ("cp", "sp", "ep", "gp", "pp"): 132 + if denom in purse and purse[denom] < 0: 133 + purse[denom] = 0 134 + changes.append(f"({denom} clamped to 0)") 135 + 128 136 save_character(player_id, data, base_path) 129 137 return "Character updated: " + ", ".join(changes) 138 + 139 + 140 + def adjust_coins( 141 + player_id: str, 142 + deltas: dict[str, int], 143 + base_path: Path | None = None, 144 + ) -> str: 145 + """Apply relative coin changes (positive=gain, negative=spend). 146 + 147 + Each denomination is clamped to 0 minimum. Returns the new purse state. 148 + """ 149 + data = load_character(player_id, base_path) 150 + if data is None: 151 + return f"No character found for player '{player_id}'" 152 + 153 + purse = data.get("purse", {}) 154 + if not isinstance(purse, dict): 155 + purse = {} 156 + data["purse"] = purse 157 + 158 + changes = [] 159 + for denom, delta in deltas.items(): 160 + if denom not in ("cp", "sp", "ep", "gp", "pp"): 161 + continue 162 + old = purse.get(denom, 0) 163 + new = old + delta 164 + if new < 0: 165 + changes.append(f"{denom} {old} → 0 (short {-new} {denom})") 166 + purse[denom] = 0 167 + else: 168 + changes.append(f"{denom} {old} → {new}") 169 + purse[denom] = new 170 + 171 + save_character(player_id, data, base_path) 172 + 173 + # Format the current purse for the result 174 + coins = [] 175 + for denom in ("pp", "gp", "ep", "sp", "cp"): 176 + amount = purse.get(denom, 0) 177 + if amount: 178 + coins.append(f"{amount} {denom}") 179 + purse_str = ", ".join(coins) if coins else "empty" 180 + 181 + return f"Coins adjusted: {'; '.join(changes)}. Purse: {purse_str}" 130 182 131 183 132 184 def _set_nested(data: dict, key: str, value) -> None:
+31 -1
src/storied/engine.py
··· 51 51 return _extract_json_field(tool_json, "reason") 52 52 53 53 54 + def _format_coin_notification(tool_json: str) -> str: 55 + """Format an adjust_coins call as a friendly notification.""" 56 + try: 57 + args = json.loads(tool_json) 58 + deltas = args.get("deltas", {}) 59 + except (json.JSONDecodeError, AttributeError): 60 + return "Adjusting coins" 61 + 62 + spending = [] 63 + receiving = [] 64 + denom_names = {"cp": "copper", "sp": "silver", "ep": "electrum", "gp": "gold", "pp": "platinum"} 65 + for denom in ("pp", "gp", "ep", "sp", "cp"): 66 + amount = deltas.get(denom, 0) 67 + if amount < 0: 68 + spending.append(f"{-amount} {denom_names.get(denom, denom)}") 69 + elif amount > 0: 70 + receiving.append(f"{amount} {denom_names.get(denom, denom)}") 71 + 72 + if spending and receiving: 73 + return f"Spending {', '.join(spending)}; receiving {', '.join(receiving)}" 74 + elif spending: 75 + return f"Spending {', '.join(spending)}" 76 + elif receiving: 77 + return f"Receiving {', '.join(receiving)}" 78 + return "Adjusting coins" 79 + 80 + 54 81 def _tool_notification(name: str) -> str: 55 82 """Build a friendly tool notification string from an MCP tool name. 56 83 ··· 62 89 "roll": "Rolling", 63 90 "recall": "Recalling", 64 91 "update_character": "Updating character sheet", 92 + "adjust_coins": "Adjusting coins", 65 93 "create_character": "Creating character", 66 94 "set_scene": "Setting scene", 67 95 "establish": "Establishing", ··· 452 480 if short == "end_initiative": 453 481 self.combat_ended = True 454 482 455 - if short in ("roll", "run_code") and not self.debug: 483 + if short in ("roll", "run_code", "adjust_coins") and not self.debug: 456 484 # Signal the CLI to flush the renderer before we 457 485 # wait for tool input (the notification comes at 458 486 # ToolStop once we know the reason/description). ··· 477 505 desc = _extract_json_field(current_tool_json, "description") 478 506 label = desc if desc else "Running code" 479 507 yield f"[{label}...]" 508 + elif deferred_notification and current_tool_name == "adjust_coins": 509 + yield f"[{_format_coin_notification(current_tool_json)}...]" 480 510 481 511 if self.debug and current_tool_json: 482 512 truncated = current_tool_json[:200]
+2 -2
src/storied/sandbox.py
··· 81 81 - all other tools go through execute_tool (return str) 82 82 """ 83 83 from storied.tools import ( 84 - recall, establish, mark, note_discovery, 84 + adjust_coins, recall, establish, mark, note_discovery, 85 85 set_scene, update_character, create_character, tune, end_session, 86 86 ) 87 87 from storied.initiative import ( ··· 91 91 92 92 ctx_params = {"ctx", "tracker"} 93 93 str_fns = [ 94 - recall, establish, mark, note_discovery, set_scene, 94 + adjust_coins, recall, establish, mark, note_discovery, set_scene, 95 95 update_character, create_character, tune, end_session, 96 96 enter_initiative, next_turn, add_combatant, remove_combatant, 97 97 damage, heal, condition, end_initiative,
+4
src/storied/tools/__init__.py
··· 16 16 _sync_player_hp, 17 17 ) 18 18 from storied.tools.character import ( 19 + adjust_coins, 19 20 create_character, 20 21 update_character, 21 22 ) ··· 77 78 78 79 elif tool_name == "update_character": 79 80 return update_character(tool_input["updates"], ctx) 81 + 82 + elif tool_name == "adjust_coins": 83 + return adjust_coins(tool_input["deltas"], ctx) 80 84 81 85 elif tool_name == "create_character": 82 86 return create_character(
+40
src/storied/tools/character.py
··· 1 1 """Character management tools.""" 2 2 3 + from storied.character import adjust_coins as char_adjust_coins 3 4 from storied.character import create_character as char_create 4 5 from storied.character import update_character as char_update 5 6 from storied.tools._context import ToolContext ··· 95 96 ) 96 97 97 98 99 + def adjust_coins(deltas: dict[str, int], ctx: ToolContext) -> str: 100 + """Adjust the player's coins by relative amounts. 101 + 102 + Use negative values to spend, positive to gain. Coins are clamped to 0 103 + (the character can't go into debt). Returns the new purse balance. 104 + 105 + Args: 106 + deltas: Coin changes by denomination. Negative to spend, positive to gain. 107 + Examples: {"gp": -5} to spend 5 gold, 108 + {"gp": 10, "sp": 5} to gain 10 gold and 5 silver. 109 + Keys: cp (copper), sp (silver), ep (electrum), gp (gold), pp (platinum). 110 + 111 + Returns: 112 + Summary of changes and new purse balance 113 + """ 114 + return char_adjust_coins(ctx.player_id, deltas, ctx.base_path) 115 + 116 + 98 117 DEFINITIONS: list[dict] = [ 99 118 { 100 119 "name": "update_character", ··· 153 172 "backstory": {"type": "string", "description": "Character backstory and personality"}, 154 173 }, 155 174 "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], 175 + }, 176 + }, 177 + { 178 + "name": "adjust_coins", 179 + "description": adjust_coins.__doc__, 180 + "input_schema": { 181 + "type": "object", 182 + "properties": { 183 + "deltas": { 184 + "type": "object", 185 + "description": "Coin changes by denomination. Negative to spend, positive to gain.", 186 + "properties": { 187 + "cp": {"type": "integer", "description": "Copper pieces"}, 188 + "sp": {"type": "integer", "description": "Silver pieces"}, 189 + "ep": {"type": "integer", "description": "Electrum pieces"}, 190 + "gp": {"type": "integer", "description": "Gold pieces"}, 191 + "pp": {"type": "integer", "description": "Platinum pieces"}, 192 + }, 193 + }, 194 + }, 195 + "required": ["deltas"], 156 196 }, 157 197 }, 158 198 ]
+62
tests/test_character.py
··· 400 400 def test_includes_body(self, rich_character: dict): 401 401 result = format_character_context(rich_character) 402 402 assert "Sneak Attack" in result 403 + 404 + 405 + class TestPurseClamping: 406 + """Tests for purse validation in update_character.""" 407 + 408 + def test_negative_coins_clamped_to_zero(self, basic_character: dict, char_ctx: ToolContext): 409 + result = update_character({"purse.sp": -20}, char_ctx) 410 + data = load_character("test-player", char_ctx.base_path) 411 + assert data["purse"]["sp"] == 0 412 + assert "clamped" in result.lower() 413 + 414 + def test_valid_coins_not_clamped(self, basic_character: dict, char_ctx: ToolContext): 415 + result = update_character({"purse.gp": 25}, char_ctx) 416 + data = load_character("test-player", char_ctx.base_path) 417 + assert data["purse"]["gp"] == 25 418 + assert "clamped" not in result.lower() 419 + 420 + def test_zero_coins_not_clamped(self, basic_character: dict, char_ctx: ToolContext): 421 + update_character({"purse.gp": 0}, char_ctx) 422 + data = load_character("test-player", char_ctx.base_path) 423 + assert data["purse"]["gp"] == 0 424 + 425 + 426 + class TestAdjustCoins: 427 + """Tests for the adjust_coins function.""" 428 + 429 + def test_spend_coins(self, basic_character: dict, char_ctx: ToolContext): 430 + from storied.character import adjust_coins 431 + result = adjust_coins("test-player", {"gp": -5}, char_ctx.base_path) 432 + data = load_character("test-player", char_ctx.base_path) 433 + assert data["purse"]["gp"] == 45 434 + assert "45" in result 435 + 436 + def test_gain_coins(self, basic_character: dict, char_ctx: ToolContext): 437 + from storied.character import adjust_coins 438 + adjust_coins("test-player", {"gp": 10}, char_ctx.base_path) 439 + data = load_character("test-player", char_ctx.base_path) 440 + assert data["purse"]["gp"] == 60 441 + 442 + def test_spend_more_than_have(self, basic_character: dict, char_ctx: ToolContext): 443 + from storied.character import adjust_coins 444 + result = adjust_coins("test-player", {"gp": -100}, char_ctx.base_path) 445 + data = load_character("test-player", char_ctx.base_path) 446 + assert data["purse"]["gp"] == 0 447 + assert "short" in result.lower() 448 + 449 + def test_multi_denomination(self, basic_character: dict, char_ctx: ToolContext): 450 + from storied.character import adjust_coins 451 + adjust_coins("test-player", {"gp": -5, "sp": 10}, char_ctx.base_path) 452 + data = load_character("test-player", char_ctx.base_path) 453 + assert data["purse"]["gp"] == 45 454 + assert data["purse"]["sp"] == 10 455 + 456 + def test_returns_new_balance(self, basic_character: dict, char_ctx: ToolContext): 457 + from storied.character import adjust_coins 458 + result = adjust_coins("test-player", {"gp": -5}, char_ctx.base_path) 459 + assert "gp" in result.lower() 460 + 461 + def test_no_character(self, char_ctx: ToolContext): 462 + from storied.character import adjust_coins 463 + result = adjust_coins("nonexistent", {"gp": 5}, char_ctx.base_path) 464 + assert "no character" in result.lower()