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
This commit is contained in:
Dejvino
2026-06-25 07:35:30 +02:00
parent d68ea695f8
commit 2cfd32ca55
5 changed files with 227 additions and 66 deletions
+112 -44
View File
@@ -2,7 +2,7 @@
"""
run.py — The Chaos TTRPG Session Client
Layout: banner top | log (main) + character (right) | input bottom.
Layout: banner | TODO | CHAR/LOG (tabs) | TURN (desc + prompt) | status | input.
Music: polls session/ambience.md, plays via miniaudio.
"""
@@ -14,9 +14,9 @@ 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
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:
@@ -36,6 +36,9 @@ 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'
@@ -48,10 +51,25 @@ 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 append_log(text):
with open(LOG_PATH, 'a') as f:
f.write(f"- {text}\n")
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():
@@ -75,18 +93,18 @@ def read_log_tail(n=200):
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 ——"]
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 ───────────────────────────────────────
@@ -110,7 +128,6 @@ def status_summary():
return f"{name} ❤ {health}"
# ── Log line count ───────────────────────────────────────
def log_count():
return len(read_log_tail())
@@ -228,16 +245,27 @@ class TodoPane(AutoStatic):
items = read_todo()
self.update("\n".join(f" ☐ {i}" for i in items))
class TranscriptPane(AutoStatic):
def load(self):
lines = read_log_tail()
self.update("\n".join(lines[-80:]))
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):
lines = read_char_sheet()
self.update("\n".join(f" {l}" for l in lines))
if not CHAR_PATH.exists():
self.update("*No character sheet*")
return
self.update(RichMarkdown(CHAR_PATH.read_text().strip()))
class StatusBar(AutoStatic):
@@ -254,6 +282,19 @@ class StatusBar(AutoStatic):
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
@@ -295,6 +336,7 @@ class ChaosTUI(App):
height: 100%;
background: #111111;
}
#todo-header {
background: #3a2a1a;
color: #e0b060;
@@ -308,33 +350,50 @@ class ChaosTUI(App):
padding: 0 1;
height: 5;
max-height: 5;
overflow-y: auto;
}
#char-header {
background: #2d2d3a;
color: #b0a0e0;
text-style: bold;
padding: 0 1;
height: 1;
#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;
padding: 0 1;
color: #c0c0c0;
height: 14;
max-height: 14;
padding: 0 1;
}
#log-header {
background: #1d2d1d;
color: #7dcd7d;
#transcript {
background: #1a2a1a;
color: #c8c8c8;
padding: 0 1;
}
#turn-header {
background: #2d2d2d;
color: #e0c080;
text-style: bold;
padding: 0 1;
height: 1;
}
#transcript {
padding: 0 1;
color: #c8c8c8;
#turn-scroll {
height: 1fr;
}
#turn-content {
background: #161616;
color: #d8d8d8;
padding: 0 2;
}
#status-bar {
background: #222222;
color: #888888;
@@ -362,10 +421,16 @@ class ChaosTUI(App):
with Vertical(id="main"):
yield Static("TODO", id="todo-header")
yield TodoPane(id="todo-content")
yield Static("CHARACTER", id="char-header")
yield CharPane(id="char-content")
yield Static("LOG", id="log-header")
yield TranscriptPane(id="transcript")
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")
@@ -373,8 +438,9 @@ class ChaosTUI(App):
def on_mount(self):
ensure_log()
if not TURN_REACTION_PATH.exists():
TURN_REACTION_PATH.write_text('')
self.input.focus()
# start ambience polling
self.set_interval(REFRESH_SECS, self._check_ambience)
def _check_ambience(self):
@@ -385,10 +451,12 @@ class ChaosTUI(App):
def on_input(self, event: Input.Submitted):
text = event.value.strip()
if text:
append_log(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()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Append a turn to the story book and clear turn temp files.
Usage:
python3 scripts/store_turn.py "Dillion stepped into the chamber..."
python3 scripts/store_turn.py < reaction.txt
"""
import sys
from pathlib import Path
from datetime import date
SESSION = Path(__file__).resolve().parent.parent / 'session'
BOOK = SESSION / 'book.md'
TURN_DESC = SESSION / 'turn_description.md'
TURN_PROMPT = SESSION / 'turn_prompt.md'
TURN_REACTION = SESSION / 'turn_reaction.md'
CLEAR_FILES = [TURN_DESC, TURN_PROMPT, TURN_REACTION]
def get_reaction() -> str:
if len(sys.argv) > 1:
return sys.argv[1]
if not sys.stdin.isatty():
return sys.stdin.read().strip()
return ''
def main():
reaction = get_reaction()
if not reaction:
print("No reaction text provided. Pass as argument or pipe stdin.")
sys.exit(1)
description = TURN_DESC.read_text().strip() if TURN_DESC.exists() else ''
timestamp = date.today().isoformat()
entry_parts = []
if description:
entry_parts.append(description)
if reaction:
entry_parts.append(reaction)
entry = '\n\n'.join(entry_parts)
heading = f'\n\n## Turn — {timestamp}\n\n'
BOOK.parent.mkdir(parents=True, exist_ok=True)
with open(BOOK, 'a') as f:
f.write(heading + entry + '\n')
for fpath in CLEAR_FILES:
if fpath.exists():
fpath.write_text('')
print(f"✓ Turn stored → {BOOK}")
if __name__ == '__main__':
main()