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
This commit is contained in:
Dejvino
2026-06-23 23:15:17 +02:00
commit d4a19ef438
15 changed files with 1382 additions and 0 deletions
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
draw.py — Draw a card from The Chaos deck.
Usage:
python3 draw.py <deck> <table> [count]
Decks: souls, cook, creatures, curiosities
Tables: see the YAML files (e.g. traits, rumours, type, drink_or_drug)
Examples:
python3 draw.py souls thing
python3 draw.py cook rumours 3
python3 draw.py creatures type appearance
python3 draw.py curiosities creepy_vibe
"""
import sys
import yaml
import random
import os
DECK_DIR = os.path.join(os.path.dirname(__file__), '..', 'rules', 'deck')
DECKS = {
'souls': 'souls.yaml',
'cook': 'cook.yaml',
'creatures': 'creatures.yaml',
'curiosities': 'curiosities.yaml',
}
def load_deck(name):
path = os.path.join(DECK_DIR, DECKS[name])
with open(path) as f:
return yaml.safe_load(f)
def draw_from_table(data, table_name):
entry = data.get(table_name)
if not entry:
print(f"Table '{table_name}' not found in deck.")
sys.exit(1)
table = entry.get('table')
if table:
keys = sorted(table.keys(), key=int)
roll = random.randint(keys[0], keys[-1])
result = table[roll]
if isinstance(result, dict):
return result.get('name', str(result)), roll
return result, roll
one_to_six = entry.get('animal_table') or entry.get('trinket_table')
if one_to_six and entry.get('has_table'):
r1 = random.randint(1, 6)
r2 = random.randint(1, 6)
return f"{one_to_six[r1]} and has {entry['has_table'][r2]}", f"{r1},{r2}"
faction_keys = [k for k in entry if k.startswith('table_')]
if faction_keys:
key = random.choice(faction_keys)
items = entry[key]['factions']
return random.choice(items), key
return None, None
def main():
if len(sys.argv) < 3:
print(__doc__.strip())
sys.exit(1)
deck_name = sys.argv[1].lower()
table_name = sys.argv[2].lower()
count = int(sys.argv[3]) if len(sys.argv) > 3 else 1
if deck_name not in DECKS:
print(f"Unknown deck '{deck_name}'. Choose from: {', '.join(DECKS.keys())}")
sys.exit(1)
data = load_deck(deck_name)
print(f"── [{deck_name}] drawing from '{table_name}' ──")
for _ in range(count):
result, roll = draw_from_table(data, table_name)
if result:
if roll is not None:
print(f" [{roll}] → {result}")
else:
print(f" → {result}")
else:
# Try direct pick from top-level
if table_name == 'rules':
print(" (No card — consult mechanics.md)")
else:
print(f" No result for '{table_name}'")
if __name__ == '__main__':
main()
Executable
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
roll.py — Roll dice for The Chaos TTRPG.
Usage:
python3 roll.py <formula> [modifier]
Formulas:
1d6 Roll 1 six-sided die
2d6 Roll 2, sum them
3d6 Roll 3, sum them
2d6x10 Roll 2d6, multiply by 10
3d6*5 Roll 3d6, multiply by 5
odds Roll 1d6, show success (4+) or failure (3-)
trait N Roll 3d6 vs trait score N — success if under
Modifier: +/- number added to total
Examples:
python3 roll.py 1d6
python3 roll.py 3d6
python3 roll.py 2d6x10
python3 roll.py odds
python3 roll.py trait 9
python3 roll.py 1d6 +1
"""
import sys
import random
import re
def roll_dice(count, sides=6):
return [random.randint(1, sides) for _ in range(count)]
def main():
if len(sys.argv) < 2:
print(__doc__.strip())
sys.exit(1)
formula = sys.argv[1].lower()
modifier = 0
if len(sys.argv) >= 3:
try:
modifier = int(sys.argv[2].replace('+', ''))
except ValueError:
pass
if formula == 'odds':
r = random.randint(1, 6)
outcome = 'SUCCESS (favours character)' if r >= 4 else 'FAILURE (favours trouble)'
print(f" [{r}] → {outcome}")
return
if formula == 'trait' and len(sys.argv) >= 3:
target = int(sys.argv[2])
rolls = roll_dice(3)
total = sum(rolls)
outcome = 'SUCCESS' if total < target else 'FAILURE'
print(f" [{', '.join(map(str, rolls))}] = {total} vs trait {target} → {outcome}")
return
m = re.match(r'(\d+)d6(?:\s*([xX*])\s*(\d+))?', formula)
if not m:
print(f"Unknown formula: {formula}")
sys.exit(1)
count = int(m.group(1))
rolls = roll_dice(count)
total = sum(rolls) + modifier
mult = m.group(2)
mult_val = int(m.group(3)) if m.group(3) else 1
if mult:
total = total * mult_val
label = f"×{mult_val}" if mult else ""
mod_str = f" {'+' if modifier >= 0 else ''}{modifier}" if modifier != 0 else ""
if count > 1:
print(f" [{', '.join(map(str, rolls))}] = {sum(rolls)}{mod_str}{label} → {total}")
else:
print(f" [{rolls[0]}]{mod_str}{label} → {total}")
if __name__ == '__main__':
main()
Executable
+241
View File
@@ -0,0 +1,241 @@
#!/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()