splinter-keep/tools/run.py
2026-07-04 22:01:00 +02:00

474 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""
run.py — The Chaos TTRPG Session Client (Game Mode)
Owns the TUI and game loop. Layout:
PLAY (narrative + choices + input) | CHAR | LOG | BOOK tabs
"""
from __future__ import annotations
import json
import threading
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Input, Static, TabbedContent, TabPane
from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme
from engine import GameEngine
from engine_lib.models import TurnResult, END_MARKER
from engine_lib import state
from run_utils import (
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
TODAY, REFRESH_SECS, clear_llm_log, ensure_log,
load_book_pages,
)
from run_ambience import AmbiencePlayer
from run_widgets import (
app_ambience_player as _widget_player_ref,
CharPane, StatusBar, TodoPane, TranscriptPane,
)
# ── Global state ─────────────────────────────────────────
app_ambience_player: AmbiencePlayer | None = None
# ── UI Theme ─────────────────────────────────────────────
MARKDOWN_THEME = Theme({
"markdown.h1": "bold #ff6b6b on #2a0000",
"markdown.h2": "bold #ffd93d",
"markdown.h3": "bold italic #6bcbff",
"markdown.h4": "bold #95e1d3",
"markdown.code": "bold #00d2d3 on #002222",
"markdown.code_block": "on #1e1e2e",
"markdown.block_quote": "dim italic #8395a7",
"markdown.link": "underline #48dbfb",
"markdown.item": "#ff9ff3",
"markdown.strong": "bold #feca57",
"markdown.horizontal_rule": "dim #555555",
})
# ── The App ──────────────────────────────────────────────
class ChaosTUI(App):
TITLE = "The Chaos"
CSS = """
Screen { background: #121212; }
#banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; }
#main { height: 100%; background: #111111; }
#todo-header { background: #3a2d23; color: #e0b060; padding: 0 1; height: 1; }
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; height: 5; max-height:5; overflow-y:auto; scrollbar-size-vertical:2; }
#main-tabs { height: 1fr; }
TabbedContent { background: #1a1a2a; }
VerticalScroll { overflow-y: auto; scrollbar-size-vertical:2; scrollbar-color:#555; scrollbar-color-hover:#777; scrollbar-color-active:#999; }
#char-content { background: #1e1e2a; color: #c0c0c0; padding: 0 1; }
#transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; }
#play-narrative { background: #161616; color: #d8d8d8; padding: 1 2; height: auto; }
#play-narrative.meta { background: #1a1a2e; color: #b0a0e0; border-top: solid #6b4fa0; border-bottom: solid #6b4fa0; }
#play-status { background: #1a2a1a; color: #e0b060; padding: 0 2; height: 1; text-style: bold italic; text-align: center; }
#play-status.processing { background: #2a1a0a; color: #ffd93d; }
#play-input { height: 3; background: #222; color: #e0d0c0; border: solid #555; padding: 0 1; }
#play-input:focus { border: solid #e0ad4c; }
#play-input:disabled { background: #1a1a1a; color: #666; border: solid #333; }
#book-header { background: #2d2d2d; color: #e0c080; text-style: bold; padding: 0 1; height: 1; }
#book-nav { height: 3; background: #222; align: center middle; }
#book-nav Button { width: 10; margin: 0 1; }
#book-nav Button:disabled { color: #444; }
#book-nav Button:hover { text-style: bold; }
#book-nav-center { height: 3; width: 1fr; }
#book-page-label { height: 1; color: #c0b090; text-style: bold; padding: 0 2; text-align: center; }
#book-progress { height: 1; background: #1a1a1a; color: #e0b060; padding: 0 2; text-align: center; }
#book-scroll { height: 1fr; }
#book-content { background: #161616; color: #d8d8d8; padding: 0 2; }
#status-bar { background: #222; color: #888; padding: 0 1; height: 1; text-style: italic; }
#mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; }
#mute-btn:hover { background: #3a3a3a; color: #ccc; }
#mute-btn.muted { color: #ff6b6b; text-style: bold; }
#end-game-btn { display: none; margin: 1 2; height: 3; background: #5a3a00; color: #ffd93d; border: solid #8a5a00; }
#end-game-btn.visible { display: block; }
#end-game-btn:hover { background: #7a4a00; }
"""
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("escape", "quit", "Quit"),
]
def __init__(self, *args, no_music=False, **kwargs):
super().__init__(*args, **kwargs)
global app_ambience_player
app_ambience_player = None if no_music else AmbiencePlayer()
import run_widgets
run_widgets.app_ambience_player = app_ambience_player
self.engine = GameEngine()
self._last_result: TurnResult | None = None
self._is_processing: bool = False
self._spinner_frames = ["", "", "", ""]
self._thinking_frame = 0
self._thinking_timer_handle = None
self._dm_action = "DM is preparing a response"
self._book_page = 0
self._book_pages: list[str] = []
self._prev_page_count = 0
self._settings_loaded = False
self._game_over = False
def _load_settings(self) -> dict:
defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0}
if SETTINGS_PATH.exists():
try:
data = json.loads(SETTINGS_PATH.read_text())
if isinstance(data, dict):
defaults.update(data)
except (json.JSONDecodeError, OSError):
pass
return defaults
def _save_settings(self) -> None:
if not self._settings_loaded:
return
active = self.query_one("#main-tabs", TabbedContent).active or "play-tab"
data = {
"active_tab": active,
"music_muted": app_ambience_player.is_muted if app_ambience_player else False,
"book_page": self._book_page,
}
try:
SETTINGS_PATH.write_text(json.dumps(data, indent=2) + "\n")
except OSError:
pass
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="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 Static("", id="play-status")
yield Input(placeholder="Type your action and press Enter...", id="play-input")
yield Button("Close the Book and Start a New One", id="end-game-btn", variant="warning")
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")
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")
yield Button("", id="mute-btn", classes="mute-button")
def on_mount(self):
clear_llm_log()
ensure_log()
self.console._theme = MARKDOWN_THEME
self._init_book()
self.set_interval(REFRESH_SECS, self._check_ambience)
self.set_interval(REFRESH_SECS, self._reload_book)
self.call_after_refresh(self._update_mute_button)
self.call_after_refresh(self._apply_settings)
self.call_after_refresh(self._begin_game)
def _apply_settings(self) -> None:
settings = self._load_settings()
self._book_page = settings.get("book_page", 0)
self._prev_page_count = len(self._book_pages)
self._reload_book()
if settings.get("music_muted") and app_ambience_player and not app_ambience_player.is_muted:
app_ambience_player.toggle_mute()
self._update_mute_button()
try:
self.query_one("#main-tabs", TabbedContent).active = settings.get("active_tab", "play-tab")
except Exception:
pass
self._settings_loaded = True
def _begin_game(self):
self._game_over = False
self._last_narrative: str = ""
pages = load_book_pages()
if pages and pages != ["*The story has not begun.*"]:
parts = []
parts.append(pages[-1])
if CHANGES_PATH.exists():
saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()]
if saved:
parts.append(self._render_changes(saved))
self._set_narrative("\n\n".join(parts))
self._enable_input()
return
self._call_llm()
def _archive_and_reset(self) -> None:
"""Archive the session and reset for a new game."""
self._game_over = False
btn = self.query_one("#end-game-btn", Button)
btn.remove_class("visible")
archive_path = state.archive_session()
self._book_pages = ["*The story has not begun.*"]
self._book_page = 0
self._render_book_page()
self._begin_game()
self._save_settings()
def _check_ambience(self):
if app_ambience_player:
app_ambience_player.poll()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "mute-btn":
if app_ambience_player:
app_ambience_player.toggle_mute()
self._update_mute_button()
self._save_settings()
elif event.button.id == "end-game-btn":
self._archive_and_reset()
def _update_mute_button(self) -> None:
btn = self.query_one("#mute-btn", Button)
if app_ambience_player and app_ambience_player.is_muted:
btn.label = "♪ muted"
btn.classes = "muted"
btn.tooltip = "Unmute music"
else:
btn.label = ""
btn.classes = ""
btn.tooltip = "Mute music"
def _call_llm(self, player_action: str | None = None):
if self._is_processing:
return
self._is_processing = True
input_widget = self.query_one("#play-input", Input)
input_widget.disabled = True
input_widget.placeholder = "DM is at work..."
self._show_thinking()
t = threading.Thread(target=self._run_generation, args=(player_action,), daemon=True)
t.start()
def _run_generation(self, player_action: str | None) -> None:
import traceback
def on_thought(thought: str) -> None:
self.call_from_thread(self._on_thought, thought)
def on_action(action: str) -> None:
self.call_from_thread(self._on_action, action)
try:
result = self.engine.generate_turn(
player_action=player_action,
on_thought=on_thought,
on_action=on_action,
)
except Exception as e:
self.call_from_thread(self._on_generation_error, e, traceback.format_exc())
return
self.call_from_thread(self._on_generation_done, result, player_action)
def _show_thinking(self) -> None:
self._dm_action = "DM is preparing a response"
self._thinking_frame = 0
status = self.query_one("#play-status", Static)
status.add_class("processing")
status.update(f"{self._spinner_frames[0]} {self._dm_action}")
self._thinking_timer_handle = self.set_interval(0.5, self._tick_thinking)
def _hide_thinking(self) -> None:
if self._thinking_timer_handle:
self._thinking_timer_handle.stop()
self._thinking_timer_handle = None
status = self.query_one("#play-status", Static)
status.remove_class("processing")
status.update("")
def _on_thought(self, thought: str) -> None:
display = thought[:60] + "" if len(thought) > 60 else thought
self._dm_action = display
self._thinking_frame = 0
status = self.query_one("#play-status", Static)
status.add_class("processing")
status.update(f"{self._spinner_frames[0]} {display}")
def _on_action(self, action: str) -> None:
self._dm_action = action
self._thinking_frame = 0
status = self.query_one("#play-status", Static)
status.add_class("processing")
status.update(f"{self._spinner_frames[0]} {action}")
def _tick_thinking(self) -> None:
if not self._is_processing:
return
self._thinking_frame = (self._thinking_frame + 1) % len(self._spinner_frames)
self.query_one("#play-status", Static).update(
f"{self._spinner_frames[self._thinking_frame]} {self._dm_action}")
def _on_generation_done(self, result: TurnResult, player_action: str | None) -> None:
self._is_processing = False
self._hide_thinking()
if result.error:
self._show_error(result.error, result.debug_info)
return
if result.is_meta:
self._display_scene(result)
self._enable_input()
return
if result.book_log:
turn_num = state.archive_turn(result.book_log)
if result.log_entry:
state.append_log(f"- **Turn {turn_num}** — {result.log_entry}")
else:
summary = result.book_log.strip().split(chr(10))[0][:80]
state.append_log(f"- **Turn {turn_num}** — {summary}")
result.book_log = load_book_pages()[-1]
elif result.log_entry:
state.append_log(f"- {result.log_entry}")
state.apply_state(result)
if result.game_over:
self._game_over = True
self._set_narrative(result.book_log if result.book_log else "(The story has ended.)")
inp = self.query_one("#play-input", Input)
inp.disabled = True
inp.placeholder = "The story has ended."
btn = self.query_one("#end-game-btn", Button)
btn.add_class("visible")
return
if result.book_log or not result.user_prompt:
self._display_scene(result)
else:
if self._last_narrative:
self._set_narrative(f"{self._last_narrative}\n\n---\n\n{result.user_prompt}")
self._enable_input()
else:
self._display_scene(result)
if result.book_log:
self._last_result = result
def _on_generation_error(self, error: Exception, traceback_str: str) -> None:
self._is_processing = False
self._hide_thinking()
err_msg = f"{type(error).__name__}: {error}"
self._show_error(err_msg, traceback_str)
@staticmethod
def _render_changes(changes: list[str]) -> str:
return "**Changes:**\n" + "\n".join(f"- {c}" for c in changes)
def _display_scene(self, result: TurnResult) -> None:
parts = []
if result.book_log:
parts.append(result.book_log)
if result.changes:
parts.append(self._render_changes(result.changes))
if result.user_prompt:
parts.append(f"---\n\n{result.user_prompt}")
self._set_narrative("\n\n".join(parts) if parts else "", meta=result.is_meta)
self._enable_input()
def _enable_input(self, value: str = "") -> None:
inp = self.query_one("#play-input", Input)
inp.disabled = False
inp.placeholder = "Type your action and press Enter..."
inp.value = value
inp.focus()
def _set_narrative(self, text: str, meta: bool = False) -> None:
self._last_narrative = text
widget = self.query_one("#play-narrative", Static)
widget.set_class(meta, "meta")
widget.update(RichMarkdown(text))
self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False)
def _show_error(self, error: str, debug_info: str = "") -> None:
t = f"**Error:** {error}\n\n" + (f"**Debug Info:**\n\n{debug_info}\n\n" if debug_info else "")
self._set_narrative(t + "Check your session/config.json and ensure your LLM provider is running.")
self._enable_input(value=self._last_player_action if hasattr(self, '_last_player_action') else "")
def on_input_submitted(self, event: Input.Submitted) -> None:
action = event.value.strip()
if not action or self._is_processing:
event.stop()
return
event.stop()
self._last_player_action = action
self._call_llm(player_action=action)
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:
self._book_page = len(self._book_pages) - 1
self._prev_page_count = len(self._book_pages)
self._book_page = max(0, min(self._book_page, len(self._book_pages) - 1))
self._render_book_page()
def _render_book_page(self):
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}")
pct = (self._book_page + 1) / total if total else 1
self.query_one("#book-progress").update(f"[{''*round(pct*20)}{''*(20-round(pct*20))}]")
self.query_one("#book-prev").disabled = (self._book_page == 0)
self.query_one("#book-next").disabled = (self._book_page == total - 1)
def action_prev_page(self):
if self._book_page > 0:
self._book_page -= 1
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_next_page(self):
if self._book_page < len(self._book_pages) - 1:
self._book_page += 1
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
@on(Button.Pressed, "#book-prev")
def on_book_prev(self):
self.action_prev_page()
self._save_settings()
@on(Button.Pressed, "#book-next")
def on_book_next(self):
self.action_next_page()
self._save_settings()
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
self._save_settings()
def main():
import argparse
parser = argparse.ArgumentParser(description="The Chaos TUI")
parser.add_argument('--no-music', action='store_true', help='Disable ambience music')
ChaosTUI(no_music=parser.parse_args().no_music).run()
if __name__ == '__main__':
main()