#!/usr/bin/env python3 """ engine.py — The Chaos Game Engine Owns the LLM interaction, prompt assembly, response parsing, and game state persistence. The TUI (run.py) calls this module — they do not depend on each other, only on the shared session/ file layout. Split into sub-modules: paths, models, prompts, state, tools_handler, llm. """ from __future__ import annotations import json import re import sys from collections import Counter from datetime import datetime from pathlib import Path from typing import Iterator, Optional from paths import ( CHAR_PATH, WORLD_PATH, BOOK_PATH, CONFIG_PATH, LOG_DIR, ) from models import GenerationResult, TurnResult from prompts import SYSTEM_PROMPT, PROSE_PROMPT import state # read_file, read_recent_log, read_recent_book, truncate_world, append_llm_log from tools_handler import ( execute_tool, describe_tool_action, describe_change, parse_changes_block, extract_tool_calls, ) from llm import set_llm_env, call_llm # ── Game Engine ──────────────────────────────────────────────────────────── class GameEngine: """Owns the LLM interaction and game state persistence.""" def __init__(self, session_dir: str | Path | None = None): from paths import SESSION_DIR self.session_dir = Path(session_dir) if session_dir else SESSION_DIR self.config: dict = {} self._load_config() # ── Config ────────────────────────────────────────────────────────── def _load_config(self) -> None: if not CONFIG_PATH.exists(): print( "No session/config.json found. Creating default.\n" "Edit the model field (e.g. 'ollama/llama3.1', 'openai/gpt-4', " "'anthropic/claude-sonnet-4-20250514') and set api_key if needed.", file=sys.stderr, ) self.config = { "llm": { "model": "ollama/llama3.1", "api_key": None, "api_base": None, "temperature": 0.8, "max_tokens": 300, } } self._save_config() else: raw = CONFIG_PATH.read_text() self.config = json.loads(raw) llm = self.config.get("llm", {}) if not llm.get("api_key"): llm["api_key"] = None def _save_config(self) -> None: CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.write_text(json.dumps(self.config, indent=2) + "\n") @property def model(self) -> str: return self.config.get("llm", {}).get("model", "ollama/llama3.1") @property def api_key(self) -> str | None: return self.config.get("llm", {}).get("api_key") @property def api_base(self) -> str | None: return self.config.get("llm", {}).get("api_base") @property def temperature(self) -> float: return self.config.get("llm", {}).get("temperature", 0.8) @property def max_tokens(self) -> int: return self.config.get("llm", {}).get("max_tokens", 512) @property def timeout(self) -> int: return self.config.get("llm", {}).get("timeout", 120) # ── Context Assembly ──────────────────────────────────────────────── def build_system_prompt(self) -> str: """Assemble the system prompt with current game state.""" char = state.read_file(CHAR_PATH) or "*No character sheet.*" world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*" log = state.read_recent_log() story = state.read_recent_book() return SYSTEM_PROMPT.substitute( character=char, world=world, log=log, story=story ) def build_user_message( self, player_action: str | None = None, last_prompt: str | None = None, **kwargs: str | None, ) -> str: """Build the user message for this turn's LLM call.""" if kwargs: raise TypeError( f"build_user_message() got unexpected keyword arguments: " f"{set(kwargs)}. Did you mean 'last_prompt' instead of one of these?" ) parts = [] if last_prompt: parts.append(f"## Situation\n{last_prompt}") if player_action: parts.append(f"## Player's Request\n{player_action}") has_existing_story = bool( state.read_file(BOOK_PATH).strip() ) if not last_prompt else True if not player_action and not last_prompt: if has_existing_story: raise RuntimeError(f"User action is required for every turn.") else: parts.append( "## Instructions\n" "This is a new story. Welcome the player and guide them through the game setup." ) else: parts.append( "## Instructions\n" "Advance the story based on the player's request. " "All state is shown above — write the outcome directly." ) return "\n\n".join(parts) # ── LLM Call ──────────────────────────────────────────────────────── def generate( self, player_action: str | None = None, last_narrative: str | None = None, ) -> GenerationResult: """ Synchronous generation. Calls the LLM, parses the response, and returns a GenerationResult. """ system = self.build_system_prompt() user = self.build_user_message( player_action=player_action, last_prompt=last_narrative ) messages = [ {"role": "system", "content": system}, {"role": "user", "content": user}, ] try: import litellm except ImportError: return GenerationResult( narrative="", error="litellm is not installed. Run: pip install litellm", ) set_llm_env(self.model, self.api_key, self.api_base) try: response = litellm.completion( model=self.model, messages=messages, temperature=self.temperature, stream=False, timeout=self.timeout, ) text = response.choices[0].message.content or "" except Exception as e: return GenerationResult( narrative="", error=f"LLM call failed: {e}", ) return self.parse_response(text) def generate_stream( self, player_action: str | None = None, last_narrative: str | None = None, ) -> Iterator[str]: """ Streaming generator. Yields text chunks as they arrive from the LLM. On completion, the final yield is the FULL text (for parsing). """ system = self.build_system_prompt() user = self.build_user_message( player_action=player_action, last_prompt=last_narrative ) messages = [ {"role": "system", "content": system}, {"role": "user", "content": user}, ] try: import litellm except ImportError: yield json.dumps({ "error": "litellm is not installed. Run: pip install litellm" }) return set_llm_env(self.model, self.api_key, self.api_base) try: response = litellm.completion( model=self.model, messages=messages, temperature=self.temperature, stream=True, timeout=self.timeout, ) full_text = "" for chunk in response: delta = chunk.choices[0].delta.content or "" if delta: full_text += delta yield full_text yield full_text except Exception as e: yield json.dumps({"error": f"LLM call failed: {e}"}) # ──── Three-phase generation ──────────────────────────────────────── def generate_with_tools( self, player_action: str | None = None, last_prompt: str | None = None, on_thought: callable = None, on_action: callable = None, on_player_roll: callable = None, on_debug: callable = None, ) -> TurnResult: """ Three-phase generation: 1. **Prose** — LLM writes the full book_log from context + player action. 2. **Summarize** — LLM condenses the book_log into one log line. 3. **Extract** — LLM reads the book_log and outputs tool calls for state changes. """ set_llm_env(self.model, self.api_key, self.api_base) import random datetime_now = datetime.now() state.append_llm_log(f"\n{'='*60}") state.append_llm_log(f"=== Turn — {datetime_now.strftime('%Y-%m-%d %H:%M:%S')} ===") state.append_llm_log(f"{'='*60}") if player_action: state.append_llm_log(f"Player: {player_action}") elif last_prompt: state.append_llm_log(f"Resume from: {last_prompt[:120]}") die_roll = random.randint(1, 6) state.append_llm_log(f"Dice: {die_roll} (1d6)") book_log = None changes_block = "" log_entry = None user_prompt = self._auto_prompt("") ambience = None debug_info = "" changes = [] for outer_attempt in range(3): # ── Phase 1: Prose ──────────────────────────────────────────── if on_action: on_action(f"Phase 1/3: writing story (dice={die_roll})") if on_debug: on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll, "outer_attempt": outer_attempt + 1}) system = PROSE_PROMPT.substitute( character=state.read_file(CHAR_PATH) or "*No character sheet.*", world=state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*", log=state.read_recent_log(), story=state.read_recent_book(), ) user = self.build_user_message( player_action=player_action, last_prompt=last_prompt, ) user += f"\n\n*A die is cast: **{die_roll}** (1d6).*" text = call_llm([ {"role": "system", "content": system}, {"role": "user", "content": user}, ], model=self.model, temperature=self.temperature, timeout=self.timeout, max_tokens=1024, label=f"Prose attempt {outer_attempt + 1}", on_debug=on_debug) if not text or not text.strip(): if on_debug: on_debug("phase", {"phase": 1, "status": "empty", "attempt": outer_attempt + 1}) continue raw = text.strip() changes_block = "" if "### Changes" in raw: parts = raw.split("### Changes", 1) book_log = parts[0].strip() changes_block = "### Changes" + parts[1] else: book_log = raw if on_debug: preview = book_log[:150].replace("\n", "\\n") on_debug("phase", {"phase": 1, "status": "done", "chars": len(book_log), "changes": bool(changes_block), "preview": preview}) # ── Validation ──────────────────────────────────────────────── if on_debug: on_debug("phase", {"phase": 1, "name": "validation", "status": "start"}) valid, reason = self._validate_narrative(book_log, on_debug=on_debug) if not valid: if on_debug: on_debug("phase", {"phase": 1, "status": "validation_failed", "reason": reason, "outer_attempt": outer_attempt + 1}) book_log = None continue # ── Phase 2: Summarize ──────────────────────────────────────── if on_action: on_action("Phase 2/3: summarizing story") if on_debug: on_debug("phase", {"phase": 2, "name": "summarize", "status": "start"}) log_context = state.read_recent_log() log_entry = None for p2_attempt in range(2): context = book_log if changes_block: context += f"\n\n{changes_block}" text = call_llm([ {"role": "user", "content": f"Given the session log so far, summarize the new story in one line. " f"Focus on who was involved (character and NPC names):\n\n" f"## Session Log\n{log_context}\n\n" f"## New Story\n{context}"} ], model=self.model, temperature=self.temperature, timeout=self.timeout, max_tokens=self.max_tokens, label=f"Summarize attempt {p2_attempt + 1}", on_debug=on_debug) if text and text.strip(): log_entry = text.strip().split("\n")[0][:300] if on_debug: on_debug("phase", {"phase": 2, "status": "done", "summary": log_entry}) break if not log_entry: log_entry = book_log.split("\n")[0][:120] if on_debug: on_debug("phase", {"phase": 2, "status": "fallback", "summary": log_entry}) # ── Phase 3: Extract state changes ──────────────────────────── if on_action: on_action("Phase 3/3: extracting state changes") if on_debug: on_debug("phase", {"phase": 3, "name": "extract", "status": "start"}) user_prompt = self._auto_prompt(book_log) ambience = None phase3_errors = [] changes = [] # Step 1: Parse ### Changes block directly if changes_block.strip(): for tc in parse_changes_block(changes_block): name = tc["tool"] args = tc.get("args", {}) if name == "finalize_turn": continue result = execute_tool(name, args) if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): phase3_errors.append(f"{name}: {result}") else: desc = describe_change(name, args) if desc: changes.append(desc) # Step 2: LLM Phase 3 for finalize_turn + any extra changes previous_attempt = None phase3_ok = False for p3_attempt in range(5): current_char = state.read_file(CHAR_PATH) or "*No character.*" current_world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world.*" phase3_prompt = ( f"## Current Character\n{current_char}\n\n" f"## Current World\n{current_world}\n\n" f"## Story\n{book_log}\n\n" ) if changes_block.strip(): phase3_prompt += ( f"## Changes already applied\n{changes_block}\n\n" f"Output the finalize_turn tool to end the turn. " f"Add extra tool calls if you spot changes the list above missed.\n\n" ) else: phase3_prompt += ( f"Read the story and compare with current state. Output tool calls for any changes:\n\n" ) phase3_prompt += ( f"Output ```tool blocks for changes only. Examples:\n\n" ) if previous_attempt: phase3_prompt += ( f"--- PREVIOUS ATTEMPT (had errors) ---\n" f"{previous_attempt['output']}\n\n" f"--- FEEDBACK ---\n" f"{previous_attempt['feedback']}\n\n" f"Fix the issues above. Output corrected tool calls only.\n\n" ) text = call_llm([ {"role": "user", "content": phase3_prompt + f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n" f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n" f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n" f"```tool\n{{\"tool\": \"remove_from_inventory\", \"args\": {{\"item\": \"Torches (10)\"}}}}\n```\n" f"```tool\n{{\"tool\": \"replace_gear\", \"args\": {{\"before\": \"Mace (1d6+1)\", \"after\": \"Mace (1d6+2, sharpened)\"}}}}\n```\n" f"```tool\n{{\"tool\": \"add_note\", \"args\": {{\"note\": \"Found a hidden passage under the temple\"}}}}\n```\n" f"```tool\n{{\"tool\": \"replace_note\", \"args\": {{\"before\": \"Old note text\", \"after\": \"New note text\"}}}}\n```\n" f"```tool\n{{\"tool\": \"world_update\", \"args\": {{\"content\": \"# The World\\n\\n...full new world state...\"}}}}\n```\n" f"```tool\n{{\"tool\": \"journal_update\", \"args\": {{\"add\": [\"Investigate the mine\"], \"done\": [\"Defeat the demon\"]}}}}\n```\n" f"```tool\n{{\"tool\": \"finalize_turn\", \"args\": {{\"user_prompt\": \"What do you do?\", \"ambience\": \"dungeon\"}}}}\n```\n\n" f"Only output tools for things that actually changed. Omit unchanged fields."} ], model=self.model, temperature=self.temperature, timeout=self.timeout, max_tokens=self.max_tokens, label=f"Extract attempt {p3_attempt + 1}", on_debug=on_debug) if not text or not text.strip(): if on_debug: on_debug("phase", {"phase": 3, "status": "empty", "attempt": p3_attempt + 1}) continue tool_calls_list = extract_tool_calls( text, round_num=p3_attempt + 1, on_debug=on_debug ) if on_debug and tool_calls_list: names = [tc.get("tool", "?") for tc in tool_calls_list if tc.get("tool") != "finalize_turn"] fin = any(tc.get("tool") == "finalize_turn" for tc in tool_calls_list) on_debug("phase", {"phase": 3, "status": "tools_found", "tools": names, "has_finalize": fin}) errors = [] attempt_changes = [] for tc in tool_calls_list: name = tc.get("tool", "?") args = tc.get("args", {}) if name == "finalize_turn": if args.get("user_prompt"): user_prompt = args["user_prompt"] if args.get("ambience"): ambience = args["ambience"] continue if on_action: on_action(f"State: {describe_tool_action(name, args)}") if on_debug: on_debug("tool_call", {"round": p3_attempt + 1, "tool": name, "args": args}) if name == "player_roll" and on_player_roll: dice = args.get("dice", "1d6") reason = args.get("reason", "a check") roll_val = on_player_roll(dice, reason) result = f"Player rolled {dice} for '{reason}': {roll_val}" else: result = execute_tool(name, args) if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): errors.append(f"{name}: {result}") else: desc = describe_change(name, args) if desc: attempt_changes.append(desc) if on_debug: on_debug("tool_result", {"round": p3_attempt + 1, "tool": name, "result": result}) if not errors: phase3_ok = True debug_info = "" changes.extend(attempt_changes) if on_debug: on_debug("phase", {"phase": 3, "status": "done", "applied": len([tc for tc in tool_calls_list if tc.get("tool") != "finalize_turn"])}) break phase3_errors = errors debug_info = "; ".join(errors) if on_debug: on_debug("phase", {"phase": 3, "status": "errors", "errors": errors, "attempt": p3_attempt + 1}) feedback_lines = ["The previous tool calls had errors:"] for e in errors: feedback_lines.append(f"- {e}") feedback_lines.append("") feedback_lines.append("Fix ALL issues above. Use correct tool names, valid JSON, and reasonable values.") previous_attempt = {"output": text, "feedback": "\n".join(feedback_lines)} if phase3_ok: break if on_debug: on_debug("phase", {"phase": 3, "status": "exhausted", "errors": phase3_errors}) on_debug("phase", {"phase": 1, "status": "retry_after_phase3_failure", "outer_attempt": outer_attempt + 1}) book_log = None if not book_log: return TurnResult(error="Generation failed after exhausting all retries") # ── Finalize ────────────────────────────────────────────────────── if on_action: on_action("Turn complete") if on_debug: on_debug("phase_done", { "book_log_chars": len(book_log), "log_entry": log_entry, "user_prompt": user_prompt, "ambience": ambience, "extract_errors": debug_info or None, }) state.append_llm_log( f"\n--- FINAL ---\n" f"book_log: {book_log[:200]}\n" f"log_entry: {log_entry}\n" f"user_prompt: {user_prompt}\n" f"ambience: {ambience}\n" ) return TurnResult( book_log=book_log, log_entry=log_entry, user_prompt=user_prompt, ambience=ambience, debug_info=debug_info, changes=changes, ) # ──── Single-call generation ───────────────────────────────────────── def generate_with_tools_single( self, player_action: str | None = None, last_prompt: str | None = None, on_thought: callable = None, on_action: callable = None, on_player_roll: callable = None, on_debug: callable = None, ) -> TurnResult: """ Single-call generation using tools. Uses a single LLM call with all tools available — LLM outputs narrative + tool blocks in one go. No retry loop. """ datetime_now = datetime.now() state.append_llm_log(f"\n{'='*60}") state.append_llm_log(f"=== Turn — {datetime_now.strftime('%Y-%m-%d %H:%M:%S')} ===") state.append_llm_log(f"{'='*60}") if player_action: state.append_llm_log(f"Player: {player_action}") elif last_prompt: state.append_llm_log(f"Resume from: {last_prompt[:120]}") strategy_name = "tools" if on_action: on_action(f"LLM: {self.model} | temp={self.temperature} | tokens={self.max_tokens} | strategy={strategy_name}") if on_debug: on_debug("config", {"model": self.model, "temperature": self.temperature, "max_tokens": self.max_tokens, "strategy": strategy_name}) import random die_roll = random.randint(1, 6) state.append_llm_log(f"Dice: {die_roll} (1d6)") system = """You are an RPG dungeon master. The player just took an action. Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block. Use these tools to perform every action. Wrap each in its own ```tool block: ```tool {"tool": "narrative", "args": {"text": "The full vivid narrative prose goes here."}} ``` ```tool {"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}} ``` ```tool {"tool": "modify_traits", "args": {"dex": 15}} ``` ```tool {"tool": "add_to_inventory", "args": {"item": "Silver key"}} ``` ```tool {"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}} ``` ```tool {"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}} ``` ```tool {"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}} ``` ```tool {"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}} ``` ```tool {"tool": "world_update", "args": {"content": "# The World\n\n...full new world state..."}} ``` ```tool {"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}} ``` ```tool {"tool": "finalize_turn", "args": {"user_prompt": "What do you do?", "ambience": "dungeon"}} ``` """ system += PROSE_PROMPT.substitute( character=state.read_file(CHAR_PATH) or "*No character sheet.*", world=state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*", log=state.read_recent_log(), story=state.read_recent_book(), ) user = self.build_user_message( player_action=player_action, last_prompt=last_prompt, ) user += f"\n\n*A die is cast: **{die_roll}** (1d6).*" start_time = datetime.now() set_llm_env(self.model, self.api_key, self.api_base) state.append_llm_log(f"\n[TOOL] Single call — {len(system)} chars system, {len(user)} chars user") state.append_llm_log(f"System preview: {system.split(chr(10))[0][:80]}...") state.append_llm_log(f"User preview: {user.split(chr(10))[0][:80]}...") text = call_llm( [{"role": "system", "content": system}, {"role": "user", "content": user}], model=self.model, temperature=self.temperature, timeout=self.timeout, max_tokens=4096, label="Single tool call", on_debug=on_debug, ) total_elapsed = (datetime.now() - start_time).total_seconds() * 1000 if text: state.append_llm_log(f"\n[TOOL] got {len(text)} chars in {total_elapsed:.1f}ms") if not text or not text.strip(): return TurnResult(error="Single tool call returned empty response") raw = text.strip() book_log = "" log_entry = None user_prompt = self._auto_prompt("") ambience = None tool_calls = [] changes = [] phase3_errors = [] tool_pattern = r"```tool\s*\n?(.*?)\n?```" matches = re.findall(tool_pattern, text, re.DOTALL) if matches: for block in matches: block = block.strip() try: tc = json.loads(block) tool_calls.append(tc) name = tc.get("tool", "unknown") args = tc.get("args", {}) state.append_llm_log(f"\n[EXTRACT] {name}: {json.dumps(args)[:100]}") if name == "narrative": book_log = args.get("text", book_log) elif name == "finalize_turn": if args.get("user_prompt"): user_prompt = args["user_prompt"] if args.get("ambience"): ambience = args["ambience"] except json.JSONDecodeError as e: state.append_llm_log(f"\n[EXTRACT] bad JSON: {e}") continue log_entry = None if book_log: clean = re.sub(r'\s+', ' ', book_log).strip() first_sentence = re.split(r'(?<=[.!?])\s+', clean) if first_sentence: log_entry = first_sentence[0].strip()[:200] else: log_entry = clean[:200] state.append_llm_log(f"\n[SUMMARY] \"{log_entry}\"") extr_start = datetime.now() for tc in tool_calls: name = tc.get("tool", "unknown") args = tc.get("args", {}) if name in ("finalize_turn", "narrative"): continue result = execute_tool(name, args) if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): phase3_errors.append(f"{name}: {result}") else: desc = describe_change(name, args) if desc: changes.append(desc) apply_elapsed = (datetime.now() - extr_start).total_seconds() * 1000 state.append_llm_log(f"\n[APPLY] {len(changes)} changes in {apply_elapsed:.1f}ms") else: state.append_llm_log(f"\n[TOOL] no tool blocks found") elapsed = (datetime.now() - start_time).total_seconds() * 1000 if on_action: on_action("Turn complete") if on_debug: applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"]) on_debug("phase_done", { "book_log_chars": len(book_log), "log_entry": log_entry, "user_prompt": user_prompt, "ambience": ambience, "extract_errors": phase3_errors or None, "total_elapsed_ms": elapsed, "tool_calls_count": len(tool_calls), "applied_changes_count": applied, "tool_call_results": tool_calls, }) self._log_turn_details( player_action=player_action or last_prompt or "", last_prompt=last_prompt or "", strategy_name=strategy_name, die_roll=die_roll, model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, book_log=book_log, log_entry=log_entry or "", ambience=ambience, tool_calls=tool_calls, on_debug=on_debug, ) return TurnResult( book_log=book_log, log_entry=log_entry, user_prompt=user_prompt, ambience=ambience, debug_info="; ".join(phase3_errors) if phase3_errors else "", changes=changes, ) # ── Helpers ───────────────────────────────────────────────────────── @staticmethod def _auto_prompt(book_log: str) -> str: """Fallback player prompt.""" return "**What do you do?**" def _validate_narrative(self, book_log: str, *, on_debug: callable = None) -> tuple[bool, str]: """Check if book_log is acceptable narrative. Returns (ok, reason).""" lines = book_log.strip().split("\n") if not lines: return False, "Empty narrative" common = Counter(lines).most_common(1) if common and common[0][1] >= 5: return False, f"Repetition: '{common[0][0][:60]}' ×{common[0][1]}" mech_lines = [l for l in lines if re.match( r'^\*\*(?:Roll|Damage|Success|Failure|Check|Save|Hit|Miss|' r'Strenght|Dexterity|Willpower|STR|DEX|WIL|' r'(?:[A-Z][a-z]+(?: \(\w+\))?:))', l )] if mech_lines: ratio = len(mech_lines) / len(lines) if ratio > 0.3: return False, f"Game mechanics dominate ({len(mech_lines)}/{len(lines)} lines)" if re.search(r'```(?:tool|json)', book_log): return False, "Contains unprocessed tool blocks" prose = re.sub(r'[*_#>`~\-\d]', '', book_log).strip() if len(prose) < 50: return False, "Too short to be meaningful" text = call_llm([ {"role": "user", "content": f"Rate this RPG narrative quality 1-5.\n" f"1 = unreadable (spam, repetition, pure mechanics, garbled)\n" f"2 = poor (mostly mechanics, little story)\n" f"3 = acceptable (some narrative but rough)\n" f"4 = good (solid prose, minor issues)\n" f"5 = excellent (vivid, engaging)\n" f"Reply with ONLY a single digit 1-5.\n\n" f"{book_log[:600]}"} ], model=self.model, temperature=self.temperature, timeout=self.timeout, max_tokens=2, label="Narrative validation", on_debug=on_debug) if text and text.strip().isdigit(): score = int(text.strip()) if score < 3: return False, f"Quality score: {score}/5" return True, "" # ── Response Parsing ──────────────────────────────────────────────── @staticmethod def parse_response(text: str) -> GenerationResult: """ Parse a full LLM response into a GenerationResult. Extracts the JSON block and splits narrative from it. """ if text.startswith('{"error":'): try: err = json.loads(text).get("error", "Unknown error") except json.JSONDecodeError: err = "Unknown error" return GenerationResult(narrative="", error=err) json_pattern = r"```json\s*\n?(.*?)\n?```" matches = re.findall(json_pattern, text, re.DOTALL) narrative = text data = {} if matches: json_str = matches[-1].strip() narrative = text[: text.rfind("```json")] narrative_lines = [] for line in narrative.splitlines(): if not line.lstrip().startswith('book_log:'): narrative_lines.append(line) narrative = "\n".join(narrative_lines).strip() try: data = json.loads(json_str) except json.JSONDecodeError: pass else: text_stripped = text.strip() if text_stripped.startswith("{") and text_stripped.endswith("}"): try: data = json.loads(text_stripped) narrative = data.get("narrative", "") except json.JSONDecodeError: pass return GenerationResult( narrative=narrative or text, choices=data.get("choices", []), log_entry=data.get("log_entry"), ambience=data.get("ambience"), character_updates=data.get("character_updates"), world_updates=data.get("world_updates"), journal_add=data.get("journal_add", []), journal_done=data.get("journal_done", []), ) # ── Logging ───────────────────────────────────────────────────────── def _log_turn_details( self, player_action: str, last_prompt: str, strategy_name: str, die_roll: int, model: str, temperature: float, max_tokens: int, book_log: str, log_entry: str, ambience: Optional[str], tool_calls: list, on_debug, ) -> None: """Write structured turn summary to llm.log and fire TUI debug event.""" ts = datetime.now().isoformat() output_chars = len(book_log) output_words = len(book_log.split()) if book_log else 0 applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"]) state.append_llm_log("") state.append_llm_log(f"┌─ Turn Details — {ts}") state.append_llm_log(f"├─ Input: {player_action}") state.append_llm_log(f"├─ Last Prompt: {last_prompt}") state.append_llm_log(f"├─ Strategy: {strategy_name}") state.append_llm_log(f"├─ Dice: {die_roll} (1d6)") state.append_llm_log(f"├─ Model: {model} | Temp: {temperature} | Tokens: {max_tokens}") state.append_llm_log(f"├─ Output: {output_chars} chars ({output_words} words)") state.append_llm_log(f"├─ Log Entry: {log_entry}") state.append_llm_log(f"├─ Ambience: {ambience or 'None'}") tools_preview = ", ".join(tc.get("tool", "?") for tc in tool_calls) state.append_llm_log(f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})") state.append_llm_log( "└─────────────────────────────────────────────────────────────────────────────────────────┘" ) if on_debug: on_debug("turn_details", { "timestamp": ts, "model": model, "temperature": temperature, "max_tokens": max_tokens, "strategy_name": strategy_name, "die_roll": die_roll, "player_action": player_action, "book_log_chars": output_chars, "book_log_words": output_words, "ambience": ambience, "tool_calls_count": len(tool_calls), "applied_changes_count": applied, "tool_call_results": tool_calls, }) # ── CLI entry point (for testing) ───────────────────────────────────────── def main(): """Generate a turn from the command line (debug/testing).""" import argparse parser = argparse.ArgumentParser(description="The Chaos Game Engine (CLI)") parser.add_argument("--action", "-a", help="Player action text") parser.add_argument("--last", "-l", help="Last narrative text") args = parser.parse_args() engine = GameEngine() result = engine.generate_with_tools_single( player_action=args.action, last_prompt=args.last, ) if result.error: print(f"ERROR: {result.error}", file=sys.stderr) sys.exit(1) print(result.book_log) if result.user_prompt: print(f"\n{result.user_prompt}") if result.log_entry: print(f"\n[Log] {result.log_entry}") if result.ambience: print(f"[Ambience] {result.ambience}") if __name__ == "__main__": main()