Refactored tools to follow the same pattern

This commit is contained in:
Dejvino 2026-07-05 10:40:14 +02:00
parent 3e1778d0d7
commit f0774fc946
5 changed files with 292 additions and 161 deletions

View File

@ -21,13 +21,10 @@ from engine_lib.paths import CHARACTER_CREATION_PATH, RULES_INJECTION_PATH
class GameEngine: class GameEngine:
REQUIRED_TOOL_ARGS: dict[str, list[str]] = { REQUIRED_TOOL_ARGS: dict[str, list[str]] = {
"add_to_inventory": ["item"], "modify_inventory": ["operation", "item"],
"remove_from_inventory": ["item"], "modify_note": ["operation"],
"replace_gear": ["before", "after"], "modify_world": ["operation", "value"],
"add_note": ["note"], "modify_journal": ["operation", "value"],
"replace_note": ["before", "after"],
"world_update": ["content"],
"journal_update": ["add", "done"],
} }
def __init__(self, session_dir: str | Path | None = None): def __init__(self, session_dir: str | Path | None = None):
@ -42,10 +39,6 @@ class GameEngine:
if not req: if not req:
continue continue
args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"} args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
if name == "journal_update":
if not args.get("add") and not args.get("done"):
missing.append(f"{name}: needs at least one of `add` or `done`")
continue
for arg in req: for arg in req:
val = args.get(arg) val = args.get(arg)
if val is None or (isinstance(val, str) and not val.strip()): if val is None or (isinstance(val, str) and not val.strip()):
@ -96,7 +89,7 @@ class GameEngine:
"## Instructions\n" "## Instructions\n"
"The player's message starts with `>` — this is a meta out-of-character question to the DM. " "The player's message starts with `>` — this is a meta out-of-character question to the DM. "
"Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. " "Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. "
"Use the `narrative` tool to output your meta response. Do NOT call any other tools (no journal_update, no finalize_turn, no rolls, no state changes)." "Use the `narrative` tool to output your meta response. Do NOT call any other tools (no modify_journal, no finalize_turn, no rolls, no state changes)."
) )
elif is_new_game: elif is_new_game:
base_parts.append( base_parts.append(

View File

@ -18,31 +18,37 @@ Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside to
Wrap each action in its own ```tool block: Wrap each action in its own ```tool block:
```tool ```tool
{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}} {"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}
``` ```
```tool ```tool
{"tool": "modify_traits", "args": {"dex": 15}} {"tool": "modify_vitals", "args": {"stat": "HP", "operation": "diff", "value": -3}}
``` ```
```tool ```tool
{"tool": "add_to_inventory", "args": {"item": "Silver key"}} {"tool": "modify_cash", "args": {"operation": "diff", "value": -5}}
``` ```
```tool ```tool
{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}} {"tool": "modify_traits", "args": {"str": 12, "dex": 15}}
``` ```
```tool ```tool
{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}} {"tool": "modify_inventory", "args": {"operation": "add", "item": "Torch", "value": 1}}
``` ```
```tool ```tool
{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}} {"tool": "modify_inventory", "args": {"operation": "remove", "item": "Torch", "value": 1}}
``` ```
```tool ```tool
{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}} {"tool": "modify_inventory", "args": {"operation": "replace", "item": "Mace (1d6+1)", "value": "Mace (1d6+2, sharpened)"}}
``` ```
```tool ```tool
{"tool": "world_update", "args": {"content": "# The World\\n\\n...full new world state..."}} {"tool": "modify_note", "args": {"operation": "add", "value": "Found a hidden passage under the temple"}}
``` ```
```tool ```tool
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}} {"tool": "modify_world", "args": {"operation": "set", "value": "# The World\\n\\n...full new world state..."}}
```
```tool
{"tool": "modify_journal", "args": {"operation": "add", "value": "Investigate the mine"}}
```
```tool
{"tool": "modify_journal", "args": {"operation": "done", "value": "Defeat the demon"}}
``` ```
```tool ```tool
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins.", "meta_log": "Kael rolled a 4 (1d6) for perception — success, spotted the hidden door. HP lowered to 5 after goblin ambush."}} {"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins.", "meta_log": "Kael rolled a 4 (1d6) for perception — success, spotted the hidden door. HP lowered to 5 after goblin ambush."}}
@ -63,7 +69,7 @@ or with a category:
You are the sole authority over the game state. The player's action is a **proposal**, not a fact. If their action contradicts the character sheet (e.g. using an item they don't have, spending cash they don't have, claiming stats they don't have), narrate the failure and do NOT call any state-changing tools. You are the sole authority over the game state. The player's action is a **proposal**, not a fact. If their action contradicts the character sheet (e.g. using an item they don't have, spending cash they don't have, claiming stats they don't have), narrate the failure and do NOT call any state-changing tools.
**Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `remove_from_inventory` for that item AND apply the effects (e.g. `modify_vitals` for HP potions). If it's not on the sheet, reject the action do not let them use items they don't have. **Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `modify_inventory` with operation `remove` for that item AND apply the effects (e.g. `modify_vitals` for HP potions, `modify_cash` for cash changes). If it's not on the sheet, reject the action do not let them use items they don't have.
## Ending the Game ## Ending the Game
@ -88,7 +94,7 @@ $log
### Journal (TODO / DONE) ### Journal (TODO / DONE)
$journal $journal
**journal_update rule**: When calling `journal_update`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions match the TODO text character-for-character. Mark items as `done` exactly as they appear in TODO. Add new items with exact wording matching their entry in the list. **modify_journal rule**: When calling `modify_journal`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions match the TODO text character-for-character. Use operation `done` for items exactly as they appear in TODO. Use operation `add` for new items with exact wording matching their entry in the list.
### Story ### Story
$story""") $story""")

View File

@ -11,15 +11,13 @@ from .state import read_file, validate_update_size, update_journal, append_llm_l
TOOL_REGISTRY: dict[str, dict] = { TOOL_REGISTRY: dict[str, dict] = {
"modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"str": "optional", "dex": "optional", "wil": "optional"}}, "modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"stat": "str/dex/wil", "operation": "set|diff", "value": "new value"}},
"modify_vitals": {"description": "Change HP, cash, weapon, armour.", "args": {"current_hp": "optional", "max_hp": "optional", "cash": "optional", "weapon": "optional", "armour": "optional"}}, "modify_vitals": {"description": "Change HP, max_hp, weapon, armour.", "args": {"stat": "HP|max_hp|weapon|armour", "operation": "set|diff", "value": "number or string"}},
"add_to_inventory": {"description": "Add item to gear.", "args": {"item": "item name and stats"}}, "modify_cash": {"description": "Change cash amount.", "args": {"operation": "set|diff", "value": "number"}},
"remove_from_inventory": {"description": "Remove item from gear.", "args": {"item": "exact item text"}}, "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"}},
"replace_gear": {"description": "Replace gear by exact match.", "args": {"before": "exact text", "after": "new text"}}, "modify_note": {"description": "Add, remove, or replace a note.", "args": {"operation": "add|remove|replace", "stat": "existing text (for remove/replace)", "value": "note text"}},
"add_note": {"description": "Add note to sheet.", "args": {"note": "note content"}}, "modify_world": {"description": "Replace full world state.", "args": {"operation": "set", "value": "full world markdown"}},
"replace_note": {"description": "Replace note by exact match.", "args": {"before": "exact text", "after": "new text"}}, "modify_journal": {"description": "Update TODO/DONE list.", "args": {"operation": "add|done|remove", "value": "entry 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": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened", "meta_log": "optional behind-the-scenes mechanics explanation"}}, "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)"}}, "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)"}},
} }
@ -35,6 +33,37 @@ def patch_character(pattern: str, repl: str, count: int = 1, flags: int = 0) ->
return "" 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: def tool_modify_traits(args: dict) -> str:
errors = [] errors = []
for stat in ("str", "dex", "wil"): for stat in ("str", "dex", "wil"):
@ -49,103 +78,212 @@ def tool_modify_traits(args: dict) -> str:
def tool_modify_vitals(args: dict) -> str: def tool_modify_vitals(args: dict) -> str:
errors = [] stat = args.get("stat", "")
for field, label in [("current_hp", "Current Health"), ("max_hp", "Max Health"), op = args.get("operation", "set")
("cash", "Cash"), ("weapon", "Weapon"), ("armour", "Armour")]: value = args.get("value")
val = args.get(field)
if val is not None: 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( err = patch_character(
rf"^(- \*\*{label}:\*\*\s*).*", rf"\g<1>{val}", count=1, flags=re.MULTILINE rf"^(- \*\*{re.escape(label)}:\*\*\s*).*", rf"\g<1>{value}",
count=1, flags=re.MULTILINE,
) )
if err: return f"{label} set to {value}." if not err else err
errors.append(err) elif op == "diff":
return "; ".join(errors) if errors else "Vitals updated." if stat in ("weapon", "armour"):
return f"**Error:** diff not supported for {stat}. Use set."
try:
def tool_add_to_inventory(args: dict) -> str: diff = int(value)
item = (args or {}).get("item", "") except (TypeError, ValueError):
if not item: return f"**Error:** value must be a number for diff, got {value!r}"
return "**Error:** `item` is required." return _apply_diff(label, diff)
text = CHAR_PATH.read_text()
if item in text:
return f"Item already in inventory: {item}"
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: else:
text += f"\n## Gear\n- {item}\n" return f"**Error:** unknown operation '{op}'. Use set or diff."
CHAR_PATH.write_text(text)
return f"Added to inventory: {item}"
def tool_remove_from_inventory(args: dict) -> str: def tool_modify_cash(args: dict) -> str:
item = (args or {}).get("item", "") 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: if not item:
return "**Error:** `item` is required." return "**Error:** `item` is required."
err = patch_character(rf"^- {re.escape(item)}\n?", "", count=1, flags=re.MULTILINE)
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: if err:
return f"**Error:** item not found: {item}" return f"**Error:** item not found: {item}"
return f"Removed from inventory: {item}" return f"Removed from inventory: {full_line}"
elif op == "replace":
def tool_replace_gear(args: dict) -> str: after = value or ""
before = (args or {}).get("before", "") if not after:
after = (args or {}).get("after", "") return "**Error:** `value` (new text) is required for replace."
if not before or not after: existing = _find_gear_line(text, item)
return "**Error:** `before` and `after` are required." if not existing:
err = patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE) 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: if err:
return f"**Error:** gear not found: {before}" return f"**Error:** item not found: {item}"
return f"Gear replaced: {before}{after}" return f"Gear replaced: {full_line}{after}"
return "**Error:** unknown operation. Use add, remove, or replace."
def tool_add_note(args: dict) -> str: def tool_modify_note(args: dict) -> str:
note = (args or {}).get("note", "") op = args.get("operation", "")
if not note: stat = args.get("stat", "")
return "**Error:** `note` is required." value = args.get("value", "")
if op == "add":
if not value:
return "**Error:** `value` (note text) is required for add."
text = CHAR_PATH.read_text() text = CHAR_PATH.read_text()
notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE) notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE)
if notes_section: if notes_section:
text = text[:notes_section.end()] + f"- {note}\n" + text[notes_section.end():] text = text[:notes_section.end()] + f"- {value}\n" + text[notes_section.end():]
else: else:
text += f"\n## Notes & Scribbles\n- {note}\n" text += f"\n## Notes & Scribbles\n- {value}\n"
CHAR_PATH.write_text(text) CHAR_PATH.write_text(text)
return f"Note added: {note}" return f"Note added: {value}"
elif op == "remove":
def tool_replace_note(args: dict) -> str: if not stat:
before = (args or {}).get("before", "") return "**Error:** `stat` (exact note text) is required for remove."
after = (args or {}).get("after", "") err = patch_character(rf"^- {re.escape(stat)}\n?", "", count=1, flags=re.MULTILINE)
if not before or not after:
return "**Error:** `before` and `after` are required."
err = patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE)
if err: if err:
return f"**Error:** note not found: {before}" 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 f"Note replaced."
return "**Error:** unknown operation. Use add, remove, or replace."
def tool_world_update(args: dict) -> str:
content = (args or {}).get("content", "") def tool_modify_world(args: dict) -> str:
if not content: op = args.get("operation", "set")
return "**Error:** `content` is required." value = args.get("value", "")
if not validate_update_size("world", content, WORLD_PATH):
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)." return "**Error:** Update rejected — content is too short (likely a partial paste)."
WORLD_PATH.write_text(content.strip() + "\n") WORLD_PATH.write_text(value.strip() + "\n")
return "World state updated." return "World state updated."
def tool_journal_update(args: dict) -> str: def tool_modify_journal(args: dict) -> str:
add = (args or {}).get("add", []) op = args.get("operation", "")
done = (args or {}).get("done", []) value = args.get("value", "")
if isinstance(add, str):
add = [add] if not value:
if isinstance(done, str): return "**Error:** `value` (entry text) is required."
done = [done]
if not add and not done: if op == "add":
return "**Error:** Provide at least one of `add` or `done`." update_journal(add=[value])
update_journal(add=add, done=done) return f"Journal entry added: {value}"
return "Journal updated." 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: def tool_finalize_turn(args: dict) -> str:
@ -187,13 +325,11 @@ def execute_tool(tool_name: str, args: dict) -> str:
fn_map = { fn_map = {
"modify_traits": tool_modify_traits, "modify_traits": tool_modify_traits,
"modify_vitals": tool_modify_vitals, "modify_vitals": tool_modify_vitals,
"add_to_inventory": tool_add_to_inventory, "modify_cash": tool_modify_cash,
"remove_from_inventory": tool_remove_from_inventory, "modify_inventory": tool_modify_inventory,
"replace_gear": tool_replace_gear, "modify_note": tool_modify_note,
"add_note": tool_add_note, "modify_world": tool_modify_world,
"replace_note": tool_replace_note, "modify_journal": tool_modify_journal,
"world_update": tool_world_update,
"journal_update": tool_journal_update,
"finalize_turn": tool_finalize_turn, "finalize_turn": tool_finalize_turn,
"read_rules": tool_read_rules, "read_rules": tool_read_rules,
} }
@ -212,36 +348,31 @@ def execute_tool(tool_name: str, args: dict) -> str:
def describe_change(tool_name: str, args: dict) -> str: def describe_change(tool_name: str, args: dict) -> str:
"""Build a compact human-readable change description from a tool call.""" """Build a compact human-readable change description from a tool call."""
if tool_name == "modify_vitals": if tool_name == "modify_vitals":
parts = [] return f"{args.get('stat', '?')}: {args.get('operation', '?')} {args.get('value', '?')}"
for k, v in args.items(): elif tool_name == "modify_cash":
label = k.replace("_", " ").title() op = args.get("operation", "?")
parts.append(f"{label}: {v}") val = args.get("value", "?")
return f"{', '.join(parts)}" if parts else "" return f"💰 Cash {op} {val}"
elif tool_name == "modify_traits": elif tool_name == "modify_traits":
parts = [] parts = []
for k, v in args.items(): for k, v in args.items():
parts.append(f"{k.upper()}: {v}") parts.append(f"{k.upper()}: {v}")
return f"{', '.join(parts)}" return f"{', '.join(parts)}"
elif tool_name == "add_to_inventory": elif tool_name == "modify_inventory":
return f"+ {args.get('item', '?')}" op = args.get("operation", "?")
elif tool_name == "remove_from_inventory": item = args.get("item", "?")
return f" {args.get('item', '?')}" val = args.get("value", "")
elif tool_name == "replace_gear": return f"{'' if op == 'replace' else '+' if op == 'add' else ''} {item}{f' ({val})' if val else ''}"
return f"{args.get('before', '?')}{args.get('after', '?')}" elif tool_name == "modify_note":
elif tool_name == "add_note": op = args.get("operation", "?")
note = args.get("note", "?") val = (args.get("value") or args.get("stat") or "?")[:60]
return f"📝 {note[:60]}{'' if len(note) > 60 else ''}" return f"📝 {op}: {val}{'' if len(val) >= 60 else ''}"
elif tool_name == "replace_note": elif tool_name == "modify_world":
return f"📝 {args.get('before', '?')[:40]}{args.get('after', '?')[:40]}"
elif tool_name == "world_update":
return "🌍 World updated" return "🌍 World updated"
elif tool_name == "journal_update": elif tool_name == "modify_journal":
parts = [] op = args.get("operation", "?")
for a in args.get("add", []): val = args.get("value", "?")
parts.append(f"📋 {a}") return f"{'' if op == 'done' else '📋'} {val}"
for d in args.get("done", []):
parts.append(f"{d}")
return "; ".join(parts) if parts else ""
elif tool_name == "finalize_turn": elif tool_name == "finalize_turn":
a = args.get("ambience", "") a = args.get("ambience", "")
return f"{a}" if a else "" return f"{a}" if a else ""

View File

@ -15,7 +15,7 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state? 3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state?
4. **Self-Contained Narrative**: The narrative must read clearly on its own explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line. 4. **Self-Contained Narrative**: The narrative must read clearly on its own explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line.
5. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable. 5. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable.
6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `journal_update` to mark it done. Unchecked items left stale too long may need prompting. 6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `modify_journal` with operation `done` to mark it done. Unchecked items left stale too long may need prompting.
7. **Player Speech**: If the player action contains direct speech (quoted text like `"Hello"` or `'Hello'`), the narrative MUST include the player character speaking those words or equivalent dialogue. If the player's speech can be incorporated given the context, the turn should reflect it. Only skip if the speech is completely impossible given the situation. 7. **Player Speech**: If the player action contains direct speech (quoted text like `"Hello"` or `'Hello'`), the narrative MUST include the player character speaking those words or equivalent dialogue. If the player's speech can be incorporated given the context, the turn should reflect it. Only skip if the speech is completely impossible given the situation.
## Character (before changes) ## Character (before changes)
@ -48,20 +48,21 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
## Instructions ## Instructions
Check all criteria. **Completeness** is critical scan the narrative for every event that should change state and verify it has a corresponding tool call: Check all criteria. **Completeness** is critical scan the narrative for every event that should change state and verify it has a corresponding tool call:
- **Item used** must have `remove_from_inventory` - **Item used** must have `modify_inventory` with operation `remove`
- **Item acquired** must have `add_to_inventory` or `replace_gear` - **Item acquired** must have `modify_inventory` with operation `add` or `replace`
- **HP changed** must have `modify_vitals` - **HP changed** must have `modify_vitals`
- **Cash changed** must have `modify_vitals` - **Cash changed** must have `modify_cash`
- **World changed** must have `world_update` - **World changed** must have `modify_world`
- **NPC/location/thread changes** must have `world_update` or `add_note` - **NPC/location/thread changes** must have `modify_world` or `modify_note`
- **TODO resolved** must have `journal_update` with `done` - **TODO resolved** must have `modify_journal` with operation `done`
Missing tool calls = regenerate. Also check that: Missing tool calls = regenerate. Also check that:
- The narrative explicitly describes the character acting not just the world reacting - The narrative explicitly describes the character acting not just the world reacting
- A reader should understand what happened without seeing the "Player Action" line above - A reader should understand what happened without seeing the "Player Action" line above
- Items removed were actually in inventory - Items removed were actually in inventory
- Items added are reasonable and don't duplicate existing items - Items added are reasonable and don't duplicate existing items
- HP/cash changes follow logically from the narrative - HP changes follow logically from the narrative
- Cash changes follow logically from the narrative (spending deduct, earning add)
- No impossible modifications - No impossible modifications
For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate. For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate.

View File

@ -65,8 +65,8 @@ def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
"I use my healing salve", "I use my healing salve",
narrative="Kael applies the salve to his wound.", narrative="Kael applies the salve to his wound.",
log_entry="Kael used his healing salve to restore 2 HP.", log_entry="Kael used his healing salve to restore 2 HP.",
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}}, changes=[{"tool": "modify_inventory", "args": {"operation": "remove", "item": "Healing Salve"}},
{"tool": "modify_vitals", "args": {"current_hp": 8}}], {"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}],
story="At the tavern", story="At the tavern",
log="- Entered the tavern", log="- Entered the tavern",
) )
@ -91,7 +91,7 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
"I buy a round for the house", "I buy a round for the house",
narrative="Kael orders drinks for everyone.", narrative="Kael orders drinks for everyone.",
log_entry="Kael bought a round at the tavern.", log_entry="Kael bought a round at the tavern.",
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}], changes=[{"tool": "modify_cash", "args": {"cash": 0}}],
story="At the tavern", story="At the tavern",
log="- Entered the tavern", log="- Entered the tavern",
) )
@ -110,19 +110,19 @@ def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
mock_read_file.side_effect = _mock_read mock_read_file.side_effect = _mock_read
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no remove_from_inventory", "regenerate") mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no modify_inventory with operation remove", "regenerate")
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I use my healing salve", "I use my healing salve",
narrative="Kael applies the salve to his wound.", narrative="Kael applies the salve to his wound.",
log_entry="Kael used his healing salve.", log_entry="Kael used his healing salve.",
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}], changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}],
story="At the tavern", story="At the tavern",
log="- Entered the tavern", log="- Entered the tavern",
) )
assert valid is False assert valid is False
assert reason == "Narrative says salve used but no remove_from_inventory" assert reason == "Narrative says salve used but no modify_inventory with operation remove"
assert action == "regenerate" assert action == "regenerate"
print("✓ turn validation returns (False, reason, 'regenerate')") print("✓ turn validation returns (False, reason, 'regenerate')")
@ -141,7 +141,7 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
"I attack the dragon", "I attack the dragon",
narrative="Kael swings his sword.", narrative="Kael swings his sword.",
log_entry="Kael attacked the dragon.", log_entry="Kael attacked the dragon.",
changes=[{"tool": "modify_vitals", "args": {"current_hp": 10}}], changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 10}}],
story="A dragon appears!", story="A dragon appears!",
log="- Dragon spotted", log="- Dragon spotted",
) )