474 lines
20 KiB
Python
Executable File
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()
|