More refactors

This commit is contained in:
Dejvino 2026-06-30 20:03:53 +02:00
parent e9a1187f34
commit 545d3bcac0
20 changed files with 1633 additions and 1686 deletions

View File

@ -1,15 +1,33 @@
#!/bin/bash #!/bin/bash
ERRORS=$(python3 -c "import os; [f for f in os.listdir('./tools') if f.endswith('.py') and os.path.getsize(os.path.join('./tools', f)) > 2048]") THRESHOLD=30000 # bytes — flag files over ~30 KB
if [ -z "$ERRORS" ]; then
echo "Compiling tools/*.py..." # Check all .py files in tools/ and tools/engine_lib/
if python3 -m compileall tools/*.py; then OVERSIZED=$(python3 -c "
import os
threshold = $THRESHOLD
dirs = ['./tools', './tools/engine_lib']
for d in dirs:
if not os.path.isdir(d):
continue
for f in sorted(os.listdir(d)):
if not f.endswith('.py'):
continue
path = os.path.join(d, f)
size = os.path.getsize(path)
if size > threshold:
print(f'{path} ({size} bytes)')
")
if [ -n "$OVERSIZED" ]; then
echo "Oversized files (>${THRESHOLD} bytes) — consider refactoring:"
echo "$OVERSIZED"
exit 1
fi
echo "Compiling tools/*.py and tools/engine_lib/*.py..."
if python3 -m compileall tools/*.py tools/engine_lib/*.py; then
echo "OK" echo "OK"
else else
echo "Compilation failed" echo "Compilation failed"
exit 1 exit 1
fi fi
else
echo "You need to refactor this:"
echo "$ERRORS"
exit 1
fi

View File

@ -2,248 +2,85 @@
""" """
engine.py The Chaos Game Engine engine.py The Chaos Game Engine
Owns the LLM interaction, prompt assembly, response parsing, and game state Thin coordinator that owns the GameEngine class. All heavy lifting is
persistence. The TUI (run.py) calls this module they do not depend on each delegated to sub-modules: paths, models, prompts, config, context,
other, only on the shared session/ file layout. state, tools_handler, llm, validation, parsing, strategies.
Split into sub-modules: paths, models, prompts, state, tools_handler, llm.
""" """
from __future__ import annotations from __future__ import annotations
import json
import re
import sys import sys
from collections import Counter
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Iterator, Optional
from paths import ( from engine_lib.paths import CONFIG_PATH
CHAR_PATH, WORLD_PATH, BOOK_PATH, CONFIG_PATH, LOG_DIR, from engine_lib.models import GenerationResult, TurnResult
) from engine_lib import config
from models import GenerationResult, TurnResult from engine_lib import strategies
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: class GameEngine:
"""Owns the LLM interaction and game state persistence.""" """Owns configuration and delegates generation to standalone strategies."""
def __init__(self, session_dir: str | Path | None = None): def __init__(self, session_dir: str | Path | None = None):
from paths import SESSION_DIR from engine_lib.paths import SESSION_DIR
self.session_dir = Path(session_dir) if session_dir else SESSION_DIR self.session_dir = Path(session_dir) if session_dir else SESSION_DIR
self.config: dict = {} self.config = config.load_config(CONFIG_PATH)
self._load_config()
# ── Config ────────────────────────────────────────────────────────── # ── Config accessors ────────────────────────────────────────────────
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 @property
def model(self) -> str: def model(self) -> str:
return self.config.get("llm", {}).get("model", "ollama/llama3.1") return config.get_model(self.config)
@property @property
def api_key(self) -> str | None: def api_key(self) -> str | None:
return self.config.get("llm", {}).get("api_key") return config.get_api_key(self.config)
@property @property
def api_base(self) -> str | None: def api_base(self) -> str | None:
return self.config.get("llm", {}).get("api_base") return config.get_api_base(self.config)
@property @property
def temperature(self) -> float: def temperature(self) -> float:
return self.config.get("llm", {}).get("temperature", 0.8) return config.get_temperature(self.config)
@property @property
def max_tokens(self) -> int: def max_tokens(self) -> int:
return self.config.get("llm", {}).get("max_tokens", 512) return config.get_max_tokens(self.config)
@property @property
def timeout(self) -> int: def timeout(self) -> int:
return self.config.get("llm", {}).get("timeout", 120) return config.get_timeout(self.config)
# ── Context Assembly ──────────────────────────────────────────────── # ── Generation (delegated) ──────────────────────────────────────────
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( def generate(
self, self,
player_action: str | None = None, player_action: str | None = None,
last_narrative: str | None = None, last_narrative: str | None = None,
) -> GenerationResult: ) -> GenerationResult:
""" return strategies.generate(
Synchronous generation. Calls the LLM, parses the response, player_action=player_action,
and returns a GenerationResult. last_narrative=last_narrative,
"""
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, model=self.model,
messages=messages,
temperature=self.temperature, temperature=self.temperature,
stream=False,
timeout=self.timeout, timeout=self.timeout,
) max_tokens=self.max_tokens,
text = response.choices[0].message.content or "" api_key=self.api_key,
except Exception as e: api_base=self.api_base,
return GenerationResult(
narrative="",
error=f"LLM call failed: {e}",
) )
return self.parse_response(text) def generate_stream(self, player_action=None, last_narrative=None):
yield from strategies.generate_stream(
def generate_stream( player_action=player_action,
self, last_narrative=last_narrative,
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, model=self.model,
messages=messages,
temperature=self.temperature, temperature=self.temperature,
stream=True,
timeout=self.timeout, timeout=self.timeout,
max_tokens=self.max_tokens,
api_key=self.api_key,
api_base=self.api_base,
) )
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( def generate_with_tools(
self, self,
@ -254,301 +91,20 @@ class GameEngine:
on_player_roll: callable = None, on_player_roll: callable = None,
on_debug: callable = None, on_debug: callable = None,
) -> TurnResult: ) -> TurnResult:
""" return strategies.generate_with_tools(
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, player_action=player_action,
last_prompt=last_prompt, last_prompt=last_prompt,
on_thought=on_thought,
on_action=on_action,
on_player_roll=on_player_roll,
on_debug=on_debug,
model=self.model,
temperature=self.temperature,
timeout=self.timeout,
max_tokens=self.max_tokens,
api_key=self.api_key,
api_base=self.api_base,
) )
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( def generate_with_tools_single(
self, self,
@ -559,368 +115,20 @@ class GameEngine:
on_player_roll: callable = None, on_player_roll: callable = None,
on_debug: callable = None, on_debug: callable = None,
) -> TurnResult: ) -> TurnResult:
""" return strategies.generate_with_tools_single(
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, player_action=player_action,
last_prompt=last_prompt, last_prompt=last_prompt,
) on_thought=on_thought,
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*" on_action=on_action,
on_player_roll=on_player_roll,
start_time = datetime.now() on_debug=on_debug,
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, model=self.model,
temperature=self.temperature, temperature=self.temperature,
timeout=self.timeout,
max_tokens=self.max_tokens, max_tokens=self.max_tokens,
book_log=book_log, api_key=self.api_key,
log_entry=log_entry or "", api_base=self.api_base,
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) ───────────────────────────────────────── # ── CLI entry point (for testing) ─────────────────────────────────────────

View File

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
config.py LLM configuration loading and accessors for The Chaos engine.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from .paths import CONFIG_PATH
DEFAULT_CONFIG: dict = {
"llm": {
"model": "ollama/llama3.1",
"api_key": None,
"api_base": None,
"temperature": 0.8,
"max_tokens": 300,
}
}
def load_config(path: Path = CONFIG_PATH) -> dict:
"""Load config from path, creating default if missing. Returns config dict."""
if not 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,
)
cfg = dict(DEFAULT_CONFIG)
save_config(cfg, path)
return cfg
raw = path.read_text()
cfg = json.loads(raw)
llm = cfg.get("llm", {})
if not llm.get("api_key"):
llm["api_key"] = None
return cfg
def save_config(config: dict, path: Path = CONFIG_PATH) -> None:
"""Save config dict to path."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(config, indent=2) + "\n")
# ── Accessors ──────────────────────────────────────────────────────────────
def get_model(config: dict) -> str:
return config.get("llm", {}).get("model", "ollama/llama3.1")
def get_api_key(config: dict) -> str | None:
return config.get("llm", {}).get("api_key")
def get_api_base(config: dict) -> str | None:
return config.get("llm", {}).get("api_base")
def get_temperature(config: dict) -> float:
return config.get("llm", {}).get("temperature", 0.8)
def get_max_tokens(config: dict) -> int:
return config.get("llm", {}).get("max_tokens", 512)
def get_timeout(config: dict) -> int:
return config.get("llm", {}).get("timeout", 120)

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
context.py System prompt and user message assembly for The Chaos engine.
All functions are standalone no dependency on GameEngine.
"""
from __future__ import annotations
from .paths import CHAR_PATH, WORLD_PATH, BOOK_PATH
from .prompts import SYSTEM_PROMPT
from . import state
def build_system_prompt() -> 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_prose_prompt() -> str:
"""Assemble the prose-generation prompt with current game state."""
from .prompts import PROSE_PROMPT
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 PROSE_PROMPT.substitute(
character=char, world=world, log=log, story=story
)
def build_user_message(
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("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)

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import os import os
from state import append_llm_log from .state import append_llm_log
def set_llm_env(model: str, api_key: str | None, api_base: str | None) -> None: def set_llm_env(model: str, api_key: str | None, api_base: str | None) -> None:

121
tools/engine_lib/parsing.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
parsing.py LLM response parsing and turn logging for The Chaos engine.
Standalone functions no dependency on GameEngine.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Optional
from .models import GenerationResult
from . import state
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", []),
)
def log_turn_details(
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,
) -> 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,
})

View File

@ -1,8 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
config_paths.py Path constants for The Chaos game engine. paths.py Path constants for The Chaos game engine.
Shared by engine.py, run.py, and all sub-modules.
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -13,12 +13,12 @@ import sys
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from paths import ( from .paths import (
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
LOG_DIR, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH, LOG_DIR, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
AUDIO_DIR, TODAY, AUDIO_DIR, TODAY,
) )
from models import TurnResult from .models import TurnResult
def read_file(path: Path) -> str: def read_file(path: Path) -> str:

View File

@ -0,0 +1,657 @@
#!/usr/bin/env python3
"""
strategies.py Generation strategies for The Chaos engine.
Contains the three-phase conversational approach and the single-call
tool-based approach. All functions are standalone no dependency on
GameEngine (config values and callbacks are passed explicitly).
"""
from __future__ import annotations
import json
import random
import re
from datetime import datetime
from typing import Iterator
from .models import GenerationResult, TurnResult
from .prompts import PROSE_PROMPT
from .llm import set_llm_env, call_llm
from .tools_handler import (
execute_tool, describe_tool_action, describe_change,
parse_changes_block, extract_tool_calls,
)
from .context import build_system_prompt, build_user_message, build_prose_prompt
from .validation import auto_prompt, validate_narrative
from .parsing import parse_response, log_turn_details
from . import state
# ── Synchronous (legacy) ───────────────────────────────────────────────────
def generate(
player_action: str | None = None,
last_narrative: str | None = None,
*,
model: str,
temperature: float,
timeout: int,
max_tokens: int,
api_key: str | None = None,
api_base: str | None = None,
) -> GenerationResult:
"""
Synchronous generation. Calls the LLM, parses the response,
and returns a GenerationResult.
"""
system = build_system_prompt()
user = 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(model, api_key, api_base)
try:
response = litellm.completion(
model=model,
messages=messages,
temperature=temperature,
stream=False,
timeout=timeout,
)
text = response.choices[0].message.content or ""
except Exception as e:
return GenerationResult(
narrative="",
error=f"LLM call failed: {e}",
)
return parse_response(text)
# ── Streaming (legacy) ─────────────────────────────────────────────────────
def generate_stream(
player_action: str | None = None,
last_narrative: str | None = None,
*,
model: str,
temperature: float,
timeout: int,
max_tokens: int,
api_key: str | None = None,
api_base: 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 = build_system_prompt()
user = 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(model, api_key, api_base)
try:
response = litellm.completion(
model=model,
messages=messages,
temperature=temperature,
stream=True,
timeout=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(
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,
*,
model: str,
temperature: float,
timeout: int,
max_tokens: int,
api_key: str | None = None,
api_base: str | None = 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(model, api_key, api_base)
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 = auto_prompt("")
ambience = None
debug_info = ""
changes: list[str] = []
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 = build_prose_prompt()
user = 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=model, temperature=temperature, timeout=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 = validate_narrative(book_log, model=model, temperature=temperature, timeout=timeout, 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=model, temperature=temperature, timeout=timeout,
max_tokens=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 = 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):
from paths import CHAR_PATH, WORLD_PATH
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=model, temperature=temperature, timeout=timeout,
max_tokens=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: list[str] = []
attempt_changes: list[str] = []
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")
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(
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,
*,
model: str,
temperature: float,
timeout: int,
max_tokens: int,
api_key: str | None = None,
api_base: str | None = 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: {model} | temp={temperature} | tokens={max_tokens} | strategy={strategy_name}")
if on_debug:
on_debug("config", {"model": model, "temperature": temperature, "max_tokens": max_tokens, "strategy": strategy_name})
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 += build_prose_prompt()
user = 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(model, api_key, 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=model, temperature=temperature, timeout=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 = auto_prompt("")
ambience = None
tool_calls = []
changes: list[str] = []
phase3_errors: list[str] = []
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,
})
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=model,
temperature=temperature,
max_tokens=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,
)

View File

@ -12,8 +12,8 @@ import json
import random import random
import re import re
from paths import CHAR_PATH, WORLD_PATH, LOG_DIR, TODAY from .paths import CHAR_PATH, WORLD_PATH, LOG_DIR, TODAY
from state import read_file, validate_update_size, update_journal, append_llm_log from .state import read_file, validate_update_size, update_journal, append_llm_log
# ── Tool Registry ─────────────────────────────────────────────────────────── # ── Tool Registry ───────────────────────────────────────────────────────────

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
validation.py Narrative quality validation for The Chaos engine.
Standalone functions no dependency on GameEngine.
"""
from __future__ import annotations
import re
from collections import Counter
from .llm import call_llm
def auto_prompt(book_log: str = "") -> str:
"""Fallback player prompt."""
return "**What do you do?**"
def validate_narrative(
book_log: str,
*,
model: str,
temperature: float,
timeout: int,
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=model, temperature=temperature, timeout=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, ""

File diff suppressed because it is too large Load Diff

97
tools/run_ambience.py Normal file
View File

@ -0,0 +1,97 @@
import os
import random
from run_utils import AMBIENCE_PATH, AMBIENCE_OPTIONS_PATH, AUDIO_DIR, parse_ambience_options
try:
import miniaudio
HAS_AUDIO = True
except ImportError:
HAS_AUDIO = False
class AmbiencePlayer:
def __init__(self):
self.current_ambience = 'silence'
self._last_mtime = 0
self._options = {}
self._device = None
self._stream = None
self._muted = False
self.load_options()
@property
def available(self):
return HAS_AUDIO
@property
def ambience_name(self):
return self.current_ambience
@property
def is_muted(self):
return self._muted
def toggle_mute(self):
self._muted = not self._muted
if self._muted:
self._stop()
else:
self._load_current()
def load_options(self):
self._options = parse_ambience_options()
def _stop(self):
if self._device:
try:
self._device.close()
except Exception:
pass
self._device = None
self._stream = None
def poll(self):
if not HAS_AUDIO:
return
try:
mtime = os.path.getmtime(AMBIENCE_PATH)
except OSError:
return
if mtime == self._last_mtime:
return
self._last_mtime = mtime
try:
name = AMBIENCE_PATH.read_text().strip().lower()
except OSError:
return
self.current_ambience = name
self._stop()
if not self._muted and name != 'silence' and name in self._options:
self._play_current()
def _switch_to(self, name):
if name == self.current_ambience:
return
self.current_ambience = name
self._stop()
if self._muted or name == 'silence' or name not in self._options:
return
self._play_current()
def _play_current(self):
tracks = self._options.get(self.current_ambience, [])
valid = [t for t in tracks if t.exists()]
if not valid:
return
track = random.choice(valid)
try:
self._stream = miniaudio.stream_file(str(track))
self._device = miniaudio.PlaybackDevice()
self._device.start(self._stream)
except Exception:
self.current_ambience = None
self._stop()
def _load_current(self):
if self.current_ambience and self.current_ambience != 'silence':
self._play_current()

125
tools/run_utils.py Normal file
View File

@ -0,0 +1,125 @@
from pathlib import Path
from datetime import date
BASE = Path(__file__).resolve().parent.parent
SESSION = BASE / 'session'
LOG_DIR = SESSION / 'log'
CHAR_PATH = SESSION / 'character.md'
WORLD_PATH = SESSION / 'world.md'
JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
BOOK_PATH = SESSION / 'book.md'
LAST_PROMPT_PATH = SESSION / 'last_prompt.md'
CHANGES_PATH = SESSION / 'changes.md'
SETTINGS_PATH = SESSION / 'settings.json'
AUDIO_DIR = SESSION / 'audio'
TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md'
REFRESH_SECS = 2
def ensure_log():
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not LOG_PATH.exists():
LOG_PATH.write_text(f"# Session Log — {TODAY}\n\n")
_populate_if_empty()
def _populate_if_empty():
content = LOG_PATH.read_text().strip()
if content and len(content.splitlines()) > 2:
return
def clear_llm_log():
from engine_lib.paths import LLM_LOG_PATH
LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
LLM_LOG_PATH.write_text("")
def read_todo():
if not JOURNAL_PATH.exists():
return ["—— No journal yet ——"]
lines = JOURNAL_PATH.read_text().splitlines()
in_todo = False
todo = []
for l in lines:
if l.strip().lstrip("#").strip().startswith("TODO"):
in_todo = True
continue
if l.strip().startswith("#") and in_todo:
break
if in_todo and l.strip():
todo.append(l.strip().lstrip("- "))
return todo or ["—— All done! ——"]
def read_log_tail(n=200):
if not LOG_PATH.exists():
return []
lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith("#")][-n:]
def status_summary():
if not CHAR_PATH.exists():
return "no character"
lines = CHAR_PATH.read_text().splitlines()
name = "?"
health = "?"
for l in lines:
if l.startswith('**Name:**'):
name = l.split(':', 1)[1].strip().strip('_').strip('*')
if l.startswith('**Current Health:**'):
h = l.split(':', 1)[1].strip().strip('_').strip('*')
if h:
health = h
if l.startswith('**Max Health:**'):
m = l.split(':', 1)[1].strip().strip('_').strip('*')
if m and health == '?':
health = m
return f"{name}{health}"
def log_count():
return len(read_log_tail())
def load_book_pages():
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
return ["*The story has not begun.*"]
text = BOOK_PATH.read_text().strip()
turns = text.split('\n## ')
pages = []
for i, t in enumerate(turns):
pages.append(t if i == 0 else '## ' + t)
return pages or ["*The story has not begun.*"]
def parse_ambience_options():
if not AMBIENCE_OPTIONS_PATH.exists():
return {}
options = {}
lines = AMBIENCE_OPTIONS_PATH.read_text().splitlines()
in_table = False
for line in lines:
s = line.strip()
if not s.startswith('|') or not s.endswith('|'):
in_table = False
continue
parts = [p.strip() for p in s.split('|')]
parts = [p for p in parts if p]
if len(parts) < 2:
continue
if not in_table:
in_table = True
continue
if all(c in '-:| ' for c in s):
continue
name = parts[0].lower()
files = [f.strip() for f in parts[1].split(',') if f.strip()]
paths = [AUDIO_DIR / f for f in files]
options[name] = paths
return options

157
tools/run_widgets.py Normal file
View File

@ -0,0 +1,157 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Button, Input, Static
from rich.markdown import Markdown as RichMarkdown
from run_utils import (
CHAR_PATH, TODAY, REFRESH_SECS,
clear_llm_log, ensure_log, read_todo, read_log_tail,
status_summary, log_count,
)
from run_ambience import HAS_AUDIO
# module-level ref filled by ChaosTUI
app_ambience_player: object | None = None
class RollModal(Screen):
CSS = """
RollModal {
align: center middle;
background: rgba(0, 0, 0, 0.75);
}
#roll-dialog {
width: 44;
height: auto;
padding: 2 3;
background: #2a2a3a;
border: thick #e0ad4c;
}
#roll-title {
text-style: bold;
color: #ffd93d;
text-align: center;
height: 3;
}
#roll-reason {
color: #c0b090;
text-align: center;
height: 3;
}
#roll-input {
margin: 1 0;
}
#roll-submit {
width: 100%;
}
#roll-hint {
color: #888888;
text-align: center;
height: 1;
}
"""
def __init__(self, dice: str, reason: str) -> None:
super().__init__()
self.dice = dice
self.reason = reason
def compose(self) -> ComposeResult:
with Vertical(id="roll-dialog"):
yield Static(f"[bold]🎲 ROLL {self.dice}[/bold]", id="roll-title")
yield Static(f"Reason: {self.reason}", id="roll-reason")
yield Input(placeholder="Enter the number you rolled...", id="roll-input")
yield Button("Submit", id="roll-submit", variant="primary")
yield Static("(or press Enter)", id="roll-hint")
def on_input_submitted(self, event: Input.Submitted) -> None:
self._submit(event.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "roll-submit":
self._submit(self.query_one("#roll-input", Input).value)
def _submit(self, value: str) -> None:
val = value.strip()
if val:
self.dismiss(val)
class AutoStatic(Static):
def load(self):
raise NotImplementedError
def on_mount(self):
clear_llm_log()
ensure_log()
self.load()
self.set_interval(REFRESH_SECS, self.load)
class TodoPane(AutoStatic):
def load(self):
items = read_todo()
self.update("\n".join(f"{i}" for i in items))
class TranscriptPane(AutoStatic):
def load(self):
lines = read_log_tail()
display = "\n".join(lines[-80:])
if lines:
display += "\n\n>>--- NOW --->"
self.update(display)
self.call_after_refresh(self._scroll_bottom)
def _scroll_bottom(self):
if self.parent and hasattr(self.parent, 'scroll_end'):
self.parent.scroll_end(animate=False)
class DebugPane(Static):
MAX_LINES = 200
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._lines: list[str] = []
def append(self, text: str) -> None:
self._lines.append(text)
if len(self._lines) > self.MAX_LINES:
self._lines.pop(0)
self.update("\n".join(self._lines[-100:]))
self.call_after_refresh(self._scroll_bottom)
def _scroll_bottom(self):
if self.parent and hasattr(self.parent, 'scroll_end'):
self.parent.scroll_end(animate=False)
def clear(self) -> None:
self._lines.clear()
self.update("")
class CharPane(AutoStatic):
def load(self):
if not CHAR_PATH.exists():
self.update("*No character sheet*")
return
self.update(RichMarkdown(CHAR_PATH.read_text().strip()))
class StatusBar(AutoStatic):
def load(self):
char = status_summary()
count = log_count()
todo = len(read_todo())
music = ""
if not HAS_AUDIO:
music = " │ ♫ (install miniaudio)"
elif app_ambience_player:
name = app_ambience_player.ambience_name
music = f" │ ♫ {name}"
self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}")

View File

@ -7,12 +7,17 @@ import ast
MODULES = [ MODULES = [
'engine.py', 'engine.py',
'config_paths.py', 'engine_lib/paths.py',
'models.py', 'engine_lib/models.py',
'prompts.py', 'engine_lib/prompts.py',
'state.py', 'engine_lib/config.py',
'tools_handler.py', 'engine_lib/context.py',
'llm.py', 'engine_lib/state.py',
'engine_lib/tools_handler.py',
'engine_lib/llm.py',
'engine_lib/validation.py',
'engine_lib/parsing.py',
'engine_lib/strategies.py',
] ]
def check_missing_imports(): def check_missing_imports():

View File

@ -22,12 +22,17 @@ def test_engine_import():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
modules_to_test = [ modules_to_test = [
('paths', ['BASE_DIR', 'SESSION_DIR', 'CHAR_PATH', 'LLM_LOG_PATH']), ('engine_lib.paths', ['BASE_DIR', 'SESSION_DIR', 'CHAR_PATH', 'LLM_LOG_PATH']),
('models', ['GenerationResult', 'TurnResult']), ('engine_lib.models', ['GenerationResult', 'TurnResult']),
('prompts', ['SYSTEM_PROMPT', 'PROSE_PROMPT']), ('engine_lib.prompts', ['SYSTEM_PROMPT', 'PROSE_PROMPT']),
('state', ['read_file', 'apply_state', 'append_log', 'append_llm_log']), ('engine_lib.config', ['load_config', 'save_config', 'get_model']),
('tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']), ('engine_lib.context', ['build_system_prompt', 'build_user_message', 'build_prose_prompt']),
('llm', ['call_llm', 'set_llm_env']), ('engine_lib.state', ['read_file', 'apply_state', 'append_log', 'append_llm_log']),
('engine_lib.tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']),
('engine_lib.llm', ['call_llm', 'set_llm_env']),
('engine_lib.validation', ['validate_narrative', 'auto_prompt']),
('engine_lib.parsing', ['parse_response', 'log_turn_details']),
('engine_lib.strategies', ['generate_with_tools', 'generate_with_tools_single']),
('engine', ['GameEngine']), ('engine', ['GameEngine']),
] ]