412 lines
16 KiB
Python
412 lines
16 KiB
Python
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
|