TUI now owns the full game loop with embedded LLM engine

The game is now self-contained: run.sh starts the TUI, which calls the
LLM directly via engine.py. No external agent (OpenCode) needed.

- tools/engine.py: Game engine with prompt builder, litellm client,
  response parser (JSON block extraction), and state persistence
- tools/run.py: Refactored TUI with PLAY/CHAR/LOG/BOOK tabs. PLAY tab
  has streaming narrative pane, dynamic choice buttons, and text input.
  Game loop: scene -> input -> resolve -> archive -> apply -> scene
- session/config.json: LLM provider configuration (model, api_key, etc.)
- AGENTS.md: Updated to document the new architecture
- tools/__init__.py: Package marker for clean imports
- session/turn_description.md, turn_reaction.md: Deprecated - no longer
  needed now that the TUI drives the game loop internally
This commit is contained in:
Dejvino
2026-06-25 12:12:04 +02:00
parent 9f8db6e64c
commit 4b9078d41f
10 changed files with 1014 additions and 181 deletions
View File
+530
View File
@@ -0,0 +1,530 @@
#!/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.
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from string import Template
from typing import Iterator, Optional
# ── Paths ──────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent.parent
SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md'
WORLD_PATH = SESSION_DIR / 'world.md'
BOOK_PATH = SESSION_DIR / 'book.md'
JOURNAL_PATH = SESSION_DIR / 'journal.md'
AMBIENCE_PATH = SESSION_DIR / 'ambience.md'
LOG_DIR = SESSION_DIR / 'log'
TODAY = date.today().isoformat()
# ── Structured output ──────────────────────────────────────────────────────
@dataclass
class GenerationResult:
narrative: str
choices: list[str] = field(default_factory=list)
log_entry: Optional[str] = None
ambience: Optional[str] = None
character_updates: Optional[str] = None
world_updates: Optional[str] = None
journal_add: list[str] = field(default_factory=list)
journal_done: list[str] = field(default_factory=list)
error: Optional[str] = None
# ── DM System Prompt Template ──────────────────────────────────────────────
SYSTEM_PROMPT = Template("""You are the Dungeon Master for **The Chaos**, a solo card-based rules-light fantasy TTRPG. Your job is to narrate an immersive, responsive story for one player character.
## Tone & Style
- Write in **second person** ("You", "Dillion") — the player is Dillion.
- Use vivid sensory descriptions — sight, sound, smell, touch.
- Keep narration tight and cinematic. No monologues.
- Use **bold** for emphasis, *italic* for thoughts/sounds.
- NPC dialogue goes in **"quotes with bold names."**
- Present **2-4 clear choices** at the end of each scene.
- Each turn should advance the story meaningfully.
## Game Rules (Quick Reference)
### Core Dice
- **Odds**: 1d6, 4+ favours character, 3- is trouble.
- **Traits**: 3d6, must roll UNDER the trait score.
- **Combat hit**: 1d6 ± mods, 4+ hits.
- **Damage**: 1d6 ± weapon mod - armour reduction.
- **Initiative**: both sides roll 1d6, higher acts first.
### Combat Flow
1. Distance: 2d6 × 10 (metres/feet)
2. Surprise: 1d6
3. Grit: 2d6 for creatures (higher = more determined)
4. Initiative: 1d6
5. Turns: state intent → roll 1d6 ± mods → 4+ success, 3- take hit
### Wounds (0 HP)
1d6: 1-2 die, 3-4 lasting wound (-1 max HP), 5-6 -1 all rolls until healed
### Roll Modifiers
Favourable +1, Risky -1, Desperate -2, Well-prepared +1, Poor visibility -1, Relevant trait +1
### Exploration
6 ten-minute watches per hour. Each meaningful action advances a watch.
## Output Format
IMPORTANT: End every response with a JSON fenced code block:
```json
{
"choices": ["Choice 1", "Choice 2", "Choice 3"],
"log_entry": "- **time of day** — brief description of what happened.",
"ambience": "ambience_name_or_null",
"character_updates": null,
"world_updates": null,
"journal_add": [],
"journal_done": []
}
```
Rules for the JSON block:
- **choices**: 2-4 brief action options presented to the player.
- **log_entry**: One-line log entry summarizing this turn's action.
- **ambience**: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. Set to null to keep current.
- **character_updates**: ONLY include if HP, cash, gear, or stats changed. Provide the FULL updated character sheet markdown. Otherwise null.
- **world_updates**: ONLY include if NPCs, locations, or world state changed. Provide the FULL updated world markdown. Otherwise null.
- **journal_add**: New TODO items to add.
- **journal_done**: TODO items that are now completed.
When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state.
## Current Game State
### Character
$character
### World
$world
### Recent Events
$log""")
# trailing """ is intentional — the template ends here
# ── Game Engine ────────────────────────────────────────────────────────────
class GameEngine:
"""Owns the LLM interaction and game state persistence."""
def __init__(self, session_dir: str | Path = SESSION_DIR):
self.session_dir = Path(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,
}
}
self._save_config()
else:
raw = CONFIG_PATH.read_text()
self.config = json.loads(raw)
# Ensure api_key is None not empty string
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)
# ── Context Assembly ────────────────────────────────────────────────
def _read_file(self, path: Path) -> str:
return path.read_text().strip() if path.exists() else ""
def _read_recent_log(self, max_entries: int = 15) -> str:
"""Read the latest log file and return the last N entries."""
log_path = LOG_DIR / f"{TODAY}.md"
if not log_path.exists():
# Check yesterday's log
from datetime import timedelta
yesterday = (date.today() - timedelta(days=1)).isoformat()
log_path = LOG_DIR / f"{yesterday}.md"
if not log_path.exists():
return "*No recent events.*"
lines = log_path.read_text().splitlines()
entries = [l for l in lines if l.strip().startswith("- ")]
return "\n".join(entries[-max_entries:]) or "*No recent events.*"
def _read_recent_book(self, max_turns: int = 3) -> str:
"""Return the last N turns from the book as context."""
text = self._read_file(BOOK_PATH)
if not text:
return "*No prior story.*"
turns = text.split("\n## ")
recent = turns[-max_turns:]
return "\n## ".join(recent) if len(turns) > 1 else recent[0]
def build_system_prompt(self) -> str:
"""Assemble the system prompt with current game state."""
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
world = self._read_file(WORLD_PATH) or "*No world state.*"
log = self._read_recent_log()
return SYSTEM_PROMPT.substitute(character=char, world=world, log=log)
def build_user_message(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> str:
"""Build the user message for this turn's LLM call."""
parts = []
if last_narrative:
parts.append(f"## Previously\n{last_narrative}")
if player_action:
parts.append(f"## Player Action\n{player_action}")
if not player_action and not last_narrative:
parts.append(
"## Instructions\n"
"Establish the opening scene. Dillion is at the Splintered "
"Tankard in the Keep. Describe the setting and present "
"choices for what he might do. End with a JSON block."
)
else:
parts.append(
"## Instructions\n"
"Describe the outcome of the player's action using game "
"mechanics where appropriate. Then present new choices. "
"End with a JSON block."
)
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.
The TUI calls this from a worker thread — see run.py.
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_narrative=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 API key / base if provided
if self.api_key:
# litellm reads env vars or we can pass via kwargs
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=False,
)
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_narrative=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
if self.api_key:
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=True,
)
full_text = ""
for chunk in response:
delta = chunk.choices[0].delta.content or ""
if delta:
full_text += delta
yield full_text # partial narrative for streaming display
# Final yield is the completed text
yield full_text
except Exception as e:
yield json.dumps({"error": f"LLM call failed: {e}"})
# ── 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.
"""
# Check for error JSON
if text.startswith('{"error":'):
try:
err = json.loads(text).get("error", "Unknown error")
except json.JSONDecodeError:
err = "Unknown error"
return GenerationResult(narrative="", error=err)
# Extract JSON block — find the last ```json ... ``` block
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()
# Remove the json block from the narrative
narrative = text[: text.rfind("```json")]
narrative = narrative.strip()
try:
data = json.loads(json_str)
except json.JSONDecodeError:
# Try to salvage partial JSON
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", []),
)
# ── State Persistence ───────────────────────────────────────────────
def apply_state(self, result: GenerationResult) -> None:
"""Write state changes from a GenerationResult to disk."""
if result.character_updates:
CHAR_PATH.write_text(result.character_updates.strip() + "\n")
if result.world_updates:
WORLD_PATH.write_text(result.world_updates.strip() + "\n")
if result.log_entry:
self.append_log(result.log_entry)
if result.ambience:
AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n")
if result.journal_add or result.journal_done:
self._update_journal(
add=result.journal_add, done=result.journal_done
)
def archive_turn(self, narrative: str) -> None:
"""Append the narrative as a new turn in book.md."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
heading = f"\n\n## Turn — {timestamp}\n\n"
BOOK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(BOOK_PATH, "a") as f:
f.write(heading + narrative.strip() + "\n")
def append_log(self, entry: str) -> None:
"""Append a log entry to today's log file."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{TODAY}.md"
if not log_path.exists():
log_path.write_text(f"# Session Log — {TODAY}\n\n")
with open(log_path, "a") as f:
f.write(entry.strip() + "\n")
def _update_journal(
self, add: list[str] | None = None, done: list[str] | None = None
) -> None:
"""Add or complete TODO items in journal.md."""
if not JOURNAL_PATH.exists():
JOURNAL_PATH.write_text("# Journal\n\n## TODO\n\n## DONE\n\n")
lines = JOURNAL_PATH.read_text().splitlines()
new_lines = []
in_todo = False
in_done = False
for line in lines:
stripped = line.strip()
if stripped.startswith("## TODO"):
in_todo = True
in_done = False
elif stripped.startswith("## DONE"):
in_todo = False
in_done = True
new_lines.append(line)
# Find insertion points
todo_idx = None
done_idx = None
for i, line in enumerate(lines):
stripped = line.strip()
if stripped == "## TODO":
todo_idx = i
elif stripped == "## DONE":
done_idx = i
if done:
for item in done:
# Remove from TODO if present
new_lines = [
l for l in new_lines
if l.strip().lstrip("- ").lstrip("☐ ") != item
]
# Find DONE section and add
if done_idx is not None:
done_entry = f"- {item}"
if done_idx + 1 < len(new_lines):
new_lines.insert(done_idx + 1, done_entry)
else:
new_lines.append(done_entry)
if add:
for item in add:
entry = f"- {item}"
if entry not in new_lines:
if todo_idx is not None:
new_lines.insert(todo_idx + 1, entry)
else:
new_lines.append(entry)
JOURNAL_PATH.write_text("\n".join(new_lines) + "\n")
# ── 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(
player_action=args.action,
last_narrative=args.last,
)
if result.error:
print(f"ERROR: {result.error}", file=sys.stderr)
sys.exit(1)
print(result.narrative)
if result.choices:
print("\n--- Choices ---")
for c in result.choices:
print(f" [{c}]")
if result.log_entry:
print(f"\n[Log] {result.log_entry}")
if result.ambience:
print(f"[Ambience] {result.ambience}")
if __name__ == "__main__":
main()
+267 -104
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""
run.py — The Chaos TTRPG Session Client
run.py — The Chaos TTRPG Session Client (Game Mode)
Layout: banner | TODO | CHAR/LOG (tabs) | BOOK (paged) | status.
Music: polls session/ambience.md, plays via miniaudio.
Owns the TUI and game loop. Layout:
PLAY (narrative + choices + input) | CHAR | LOG | BOOK tabs
"""
from __future__ import annotations
import os
import random
@@ -12,20 +13,24 @@ import sys
from datetime import date
from pathlib import Path
from textual import on
from textual import on, work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Static, TabbedContent, TabPane
from textual.widgets import Button, Input, Static, TabbedContent, TabPane
from textual.worker import Worker, WorkerState, get_current_worker
from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme
# ── Game engine ─────────────────────────────────────────
from engine import GameEngine, GenerationResult
# ── Optional miniaudio ────────────────────────────────────
try:
import miniaudio
HAS_AUDIO = True
except ImportError:
HAS_AUDIO = False
print("Note: miniaudio not installed — no ambience music. Install with: pip install miniaudio", file=sys.stderr)
print("Note: miniaudio not installed — no ambience music.", file=sys.stderr)
# ── Paths ────────────────────────────────────────────────
@@ -38,7 +43,6 @@ JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
BOOK_PATH = SESSION / 'book.md'
TURN_DESC_PATH = SESSION / 'turn_description.md'
AUDIO_DIR = SESSION / 'audio'
TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md'
@@ -61,7 +65,7 @@ MARKDOWN_THEME = Theme({
})
# ── Helpers ──────────────────────────────────────────────
# ── Helpers (file reading, status, book, ambience) ───────
def ensure_log():
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not LOG_PATH.exists():
@@ -108,8 +112,6 @@ def read_log_tail(n=200):
lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
# ── Status summary ───────────────────────────────────────
def status_summary():
if not CHAR_PATH.exists():
return "no character"
@@ -129,34 +131,20 @@ def status_summary():
health = m
return f"{name} ❤ {health}"
def log_count():
return len(read_log_tail())
# ── Book helpers ─────────────────────────────────────────
def load_book_pages():
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
pages = ["*The story has not begun.*"]
else:
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)
if not pages:
pages = ["*The story has not begun.*"]
# Append current turn description as virtual last page
if TURN_DESC_PATH.exists():
desc = TURN_DESC_PATH.read_text().strip()
if desc:
pages.append(f"## ⚡ Current Turn\n\n{desc}")
return pages
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.*"]
# ── Ambience subsystem ───────────────────────────────────
def parse_ambience_options():
"""Parse ambience_options.md into {name: [filepath, ...]}"""
if not AMBIENCE_OPTIONS_PATH.exists():
return {}
options = {}
@@ -183,9 +171,8 @@ def parse_ambience_options():
return options
# ── Ambience subsystem ───────────────────────────────────
class AmbiencePlayer:
"""Monitors ambience.md and plays background music via miniaudio."""
def __init__(self):
self.current_ambience = 'silence'
self._last_mtime = 0
@@ -250,10 +237,12 @@ class AmbiencePlayer:
self.current_ambience = None
# module-level ref
app_ambience_player = None
# ── Auto-refreshing panels ───────────────────────────────
class AutoStatic(Static):
"""A Static that reloads its content on an interval."""
def load(self):
raise NotImplementedError
@@ -273,7 +262,7 @@ class TranscriptPane(AutoStatic):
lines = read_log_tail()
display = "\n".join(lines[-80:])
if lines:
display += "\n >>--- NOW --->"
display += "\n\n>>--- NOW --->"
self.update(display)
self.call_after_refresh(self._scroll_bottom)
@@ -304,10 +293,6 @@ class StatusBar(AutoStatic):
self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}")
# module-level ref so StatusBar can reach it
app_ambience_player = None
# ── The App ──────────────────────────────────────────────
class ChaosTUI(App):
TITLE = "The Chaos"
@@ -315,7 +300,6 @@ class ChaosTUI(App):
Screen {
background: #121212;
}
#banner {
dock: top;
height: 1;
@@ -323,12 +307,10 @@ class ChaosTUI(App):
color: #e0ad4c;
text-align: center;
}
#main {
height: 100%;
background: #111111;
}
#todo-header {
background: #3a2a1a;
color: #e0b060;
@@ -345,11 +327,6 @@ class ChaosTUI(App):
overflow-y: auto;
scrollbar-size-vertical: 2;
}
#middle-tabs {
height: 25%;
min-height: 8;
}
TabbedContent {
background: #1a1a2a;
}
@@ -371,10 +348,43 @@ class ChaosTUI(App):
padding: 0 1;
}
#book-section {
height: 1fr;
layout: vertical;
/* Play tab */
#play-narrative {
background: #161616;
color: #d8d8d8;
padding: 1 2;
height: auto;
}
#play-choices {
height: auto;
min-height: 3;
background: #1e1e2a;
padding: 0 1;
align: center middle;
}
#play-choices Button {
margin: 0 1;
min-width: 12;
}
#play-input {
dock: bottom;
height: 3;
background: #222222;
color: #e0d0c0;
border: solid #555555;
padding: 0 1;
}
#play-input:focus {
border: solid #e0ad4c;
}
#play-processing {
background: #1a1a2a;
color: #888888;
padding: 1 2;
text-style: italic;
}
/* Book tab */
#book-header {
background: #2d2d2d;
color: #e0c080;
@@ -408,12 +418,6 @@ class ChaosTUI(App):
padding: 0 2;
text-align: center;
}
#book-hint {
height: 1;
color: #808080;
padding: 0 2;
text-align: center;
}
#book-progress {
height: 1;
background: #1a1a1a;
@@ -442,14 +446,6 @@ class ChaosTUI(App):
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("escape", "quit", "Quit"),
("h", "prev_page", "Previous turn"),
("l", "next_page", "Next turn"),
("[", "prev_page", "Previous turn"),
("]", "next_page", "Next turn"),
("j", "skip_fwd", "Skip 5 ahead"),
("k", "skip_bwd", "Skip 5 back"),
("g", "first_page", "First page"),
("G", "last_page", "Last page"),
]
def __init__(self, *args, no_music=False, **kwargs):
@@ -459,47 +455,229 @@ class ChaosTUI(App):
app_ambience_player = AmbiencePlayer()
else:
app_ambience_player = None
# Game engine
self.engine = GameEngine()
# Game loop state
self._last_narrative: str = ""
self._last_result: GenerationResult | None = None
self._is_processing: bool = False
# Book viewer state
self._book_page = 0
self._book_pages = []
self._prev_page_count = 0
# ── Compose ──────────────────────────────────────────
def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Vertical(id="main"):
yield Static("TODO", id="todo-header")
yield TodoPane(id="todo-content")
with TabbedContent(initial="char-tab", id="middle-tabs"):
with TabbedContent(initial="play-tab", id="main-tabs"):
with TabPane("PLAY", id="play-tab"):
with VerticalScroll(id="play-scroll"):
yield Static("*Awaiting the fates...*", id="play-narrative")
yield Horizontal(id="play-choices")
yield Input(
placeholder="Type your action and press Enter...",
id="play-input",
)
with TabPane("CHARACTER", id="char-tab"):
with VerticalScroll():
yield CharPane(id="char-content")
with TabPane("LOG", id="log-tab"):
with VerticalScroll():
yield TranscriptPane(id="transcript")
yield Static("BOOK", id="book-header")
with Vertical(id="book-section"):
with Horizontal(id="book-nav"):
yield Button("<< Prev", id="book-prev")
with Vertical(id="book-nav-center"):
yield Static("", id="book-page-label")
yield Static("", id="book-hint")
yield Static("", id="book-progress")
yield Button("Next >>", id="book-next")
with VerticalScroll(id="book-scroll"):
yield Static("", id="book-content")
with TabPane("BOOK", id="book-tab"):
yield Static("BOOK", id="book-header")
with Vertical(id="book-section"):
with Horizontal(id="book-nav"):
yield Button("<< Prev", id="book-prev")
with Vertical(id="book-nav-center"):
yield Static("", id="book-page-label")
yield Static("", id="book-progress")
yield Button("Next >>", id="book-next")
with VerticalScroll(id="book-scroll"):
yield Static("", id="book-content")
yield StatusBar(id="status-bar")
def on_mount(self):
ensure_log()
self.console._theme = MARKDOWN_THEME
self._reload_book()
self._render_book_page()
self._init_book()
self.set_interval(REFRESH_SECS, self._check_ambience)
self.set_interval(REFRESH_SECS, self._reload_book)
# Start the game
self.call_after_refresh(self._begin_game)
def _begin_game(self):
"""Generate the first scene of the game."""
self._call_llm()
# ── Ambience ─────────────────────────────────────────
def _check_ambience(self):
if app_ambience_player:
app_ambience_player.poll()
# ── Game Loop ─────────────────────────────────────────
def _call_llm(self, player_action: str | None = None):
"""Called when we need new content from the LLM (scene or resolution)."""
if self._is_processing:
return
self._is_processing = True
input_widget = self.query_one("#play-input", Input)
input_widget.disabled = True
self._set_narrative("✦ *The fates conspire...* ✦")
self._clear_choices()
self._run_generation(player_action)
@work(thread=True, exit_on_error=False)
def _run_generation(self, player_action: str | None) -> None:
"""Worker thread: calls engine.generate() and posts result back."""
worker = get_current_worker()
# Provide previous narrative as context on subsequent calls
last_narrative = self._last_narrative if self._last_narrative else None
result = self.engine.generate(
player_action=player_action,
last_narrative=last_narrative,
)
if worker.is_cancelled:
return
self.call_from_thread(self._on_generation_done, result, player_action)
def _on_generation_done(
self, result: GenerationResult, player_action: str | None
) -> None:
"""Handle the completed generation on the main thread."""
self._is_processing = False
if result.error:
self._show_error(result.error)
return
# If this was a resolution (player acted), archive the previous turn
if player_action and self._last_narrative:
archive_text = (
f"{self._last_narrative}\n\n"
f"---\n\n"
f"**Player chose:** {player_action}\n\n"
f"{result.narrative}"
)
self.engine.archive_turn(archive_text)
# Apply state changes
if result.character_updates or result.world_updates:
self.engine.apply_state(result)
# Display the scene
self._display_scene(result)
# Store for next turn
self._last_narrative = result.narrative
self._last_result = result
def _display_scene(self, result: GenerationResult) -> None:
"""Update the UI with a new scene."""
self._set_narrative(result.narrative)
self._set_choices(result.choices)
self._enable_input()
# Focus the input
input_widget = self.query_one("#play-input", Input)
input_widget.focus()
def _enable_input(self) -> None:
input_widget = self.query_one("#play-input", Input)
input_widget.disabled = False
input_widget.value = ""
input_widget.focus()
def _set_narrative(self, text: str) -> None:
widget = self.query_one("#play-narrative", Static)
widget.update(RichMarkdown(text))
# Scroll to top
scroll = self.query_one("#play-scroll", VerticalScroll)
scroll.scroll_home(animate=False)
def _clear_choices(self) -> None:
container = self.query_one("#play-choices", Horizontal)
container.remove_children()
def _set_choices(self, choices: list[str]) -> None:
container = self.query_one("#play-choices", Horizontal)
container.remove_children()
for choice in choices:
btn = Button(choice, classes="choice-btn")
container.mount(btn)
def _show_error(self, error: str) -> None:
self._set_narrative(
f"**Error:** {error}\n\n"
"Check your session/config.json and ensure your LLM provider is running."
)
self._enable_input()
# ── Input handling ────────────────────────────────────
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Player pressed Enter in the input widget."""
action = event.value.strip()
if not action or self._is_processing:
event.stop()
return
event.stop()
self._handle_player_action(action)
@on(Button.Pressed, ".choice-btn")
def on_choice_clicked(self, event: Button.Pressed) -> None:
"""Player clicked a choice button."""
if self._is_processing:
return
action = event.button.label
self._handle_player_action(str(action))
def _handle_player_action(self, action: str) -> None:
"""Common handler for player actions from input or buttons."""
# Log the action
from datetime import datetime
timestamp = datetime.now().strftime("%H:%M")
time_of_day = self._guess_time_of_day()
log_entry = f"- **{time_of_day}** — {action}"
self.engine.append_log(log_entry)
# Call LLM to resolve
self._call_llm(player_action=action)
def _guess_time_of_day(self) -> str:
"""Simple time-of-day label based on hour."""
from datetime import datetime
h = datetime.now().hour
if h < 6:
return "Night"
elif h < 12:
return "Morning"
elif h < 14:
return "Midday"
elif h < 18:
return "Afternoon"
elif h < 21:
return "Evening"
else:
return "Night"
# ── Book viewer ───────────────────────────────────────
def _init_book(self):
self._reload_book()
self._render_book_page()
def _reload_book(self):
self._book_pages = load_book_pages()
if len(self._book_pages) > self._prev_page_count:
@@ -509,10 +687,15 @@ class ChaosTUI(App):
self._render_book_page()
def _render_book_page(self):
self.query_one("#book-content").update(RichMarkdown(self._book_pages[self._book_page]))
if not self._book_pages:
return
self.query_one("#book-content").update(
RichMarkdown(self._book_pages[self._book_page])
)
total = len(self._book_pages)
self.query_one("#book-page-label").update(f"Page {self._book_page + 1} of {total}")
self.query_one("#book-hint").update("h prev | l next | j +5 | k -5 | g first | G last")
self.query_one("#book-page-label").update(
f"Page {self._book_page + 1} of {total}"
)
pct = (self._book_page + 1) / total if total else 1
fill = round(pct * 20)
bar = "█" * fill + "░" * (20 - fill)
@@ -532,32 +715,12 @@ class ChaosTUI(App):
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_skip_fwd(self):
self._book_page = min(len(self._book_pages) - 1, self._book_page + 5)
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_skip_bwd(self):
self._book_page = max(0, self._book_page - 5)
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_first_page(self):
self._book_page = 0
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_last_page(self):
self._book_page = len(self._book_pages) - 1
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
@on(Button.Pressed, "#book-prev")
def on_prev(self):
def on_book_prev(self):
self.action_prev_page()
@on(Button.Pressed, "#book-next")
def on_next(self):
def on_book_next(self):
self.action_next_page()