diff --git a/tools/engine.py b/tools/engine.py index 2db7010..a735024 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -30,6 +30,8 @@ JOURNAL_PATH = SESSION_DIR / 'journal.md' AMBIENCE_PATH = SESSION_DIR / 'ambience.md' LOG_DIR = SESSION_DIR / 'log' LLM_LOG_PATH = SESSION_DIR / 'llm.log' +AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md" +AUDIO_DIR = SESSION_DIR / "audio" TODAY = date.today().isoformat() @@ -105,8 +107,8 @@ Each turn follows this sequence: 3. **You MUST call `finalize_turn` to end the turn.** There is no other way to complete a turn. The loop will keep calling you until you do. The **finalize_turn** tool produces all data for this turn: -- **book_log** `[Required]` — **Everything that happens this turn, narrated in full.** This is appended to the story book and forms the permanent record of the adventure. Include sensory details, dialogue, outcomes — the whole scene. -- **user_prompt** `[Required]` — **Short prompt for the player only, NOT recorded in the book.** Ask what they do next. 1-3 sentences. Don't put important narrative details here — they belong in `book_log`. +- **book_log** `[Required]` — **The complete self-contained narrative of this turn.** Describe what the player did (based on their action input) and what happened as a result, with all sensory/dialogue/mechanical details. This is the permanent story record — it must stand alone without the player's input text. The player's action is implicit in the narrative, not quoted. +- **user_prompt** `[Required]` — **Short prompt for the player only, NOT recorded in the book.** Ask what they do next. 1-3 sentences. Do NOT recap the action — that belongs in `book_log`. - **log_entry** `[Optional]` — One-sentence summary of what happened (action + outcome). Keep it tight. - **ambience** `[Optional]` — One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. @@ -188,7 +190,7 @@ Tool reference (`[R]` = required, `[O]` = optional): `[O] done`: ["completed item", ...] `[R] dm_status`: "..." - **finalize_turn** — **REQUIRED to end the turn.** The loop will NOT stop without it. Call this ALONE — do not mix with get tools. - `[R] book_log`: "full narrative of what happened this turn — appended to story book (permanent record)" + `[R] book_log`: "self-contained narrative of what the player did this turn — permanent story record, must stand alone" `[R] user_prompt`: "short prompt for the player — NOT recorded, 1-3 sentences" `[O] log_entry`: "one-sentence summary (action + outcome)" `[O] ambience`: "soundscape name: silence|calm|combat|dungeon|forest|tavern|tension|town|wilds" @@ -281,7 +283,7 @@ class GameEngine: def _read_file(self, path: Path) -> str: return path.read_text().strip() if path.exists() else "" - def _read_recent_log(self, max_entries: int = 15) -> str: + def _read_recent_log(self, max_entries: int = 5) -> str: """Read the latest log file and return the last N entries.""" log_path = LOG_DIR / f"{TODAY}.md" if not log_path.exists(): @@ -295,7 +297,7 @@ class GameEngine: entries = [l for l in lines if l.strip().startswith("- ")] return "\n".join(entries[-max_entries:]) or "*No recent events.*" - def _read_recent_book(self, max_turns: int = 3) -> str: + def _read_recent_book(self, max_turns: int = 1) -> str: """Return the last N turns from the book as context.""" text = self._read_file(BOOK_PATH) if not text: @@ -304,6 +306,34 @@ class GameEngine: recent = turns[-max_turns:] return "\n## ".join(recent) if len(turns) > 1 else recent[0] + def _get_valid_ambiences(self) -> set[str]: + """Parse ambience_options.md and return set of valid ambience names with associated audio files.""" + valid = {"silence"} # silence always valid (stops music) + if not AMBIENCE_OPTIONS_PATH.exists(): + return valid + in_table = False + for line in AMBIENCE_OPTIONS_PATH.read_text().splitlines(): + s = line.strip() + if not s.startswith("|") or not s.endswith("|"): + in_table = False + continue + if in_table and all(c in "-:| " for c in s): + continue + parts = [p.strip() for p in s.split("|") if p.strip()] + if not parts: + continue + if not in_table: + in_table = True + continue + name = parts[0].lower() + files_str = parts[1] if len(parts) > 1 else "" + files = [f.strip() for f in files_str.split(",")] + # Only add if at least one file exists (or is listed) + has_files = any((AUDIO_DIR / f).exists() or f for f in files) + if has_files: + valid.add(name) + return valid + def build_system_prompt(self) -> str: """Assemble the system prompt with current game state.""" char = self._read_file(CHAR_PATH) or "*No character sheet.*" @@ -405,7 +435,7 @@ class GameEngine: messages=messages, temperature=self.temperature, stream=False, - timeout=30, + timeout=60, ) text = response.choices[0].message.content or "" except Exception as e: @@ -451,7 +481,7 @@ class GameEngine: messages=messages, temperature=self.temperature, stream=True, - timeout=30, + timeout=60, ) full_text = "" for chunk in response: @@ -765,7 +795,7 @@ class GameEngine: except ImportError: return TurnResult(error="litellm not installed") - max_rounds = 10 + max_rounds = 30 debug_entries: list[str] = [] attempt = 0 round_used = 0 @@ -790,7 +820,8 @@ class GameEngine: messages=messages, temperature=self.temperature, stream=False, - timeout=30, + timeout=60, + max_tokens=512, ) text = response.choices[0].message.content or "" self._append_llm_log( @@ -835,16 +866,42 @@ class GameEngine: names = [tc.get("tool", "?") for tc in tool_calls] round_log.append(f" tools: {', '.join(names)}") - # Guard: no get tools alongside finalize_turn + # Guard: mixed get tools + finalize_turn → execute get tools, reject finalize get_tools = {"read_file", "character_get", "world_get", "journal_get"} if finalize_call and any(tc.get("tool") in get_tools for tc in other_calls): - round_log.append(" mixed get + finalize — rejected") + # Execute only the get tools, drop finalize_turn + results = [] + for tc in other_calls: + if tc.get("tool") not in get_tools: + continue + name = tc.get("tool", "?") + args = tc.get("args", {}) + if not args.get("dm_status"): + err_msg = ( + f"**Validation Error:** Tool `{name}` missing required `dm_status`. " + f"Add `\"dm_status\": \"what the DM is doing\"` to the args." + ) + results.append(err_msg) + round_log.append(f" {name}: MISSING dm_status") + if on_debug: + on_debug("validation_error", {"round": attempt, "type": "tool", "tool": name, "error": "missing dm_status"}) + continue + if on_action: + on_action(self._describe_tool_action(name, args)) + if on_debug: + on_debug("tool_call", {"round": attempt, "tool": name, "args": args}) + result = self._execute_tool(name, args) + results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}") + round_log.append(f" {name}: OK") + if on_debug: + on_debug("tool_result", {"round": attempt, "tool": name, "result": result}) + round_log.append(" finalize_turn ignored (mixed with get tools)") debug_entries.append("\n".join(round_log)) messages = messages[:2] messages.append({"role": "assistant", "content": text}) messages.append({ "role": "user", - "content": "## Validation Error\nYou used a get tool (`read_file`, `character_get`, `world_get`, `journal_get`) and `finalize_turn` in the same round. Decide: either gather information (use get tools, then stop), or finalize the turn (call `finalize_turn` alone with all data). Do not mix them." + "content": "## Tool Results\n\n" + "\n\n".join(results) + "\n\n**Note:** `finalize_turn` was ignored because you called get tools in the same round. Call `finalize_turn` alone in the next round to complete the turn." }) if on_debug: on_debug("validation_error", {"round": attempt, "type": "mixed_get_finalize", "tools": [tc.get("tool") for tc in other_calls]}) @@ -859,6 +916,14 @@ class GameEngine: errs.append("book_log [Required]") if not args.get("user_prompt"): errs.append("user_prompt [Required]") + + # Validate ambience + ambience_name = args.get("ambience") + if ambience_name and ambience_name != "silence": + valid_ambiences = self._get_valid_ambiences() + if not valid_ambiences or ambience_name not in valid_ambiences: + errs.append(f"ambience '{ambience_name}' is invalid or has no associated audio files.") + if errs: hint = ( f"Expected:\n" @@ -868,6 +933,7 @@ class GameEngine: f'"log_entry": "...", ' f'"ambience": "..."' f"}}}}\n" + f"Valid ambiences: {', '.join(valid_ambiences)}" ) round_log.append(f" finalize_turn validation errors: {', '.join(errs)}") debug_entries.append("\n".join(round_log))