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