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:
+112
-44
@@ -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()
|
||||
|
||||
|
||||
|
||||
Executable
+59
@@ -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()
|
||||
Reference in New Issue
Block a user