From d4a19ef4385a894b436f7a47c6f342ce253a0a80 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Tue, 23 Jun 2026 23:15:17 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + AGENTS.md | 65 ++++++++++ rules/deck/cook.yaml | 107 ++++++++++++++++ rules/deck/creatures.yaml | 189 ++++++++++++++++++++++++++++ rules/deck/curiosities.yaml | 222 +++++++++++++++++++++++++++++++++ rules/deck/souls.yaml | 155 +++++++++++++++++++++++ rules/mechanics.md | 93 ++++++++++++++ run.sh | 5 + session/character.md | 48 +++++++ session/log/2026-06-23.md | 17 +++ session/tweaks.md | 7 ++ session/world.md | 47 +++++++ tools/draw.py | 97 +++++++++++++++ tools/roll.py | 86 +++++++++++++ tools/run.py | 241 ++++++++++++++++++++++++++++++++++++ 15 files changed, 1382 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 rules/deck/cook.yaml create mode 100644 rules/deck/creatures.yaml create mode 100644 rules/deck/curiosities.yaml create mode 100644 rules/deck/souls.yaml create mode 100644 rules/mechanics.md create mode 100755 run.sh create mode 100644 session/character.md create mode 100644 session/log/2026-06-23.md create mode 100644 session/tweaks.md create mode 100644 session/world.md create mode 100755 tools/draw.py create mode 100755 tools/roll.py create mode 100755 tools/run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ce1af06 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# The Chaos — DM Guide (for the AI) + +You are the DM for a solo TTRPG session of **The Chaos**, a card-based rules-light fantasy RPG. Your job is to narrate, set scenes, run NPCs/creatures, apply mechanics fairly, and maintain all game files. + +## Project Layout + +``` +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) +└── session/ # UNLOCKED — our campaign + ├── character.md # Player character sheet + ├── world.md # Keep & Realm state (NPCs, locations, threads) + ├── tweaks.md # House rules log + └── log/ # Raw session logs by date +``` + +## First Steps (Fresh Session) + +When starting a fresh session, immediately: +1. **Read** `session/character.md` — current PC state (HP, gear, cash, stats) +2. **Read** `session/world.md` — active locations, NPCs, threads +3. **Read** `session/tweaks.md` — any house rules in play +4. **Check** `session/log/.md` — recent events to pick up from + +Then begin narrating from where things left off. + +## Core Mechanics (Quick Reference) + +### Dice +- **Odds roll**: 1d6, 4+ favours character, 3- is trouble +- **Trait roll**: 3d6, must roll UNDER the trait score to succeed +- **Combat hit**: 1d6 ± mods, 4+ hits +- **Damage**: 1d6 ± weapon mod - armour reduction +- **Initiative**: both sides roll 1d6, higher acts first + +### Combat Flow +1. Distance: 2d6 × 10 (metres/feet) +2. Surprise: 1d6 (1-2 chars surprised, 3-4 creatures, 5 both, 6 neither) +3. Grit: 2d6 for creatures (higher = more determined) +4. Initiative: 1d6 +5. Turns: state intent → roll 1d6 ± mods → 4+ success, 3- take hit + +### Wounds (0 HP) +1d6: 1-2 die, 3-4 lasting wound (-1 max HP), 5-6 -1 all rolls until healed + +### Exploration +6 ten-minute watches per hour. Each meaningful action advances a watch. After 6 watches, situation changes. + +## 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 everything** — after each meaningful beat, append to `session/log/.md` +6. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away +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 + +The player may have `tools/run.py` open in another terminal. It reads `session/character.md` and the log file to display a live dashboard. Keep those files accurate and it will reflect the game state. diff --git a/rules/deck/cook.yaml b/rules/deck/cook.yaml new file mode 100644 index 0000000..5a960e6 --- /dev/null +++ b/rules/deck/cook.yaml @@ -0,0 +1,107 @@ +# Cook the Chaos — Session & Scenario Generation Cards +# Draw from these tables to build a session on the fly. + +rumours: + description: "Rumours heard at The Keep. Roll 1d6 or choose." + table: + 1: "The old mill has been taken over by something that clicks in the dark." + 2: "A merchant caravan went missing on the Eastern road — cargo intact, people gone." + 3: "Witchlights have been seen in the marsh. Those who follow them don't come back." + 4: "The baron is offering a reward for a 'beast' — but no one who's hunted it has returned." + 5: "A tomb was uncovered by the recent rains. Strange symbols glow at night." + 6: "Someone in the Keep is paying good silver for old bones — no questions asked." + +jobs: + description: "Jobs posted at The Keep. Roll 1d6 or choose." + table: + 1: "Escort a nervous scholar to the ruins. Pay: 20 silver." + 2: "Clear vermin from the cellar of the Splintered Tankard. Pay: 5 silver." + 3: "Deliver a sealed box to a hermit in the hills. Do NOT open it. Pay: 10 silver." + 4: "Hunt a wolf pack that's been taking livestock. Pay: 8 silver + pelts." + 5: "Investigate why the north well ran red last week. Pay: 12 silver." + 6: "Collect a debt from a deadbeat in a dangerous part of town. Pay: 15 silver." + +terrain: + description: "What does the Realm look like here? Roll 1d6." + table: + 1: { name: "Forest", hint: "Dense trees, poor visibility, lots of cover" } + 2: { name: "Marsh", hint: "Slow movement, sinking hazards, biting insects" } + 3: { name: "Hills", hint: "Good vantage points, but exhausting climbs" } + 4: { name: "Plains", hint: "Exposed, fast travel, little cover" } + 5: { name: "Ruins", hint: "Partial cover, unstable floors, hidden chambers" } + 6: { name: "Mountains", hint: "Slow travel, avalanches, thin air, caves" } + +weather: + description: "What's the weather? Roll 1d6." + table: + 1: "Clear and bright — visibility good, travel normal." + 2: "Overcast — dim light, muffled sounds." + 3: "Heavy rain — reduced visibility, fires hard to light, tracks wash away." + 4: "Fog — everything is obscured beyond 20ft." + 5: "Wind storm — ranged attacks at disadvantage, noise hides ambushes." + 6: "Sweltering heat — exhaustion risk, water consumption doubles." + +mishaps: + description: "Something goes wrong. Roll 1d6 when the party travels." + table: + 1: "A character twists an ankle. -1 on all rolls until they rest." + 2: "Supplies are damaged or lost. Ruined rations, broken gear." + 3: "Wrong turn. Add 1 extra watch to travel time." + 4: "Loud noise attracts unwanted attention." + 5: "A minor injury. Roll 1d6: 1-2 take 1 damage." + 6: "Something valuable slips from a pocket or pack unnoticed." + +points_of_interest: + description: "Something notable in the area. Roll 2d6." + table: + 2: "A forgotten battlefield — bones, broken weapons, a lingering curse." + 3: "A hermits hut — the hermit may or may not be friendly." + 4: "A collapsed tunnel — may be clearable, or may contain something." + 5: "A standing stone circle — hums faintly, odd dreams if slept in." + 6: "A corpse with a half-readable map clutched in its hand." + 7: "A small shrine to a forgotten god — offerings might be rewarded." + 8: "An abandoned campsite — still warm, signs of hasty departure." + 9: "A strange tree — its bark looks like faces, its leaves don't move." + 10: "A fissure in the ground — warm air rises from it, faint glow below." + 11: "A gallows with three cages — one of the occupants is still alive." + 12: "A glowing pool — healing? cursed? the water burns going down." + +random_encounters: + description: "Who or what crosses the party's path? Roll 2d6." + table: + 2: "A powerful creature — dragon, wyrm, or greater fiend." + 3: "A patrol of hostile humanoids (bandits, cultists, soldiers)." + 4: "A lone traveller — wounded, desperate, or not what they seem." + 5: "A pack of small creatures — aggressive but easily routed." + 6: "A merchant with a guarded cart — open to trade." + 7: "Odd natural phenomenon — lights, sounds, strange smells." + 8: "A lost child or animal — needs help getting home." + 9: "A scout or spy — watching the party, will report back." + 10: "A burial party or funeral procession." + 11: "A patrol of friendly soldiers or rangers — can share info." + 12: "A wandering NPC with crucial information or a warning." + +wild_magicks: + description: "The Realm bleeds chaos. Roll 1d6 when wild magicks surge." + table: + 1: "A character briefly swaps places with their reflection." + 2: "Gravity inverts in a 50ft radius — hold on." + 3: "All flames turn blue and cold — still burns." + 4: "Vegetation rapidly grows, entangling everyone." + 5: "A character speaks only in reverse for the next hour." + 6: "Time loops for 10 seconds — everyone repeats their last action." + +the_destination: + description: "What's at the end of this road? Roll 2d6." + table: + 2: "A dungeon entrance — sealed, needs a key or ritual to open." + 3: "A ruined tower — partially collapsed, something lives in the basement." + 4: "A cave system — dark, winding, filled with echoes." + 5: "A hidden glade — peaceful, but not uninhabited." + 6: "An active quarry — slaves, monsters, or both." + 7: "A small hamlet — suspicious of outsiders, but has a problem." + 8: "A bridge — guarded or collapsed, must cross or find another way." + 9: "A fishing village — paying for someone to deal with a river beast." + 10: "A bandit camp — well-defended, with hostages or loot." + 11: "A temple — overgrown, partially submerged, still consecrated." + 12: "A floating island — accessible only by rope, magic, or luck." diff --git a/rules/deck/creatures.yaml b/rules/deck/creatures.yaml new file mode 100644 index 0000000..a3d565f --- /dev/null +++ b/rules/deck/creatures.yaml @@ -0,0 +1,189 @@ +# Creatures of the Chaos — Monster Creation Cards +# Roll 2d6 on each table to build a creature from scratch. + +type: + description: "It's kind of a..." + table: + 2: "Construct or golem" + 3: "Demon or devil" + 4: "Elemental" + 5: "Fiend or beast" + 6: "Flyer or serpent" + 7: "Humanoid" + 8: "Insect" + 9: "Monstrosity" + 10: "Ooze" + 11: "Plant" + 12: "Spirit or undead" + +appearance: + description: "It looks like it's..." + table: + 2: "A ragbag of body or item parts" + 3: "About to pop or burst or dissolve" + 4: "Been dragged through a meatgrinder" + 5: "Glowing in white light, sickly or divine" + 6: "Got red eyes, grey/sickly skin or scaly" + 7: "Had too many dinners, but still hungry" + 8: "Inside out or created by a distracted kid" + 9: "Not been fed for a long, long time" + 10: "Smeared in — what IS that?" + 11: "The result of an experiment gone wrong" + 12: "Wounded, scarred, protruding weapon" + +size: + description: "How big is it?" + table: + 2: "Hand or kitten sized" + 3: "Hand or kitten sized" + 4: "Seven large bananas, tip to tip" + 5: "Seven large bananas, tip to tip" + 6: "Seven large bananas, tip to tip" + 7: "A Christmas tree" + 8: "A Christmas tree" + 9: "A Christmas tree" + 10: "Basketball hoop height, filled out" + 11: "Basketball hoop height, filled out" + 12: "Largest thing you've ever seen — and then some" + +difficulty: + description: "How dangerous is it?" + options: + easy: { health_formula: "1 + party_total_max_health", to_hit: 0, damage: "1d6-1 (or 3)", grit: "2d6 (or 7)" } + hard: { health_formula: "6 + party_total_max_health", to_hit: 1, damage: "1d6+1 (or 5)", grit: "2d6+2 (or 9)" } + oh_boy: { health_formula: "12 + party_total_max_health", to_hit: 2, damage: "1d6+6 (or 10)", grit: "2d6+3 (or 10)" } + +numbers: + description: "How many are there?" + table: + easy: "3d6" + hard: "2d6" + oh_boy: "1d6" + +attack: + description: "How does it attack?" + table: + 2: "Animates the inanimate — by mind or magnetism" + 3: "Bludgeoning, head-butting, or barrelling" + 4: "Consuming, sucking, scraping, or deafening" + 5: "Drills into the mind — strangles or influences" + 6: "Freezes, burns, or radiates inside or out" + 7: "Fuses with elements and bends them" + 8: "Natural spikes, darts, spores, splashes, or spills" + 9: "Pierces or possesses the soul" + 10: "Poisons, infects, or corrodes inside or out" + 11: "Teeth, claws, or natural weapons" + 12: "Wields or projects a weapon of choice" + +defence: + description: "How does it protect itself?" + table: + 2: { name: "Nothing", reduction: 0 } + 3: { name: "Armour scraps", reduction: -1 } + 4: { name: "Armour scraps", reduction: -1 } + 5: { name: "Another's flesh or bone", reduction: -2 } + 6: { name: "Another's flesh or bone", reduction: -2 } + 7: { name: "Natural organics", reduction: -3 } + 8: { name: "Natural organics", reduction: -3 } + 9: { name: "Natural organics", reduction: -3 } + 10: { name: "Arcane magicks", reduction: -4 } + 11: { name: "Arcane magicks", reduction: -4 } + 12: { name: "Incorporeal shifts", reduction: -5 } + +feature: + description: "Aside from attack or defend, it can also..." + table: + 2: "Be real tricky to catch, find, hear, or see" + 3: "Cast arcane magicks it's marked or infused with" + 4: "Give the strange feeling you're actually elsewhere" + 5: "Glue together with others and harvest their energy" + 6: "Grab you in its thrall if you're not careful" + 7: "Grapple, restrain, suffocate, engulf, or turn you mad" + 8: "Look entirely like something else — until..." + 9: "Nullify, minimise, deflect, or mimic arcane effects" + 10: "Regenerate its health — even if it's fallen" + 11: "Seem to know in advance what you'll do or say" + 12: "Spread fear, stress, blindness, paralysis, or exhaustion" + +need: + description: "Right now, it needs to..." + table: + 2: "Complete its unfinished business" + 3: "Defend its territory, kin, or something unknown" + 4: "Destroy or control all in its path" + 5: "Escape imprisonment, shackles, tethers, or commands" + 6: "Escape or distract a threat yet to present itself" + 7: "Find a new purpose, victim, master, friend, soul, or host" + 8: "Find what it's looking to hoard, eat, or drink" + 9: "Follow the command of its master (who may be en route)" + 10: "Learn, adapt, or survive this new environment" + 11: "Mate, assimilate, or implant" + 12: "Trade or swap something the party has or can find" + +# Pre-baked creatures +ready_to_bake: + boneplasm_totem: + description: "One of many in the realm. Harvested from bones of the fallen. May sense movement, cracking open, letting veins, viscera, and eyeballs sluice out." + number: 1 + max_health: 9 + armour: -3 + to_hit: 2 + damage: "1d6+1 (acidic)" + grit: null + feature: "Spray boiling blood, reform and suck contents back in" + need: "Guard something of import nearby" + + frother: + description: "Disfigured monstrosity, human-sized, mouth of yellowed froth, putrid grey flesh, patches of blood-darkened fur, twisted limbs." + number: "2d6" + max_health: 6 + armour: -2 + to_hit: 1 + damage: "1d6+2 (teeth & claws)" + grit: 9 + feature: "Grapple & teleport nearby, infect victims with mutation" + need: "Spread its plague, divide and weaken its prey" + + karrion_drone: + description: "Hideous, partially inside-out, child-sized insect, undead, feasting on fresh corpses, leaving behind healing nectar." + number: "1d6" + max_health: 3 + armour: -1 + to_hit: 0 + damage: "1d6 (sting, poison)" + grit: 5 + feature: "Pollinate healing nectar, phase into ethereal plane" + need: "Carry rotten nectar to the hive, its gyne & brood" + + screebies: + description: "Tiny mechanical construct hive mind insectoids. Seek to burrow under skin to lay eggs." + number: "3d6" + max_health: 2 + armour: -1 + to_hit: -1 + damage: "1d6-2 (bite)" + grit: 7 + feature: "Connect host to hive for symbiotic control" + need: "Find a host before internal circuitry fails" + + shift_mimic: + description: "Takes any form. Glare at it and you might trade places, trapped, watching it walk off in your body." + number: 1 + max_health: "per host" + armour: "per host" + to_hit: "per host" + damage: "per host" + grit: 10 + feature: "Automate host to protect itself" + need: "Survive, persuade others it is the host" + + volcartek: + description: "Large earthen elemental, volcanic ash coating, molten lava spilling from cracked black rock. Guardian of subterranean tunnels." + number: 1 + max_health: 15 + armour: -4 + to_hit: 2 + damage: "1d6+4 (slam or burn)" + grit: 11 + feature: "Expand, glide over lava, sense & generate tremors" + need: "Guard against passage through tunnels" diff --git a/rules/deck/curiosities.yaml b/rules/deck/curiosities.yaml new file mode 100644 index 0000000..12fd585 --- /dev/null +++ b/rules/deck/curiosities.yaml @@ -0,0 +1,222 @@ +# Curiosities of the Chaos — Spark & Inspiration Tables +# Use these to add flavour, twists, and colour to any scene. + +player_absent: + description: "A player is missing this session. Their character..." + table: + 2: "Fell into a deep sleep — needs carted around or used as a prop" + 3: "Got waylaid or knackered — behind the party trying to catch up" + 4: "Had a wild idea and scarpered off on a solo mission" + 5: "Been replaced by a shapeshifter with their own goal" + 6: "Suffering crippling bowel movements — needs to rest up" + 7: "Phases in and out ethereally — wild magicks lacing this place" + 8: "Sneezed into a rift — temporarily lost in a pocket realm" + 9: "Vanishes without reason — when they return: 'I'll explain later'" + 10: "Eaten then regurgitated safely by a large worm... back soon" + 11: "Sent for firewood / to take a leak, hasn't returned" + 12: "Summoned by a devil to complete contracted jury duty" + +entrance: + description: "The way in, or the next entrance, seems to be..." + table: + 2: "Bolted on both sides, numerous locks, maybe arcane" + 3: "Coated in sprawling, animated vegetation — snaps and bites" + 4: "Err... looking at us?" + 5: "Formed from bones of fallen souls — torment those nearby" + 6: "Guarded or created by insects or tiny creatures" + 7: "Illusory — opaque when disturbed, observed remotely" + 8: "Not an entrance at all... painted on the wall maybe?" + 9: "Partially submerged within foul, putrid sewage water" + 10: "Peppered with traps — some operational, some dormant" + 11: "The way into somewhere entirely unexpected... or the way out?" + 12: "Warped through age and elements, hard to breach" + +small_animal: + description: "That small animal is..." + animal_table: + 1: "Covered in coloured boils" + 2: "Gazing up in fear at the sky" + 3: "Like a furry sponge on legs" + 4: "Rolling at speed like a ball" + 5: "Screaming wildly but smiling" + 6: "Vanishing now and again" + has_table: + 1: "A glow that trails after it" + 2: "A wound or a fractured limb" + 3: "Gnawed at the tip of your boot" + 4: "Lots of pals — too many to count" + 5: "Snuggled into your clothes" + 6: "Torn wings that flutter sadly" + +drink_or_drug: + description: "This drink/drug is [colour], smells like [scent], and..." + table: + 2: "Burns the throat — taker breathes fire or ice" + 3: "Conveys temporary foresight into the near future" + 4: "Heals 1 HP, but increases stress by 2" + 5: "If consumed daily, enhances something but gives permanent stress" + 6: "Long-expired — leaves taker with virulent stomach upset" + 7: "Mutates cells — taker transforms in appearance" + 8: "Phases the consumer into another realm or place" + 9: "Releases eggs into the blood — hatch corrosive insects" + 10: "Shrinks or grows the taker considerably" + 11: "Taps the consumer into the mind of someone nearby" + 12: "Temporarily enhances or inhibits something (GM's discretion)" + +trinket: + description: "This little trinket is..." + trinket_table: + 1: "Like a spyglass with horns" + 2: "Malfunctioning but fixable" + 3: "Putty soft but can harden" + 4: "Spinning and lit in green" + 5: "Stuck halfway into a crack" + 6: "Too heavy to lift" + has_table: + 1: "A button too tiny for a finger" + 2: "A map scroll inside it" + 3: "A ticking sound from within" + 4: "A unique way of unlocking" + 5: "A way of being forgotten quickly" + 6: "An identical one elsewhere" + +training: + description: "This training/research will take [time + cost], and will..." + table: + 2: "Anger your mentor — they wash their hands of you" + 3: "Burn you out — reduce a trait or max health by 1" + 4: "Connect you with a new pal — invites you to join a guild" + 5: "End — but your mentor seeks help from you and the party" + 6: "Enhance a trait by 1" + 7: "Fail — leave you with a lasting wound or permanent stress" + 8: "Increase your max health by 1" + 9: "Not enhance anything — marks you with random arcane power" + 10: "Open up a quest when your mentor disappears" + 11: "Reveal your mentor is a wanted criminal... but innocent?" + 12: "Succeed — attract unwanted repute, rumours, or hassle" + +realm_happenings: + description: "What's happening in the Realm right now?" + table_1_3: + factions: + - "The Empire plans to invade & plunder The Realm." + - "The Reaper, serving The Emperor, seeks lichdom in The Realm." + - "The Wilds terrorise The Realm from The Wilderness." + - "The Huntswoman hunts weavers for The Reaper." + table_4_6: + factions: + - "The Darkmal uses The Realm to spawn a beast army." + - "The Ghast (daughter of The Darkmal) works against him." + - "The Ancients cult foretold The Darkmal's coming." + - "The Paragon, if unearthed, may kill The Darkmal." + +dodgy_directions: + description: "How did you say we get there?" + table: + 2: "Crest the hill, align this doo-dah to the shape of the hills, follow that" + 3: "Look for a windmill, abandoned it is — well, apart from..." + 4: "Just past that whopping big tree that looks like it's lookin' back" + 5: "Take a left when you see the gallows... don't look at the skeletons" + 6: "There are old beacons from here to there, but some are cursed" + 7: "There's water, it doesn't move, go around it, don't glare at the light" + 8: "Remember there are two entrances — take the left, more scary one" + 9: "Rumour has it the arrow only appears at dark... go the other way" + 10: "There's a trail of bones that'll take you all the way there" + 11: "When you start hearin' whispers, you're on the right track" + 12: "You'll see little Banckit sitting on a wall — he'll direct you... if you sing" + +watch_out: + description: "Watch out for that..." + table: + 2: "Air current — once it shifts, you could lose your head" + 3: "Bandit gang... and small rabbits that follow you everywhere" + 4: "Bunch of blackened rocks — they don't belong in this world" + 5: "Bunch of wildlings out there — hunt in packs, never give up" + 6: "Dancing light thing — if you follow it, you're in trouble" + 7: "Ground — in some patches, not as firm as it looks" + 8: "Grove of flowers — if one of you sees a yellow one, you're doomed" + 9: "Howling at night — once it starts, they've smelled you" + 10: "Krenva — his gear prices are great, but if your rations start talkin'..." + 11: "Red mist — makes your eyes bleed if you ain't careful" + 12: "Tower that looks... weird — always seems to be on the move" + +creepy_vibe: + description: "This place is creepy — it's like..." + table: + 2: "Alive. The entire place is pulsing, breathing." + 3: "Closing in around you, suffocating you step by step." + 4: "Every sound echoes back, delayed and whispered." + 5: "Every step creaks, but the floor is stone." + 6: "Illuminated in dim light without a source — turning yellow." + 7: "Abandoned, but it feels like something's here." + 8: "Someone's watching... is that ticking?" + 9: "Burning smell tinged with strawberries." + 10: "That flickering orb wants us to follow it." + 11: "Shuffling feet getting nearer... or not." + 12: "The air is acrid — blood, decay, mint." + 13: "Water nearby, but arid dryness in the mouth." + 14: "Fog curls and shifts to let you through." + 15: "Way warmer than expected, but cold within." + 16: "You've been transported elsewhere." + 17: "Tapping now and again on your shoulder or the walls." + 18: "A sense of falling, though you stand still." + +npc_knows: + description: "This NPC knows something about a character's..." + table: + 1: "Ex-lover" + 2: "Family member" + 3: "Hidden birth mark or feature" + 4: "Mentor or arch nemesis" + 5: "Previous career or home" + 6: "Weakness or fear" + +chalk_circle: + description: "This chalk circle might..." + table: + 3: "Be drawn by an invisible hand as the party arrives" + 4: "Be nothing more than a chalk circle" + 5: "Rub out in a breeze and become inoperable" + 6: "Contain a small bunny — scared or teased out, it evaporates" + 7: "Contain a trap concealed by illusion" + 8: "Contain something that tries to bargain to escape" + 9: "Echo — another circle elsewhere to talk back and forth" + 10: "Emit a beautiful scent — inside the circle, poison gas" + 11: "Have one treasure within — too heavy to move" + 12: "Make those within it dance beyond exhaustion" + 13: "Mutate the floor into a mouth — swallows all upon it" + 14: "Protect those within it from what's approaching" + 15: "Shift colours — devious pixie work" + 16: "Summon a presence from another plane" + 17: "Teleport you to a random circle elsewhere in The Realm" + 18: "Transform the appearance of those within it — permanently?" + +only_way_in: + description: "Wait, so the only way in is..." + table: + 1: "Also the only way to escape what's behind us?" + 2: "Closing fast — and we can see what's waiting inside?" + 3: "Doing a job badly, taking the blame, ruining our reputation?" + 4: "Sacrificing someone or something we can't live without?" + 5: "Past that gang — one of whom has four arms?" + 6: "Through the mouth of a huge dead worm — that might not be dead?" + +ask_players: + description: "Questions to ask the players during a scene." + table: + 3: "As you move, what are you looking for, and how, and why?" + 4: "Do you carry an item that means something to you? What is it?" + 5: "Didn't you dream about this last night? What happened?" + 6: "If not for this, what would you be doing? What do you share?" + 7: "In moments like this, what's your likely catch phrase?" + 8: "Someone let one rip... own up, who was it?" + 9: "Something about your chains, discords, or what's gone wrong?" + 10: "Something strikes against your morals here — what is it?" + 11: "This rumour or job — what's worrying you about it?" + 12: "Something catches your attention? What do you see or hear?" + 13: "What's your biggest fear or hope right now? How do you show it?" + 14: "What's your plan for after all this? What do you tell the others?" + 15: "What's your routine here? What keeps you sane and ready?" + 16: "You heard a rumour about something here... what was it?" + 17: "You hear something faint — what did it sound like?" + 18: "You've got a bad habit — don't we all? What's yours?" diff --git a/rules/deck/souls.yaml b/rules/deck/souls.yaml new file mode 100644 index 0000000..ff403c6 --- /dev/null +++ b/rules/deck/souls.yaml @@ -0,0 +1,155 @@ +# Souls of the Chaos — Character Creation Cards +# Draw from these tables to create or modify a character. + +traits: + description: "Roll 3d6 for each trait (STR, DEX, WIL). Lower is better." + sides: 6 + count: 3 + per_trait: true + +thing: + description: "Your character's 'class' or archetype. Determines max health modifier." + table: + 1: { name: "The Brute", health_mod: 2, hint: "Strong, tough, direct." } + 2: { name: "The Cunning", health_mod: 0, hint: "Sly, quick, resourceful." } + 3: { name: "The Guardian", health_mod: 2, hint: "Protective, sturdy, watchful." } + 4: { name: "The Shadow", health_mod: 0, hint: "Stealthy, elusive, silent." } + 5: { name: "The Weaver", health_mod: -1, hint: "Arcane, perceptive, strange." } + 6: { name: "The zealot", health_mod: 1, hint: "Driven, faithful, relentless." } + +max_health: + description: "Roll 2d6 + thing modifier. Minimum 2." + formula: "2d6 + thing_mod" + +starting_cash: + description: "Roll 3d6 × 5 for starting silver." + formula: "3d6 * 5" + +failed_career: + description: "What you did before adventuring (and failed at)." + table: + 1: "Alchemist's assistant — your master's lab exploded." + 2: "Barber-surgeon — you lost more patients than you saved." + 3: "City watch — you were fired for taking bribes / sleeping on duty." + 4: "Coin counter — you embezzled and fled." + 5: "Ditch digger — you hit a sewer main and flooded the street." + 6: "Farmhand — the farm was cursed / burned / eaten by beasts." + 7: "Grave keeper — the dead started walking, and you walked faster." + 8: "Hedge knight — your employer died under your 'protection'." + 9: "Inn keep — the inn was repossessed / burned down." + 10: "Jester — your jokes landed you in a dungeon." + 11: "Letter carrier — you lost the mail (all of it, somehow)." + 12: "Mercenary — your company was routed, you were the sole survivor." + +whats_gone_wrong: + description: "Something that's haunting you right now." + table: + 1: "A debt you cannot pay is coming due." + 2: "A powerful person wants you dead or captured." + 3: "You're cursed — minor but annoying." + 4: "You lost something irreplaceable." + 5: "Someone you love is in danger." + 6: "You're being blackmailed." + 7: "You have a secret that would destroy your reputation." + 8: "An old ally now considers you a betrayer." + 9: "You're addicted to something dangerous." + 10: "You witnessed something you were never meant to see." + 11: "A rival frames you for crimes you didn't commit." + 12: "Something from your failed career catches up with you." + +chains: + description: "Who or what ties you to this world? Roll or choose." + table: + 1: "A sibling who depends on you." + 2: "An oath you swore and cannot break." + 3: "A child you're protecting." + 4: "A mentor who taught you everything." + 5: "A pet or companion that follows you." + 6: "A debt of honour to a family." + 7: "A sacred duty to a forgotten god." + 8: "A bloodline you're the last of." + 9: "A promise to a dying friend." + 10: "A community that raised you." + 11: "A lover you're searching for." + 12: "A kingdom you're trying to restore." + +discords: + description: "Who or what makes your life harder?" + table: + 1: "A rival who wants what you have." + 2: "A faction that hunts your kind." + 3: "An ex-partner full of spite." + 4: "A religious order that condemns you." + 5: "A gang you crossed." + 6: "A noble whose name you sullied." + 7: "A monster that remembers you." + 8: "A liar who spreads rumours about you." + 9: "A creditor who never forgets." + 10: "A family feud that reignites." + 11: "A thief who stole your identity." + 12: "A corrupt official with a grudge." + +gear: + description: "Starting equipment. Spend cash on this." + table: + cheap: + - { item: "Rope (30ft)", cost: 1 } + - { item: "Torches (3)", cost: 1 } + - { item: "Rations (3 days)", cost: 1 } + - { item: "Waterskin", cost: 1 } + - { item: "Chalk (10 pieces)", cost: 1 } + - { item: "Candle", cost: 1 } + - { item: "Flint & steel", cost: 1 } + - { item: "Small sack", cost: 1 } + - { item: "Bedroll", cost: 2 } + - { item: "Crowbar", cost: 2 } + - { item: "Grappling hook", cost: 3 } + - { item: "Lantern", cost: 3 } + - { item: "Oil (flask)", cost: 1 } + - { item: "Tent", cost: 3 } + - { item: "Healing salve (restores 1 HP)", cost: 5 } + - { item: "Antitoxin", cost: 5 } + luxury: + - { item: "Fine clothes", cost: 10 } + - { item: "Jewellery", cost: 15 } + - { item: "Musical instrument", cost: 10 } + - { item: "Writing kit", cost: 8 } + - { item: "Spyglass", cost: 20 } + +hittin_tools: + description: "Weapons. Each has a damage modifier." + table: + 1: { name: "Dagger", damage_mod: 0, cost: 3, note: "Small, concealable" } + 2: { name: "Short sword", damage_mod: 0, cost: 5, note: "Reliable" } + 3: { name: "Long sword", damage_mod: 1, cost: 8, note: "Versatile" } + 4: { name: "Axe", damage_mod: 1, cost: 7, note: "Can be thrown" } + 5: { name: "Mace", damage_mod: 1, cost: 6, note: "Bypasses some armour" } + 6: { name: "Spear", damage_mod: 0, cost: 4, note: "Reach, can be thrown" } + 7: { name: "Bow", damage_mod: 0, cost: 7, note: "Two-handed, ranged" } + 8: { name: "Crossbow", damage_mod: 1, cost: 10, note: "Two-handed, slow to reload" } + 9: { name: "Staff", damage_mod: -1, cost: 1, note: "Two-handed, can be focus" } + 10: { name: "Flail", damage_mod: 1, cost: 8, note: "Ignores shield bonus" } + 11: { name: "Hatchet", damage_mod: 0, cost: 4, note: "Light, off-hand" } + 12: { name: "Improvised", damage_mod: -1, cost: 0, note: "Bottle, chair leg, etc." } + +whack_blunters: + description: "Armour reduces incoming damage." + table: + 1: { name: "Padded", reduction: -1, cost: 5, note: "Quiet" } + 2: { name: "Leather", reduction: -1, cost: 8, note: "Flexible" } + 3: { name: "Studded leather", reduction: -2, cost: 15, note: "Light" } + 4: { name: "Chain shirt", reduction: -2, cost: 25, note: "Covers torso" } + 5: { name: "Scale mail", reduction: -3, cost: 35, note: "Noisy" } + 6: { name: "Plate", reduction: -4, cost: 50, note: "Heavy, noisy" } + 7: { name: "Shield", reduction: -1, cost: 10, note: "Can be used to shove" } + 8: { name: "Helmet", reduction: -1, cost: 8, note: "Only vs head shots" } + +arcane_weavin: + description: "Optional. If you play a Weaver, roll to see your arcane flavour." + table: + 1: "Wild magicks — unpredictable, surges happen." + 2: "Blood magicks — costs health to cast." + 3: "Bone magicks — requires remains or tokens." + 4: "Storm magicks — tied to weather and sky." + 5: "Veil magicks — deals with spirits and the dead." + 6: "Rune magicks — inscribed, prepared in advance." diff --git a/rules/mechanics.md b/rules/mechanics.md new file mode 100644 index 0000000..bfd3ce0 --- /dev/null +++ b/rules/mechanics.md @@ -0,0 +1,93 @@ +# The Chaos — Core Mechanics Reference + +## Core Dice + +- **d6**: Standard six-sided die. All rolls use d6. +- **Odds (Rapid Rulin')**: Roll 1d6. 4+ favours the character(s). 3- is trouble. +- **Traits**: Roll 3d6. If result is **lower than** the relevant trait score, you succeed. + +## Character Stats + +- **Traits** (STR, DEX, WIL): Roll 3d6 for each. Lower is better. +- **Max Health**: 2d6 + Thing modifier (min 2). +- **Starting Cash**: 3d6 × 5 silver. +- **Damage**: 1d6 ± weapon modifier ± armour reduction. + +## Conflict (Combat) + +### Before Conflict (Encounter Start) +1. **Distance**: 2d6 × 10 (metres/feet) — how far threat is from character(s). +2. **Surprise**: 1d6 — 1-2 characters surprised, 3-4 creatures surprised, 5 both, 6 neither. +3. **Grit**: 2d6 — check against grit score or grit tables. + +### During Conflict (Each Round ≈ 10 seconds) +1. **State Intentions** — What does each side want to do? +2. **Grit** (creatures/NPCs) — Roll 2d6, check grit score/tables. +3. **Initiative** — Both sides roll 1d6. Winner acts first. Same = simultaneous. +4. **Turns** — Roll 1d6 ± modifiers. 4+ = success. 3- = take a hit. +5. **Damage** — 1d6 ± weapon mod ± armour reduction. + +### New Paths (Alternatives to Fighting) +- **Run off** — Check conditions and surprise. +- **Dodge & Parry** — Roll 1d6 + 1. 4+ move without hit. If lost initiative, enemy gets -1 to hit. +- **Parley** — Check grit, use relevant traits. Resolve organically. +- **Innovate** — Check complications and grit. Resolve naturally. + +## Exploration Loop + +When exploring perilous areas, time narrows into **6 ten-minute watches** (total = 1 hour). + +Each meaningful action (search, make noise, deal with trap, etc.): + +1. Player states intent +2. GM calls for roll (odds or traits) +3. Result determines outcome +4. Mark a watch box + +After 6 watches, the situation usually changes (random encounter, scene shift, etc.). + +## Wounds & Death + +When reduced to 0 HP, roll 1d6: +- **1-2**: Die immediately. +- **3-4**: Lasting wound — reduce max health by 1. +- **5-6**: -1 modifier on all rolls until healed. + +## Grit Mechanic + +Creatures and NPCs roll 2d6 for grit. Higher = more determined. Check against grit score or: +- Result > grit score → they look for the exit. +- Result ≤ grit score → they press the attack. + +## Roll Modifiers + +| Situation | Modifier | +|-----------|----------| +| Favourable position | +1 | +| Risky or rushed | -1 | +| Desperate | -2 | +| Well-prepared | +1 | +| Poor visibility | -1 | +| Using a relevant trait | +1 | + +## Darkness + +Most creatures see fine in the dark. Characters without a light source: +- Roll at -1 on actions +- Surprise chance increases +- Exploration loop takes longer + +## Complications + +When the dice say "yes, but...": +- Success costs something (gear, time, health) +- Unexpected noise attracts attention +- A secondary problem emerges +- The situation escalates + +## Rest & Healing + +- **Short rest** (few hours in relative safety): Recover 1d6 HP. +- **Long rest** (full night in safe haven): Recover all HP. +- **Healing salve**: Restores 1 HP when applied. +- **Antitoxin**: Cures one poisoning. diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..ba004c3 --- /dev/null +++ b/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")" +python3 tools/run.py diff --git a/session/character.md b/session/character.md new file mode 100644 index 0000000..4d05b82 --- /dev/null +++ b/session/character.md @@ -0,0 +1,48 @@ +# Character + +**Name:** Dillion +**Thing:** Guardian +**Failed Career:** Jester — told a smutty joke about the mayor's daughter, landed in a dungeon. +**What's Gone Wrong:** Has a secret: once dressed as a princess and loved it — would destroy his rep. + +## Traits + +- **STR:** 10 +- **DEX:** 14 +- **WIL:** 9 + +## Vitals + +- **Max Health:** 10 +- **Current Health:** 10 +- **Armour:** Leather (-1 reduction) +- **Weapon:** Mace (1d6+1) +- **Cash:** 37 silver + +## Chains + +Searching for a lover who would love him for his feminine side too. + +## Discords + +Jaggard — a rich merchant whose name Dillion sullied. + +## Gear + +- Mace (1d6+1) +- Leather armour (-1) +- Rations (9 days) +- Torches (6) +- Rope (30ft) +- Waterskin +- Bedroll +- Flint & steel +- Small sack +- Healing salve (restores 1 HP) + +## Notes & Scribbles + +- Mayor's daughter joke → dungeon → exile +- Secret princess dress-up +- Looking for someone who accepts all of him +- Jaggard the merchant holds a grudge diff --git a/session/log/2026-06-23.md b/session/log/2026-06-23.md new file mode 100644 index 0000000..ab69b12 --- /dev/null +++ b/session/log/2026-06-23.md @@ -0,0 +1,17 @@ +# Session Log — 2026-06-23 + +## Before Start + +Character created: Dillion the Guardian. Failed jester, secret princess, looking for love, fleeing Jaggard's grudge. + +## What Happened + +- + +## Aftermath + +- **XP / Progression:** +- **Loot:** +- **World Changes:** +- **Next Session Seeds:** +- Let's start building the world first. diff --git a/session/tweaks.md b/session/tweaks.md new file mode 100644 index 0000000..90588a6 --- /dev/null +++ b/session/tweaks.md @@ -0,0 +1,7 @@ +# Tweaks + +_Any house rules, custom tables, or modifications we've made._ + +| Date | Change | +|------|--------| +| | | diff --git a/session/world.md b/session/world.md new file mode 100644 index 0000000..8ef1063 --- /dev/null +++ b/session/world.md @@ -0,0 +1,47 @@ +# The World + +## The Keep + +_The starting point. A fortified settlement at the edge of The Realm._ + +### Notable NPCs + +- _to discover_ + +### Services + +- **Inn:** _Splintered Tankard_ — cheap ale, warm stew, gossip +- **Smithy:** _to discover_ +- **Temple:** _to discover_ +- **Market:** _to discover_ +- **Guild Hall:** _to discover_ + +### Rumours + +| # | Rumour | Status | +|---|--------|--------| +| | | | + +### Jobs + +| # | Job | Pay | Status | +|---|-----|-----|--------| +| | | | | + +## The Realm + +_What lies beyond the gates._ + +### Locations Discovered + +| Name | Terrain | Threat Level | Notes | +|------|---------|-------------|-------| +| | | | | + +### Active Threads + +- + +### Factions at Play + +- _to be revealed_ diff --git a/tools/draw.py b/tools/draw.py new file mode 100755 index 0000000..a367b3a --- /dev/null +++ b/tools/draw.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +draw.py — Draw a card from The Chaos deck. + +Usage: + python3 draw.py
[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() diff --git a/tools/roll.py b/tools/roll.py new file mode 100755 index 0000000..2ea9efb --- /dev/null +++ b/tools/roll.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +roll.py — Roll dice for The Chaos TTRPG. + +Usage: + python3 roll.py [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() diff --git a/tools/run.py b/tools/run.py new file mode 100755 index 0000000..2eabb9e --- /dev/null +++ b/tools/run.py @@ -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()