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