diff --git a/AGENTS.md b/AGENTS.md index 8f479ca..428deb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,17 +9,22 @@ the-chaos/ ├── rules/ # LOCKED — the game itself, do not modify │ ├── deck/ # Card tables (souls, cook, creatures, curiosities) │ └── mechanics.md # Core rules reference -├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py) - └── session/ # UNLOCKED — our campaign - ├── character.md # Player character sheet - ├── world.md # Keep & Realm state (NPCs, locations, threads) - ├── journal.md # TODO / DONE task tracking - ├── tweaks.md # House rules log - ├── ambience.md # Current ambience (written by DM, read by TUI) - ├── ambience_options.md # Ambience → track file mapping - ├── ambience_sources.md # Track source URLs (for re-download) - ├── audio/ # Music files go here - └── log/ # Raw session logs by date +├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py, store_turn.py) +├── scripts/ # UNLOCKED — DM helper scripts +└── session/ # UNLOCKED — our campaign + ├── book.md # Story book (append-only turn narrative) + ├── character.md # Player character sheet + ├── world.md # Keep & Realm state (NPCs, locations, threads) + ├── journal.md # TODO / DONE task tracking + ├── tweaks.md # House rules log + ├── ambience.md # Current ambience (written by DM, read by TUI) + ├── ambience_options.md # Ambience → track file mapping + ├── ambience_sources.md # Track source URLs (for re-download) + ├── audio/ # Music files go here + ├── log/ # Raw session logs by date + ├── turn_description.md # DM narrative for current turn + ├── turn_prompt.md # "What do you do?" prompt for current turn + └── turn_reaction.md # Player's raw reaction (filled, then rewritten) ``` ## First Steps (Fresh Session) @@ -54,22 +59,35 @@ Then begin narrating from where things left off. ### Exploration 6 ten-minute watches per hour. Each meaningful action advances a watch. After 6 watches, situation changes. +## The Game Loop + +The core loop for every turn: + +1. **Print** `session/turn_description.md` and `session/turn_prompt.md` to the player. +2. **Wait** for the player to fill `session/turn_reaction.md` with their raw reaction. +3. **Process the turn:** + a. Resolve outcomes mechanically — update `character.md`, `world.md`, `journal.md`, and append to `session/log/.md`. + b. Rewrite `session/turn_reaction.md` as a coherent narrative continuation of the turn description (reads like a book). + c. Run `python3 tools/store_turn.py ""` to append both description and reaction to `session/book.md` and clear all turn temp files. +4. **Generate a new turn:** + a. Populate `session/turn_description.md` with the next scene's narrative. + b. Populate `session/turn_prompt.md` with "What do you do?". + c. Update `session/ambience.md` if the mood has changed. + ## How to Operate -1. **Set scenes** — describe the environment, NPCs, stakes. Refer to `rules/deck/` YAML files and `rules/mechanics.md` for tables and rules. -2. **Ask "what do you do?"** — let the player drive. Never pre-decide outcomes. -3. **Draw cards when needed** — use `python3 tools/draw.py ` for random results -4. **Player rolls dice physically** — they report results, you narrate outcomes -5. **Log before narrating** — After every meaningful beat (conversation, travel, roll, combat round, decision), append the beat to `session/log/.md` **before** describing the next scene. The log comes first, always. Format: `- **time of day** — brief description.` Each beat gets its own line. World changes get `- *World Change:* ...` mixed into the timeline. -6. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed. -7. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration. -8. **Set the ambience** — When the scene's mood changes (arriving in town, entering combat, exploring a dungeon), write the ambience name into `session/ambience.md`: +1. **Draw cards when needed** — use `python3 tools/draw.py
` for random results +2. **Player rolls dice physically** — they report results, you narrate outcomes +3. **Log before narrating** — After every meaningful beat (conversation, travel, roll, combat round, decision), append the beat to `session/log/.md` **before** describing the next scene. The log comes first, always. Format: `- **time of day** — brief description.` Each beat gets its own line. World changes get `- *World Change:* ...` mixed into the timeline. +4. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed. +5. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration. +6. **Set the ambience** — When the scene's mood changes, write the ambience name: ``` echo "tavern" > session/ambience.md ``` - The TUI polls this file and crossfades background music. Available names are listed in `session/ambience_options.md`. Use `silence` to stop music. -9. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`. -10. **Death is real** — if the PC dies, help the player roll a new character. That's the game. + Available names are listed in `session/ambience_options.md`. Use `silence` to stop music. +7. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`. +8. **Death is real** — if the PC dies, help the player roll a new character. That's the game. ## The TUI diff --git a/session/book.md b/session/book.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/session/book.md @@ -0,0 +1 @@ + diff --git a/session/log/2026-06-25.md b/session/log/2026-06-25.md new file mode 100644 index 0000000..d35ab50 --- /dev/null +++ b/session/log/2026-06-25.md @@ -0,0 +1,15 @@ +# Session Log — 2026-06-25 + +- **Early Afternoon** — Back at the Keep. Head to Weber's Smithy with Rina and Lark. Dillion tells Rina they'll settle with Weber first, then get her rested. +- **Early Afternoon** — Weber pays 8 silver and sharpens the mace (1d6+2 for next job). Asks if Rina is "one of Fenna's lot." Dillion explains she was locked in the cellar. +- **Early Afternoon** — Dillion, Rina, and Lark head to the Splintered Tankard. Dillion asks Otta to help Rina get cleaned up. Otta agrees — sends Rina to the back for a bath, finds her a change of clothes. +- **Early Afternoon** — While Rina recovers, Dillion hits the Market Square. Buys 6 torches (6 copper) and 5 days rations (5 silver). Pays Lark 5 silver for his help and sends him off. Lark leaves with a grin. +- **Early Afternoon** — Dillion sits down with Rina to learn her skills and background. She's a locksmith and trap-finder, proficient with a crossbow and dagger. Dillion takes her to Weber's for a crossbow, then to the market for a leather vest. +- **Late Afternoon** — Dillion and Rina head back to Three Bridges on the King's Road. Bought two long ropes on the way (4 silver). Reach the mill as the sun begins to dip. Enter through the loose board, descend into the cellar tunnel. +- **Late Afternoon** — Cautious descent into the tunnel. Air grows acrid — blood, decay, mint. The rough-hewn tunnel slopes steadily down, walls sweating moisture. After ~15 minutes, the tunnel opens into a natural cavern. Faint light pulses from bioluminescent fungi on the ceiling. A stone door with the fist-and-gear crest stands on the far wall, slightly ajar. +- **Late Afternoon** — Dillion decides to enter through the ajar stone door. Sneaks in successfully (DEX 5). Rina spots a tripwire — they step over it and proceed into the chamber beyond. +- **Late Afternoon** — Chamber contains a tall construct (grey stone skin, red eyes, cloth/rusted metal armour). It watches but doesn't attack. Dillion and Rina edge along the wall toward the left corridor without triggering it. +- **Late Afternoon** — Left corridor leads to an iron-banded wooden door with a heavy lock. Rina picks it while Dillion stands watch. +- **Late Afternoon** — Beyond the door: a storage room with two men (Beard and Scar) on guard duty. Dillion and Rina slip into cover behind crates (DEX 9). Ambush: Rina bolts Scar through the neck, Dillion cuts Beard's throat. Both killed silently. +- **Late Afternoon** — Bodies hidden. Dillion searches the guards (19 silver, key ring, handbill). Rina finds delivery manifests and a note mentioning "The Weeper" and "H." Dillion takes the loot and moves to the iron-reinforced door. Listens. Opens it safely with a rope from behind crates. Beyond: a dark tunnel sloping downward, humid air, slow rhythmic breathing sound. +- **Late Afternoon** — Dillion and Rina descend into the tunnel. It opens into a chamber with a pit at the centre — chains descending into darkness, slow wet breathing from below. A second iron door on the far side. Dillion and Rina skirt the pit silently (DEX 7). Beyond the door: a hallway with a velvet curtain, warm light and a chanting voice beyond. Dillion peeks: a weaver's study, chalk circle with a dagger at the centre. He pulls back to consult Rina. diff --git a/tools/run.py b/tools/run.py index 7fd8e3b..b88ea93 100755 --- a/tools/run.py +++ b/tools/run.py @@ -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() diff --git a/tools/store_turn.py b/tools/store_turn.py new file mode 100755 index 0000000..79e82b5 --- /dev/null +++ b/tools/store_turn.py @@ -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()