from __future__ import annotations import json import re from .paths import ( AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH, CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH, ) from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences TOOL_REGISTRY: dict[str, dict] = { "modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"stat": "str/dex/wil", "operation": "set|diff", "value": "new value"}}, "modify_vitals": {"description": "Change HP, max_hp, weapon, armour.", "args": {"stat": "HP|max_hp|weapon|armour", "operation": "set|diff", "value": "number or string"}}, "modify_cash": {"description": "Change cash amount.", "args": {"operation": "set|diff", "value": "number"}}, "modify_inventory": {"description": "Add, remove, or replace gear. For add with quantity, item name prefix matches existing stack.", "args": {"operation": "add|remove|replace", "item": "item name", "value": "quantity (int) for add/remove, new text for replace"}}, "modify_note": {"description": "Add, remove, or replace a note.", "args": {"operation": "add|remove|replace", "stat": "existing text (for remove/replace)", "value": "note text"}}, "modify_world": {"description": "Replace full world state.", "args": {"operation": "set", "value": "full world markdown"}}, "modify_journal": {"description": "Update TODO/DONE list.", "args": {"operation": "add|done|remove", "value": "entry text"}}, "finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened", "meta_log": "optional behind-the-scenes mechanics explanation"}}, "read_rules": {"description": "Read a rules file by category. Categories: mechanics (full mechanics reference), core (core mechanics), character_creation, end_game (end-game closure rules). Call when you need details beyond the Core Rules in the prompt.", "args": {"category": "optional — one of: mechanics, core, character_creation, end_game (default: mechanics)"}}, } def patch_character(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 "" VITAL_LABELS = { "HP": "Current Health", "max_hp": "Max Health", "weapon": "Weapon", "armour": "Armour", } def _parse_current_int(label: str) -> int | None: """Read a numeric value from character.md by label.""" text = CHAR_PATH.read_text() m = re.search(rf"^- \*\*{re.escape(label)}:\*\*\s*(\d+)", text, re.MULTILINE) return int(m.group(1)) if m else None def _apply_diff(label: str, diff: int) -> str: """Apply a numeric diff to a label in character.md. Returns result string.""" current = _parse_current_int(label) if current is None: return f"**Error:** cannot find numeric {label} in character sheet" new_val = current + diff err = patch_character( rf"^(- \*\*{re.escape(label)}:\*\*\s*)\d+", rf"\g<1>{new_val}", count=1, flags=re.MULTILINE, ) if err: return err return f"{label}: {current} → {new_val} ({diff:+d})" def tool_modify_traits(args: dict) -> str: errors = [] for stat in ("str", "dex", "wil"): val = args.get(stat) if val is not None: err = 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_modify_vitals(args: dict) -> str: stat = args.get("stat", "") op = args.get("operation", "set") value = args.get("value") label = VITAL_LABELS.get(stat) if not label: return f"**Error:** unknown stat '{stat}'. Use: HP, max_hp, weapon, armour." if op == "set": err = patch_character( rf"^(- \*\*{re.escape(label)}:\*\*\s*).*", rf"\g<1>{value}", count=1, flags=re.MULTILINE, ) return f"{label} set to {value}." if not err else err elif op == "diff": if stat in ("weapon", "armour"): return f"**Error:** diff not supported for {stat}. Use set." try: diff = int(value) except (TypeError, ValueError): return f"**Error:** value must be a number for diff, got {value!r}" return _apply_diff(label, diff) else: return f"**Error:** unknown operation '{op}'. Use set or diff." def tool_modify_cash(args: dict) -> str: op = args.get("operation", "set") value = args.get("value") if op == "set": err = patch_character( rf"^(- \*\*Cash:\*\*\s*).*", rf"\g<1>{value}", count=1, flags=re.MULTILINE, ) return f"Cash set to {value}." if not err else err elif op == "diff": try: diff = int(value) except (TypeError, ValueError): return f"**Error:** value must be a number for diff, got {value!r}" return _apply_diff("Cash", diff) else: return f"**Error:** unknown operation '{op}'. Use set or diff." def _find_gear_line(text: str, item_name: str) -> tuple[str, int, str] | None: """Find a gear line matching item_name (prefix match). Returns (full_line, line_start, quantity_str) or None.""" for m in re.finditer(r"^- (.+)$", text, re.MULTILINE): line = m.group(1) line_start = m.start(1) - 2 # start of "- " line_lower = line.lower().strip() name_lower = item_name.lower().strip() if line_lower.startswith(name_lower): qty_match = re.search(r"\((\d+)\)\s*$", line) qty = qty_match.group(1) if qty_match else None return line, line_start, qty return None def tool_modify_inventory(args: dict) -> str: op = args.get("operation", "") item = args.get("item", "") value = args.get("value") if not item: return "**Error:** `item` is required." text = CHAR_PATH.read_text() if op == "add": existing = _find_gear_line(text, item) if existing: full_line, line_start, qty_str = existing if qty_str is not None and value is not None: try: new_qty = int(qty_str) + int(value) except (TypeError, ValueError): return f"**Error:** value must be a number for add with quantity, got {value!r}" if new_qty <= 0: new_text = text[:line_start] + text[line_start + len(full_line) + 2:] CHAR_PATH.write_text(new_text) return f"Removed all {item} from inventory (quantity reached 0)." new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1) CHAR_PATH.write_text(text.replace(full_line, new_line, 1)) return f"{item}: {qty_str} → {new_qty}" if qty_str is None: return f"Item '{full_line}' exists but has no quantity to stack. Use replace to update it." gear_section = re.search(r"^## Gear\n", text, re.MULTILINE) insert_at = gear_section.end() if gear_section else len(text) if not gear_section: text += "\n## Gear\n" insert_at = len(text) val_str = f" ({value})" if value is not None else "" new_entry = f"- {item}{val_str}\n" text = text[:insert_at] + new_entry + text[insert_at:] CHAR_PATH.write_text(text) return f"Added to inventory: {item}{val_str}" elif op == "remove": existing = _find_gear_line(text, item) if not existing: return f"**Error:** item not found: {item}" full_line, line_start, qty_str = existing if qty_str is not None and value is not None: try: new_qty = int(qty_str) - int(value) except (TypeError, ValueError): return f"**Error:** value must be a number for remove with quantity, got {value!r}" if new_qty <= 0: new_text = text[:line_start] + text[line_start + len(full_line) + 2:] CHAR_PATH.write_text(new_text) return f"Removed all {item} from inventory (quantity reached 0)." new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1) CHAR_PATH.write_text(text.replace(full_line, new_line, 1)) return f"{item}: {qty_str} → {new_qty}" err = patch_character(rf"^- {re.escape(full_line)}\n?", "", count=1, flags=re.MULTILINE) if err: return f"**Error:** item not found: {item}" return f"Removed from inventory: {full_line}" elif op == "replace": after = value or "" if not after: return "**Error:** `value` (new text) is required for replace." existing = _find_gear_line(text, item) if not existing: return f"**Error:** item not found: {item}" full_line = existing[0] err = patch_character(rf"^- {re.escape(full_line)}", f"- {after}", count=1, flags=re.MULTILINE) if err: return f"**Error:** item not found: {item}" return f"Gear replaced: {full_line} → {after}" return "**Error:** unknown operation. Use add, remove, or replace." def tool_modify_note(args: dict) -> str: op = args.get("operation", "") stat = args.get("stat", "") value = args.get("value", "") if op == "add": if not value: return "**Error:** `value` (note text) is required for add." 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"- {value}\n" + text[notes_section.end():] else: text += f"\n## Notes & Scribbles\n- {value}\n" CHAR_PATH.write_text(text) return f"Note added: {value}" elif op == "remove": if not stat: return "**Error:** `stat` (exact note text) is required for remove." err = patch_character(rf"^- {re.escape(stat)}\n?", "", count=1, flags=re.MULTILINE) if err: return f"**Error:** note not found: {stat}" return f"Note removed." elif op == "replace": if not stat or not value: return "**Error:** `stat` (old text) and `value` (new text) are required for replace." err = patch_character(rf"^- {re.escape(stat)}", f"- {value}", count=1, flags=re.MULTILINE) if err: return f"**Error:** note not found: {stat}" return f"Note replaced." return "**Error:** unknown operation. Use add, remove, or replace." def tool_modify_world(args: dict) -> str: op = args.get("operation", "set") value = args.get("value", "") if op != "set": return f"**Error:** only 'set' operation is supported for world, got '{op}'." if not value: return "**Error:** `value` (full world markdown) is required." if not validate_update_size("world", value, WORLD_PATH): return "**Error:** Update rejected — content is too short (likely a partial paste)." WORLD_PATH.write_text(value.strip() + "\n") return "World state updated." def tool_modify_journal(args: dict) -> str: op = args.get("operation", "") value = args.get("value", "") if not value: return "**Error:** `value` (entry text) is required." if op == "add": update_journal(add=[value]) return f"Journal entry added: {value}" elif op == "done": update_journal(done=[value]) return f"Journal entry done: {value}" elif op == "remove": update_journal(done=[value]) return f"Journal entry removed: {value}" else: return f"**Error:** unknown operation '{op}'. Use add, done, or remove." def tool_finalize_turn(args: dict) -> str: """Validate ambience and write to AMBIENCE_PATH.""" raw = (args or {}).get("ambience", "").strip().lower() if not raw: AMBIENCE_PATH.write_text("silence\n") return "Ambience set to silence." valid = get_valid_ambiences() if raw not in valid: append_llm_log(f"\n[WARN] invalid ambience '{raw}', allowed: {sorted(valid)}") return f"**Error:** invalid ambience '{raw}'. Allowed: {', '.join(sorted(valid))}." AMBIENCE_PATH.write_text(raw + "\n") return f"Ambience set to {raw}." RULES_CATEGORIES = { "mechanics": MECHANICS_PATH, "core": CORE_RULES_PATH, "character_creation": CHARACTER_CREATION_PATH, "end_game": END_GAME_PATH, } def tool_read_rules(args: dict) -> str: """Read a rules file by category and return its content.""" category = (args or {}).get("category", "mechanics") path = RULES_CATEGORIES.get(category) if not path: allowed = ", ".join(RULES_CATEGORIES) return f"**Error:** unknown category '{category}'. Allowed: {allowed}." content = read_file(path) if not content: return f"**Error:** {path.name} not found." return content def execute_tool(tool_name: str, args: dict) -> str: """Execute a tool by name. Returns result string.""" fn_map = { "modify_traits": tool_modify_traits, "modify_vitals": tool_modify_vitals, "modify_cash": tool_modify_cash, "modify_inventory": tool_modify_inventory, "modify_note": tool_modify_note, "modify_world": tool_modify_world, "modify_journal": tool_modify_journal, "finalize_turn": tool_finalize_turn, "read_rules": tool_read_rules, } fn = fn_map.get(tool_name) if not fn: return f"Unknown tool: {tool_name}" try: return fn(args) except Exception as e: import traceback tb = traceback.format_exc() append_llm_log(f"\n--- TOOL ERROR ({tool_name}) ---\n{tb}") return f"Tool error ({tool_name}): {e}" def describe_change(tool_name: str, args: dict) -> str: """Build a compact human-readable change description from a tool call.""" if tool_name == "modify_vitals": return f"⚡ {args.get('stat', '?')}: {args.get('operation', '?')} {args.get('value', '?')}" elif tool_name == "modify_cash": op = args.get("operation", "?") val = args.get("value", "?") return f"💰 Cash {op} {val}" elif tool_name == "modify_traits": parts = [] for k, v in args.items(): parts.append(f"{k.upper()}: {v}") return f"⚡ {', '.join(parts)}" elif tool_name == "modify_inventory": op = args.get("operation", "?") item = args.get("item", "?") val = args.get("value", "") return f"{'↻' if op == 'replace' else '+' if op == 'add' else '−'} {item}{f' ({val})' if val else ''}" elif tool_name == "modify_note": op = args.get("operation", "?") val = (args.get("value") or args.get("stat") or "?")[:60] return f"📝 {op}: {val}{'…' if len(val) >= 60 else ''}" elif tool_name == "modify_world": return "🌍 World updated" elif tool_name == "modify_journal": op = args.get("operation", "?") val = args.get("value", "?") return f"{'✅' if op == 'done' else '📋'} {val}" elif tool_name == "finalize_turn": a = args.get("ambience", "") return f"♫ {a}" if a else "" return "" def extract_tool_calls(text: str) -> list[dict]: """Extract tool calls from ```tool blocks in LLM response.""" calls = [] seen = set() for m in re.finditer(r"```tool\s*\n?", text): try: decoder = json.JSONDecoder() obj, end = decoder.raw_decode(text, m.end()) except (json.JSONDecodeError, ValueError, StopIteration): close = text.find("```", m.end()) if close > 0: raw = text[m.end():close].strip() raw = re.sub(r'"(?:[^"\\]|\\.)*"', lambda x: x.group(0).replace("\n", "\\n"), raw, flags=re.DOTALL) try: obj = json.loads(raw) except json.JSONDecodeError: continue else: continue if not isinstance(obj, dict) or "tool" not in obj: continue key = (obj["tool"], json.dumps(obj.get("args", {}), sort_keys=True)) if key not in seen: seen.add(key) calls.append(obj) return calls