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:
+530
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user