From e74dd076993f6f36ce18fa7e0a0ca9afc5128533 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sun, 28 Jun 2026 16:30:36 +0200 Subject: [PATCH] Simpler tool calls with examples --- tools/engine.py | 214 ++++++++++++++++++++++++++++++------------------ 1 file changed, 136 insertions(+), 78 deletions(-) diff --git a/tools/engine.py b/tools/engine.py index df475b4..9016c1c 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -420,55 +420,18 @@ class GameEngine: # ── Tool Infrastructure ──────────────────────────────────────────── TOOL_REGISTRY: dict[str, dict] = { - "read_file": { - "description": "Read a game state file.", - "args": {"file": "character | world | book | log | journal"}, - }, - "roll": { - "description": "Roll dice and return the outcome.", - "args": {"dice": "e.g. 1d6, 2d6", "modifier": "optional +N or -N"}, - }, - "think": { - "description": "Internal reasoning shown in the game status bar.", - "args": {"thought": "Your reasoning."}, - }, - "player_roll": { - "description": "Ask the player to physically roll dice and enter the result.", - "args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"}, - }, - "character_get": { - "description": "Read the full character sheet.", - "args": {}, - }, - "character_update": { - "description": "Replace the character sheet with a new full version (ONLY if HP/cash/gear/stats changed).", - "args": {"content": "Full character sheet markdown"}, - }, - "world_get": { - "description": "Read the full world state.", - "args": {}, - }, - "world_update": { - "description": "Replace the world state with a new full version (ONLY if NPCs/locations/threads changed).", - "args": {"content": "Full world state markdown"}, - }, - "journal_get": { - "description": "Read the journal (TODO / DONE).", - "args": {}, - }, - "journal_update": { - "description": "Add or complete journal entries.", - "args": {"add": "Optional: list of new TODO items", "done": "Optional: list of completed items"}, - }, - "finalize_turn": { - "description": "Complete the turn.", - "args": { - "book_log": "[Required] Full narrative — appended to story book (permanent record)", - "user_prompt": "[Required] Short prompt for player — NOT recorded, 1-3 sentences", - "log_entry": "[Optional] One-sentence summary", - "ambience": "[Optional] Soundscape name", - }, - }, + "roll": {"description": "Roll dice.", "args": {"dice": "1d6", "modifier": "+1"}}, + "player_roll": {"description": "Ask player to roll.", "args": {"dice": "1d6", "reason": "why"}}, + "modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"str": "optional", "dex": "optional", "wil": "optional"}}, + "modify_vitals": {"description": "Change HP, cash, weapon, armour.", "args": {"current_hp": "optional", "max_hp": "optional", "cash": "optional", "weapon": "optional", "armour": "optional"}}, + "add_to_inventory": {"description": "Add item to gear.", "args": {"item": "item name and stats"}}, + "remove_from_inventory": {"description": "Remove item from gear.", "args": {"item": "exact item text"}}, + "replace_gear": {"description": "Replace gear by exact match.", "args": {"before": "exact text", "after": "new text"}}, + "add_note": {"description": "Add note to sheet.", "args": {"note": "note content"}}, + "replace_note": {"description": "Replace note by exact match.", "args": {"before": "exact text", "after": "new text"}}, + "world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}}, + "journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}}, + "finalize_turn": {"description": "End turn.", "args": {"user_prompt": "question for player", "ambience": "soundscape name"}}, } def _tool_think(self, args: dict) -> str: @@ -510,20 +473,98 @@ class GameEngine: mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else "" return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}" - def _tool_character_get(self, args: dict) -> str: - return self._read_file(CHAR_PATH) or "*Character sheet is empty.*" + def _patch_character(self, pattern: str, repl: str, count: int = 1, flags: int = 0) -> str: + """Apply a regex replacement to character.md. Returns error msg or empty string.""" + text = CHAR_PATH.read_text() + new, n = re.subn(pattern, repl, text, count=count, flags=flags) + if n == 0: + return f"**Error:** pattern not found:\n{pattern}" + CHAR_PATH.write_text(new) + return "" - def _tool_character_update(self, args: dict) -> str: - content = (args or {}).get("content", "") - if not content: - return "**Error:** `content` is required." - if not self._validate_update_size("character", content, CHAR_PATH): - return "**Error:** Update rejected — content is too short (likely a partial paste)." - CHAR_PATH.write_text(content.strip() + "\n") - return "Character sheet updated." + def _tool_modify_traits(self, args: dict) -> str: + errors = [] + for stat in ("str", "dex", "wil"): + val = args.get(stat) + if val is not None: + err = self._patch_character( + rf"^(- \*\*{stat.upper()}:\*\*\s*)\d+", rf"\g<1>{val}", count=1, flags=re.MULTILINE + ) + if err: + errors.append(err) + return "; ".join(errors) if errors else "Traits updated." - def _tool_world_get(self, args: dict) -> str: - return self._read_file(WORLD_PATH) or "*World state is empty.*" + def _tool_modify_vitals(self, args: dict) -> str: + errors = [] + for field, label in [("current_hp", "Current Health"), ("max_hp", "Max Health"), + ("cash", "Cash"), ("weapon", "Weapon"), ("armour", "Armour")]: + val = args.get(field) + if val is not None: + err = self._patch_character( + rf"^(- \*\*{label}:\*\*\s*).*", rf"\g<1>{val}", count=1, flags=re.MULTILINE + ) + if err: + errors.append(err) + return "; ".join(errors) if errors else "Vitals updated." + + def _tool_add_to_inventory(self, args: dict) -> str: + item = (args or {}).get("item", "") + if not item: + return "**Error:** `item` is required." + text = CHAR_PATH.read_text() + if item in text: + return f"Item already in inventory: {item}" + # Insert after last gear item or after "## Gear" header + gear_section = re.search(r"^## Gear\n", text, re.MULTILINE) + if gear_section: + insert_at = gear_section.end() + text = text[:insert_at] + f"- {item}\n" + text[insert_at:] + else: + text += f"\n## Gear\n- {item}\n" + CHAR_PATH.write_text(text) + return f"Added to inventory: {item}" + + def _tool_remove_from_inventory(self, args: dict) -> str: + item = (args or {}).get("item", "") + if not item: + return "**Error:** `item` is required." + err = self._patch_character(rf"^- {re.escape(item)}\n?", "", count=1, flags=re.MULTILINE) + if err: + return f"**Error:** item not found: {item}" + return f"Removed from inventory: {item}" + + def _tool_replace_gear(self, args: dict) -> str: + before = (args or {}).get("before", "") + after = (args or {}).get("after", "") + if not before or not after: + return "**Error:** `before` and `after` are required." + err = self._patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE) + if err: + return f"**Error:** gear not found: {before}" + return f"Gear replaced: {before} → {after}" + + def _tool_add_note(self, args: dict) -> str: + note = (args or {}).get("note", "") + if not note: + return "**Error:** `note` is required." + text = CHAR_PATH.read_text() + notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE) + if notes_section: + text = text[:notes_section.end()] + f"- {note}\n" + text[notes_section.end():] + else: + text += f"\n## Notes & Scribbles\n- {note}\n" + CHAR_PATH.write_text(text) + return f"Note added: {note}" + + def _tool_replace_note(self, args: dict) -> str: + before = (args or {}).get("before", "") + after = (args or {}).get("after", "") + if not before or not after: + return "**Error:** `before` and `after` are required." + err = self._patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE) + if err: + return f"**Error:** note not found: {before}" + return f"Note replaced." def _tool_world_update(self, args: dict) -> str: content = (args or {}).get("content", "") @@ -534,9 +575,6 @@ class GameEngine: WORLD_PATH.write_text(content.strip() + "\n") return "World state updated." - def _tool_journal_get(self, args: dict) -> str: - return self._read_file(JOURNAL_PATH) or "*Journal is empty.*" - def _tool_journal_update(self, args: dict) -> str: add = (args or {}).get("add", []) done = (args or {}).get("done", []) @@ -583,20 +621,35 @@ class GameEngine: elif tool_name == "player_roll": dice = (args or {}).get("dice", "1d6") desc = f"asking you to roll {dice}" + elif tool_name == "modify_traits": + desc = "updating traits" + elif tool_name == "modify_vitals": + desc = "updating vitals" + elif tool_name == "add_to_inventory": + desc = "adding item to inventory" + elif tool_name == "remove_from_inventory": + desc = "removing item from inventory" + elif tool_name == "replace_gear": + desc = "replacing gear" + elif tool_name == "add_note": + desc = "adding note" + elif tool_name == "replace_note": + desc = "replacing note" else: desc = f"using {tool_name}" return f"DM is {desc}..." def _execute_tool(self, tool_name: str, args: dict) -> str: fn_map = { - "read_file": self._tool_read_file, "roll": self._tool_roll, - "think": self._tool_think, - "character_get": self._tool_character_get, - "character_update": self._tool_character_update, - "world_get": self._tool_world_get, + "modify_traits": self._tool_modify_traits, + "modify_vitals": self._tool_modify_vitals, + "add_to_inventory": self._tool_add_to_inventory, + "remove_from_inventory": self._tool_remove_from_inventory, + "replace_gear": self._tool_replace_gear, + "add_note": self._tool_add_note, + "replace_note": self._tool_replace_note, "world_update": self._tool_world_update, - "journal_get": self._tool_journal_get, "journal_update": self._tool_journal_update, } fn = fn_map.get(tool_name) @@ -813,17 +866,22 @@ class GameEngine: for attempt in range(3): text = self._call_llm([ {"role": "user", "content": - f"Read the story and compare with current state. Output tool calls for changes:\n\n" + f"Read the story and compare with current state. Output tool calls for any changes:\n\n" f"## Current Character\n{current_char}\n\n" f"## Current World\n{current_world}\n\n" f"## Story\n{book_log}\n\n" - f"Output tool blocks for changes only. Include the FULL updated content:\n" - f"- character_update — content: full new sheet if HP/cash/gear/stats changed\n" - f"- world_update — content: full new world if NPCs/locations/threads changed\n" - f"- journal_update — add: [...], done: [...]\n" - f"- finalize_turn — user_prompt (question for player), ambience (soundscape)\n\n" - f"Wrap each in ```tool:\n" - f"```tool\n{{\"tool\": \"character_update\", \"args\": {{\"content\": \"# Character\\n...\"}}}}\n```"} + f"Output ```tool blocks for changes only. Examples:\n\n" + f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n" + f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n" + f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n" + f"```tool\n{{\"tool\": \"remove_from_inventory\", \"args\": {{\"item\": \"Torches (10)\"}}}}\n```\n" + f"```tool\n{{\"tool\": \"replace_gear\", \"args\": {{\"before\": \"Mace (1d6+1)\", \"after\": \"Mace (1d6+2, sharpened)\"}}}}\n```\n" + f"```tool\n{{\"tool\": \"add_note\", \"args\": {{\"note\": \"Found a hidden passage under the temple\"}}}}\n```\n" + f"```tool\n{{\"tool\": \"replace_note\", \"args\": {{\"before\": \"Old note text\", \"after\": \"New note text\"}}}}\n```\n" + f"```tool\n{{\"tool\": \"world_update\", \"args\": {{\"content\": \"# The World\\n\\n...full new world state...\"}}}}\n```\n" + f"```tool\n{{\"tool\": \"journal_update\", \"args\": {{\"add\": [\"Investigate the mine\"], \"done\": [\"Defeat the demon\"]}}}}\n```\n" + f"```tool\n{{\"tool\": \"finalize_turn\", \"args\": {{\"user_prompt\": \"What do you do?\", \"ambience\": \"dungeon\"}}}}\n```\n\n" + f"Only output tools for things that actually changed. Omit unchanged fields."} ], label=f"Extract attempt {attempt + 1}") if not text or not text.strip():