Refactored tools to follow the same pattern
This commit is contained in:
parent
3e1778d0d7
commit
f0774fc946
@ -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(
|
||||||
|
|||||||
@ -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""")
|
||||||
|
|||||||
@ -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)
|
||||||
err = patch_character(
|
if not label:
|
||||||
rf"^(- \*\*{label}:\*\*\s*).*", rf"\g<1>{val}", count=1, flags=re.MULTILINE
|
return f"**Error:** unknown stat '{stat}'. Use: HP, max_hp, weapon, armour."
|
||||||
)
|
|
||||||
if err:
|
if op == "set":
|
||||||
errors.append(err)
|
err = patch_character(
|
||||||
return "; ".join(errors) if errors else "Vitals updated."
|
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_add_to_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."
|
||||||
|
|
||||||
text = CHAR_PATH.read_text()
|
text = CHAR_PATH.read_text()
|
||||||
if item in text:
|
|
||||||
return f"Item already in inventory: {item}"
|
if op == "add":
|
||||||
gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
|
existing = _find_gear_line(text, item)
|
||||||
if gear_section:
|
if existing:
|
||||||
insert_at = gear_section.end()
|
full_line, line_start, qty_str = existing
|
||||||
text = text[:insert_at] + f"- {item}\n" + text[insert_at:]
|
if qty_str is not None and value is not None:
|
||||||
else:
|
try:
|
||||||
text += f"\n## Gear\n- {item}\n"
|
new_qty = int(qty_str) + int(value)
|
||||||
CHAR_PATH.write_text(text)
|
except (TypeError, ValueError):
|
||||||
return f"Added to inventory: {item}"
|
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_remove_from_inventory(args: dict) -> str:
|
def tool_modify_note(args: dict) -> str:
|
||||||
item = (args or {}).get("item", "")
|
op = args.get("operation", "")
|
||||||
if not item:
|
stat = args.get("stat", "")
|
||||||
return "**Error:** `item` is required."
|
value = args.get("value", "")
|
||||||
err = patch_character(rf"^- {re.escape(item)}\n?", "", count=1, flags=re.MULTILINE)
|
|
||||||
if err:
|
if op == "add":
|
||||||
return f"**Error:** item not found: {item}"
|
if not value:
|
||||||
return f"Removed from inventory: {item}"
|
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_replace_gear(args: dict) -> str:
|
def tool_modify_world(args: dict) -> str:
|
||||||
before = (args or {}).get("before", "")
|
op = args.get("operation", "set")
|
||||||
after = (args or {}).get("after", "")
|
value = args.get("value", "")
|
||||||
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:
|
|
||||||
return f"**Error:** gear not found: {before}"
|
|
||||||
return f"Gear replaced: {before} → {after}"
|
|
||||||
|
|
||||||
|
if op != "set":
|
||||||
def tool_add_note(args: dict) -> str:
|
return f"**Error:** only 'set' operation is supported for world, got '{op}'."
|
||||||
note = (args or {}).get("note", "")
|
if not value:
|
||||||
if not note:
|
return "**Error:** `value` (full world markdown) is required."
|
||||||
return "**Error:** `note` is required."
|
if not validate_update_size("world", value, WORLD_PATH):
|
||||||
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(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 = 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(args: dict) -> str:
|
|
||||||
content = (args or {}).get("content", "")
|
|
||||||
if not content:
|
|
||||||
return "**Error:** `content` is required."
|
|
||||||
if not validate_update_size("world", content, 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 ""
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user