splinter-keep/tools/run.py
Dejvino 2cfd32ca55 formalize game loop, build store_turn.py, redesign TUI
- AGENTS.md: formalized game loop (print turn → wait for reaction →
  process → generate next turn), fixed project layout paths
- tools/store_turn.py: new script to append turn to book.md and clear
  temp files
- tools/run.py: TUI redesign — TODO always on top, CHARACTER/LOG tabs,
  TURN section with rendered markdown, input writes to turn_reaction.md,
  scrolling via VerticalScroll, log auto-populates from previous day,
  >>--- NOW ---> marker at log end with auto-scroll
- session/book.md: story book (append-only narrative)
- session/log/2026-06-25.md: today's log seeded from previous session
2026-06-25 07:35:30 +02:00

474 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
run.py — The Chaos TTRPG Session Client
Layout: banner | TODO | CHAR/LOG (tabs) | TURN (desc + prompt) | status | input.
Music: polls session/ambience.md, plays via miniaudio.
"""
import os
import random
import sys
from datetime import date
from pathlib import Path
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Input, Static, TabbedContent, TabPane
from rich.markdown import Markdown as RichMarkdown
# ── 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)
# ── Paths ────────────────────────────────────────────────
BASE = Path(__file__).resolve().parent.parent
SESSION = BASE / 'session'
LOG_DIR = SESSION / 'log'
CHAR_PATH = SESSION / 'character.md'
WORLD_PATH = SESSION / 'world.md'
JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
TURN_DESC_PATH = SESSION / 'turn_description.md'
TURN_PROMPT_PATH = SESSION / 'turn_prompt.md'
TURN_REACTION_PATH = SESSION / 'turn_reaction.md'
AUDIO_DIR = SESSION / 'audio'
TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md'
REFRESH_SECS = 2
# ── Helpers ──────────────────────────────────────────────
def ensure_log():
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not LOG_PATH.exists():
LOG_PATH.write_text(f"# Session Log — {TODAY}\n\n")
_populate_if_empty()
def _populate_if_empty():
content = LOG_PATH.read_text().strip()
if content and len(content.splitlines()) > 2:
return
prev = _previous_log()
if prev:
lines = prev.read_text().splitlines()
lines[0] = f"# Session Log — {TODAY}"
LOG_PATH.write_text('\n'.join(lines) + '\n')
def _previous_log():
entries = sorted(LOG_DIR.glob('*.md'))
today_name = LOG_PATH.name
for e in reversed(entries):
if e.name != today_name:
return e
return None
def read_todo():
if not JOURNAL_PATH.exists():
return ["—— No journal yet ——"]
lines = JOURNAL_PATH.read_text().splitlines()
in_todo = False
todo = []
for l in lines:
if l.strip().lstrip('#').strip().startswith('TODO'):
in_todo = True
continue
if l.strip().startswith('#') and in_todo:
break
if in_todo and l.strip():
todo.append(l.strip().lstrip('- '))
return todo or ["—— All done! ——"]
def read_log_tail(n=200):
if not LOG_PATH.exists():
return []
lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
def read_turn_description():
if not TURN_DESC_PATH.exists():
return ""
return TURN_DESC_PATH.read_text().strip()
def read_turn_prompt():
if not TURN_PROMPT_PATH.exists():
return ""
return TURN_PROMPT_PATH.read_text().strip()
def write_reaction(text):
TURN_REACTION_PATH.write_text(text + '\n')
# ── Status summary ───────────────────────────────────────
def status_summary():
if not CHAR_PATH.exists():
return "no character"
lines = CHAR_PATH.read_text().splitlines()
name = "?"
health = "?"
for l in lines:
if l.startswith('**Name:**'):
name = l.split(':', 1)[1].strip().strip('_').strip('*')
if l.startswith('**Current Health:**'):
h = l.split(':', 1)[1].strip().strip('_').strip('*')
if h:
health = h
if l.startswith('**Max Health:**'):
m = l.split(':', 1)[1].strip().strip('_').strip('*')
if m and health == '?':
health = m
return f"{name}{health}"
def log_count():
return len(read_log_tail())
# ── Ambience subsystem ───────────────────────────────────
def parse_ambience_options():
"""Parse ambience_options.md into {name: [filepath, ...]}"""
if not AMBIENCE_OPTIONS_PATH.exists():
return {}
options = {}
lines = AMBIENCE_OPTIONS_PATH.read_text().splitlines()
in_table = False
for line in lines:
s = line.strip()
if not s.startswith('|') or not s.endswith('|'):
in_table = False
continue
parts = [p.strip() for p in s.split('|')]
parts = [p for p in parts if p]
if len(parts) < 2:
continue
if not in_table:
in_table = True
continue
if all(c in '-:| ' for c in s):
continue
name = parts[0].lower()
files = [f.strip() for f in parts[1].split(',') if f.strip()]
paths = [AUDIO_DIR / f for f in files]
options[name] = paths
return options
class AmbiencePlayer:
"""Monitors ambience.md and plays background music via miniaudio."""
def __init__(self):
self.current_ambience = 'silence'
self._last_mtime = 0
self._options = {}
self._device = None
self._stream = None
self.load_options()
@property
def available(self):
return HAS_AUDIO
@property
def ambience_name(self):
return self.current_ambience
def load_options(self):
self._options = parse_ambience_options()
def _stop(self):
if self._device:
try:
self._device.close()
except Exception:
pass
self._device = None
self._stream = None
def poll(self):
if not HAS_AUDIO:
return
try:
mtime = os.path.getmtime(AMBIENCE_PATH)
except OSError:
return
if mtime == self._last_mtime:
return
self._last_mtime = mtime
try:
name = AMBIENCE_PATH.read_text().strip().lower()
except OSError:
return
self._switch_to(name)
def _switch_to(self, name):
if name == self.current_ambience:
return
self.current_ambience = name
self._stop()
if name == 'silence' or name not in self._options:
return
tracks = self._options.get(name, [])
valid = [t for t in tracks if t.exists()]
if not valid:
return
track = random.choice(valid)
try:
self._stream = miniaudio.stream_file(str(track))
self._device = miniaudio.PlaybackDevice()
self._device.start(self._stream)
except Exception:
self.current_ambience = None
# ── Auto-refreshing panels ───────────────────────────────
class AutoStatic(Static):
"""A Static that reloads its content on an interval."""
def load(self):
raise NotImplementedError
def on_mount(self):
self.load()
self.set_interval(REFRESH_SECS, self.load)
class TodoPane(AutoStatic):
def load(self):
items = read_todo()
self.update("\n".join(f"{i}" for i in items))
class TranscriptPane(AutoStatic):
def load(self):
lines = read_log_tail()
display = "\n".join(lines[-80:])
if lines:
display += "\n >>--- NOW --->"
self.update(display)
self.call_after_refresh(self._scroll_bottom)
def _scroll_bottom(self):
if self.parent and hasattr(self.parent, 'scroll_end'):
self.parent.scroll_end(animate=False)
class CharPane(AutoStatic):
def load(self):
if not CHAR_PATH.exists():
self.update("*No character sheet*")
return
self.update(RichMarkdown(CHAR_PATH.read_text().strip()))
class StatusBar(AutoStatic):
def load(self):
char = status_summary()
count = log_count()
todo = len(read_todo())
music = ""
if not HAS_AUDIO:
music = " │ ♫ (install miniaudio)"
elif app_ambience_player:
name = app_ambience_player.ambience_name
music = f" │ ♫ {name}"
self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}")
class TurnPane(AutoStatic):
def load(self):
desc = read_turn_description()
prompt = read_turn_prompt()
parts = []
if desc:
parts.append(desc)
if prompt:
parts.append(f"---\n\n*{prompt}*")
content = "\n\n".join(parts) if parts else "*The world waits.*"
self.update(RichMarkdown(content))
# module-level ref so StatusBar can reach it
app_ambience_player = None
# ── The App ──────────────────────────────────────────────
class ChaosTUI(App):
TITLE = "The Chaos"
CSS = """
Screen {
background: #121212;
}
#banner {
dock: top;
height: 1;
background: #2a2a2a;
color: #e0ad4c;
text-align: center;
}
#input-row {
dock: bottom;
height: 3;
background: #252525;
padding: 0 0;
border-top: solid #3a3a3a;
}
Input {
background: #1e1e1e;
color: #e0e0e0;
border: none;
margin: 1 1;
}
Input:focus {
border: none;
}
#main {
height: 100%;
background: #111111;
}
#todo-header {
background: #3a2a1a;
color: #e0b060;
text-style: bold;
padding: 0 1;
height: 1;
}
#todo-content {
background: #1a1510;
color: #d0b080;
padding: 0 1;
height: 5;
max-height: 5;
overflow-y: auto;
}
#middle-tabs {
height: 25%;
min-height: 8;
}
TabbedContent {
background: #1a1a2a;
}
VerticalScroll {
overflow-y: auto;
scrollbar-size-vertical: 2;
scrollbar-color: #555555;
scrollbar-color-hover: #777777;
scrollbar-color-active: #999999;
}
#char-content {
background: #1e1e2a;
color: #c0c0c0;
padding: 0 1;
}
#transcript {
background: #1a2a1a;
color: #c8c8c8;
padding: 0 1;
}
#turn-header {
background: #2d2d2d;
color: #e0c080;
text-style: bold;
padding: 0 1;
height: 1;
}
#turn-scroll {
height: 1fr;
}
#turn-content {
background: #161616;
color: #d8d8d8;
padding: 0 2;
}
#status-bar {
background: #222222;
color: #888888;
padding: 0 1;
height: 1;
text-style: italic;
}
"""
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("escape", "quit", "Quit"),
]
def __init__(self, *args, no_music=False, **kwargs):
super().__init__(*args, **kwargs)
global app_ambience_player
if not no_music:
app_ambience_player = AmbiencePlayer()
else:
app_ambience_player = None
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 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("TURN", id="turn-header")
with VerticalScroll(id="turn-scroll"):
yield TurnPane(id="turn-content")
yield StatusBar(id="status-bar")
with Horizontal(id="input-row"):
self.input = Input(placeholder=" What do you do? (just type)", id="input")
yield self.input
def on_mount(self):
ensure_log()
if not TURN_REACTION_PATH.exists():
TURN_REACTION_PATH.write_text('')
self.input.focus()
self.set_interval(REFRESH_SECS, self._check_ambience)
def _check_ambience(self):
if app_ambience_player:
app_ambience_player.poll()
@on(Input.Submitted, "#input")
def on_input(self, event: Input.Submitted):
text = event.value.strip()
if text:
write_reaction(text)
self.input.clear()
self.query_one(TodoPane).load()
self.query_one(CharPane).load()
self.query_one(TranscriptPane).load()
self.query_one(TurnPane).load()
self.query_one(StatusBar).load()
def main():
import argparse
parser = argparse.ArgumentParser(description="The Chaos TUI")
parser.add_argument('--no-music', action='store_true', help='Disable ambience music')
args = parser.parse_args()
app = ChaosTUI(no_music=args.no_music)
app.run()
if __name__ == '__main__':
main()