splinter-keep/tools/run.py
Dejvino d4a19ef438 Initial commit: The Chaos TTRPG solo campaign skeleton
Locked: rules/ (deck cards + mechanics), tools/ (draw, roll, run)
Unlocked: session/ (character, world, tweaks, log)
Entry: run.sh launches the Textual TUI
2026-06-23 23:15:17 +02:00

242 lines
6.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""
run.py — The Chaos TTRPG Session Client
Layout: banner top | log (main) + character (right) | input bottom.
"""
import os
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
from textual.reactive import reactive
from textual.widgets import Input, Static
# ── Paths ────────────────────────────────────────────────
BASE = Path(__file__).resolve().parent.parent
SESSION = BASE / 'session'
LOG_DIR = SESSION / 'log'
CHAR_PATH = SESSION / 'character.md'
WORLD_PATH = SESSION / 'world.md'
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")
def append_log(text):
with open(LOG_PATH, 'a') as f:
f.write(f"- {text}\n")
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_char_sheet():
if not CHAR_PATH.exists():
return ["—— No character yet ——"]
lines = CHAR_PATH.read_text().splitlines()
out = []
for l in lines:
s = l.rstrip()
if s.startswith('**') and ':' in s:
out.append(s.strip('*').strip())
elif s.startswith('- **'):
out.append(s.lstrip('- ').strip('*').strip())
return out or ["—— No character yet ——"]
# ── 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}"
# ── Log line count ───────────────────────────────────────
def log_count():
return len(read_log_tail())
# ── 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 TranscriptPane(AutoStatic):
def load(self):
lines = read_log_tail()
self.update("\n".join(lines[-80:]))
class CharPane(AutoStatic):
def load(self):
lines = read_char_sheet()
self.update("\n".join(f" {l}" for l in lines))
class StatusBar(AutoStatic):
def load(self):
char = status_summary()
count = log_count()
self.update(f"{char}{count} entries │ {TODAY}")
# ── The App ──────────────────────────────────────────────
class ChaosTUI(App):
TITLE = "The Chaos"
CSS = """
Screen {
background: #121212;
}
/* ── Top banner ── */
#banner {
dock: top;
height: 1;
background: #2a2a2a;
color: #e0ad4c;
text-align: center;
}
/* ── Bottom input ── */
#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 area: log (left) + sidebar (right) ── */
#main {
height: 100%;
}
/* Log column */
#log-col {
border-right: solid #3a3a3a;
background: #111111;
}
#log-header {
background: #1d2d1d;
color: #7dcd7d;
text-style: bold;
padding: 0 1;
height: 1;
}
#transcript {
padding: 0 1;
color: #c8c8c8;
}
/* Sidebar */
#sidebar {
width: 36;
min-width: 28;
background: #181818;
}
#side-header {
background: #2d2d3a;
color: #b0a0e0;
text-style: bold;
padding: 0 1;
height: 1;
}
#char-content {
padding: 0 1;
color: #c0c0c0;
}
#status-bar {
background: #222222;
color: #888888;
padding: 0 1;
height: 1;
text-style: italic;
}
"""
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("escape", "quit", "Quit"),
]
def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Horizontal(id="main"):
with Vertical(id="log-col"):
yield Static("LOG", id="log-header")
yield TranscriptPane(id="transcript")
with Vertical(id="sidebar"):
yield Static("CHARACTER", id="side-header")
yield CharPane(id="char-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()
self.input.focus()
@on(Input.Submitted, "#input")
def on_input(self, event: Input.Submitted):
text = event.value.strip()
if text:
append_log(text)
self.input.clear()
self.query_one(TranscriptPane).load()
self.query_one(StatusBar).load()
def main():
app = ChaosTUI()
app.run()
if __name__ == '__main__':
main()