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:
parent
d68ea695f8
commit
2cfd32ca55
46
AGENTS.md
46
AGENTS.md
@ -9,8 +9,10 @@ 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
|
||||
├── 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
|
||||
@ -19,7 +21,10 @@ the-chaos/
|
||||
├── 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
|
||||
├── 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/<today>.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 "<reaction text>"` 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 <deck> <table>` 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/<today>.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 <deck> <table>` 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/<today>.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
|
||||
|
||||
|
||||
1
session/book.md
Normal file
1
session/book.md
Normal file
@ -0,0 +1 @@
|
||||
|
||||
15
session/log/2026-06-25.md
Normal file
15
session/log/2026-06-25.md
Normal file
@ -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.
|
||||
152
tools/run.py
152
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")
|
||||
with TabbedContent(initial="char-tab", id="middle-tabs"):
|
||||
with TabPane("CHARACTER", id="char-tab"):
|
||||
with VerticalScroll():
|
||||
yield CharPane(id="char-content")
|
||||
yield Static("LOG", id="log-header")
|
||||
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
tools/store_turn.py
Executable file
59
tools/store_turn.py
Executable 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()
|
||||
Loading…
Reference in New Issue
Block a user