Compare commits

...

10 Commits

Author SHA1 Message Date
Dejvino
bd0582c78b Scrollable tools 2026-07-05 10:48:54 +02:00
Dejvino
f0774fc946 Refactored tools to follow the same pattern 2026-07-05 10:40:14 +02:00
Dejvino
3e1778d0d7 Add meta log 2026-07-05 10:18:48 +02:00
Dejvino
f98392478b Better tools params handling 2026-07-04 22:01:00 +02:00
Dejvino
f36387f1bf End game rules and player name extraction 2026-07-04 21:50:51 +02:00
Dejvino
1c64ac79f4 Game rules 2026-07-04 21:15:29 +02:00
Dejvino
49df5a67e8 Game rules injected along with tool 2026-07-04 21:15:12 +02:00
Dejvino
e002bafbc8 Add meta language 2026-07-04 16:38:58 +02:00
Dejvino
83a83dd421 More texts for retries 2026-07-04 15:55:49 +02:00
Dejvino
a0d24c9d44 Validate speech and disable dice rolls 2026-07-04 15:52:22 +02:00
19 changed files with 1162 additions and 621 deletions

View File

@ -22,7 +22,10 @@ in that process.
the-chaos/ the-chaos/
├── rules/ # LOCKED — game rules, do not modify ├── rules/ # LOCKED — game rules, do not modify
│ ├── deck/ # Card tables │ ├── deck/ # Card tables
│ └── mechanics.md # Core mechanics reference │ ├── mechanics.md # Core mechanics reference
│ ├── core_mechanics.md # Condensed core rules (injected into system prompt)
│ ├── character_creation.md # Character creation rules
│ └── end_game.md # End-game closure rules
├── tools/ # Game system code ├── tools/ # Game system code
│ ├── __init__.py │ ├── __init__.py
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state) │ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
@ -201,7 +204,7 @@ Default is "tools" for faster single-call generation.
## Action Validation ## Action Validation
Before every turn, a separate lightweight LLM call (`validate_action` in `tools/engine_lib/validation.py`) checks whether the player's action is possible given the character sheet and world state. This catches impossible actions like using items not in inventory, asserting false facts, or attempting nonsensical actions. After every turn is generated, a separate lightweight LLM call (`validate_turn` in `tools/engine_lib/validation.py`) checks whether the turn is valid given the character sheet and world state. This catches impossible actions like using items not in inventory, asserting false facts, or attempting nonsensical actions.
- Uses `VALIDATION_PROMPT` template with character + world state - Uses `VALIDATION_PROMPT` template with character + world state
- Low temperature (0.2), low max tokens (256) - Low temperature (0.2), low max tokens (256)
@ -352,6 +355,7 @@ This allows compatibility with OpenAI-compatible servers that return content in
- `session/ambience.md` — Current ambience (written by engine) - `session/ambience.md` — Current ambience (written by engine)
- `session/log/<date>.md` — Session logs (written by engine) - `session/log/<date>.md` — Session logs (written by engine)
- `session/tweaks.md` — House rules (manual edit) - `session/tweaks.md` — House rules (manual edit)
- `archive/<hero>-<timestamp>/` — Archived completed games
## LLM Strategies Explained ## LLM Strategies Explained
@ -380,3 +384,30 @@ Uses single LLM call with all tools available:
4. Check config.json for LLM settings 4. Check config.json for LLM settings
5. Look for missing imports in the engine.py file 5. Look for missing imports in the engine.py file
6. Verify that the LLM provider is correctly configured in config.json 6. Verify that the LLM provider is correctly configured in config.json
## End Game Mechanics
The game ends when the LLM outputs the marker `### THE END` in the narrative. Detection is done by checking `END_MARKER in book_log` in `engine.py`.
### End Game Flow
1. LLM outputs narrative containing `### THE END`
2. Engine detects the marker and sets `TurnResult.game_over = True`
3. TUI shows the epilogue and disables input
4. A "Close the Book and Start a New One" button appears
5. On click: `state.archive_session()` copies `session/` to `archive/<hero-name>-<timestamp>/`, clears session state, and restarts the game loop
### Rules for the LLM
- The marker must be exactly `### THE END` (level-3 heading)
- After the marker: provide **Why**, **What Happened**, and **The World After** (2-3 paragraphs)
- Do NOT call state-changing tools after the marker
- Use `read_rules` with `category: "end_game"` for full details
### read_rules Categories
The `read_rules` tool accepts a `category` parameter:
- `mechanics` — Full mechanics reference (default)
- `core` — Condensed core rules
- `character_creation` — Character creation rules
- `end_game` — End-game closure rules

157
rules/character_creation.md Normal file
View File

@ -0,0 +1,157 @@
# Character Creation
## Step 1 — Traits
Roll **3d6** for each trait (STR, DEX, WIL). **Lower is better.**
| Trait | Range |
|-------|-------|
| STR | 3d6 |
| DEX | 3d6 |
| WIL | 3d6 |
## Step 2 — Choose Your Thing
Your "Thing" is your archetype. It determines your **max health modifier**.
| Thing | Health Mod | Hint |
|-------|-----------|------|
| The Brute | +2 | Strong, tough, direct |
| The Cunning | +0 | Sly, quick, resourceful |
| The Guardian | +2 | Protective, sturdy, watchful |
| The Shadow | +0 | Stealthy, elusive, silent |
| The Weaver | -1 | Arcane, perceptive, strange |
| The Zealot | +1 | Driven, faithful, relentless |
## Step 3 — Max Health
Roll **2d6 + Thing modifier**. Minimum 2.
## Step 4 — Starting Cash
Roll **3d6 × 5** silver.
## Step 5 — Failed Career
What you did before adventuring (and failed at). Roll 1d12 or choose.
| d12 | Failed Career |
|-----|--------------|
| 1 | Alchemist's assistant — your master's lab exploded |
| 2 | Barber-surgeon — you lost more patients than you saved |
| 3 | City watch — fired for taking bribes or sleeping on duty |
| 4 | Coin counter — you embezzled and fled |
| 5 | Ditch digger — hit a sewer main, flooded the street |
| 6 | Farmhand — the farm was cursed / burned / eaten by beasts |
| 7 | Grave keeper — the dead started walking, you walked faster |
| 8 | Hedge knight — your employer died under your protection |
| 9 | Inn keep — the inn was repossessed or burned down |
| 10 | Jester — your jokes landed you in a dungeon |
| 11 | Letter carrier — you lost the mail (all of it) |
| 12 | Mercenary — your company was routed, sole survivor |
## Step 6 — Gear
### Weapons
| Weapon | Damage Mod | Cost | Note |
|--------|-----------|------|------|
| Dagger | +0 | 3 | Small, concealable |
| Short sword | +0 | 5 | Reliable |
| Long sword | +1 | 8 | Versatile |
| Axe | +1 | 7 | Can be thrown |
| Mace | +1 | 6 | Bypasses some armour |
| Spear | +0 | 4 | Reach, can be thrown |
| Bow | +0 | 7 | Two-handed, ranged |
| Crossbow | +1 | 10 | Two-handed, slow to reload |
| Staff | -1 | 1 | Two-handed, can be focus |
| Flail | +1 | 8 | Ignores shield bonus |
| Hatchet | +0 | 4 | Light, off-hand |
| Improvised | -1 | 0 | Bottle, chair leg, etc. |
### Armour
| Armour | Reduction | Cost | Note |
|--------|----------|------|------|
| Padded | -1 | 5 | Quiet |
| Leather | -1 | 8 | Flexible |
| Studded leather | -2 | 15 | Light |
| Chain shirt | -2 | 25 | Covers torso |
| Scale mail | -3 | 35 | Noisy |
| Plate | -4 | 50 | Heavy, noisy |
| Shield | -1 | 10 | Can be used to shove |
| Helmet | -1 | 8 | Only vs head shots |
### Equipment
| Item | Cost |
|------|------|
| Rope (30ft) | 1 |
| Torches (3) | 1 |
| Rations (3 days) | 1 |
| Waterskin | 1 |
| Chalk (10 pieces) | 1 |
| Candle | 1 |
| Flint & steel | 1 |
| Small sack | 1 |
| Bedroll | 2 |
| Crowbar | 2 |
| Grappling hook | 3 |
| Lantern | 3 |
| Oil (flask) | 1 |
| Tent | 3 |
| Healing salve (restores 1 HP) | 5 |
| Antitoxin | 5 |
| Fine clothes | 10 |
| Jewellery | 15 |
| Musical instrument | 10 |
| Writing kit | 8 |
| Spyglass | 20 |
## Step 7 — Chains (Ties to the World)
Who or what ties you to this world? Roll 1d12 or choose.
| d12 | Chain |
|-----|-------|
| 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 |
## Step 8 — Discords (Complications)
Who or what makes your life harder? Roll 1d12 or choose.
| d12 | Discord |
|-----|---------|
| 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 |
## Optional — Arcane Weaving (Weaver only)
If you play a Weaver, roll 1d6 for your arcane flavour.
| d6 | Arcane Flavour | Description |
|----|---------------|-------------|
| 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 |
## Damage Formula
**Weapon damage**: 1d6 + weapon damage mod + other mods - target armour reduction.
## Final Steps
- Choose a name
- Write a brief description
- Record starting gear on your character sheet

63
rules/core_mechanics.md Normal file
View File

@ -0,0 +1,63 @@
## Core Mechanics
### Dice
- **d6**: All rolls use d6.
- **Odds**: 1d6, 4+ favourable, 3- trouble.
- **Traits**: 3d6, roll UNDER trait score.
### Character
- **Traits (STR/DEX/WIL)**: Roll 3d6 each. Lower is better.
- **Max HP**: 2d6 + Thing modifier (min 2).
- **Starting Cash**: 3d6 × 5 silver.
- **Damage**: 1d6 ± weapon mod ± armour reduction.
### Combat (per round ≈10s)
1. State intentions
2. Grit (creatures): 2d6 — higher = more determined
3. Initiative: both 1d6, winner acts first
4. Turn: 1d6 ± mods, 4+ success, 3- take a hit
5. Damage: 1d6 ± mods
#### Encounter Start
- Distance: 2d6 × 10
- Surprise: 1d6 — 1-2 PC, 3-4 creature, 5 both, 6 neither
- Grit: 2d6
#### Alternatives to Fighting
- Run off, Dodge & Parry (+1, 4+ move without hit), Parley, Innovate
### Exploration
- 6 × 10-min watches per hour
- Each action → roll (odds/traits) → outcome → mark watch
- After 6 watches: scene shifts
### Wounds (0 HP)
- 1d6: 1-2 die, 3-4 -1 max HP, 5-6 -1 all rolls until healed
### Grit (Creatures/NPCs)
- 2d6, higher = more determined
- > grit score → seek exit; ≤ grit score → press attack
### Modifiers
| Situation | Mod |
|---|---|
| Favourable / Well-prepared | +1 |
| Risky / Rushed / Poor visibility | -1 |
| Desperate | -2 |
### Darkness
- Most creatures see fine in dark
- PCs without light: -1 actions, increased surprise, slower exploration
### Complications ("yes, but...")
- Success costs (gear/time/HP), noise, secondary problem, escalation
### Rest & Healing
- Short rest (few hours): 1d6 HP
- Long rest (safe haven): full HP
- Healing salve: +1 HP | Antitoxin: cures poison
### End Game Conditions
The story ends when: the character dies, the main quest reaches a definitive conclusion, the player chooses to retire, or all party members have fallen.
When the story ends, the LLM outputs `### THE END` in the narrative, followed by a reason, what happened, and an epilogue describing how the world evolved.
Call `read_rules` with `category: "end_game"` for full details on ending the game.

56
rules/end_game.md Normal file
View File

@ -0,0 +1,56 @@
# End Game — Closure & Epilogue
## When the Story Ends
The game ends when one of these conditions is reached:
1. **Death** — The character dies and is not revived. The story ends with their final moments.
2. **Quest Complete** — The main story arc reaches a definitive conclusion (the Darkmal is defeated, the Realm is saved or lost, etc.).
3. **Player Retires** — The player decides their character's journey is over and invokes the end.
4. **Mutual TPK** — All party members (if any) have fallen. The Realm's fate is sealed.
## The End Marker
When the story ends, the LLM must include this exact string as a level-3 heading in the narrative:
```
### THE END
```
Everything after this marker is the **epilogue**, which contains:
1. **Why** — A brief statement of why the story ended (death, quest completion, retirement, etc.).
2. **What Happened** — A summary of the final events.
3. **The World After** — At least 2-3 paragraphs describing how the world and its key characters evolved and moved on after the event. This is the closing of the book.
## Example Epilogue Structure
```
### THE END
**Why:** Dillion fell in the final battle against the Darkmal.
**What Happened:** Dillion faced the Darkmal in the heart of the Spire. The battle was fierce — Dillion's blade found its mark, but the Darkmal's final curse was fatal. Both fell.
**The World After:**
The Realm mourned. In the years that followed, the scars of the Darkmal's reign slowly healed. The villages rebuilt, and the wilds grew quiet once more...
Rina took up Dillion's cause, traveling the Realm to protect those who could not protect themselves. She spoke often of the weaver who stood against the darkness...
The Reaper, denied his prize, vanished into the eastern wastes. Some say he still searches for a path to lichdom, but without the Darkmal's power, his reach is broken...
```
## After the End
Once the marker is detected, the TUI shows a "Close the Book and Start a New One" button. Clicking it:
1. Archives the current session folder into `archive/<hero-name>/` with a timestamp.
2. Clears all session state for a fresh start.
3. Presents the character creation flow again.
## Technical Notes
- The LLM must output `### THE END` exactly as shown — case-sensitive, with a space before "THE".
- Only the narrative tool block should contain the marker. No other tool blocks are needed for ending.
- The LLM should **not** call any state-changing tools after the marker — the epilogue is narrative-only.

View File

@ -1,4 +1,4 @@
# The Chaos — Core Mechanics Reference # The Chaos — Full Mechanics Reference
## Core Dice ## Core Dice
@ -11,7 +11,49 @@
- **Traits** (STR, DEX, WIL): Roll 3d6 for each. Lower is better. - **Traits** (STR, DEX, WIL): Roll 3d6 for each. Lower is better.
- **Max Health**: 2d6 + Thing modifier (min 2). - **Max Health**: 2d6 + Thing modifier (min 2).
- **Starting Cash**: 3d6 × 5 silver. - **Starting Cash**: 3d6 × 5 silver.
- **Damage**: 1d6 ± weapon modifier ± armour reduction. - **Damage**: 1d6 + weapon damage mod + other mods - target armour reduction.
## Equipment
### Weapons
| Weapon | Damage Mod | Cost | Note |
|--------|-----------|------|------|
| Dagger | +0 | 3 | Small, concealable |
| Short sword | +0 | 5 | Reliable |
| Long sword | +1 | 8 | Versatile |
| Axe | +1 | 7 | Can be thrown |
| Mace | +1 | 6 | Bypasses some armour |
| Spear | +0 | 4 | Reach, can be thrown |
| Bow | +0 | 7 | Two-handed, ranged |
| Crossbow | +1 | 10 | Two-handed, slow to reload |
| Staff | -1 | 1 | Two-handed, can be focus |
| Flail | +1 | 8 | Ignores shield bonus |
| Hatchet | +0 | 4 | Light, off-hand |
| Improvised | -1 | 0 | Bottle, chair leg, etc. |
### Armour
| Armour | Reduction | Cost | Note |
|--------|----------|------|------|
| Padded | -1 | 5 | Quiet |
| Leather | -1 | 8 | Flexible |
| Studded leather | -2 | 15 | Light |
| Chain shirt | -2 | 25 | Covers torso |
| Scale mail | -3 | 35 | Noisy |
| Plate | -4 | 50 | Heavy, noisy |
| Shield | -1 | 10 | Can be used to shove |
| Helmet | -1 | 8 | Only vs head shots |
### Equipment Costs
| Item | Cost | | Item | Cost |
|------|------|---|------|------|
| Rope (30ft) | 1 | | Healing salve | 5 |
| Torches (3) | 1 | | Antitoxin | 5 |
| Rations (3 days) | 1 | | Fine clothes | 10 |
| Waterskin | 1 | | Jewellery | 15 |
| Bedroll | 2 | | Musical instrument | 10 |
| Crowbar | 2 | | Writing kit | 8 |
| Grappling hook | 3 | | Spyglass | 20 |
| Lantern | 3 | | Tent | 3 |
## Conflict (Combat) ## Conflict (Combat)
@ -25,7 +67,7 @@
2. **Grit** (creatures/NPCs) — Roll 2d6, check grit score/tables. 2. **Grit** (creatures/NPCs) — Roll 2d6, check grit score/tables.
3. **Initiative** — Both sides roll 1d6. Winner acts first. Same = simultaneous. 3. **Initiative** — Both sides roll 1d6. Winner acts first. Same = simultaneous.
4. **Turns** — Roll 1d6 ± modifiers. 4+ = success. 3- = take a hit. 4. **Turns** — Roll 1d6 ± modifiers. 4+ = success. 3- = take a hit.
5. **Damage** — 1d6 ± weapon mod ± armour reduction. 5. **Damage** — 1d6 + weapon mod - target armour.
### New Paths (Alternatives to Fighting) ### New Paths (Alternatives to Fighting)
- **Run off** — Check conditions and surprise. - **Run off** — Check conditions and surprise.
@ -46,6 +88,38 @@ Each meaningful action (search, make noise, deal with trap, etc.):
After 6 watches, the situation usually changes (random encounter, scene shift, etc.). After 6 watches, the situation usually changes (random encounter, scene shift, etc.).
## Travel & Hazards
### Terrain Effects
| Terrain | Effect |
|---------|--------|
| Forest | Dense trees, poor visibility, lots of cover |
| Marsh | Slow movement, sinking hazards, biting insects |
| Hills | Good vantage points, exhausting climbs |
| Plains | Exposed, fast travel, little cover |
| Ruins | Partial cover, unstable floors, hidden chambers |
| Mountains | Slow travel, avalanches, thin air, caves |
### Weather Effects
| Weather | Effect |
|---------|--------|
| Clear | Good visibility, normal travel |
| Overcast | Dim light, muffled sounds |
| Heavy rain | Reduced visibility, fires hard to light, tracks wash away |
| Fog | Everything obscured beyond 20ft |
| Wind storm | Ranged attacks at disadvantage, noise hides ambushes |
| Sweltering heat | Exhaustion risk, water consumption doubles |
### Travel Mishaps (1d6 when travelling)
| d6 | Mishap |
|----|--------|
| 1 | Character twists an ankle. -1 on all rolls until rest |
| 2 | Supplies damaged or lost. Ruined rations, broken gear |
| 3 | Wrong turn. Add 1 extra watch to travel time |
| 4 | Loud noise attracts unwanted attention |
| 5 | Minor injury. 1d6: 1-2 take 1 damage |
| 6 | Something valuable slips from a pocket or pack unnoticed |
## Wounds & Death ## Wounds & Death
When reduced to 0 HP, roll 1d6: When reduced to 0 HP, roll 1d6:
@ -85,9 +159,126 @@ When the dice say "yes, but...":
- A secondary problem emerges - A secondary problem emerges
- The situation escalates - The situation escalates
## Stress
Stress builds from traumatic events, supernatural exposure, and sustained hardship.
### Stress Triggers
- Witnessing death or horror
- Supernatural exposure (wild magicks, curses, possession)
- Prolonged deprivation (no rest, no food, extreme environments)
- Failed grit checks in dire situations
### Stress Effects
- Each point of accumulated stress applies -1 to all rolls
- At stress > WIL score: disadvantage on all actions until stress is reduced
- At stress > 2× WIL: character breaks (flee, freeze, frenzy — GM's call)
### Stress Recovery
- Short rest in safety: reduce stress by 1
- Long rest in safe haven: reduce stress by 1d6
- Healing salve does NOT reduce stress
- Strong drink or drugs may temporarily suppress stress but risk addiction
## Wild Magicks
The Realm bleeds chaos. Wild magicks surge when:
- A Weaver fails a casting roll
- A magical artefact is used carelessly
- The veil between realms is thin (GM's call)
- The dice say "yes, but..." involving magic
### Surge Effects (1d6)
| d6 | Effect |
|----|--------|
| 1 | Character briefly swaps places with their reflection |
| 2 | Gravity inverts in a 50ft radius |
| 3 | All flames turn blue and cold — still burns |
| 4 | Vegetation rapidly grows, entangling everyone |
| 5 | Character speaks only in reverse for the next hour |
| 6 | Time loops for 10 seconds — everyone repeats their last action |
## Arcane Weaving
Weavers channel magic through their chosen flavour. Each flavour has a narrative cost:
| Flavour | Cost |
|---------|------|
| Wild magicks | Unpredictable — each casting risks a surge |
| Blood magicks | Costs 1 HP per spell |
| Bone magicks | Requires remains or a token of the dead |
| Storm magicks | Tied to weather — stronger in storms, weaker in calm |
| Veil magicks | Deals with spirits — may attract unwanted attention |
| Rune magicks | Requires preparation — inscribed in advance |
Weaving a spell: roll **Odds** (1d6, 4+). On 3- the magic fizzles or backfires (GM's discretion).
## Monster Creation
### Difficulty Tiers
| Tier | Health Formula | To-Hit | Damage | Grit |
|------|---------------|--------|--------|------|
| Easy | 1 + party total max HP | 0 | 1d6-1 (or 3) | 2d6 (or 7) |
| Hard | 6 + party total max HP | +1 | 1d6+1 (or 5) | 2d6+2 (or 9) |
| Oh Boy | 12 + party total max HP | +2 | 1d6+6 (or 10) | 2d6+3 (or 10) |
### Number Appearing
| Tier | Count |
|------|-------|
| Easy | 3d6 |
| Hard | 2d6 |
| Oh Boy | 1d6 |
### Creature Anatomy
Creatures have: type, appearance, size, difficulty, attack method, defence/reduction, special features, and a current need/motivation.
### Pre-Baked Creatures
| Creature | HP | Armour | To-Hit | Damage | Grit | Feature |
|----------|----|--------|--------|--------|------|---------|
| Boneplasm Totem | 9 | -3 | +2 | 1d6+1 (acidic) | — | Spray boiling blood, reform |
| Frother | 6 | -2 | +1 | 1d6+2 (teeth & claws) | 9 | Grapple & teleport, mutation |
| Karrion Drone | 3 | -1 | 0 | 1d6 (sting, poison) | 5 | Pollinate nectar, phase ethereal |
| Screebies (swarm) | 2 each | -1 | -1 | 1d6-2 (bite) | 7 | Burrow under skin, hive control |
| Shift Mimic | per host | per host | per host | per host | 10 | Body swap, automate host |
| Volcartek | 15 | -4 | +2 | 1d6+4 (slam/burn) | 11 | Expand, glide over lava |
## Factions of the Realm
The Realm is torn between several powers:
| Faction | Description |
|---------|-------------|
| The Empire | Plans to invade and plunder the Realm |
| The Reaper | Serves the Emperor, seeks lichdom |
| The Wilds | Terrorise the Realm from the Wilderness |
| The Huntswoman | Hunts weavers for the Reaper |
| The Darkmal | Uses the Realm to spawn a beast army |
| The Ghast | Daughter of the Darkmal, works against him |
| The Ancients | Cult that foretold the Darkmal's coming |
| The Paragon | If unearthed, may kill the Darkmal |
## Downtime Activities
### Training & Research
Cost: time (days) + silver (GM's discretion). Roll 2d6 for outcome.
| 2d6 | Outcome |
|-----|---------|
| 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 ally — 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 — lasting wound or permanent stress |
| 8 | Increase max health by 1 |
| 9 | Not enhance — marks you with random arcane power |
| 10 | Open a quest when your mentor disappears |
| 11 | Reveal your mentor is a wanted criminal... but innocent? |
| 12 | Succeed — attract unwanted repute, rumours, or hassle |
## Rest & Healing ## Rest & Healing
- **Short rest** (few hours in relative safety): Recover 1d6 HP. - **Short rest** (few hours in relative safety): Recover 1d6 HP, reduce stress by 1.
- **Long rest** (full night in safe haven): Recover all HP. - **Long rest** (full night in safe haven): Recover all HP, reduce stress by 1d6.
- **Healing salve**: Restores 1 HP when applied. - **Healing salve**: Restores 1 HP when applied.
- **Antitoxin**: Cures one poisoning. - **Antitoxin**: Cures one poisoning.

View File

@ -8,7 +8,7 @@ from datetime import datetime
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from engine_lib.models import TurnResult from engine_lib.models import TurnResult, END_MARKER
from engine_lib import config from engine_lib import config
from engine_lib.context import build_system_prompt from engine_lib.context import build_system_prompt
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
@ -16,19 +16,41 @@ from engine_lib.tools_handler import execute_tool, describe_change, extract_tool
from engine_lib.parsing import log_turn_details from engine_lib.parsing import log_turn_details
from engine_lib import state from engine_lib import state
from engine_lib.llm import call_llm from engine_lib.llm import call_llm
from engine_lib.paths import CHARACTER_CREATION_PATH, RULES_INJECTION_PATH
class GameEngine: class GameEngine:
REQUIRED_TOOL_ARGS: dict[str, list[str]] = {
"modify_inventory": ["operation", "item"],
"modify_note": ["operation"],
"modify_world": ["operation", "value"],
"modify_journal": ["operation", "value"],
}
def __init__(self, session_dir: str | Path | None = None): def __init__(self, session_dir: str | Path | None = None):
self.config = config.load_config() self.config = config.load_config()
def _check_required_tool_args(self, state_changes: list[dict]) -> str:
"""Check that all state-changing tool calls have required args. Returns empty string if OK, or a description of what's missing."""
missing = []
for tc in state_changes:
name = tc.get("tool", "")
req = self.REQUIRED_TOOL_ARGS.get(name)
if not req:
continue
args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
for arg in req:
val = args.get(arg)
if val is None or (isinstance(val, str) and not val.strip()):
missing.append(f"{name}: missing required `{arg}`")
return "; ".join(missing)
def generate_turn( def generate_turn(
self, self,
player_action: str | None = None, player_action: str | None = None,
recent_narrative: str | None = None, recent_narrative: str | None = None,
on_thought: callable = None, on_thought: callable = None,
on_action: callable = None, on_action: callable = None,
on_player_roll: callable = None,
) -> TurnResult: ) -> TurnResult:
now = datetime.now() now = datetime.now()
state.append_llm_log(f"\n{'='*60}") state.append_llm_log(f"\n{'='*60}")
@ -49,11 +71,27 @@ class GameEngine:
if on_action: if on_action:
on_action("DM is preparing a response") on_action("DM is preparing a response")
is_new_game = not player_action and not recent_narrative
system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log) system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log)
if is_new_game:
cc = state.read_file(CHARACTER_CREATION_PATH)
if cc:
system += f"\n\n## Character Creation Reference\n{cc}"
state.append_llm_log(f"\n[NEW GAME] injected character_creation.md ({len(cc)} chars)")
is_meta = bool(player_action and player_action.strip().startswith(">"))
base_parts = [] base_parts = []
if player_action: if player_action:
base_parts.append(f"## Player's Request\n{player_action}") base_parts.append(f"## Player's Request\n{player_action}")
if not player_action and not recent_narrative: if is_meta:
base_parts.append(
"## Instructions\n"
"The player's message starts with `>` — this is a meta out-of-character question to the DM. "
"Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. "
"Use the `narrative` tool to output your meta response. Do NOT call any other tools (no modify_journal, no finalize_turn, no rolls, no state changes)."
)
elif is_new_game:
base_parts.append( base_parts.append(
"## Instructions\n" "## Instructions\n"
"This is a new story. Welcome the player and guide them through the game setup." "This is a new story. Welcome the player and guide them through the game setup."
@ -64,6 +102,7 @@ class GameEngine:
"Advance the story based on the player's request. " "Advance the story based on the player's request. "
"All state is shown above — write the outcome directly." "All state is shown above — write the outcome directly."
) )
if not is_meta:
base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*") base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*")
base_user = "\n\n".join(base_parts) base_user = "\n\n".join(base_parts)
@ -75,12 +114,15 @@ class GameEngine:
changes: list[str] = [] changes: list[str] = []
start_time = datetime.now() start_time = datetime.now()
total_attempts = 0 total_attempts = 0
prev_raw = ""
_ = None # placeholder
for attempt in range(MAX_RETRIES + 1): for attempt in range(MAX_RETRIES + 1):
total_attempts = attempt + 1 total_attempts = attempt + 1
user = base_user user = base_user
if attempt > 0: if attempt > 0:
user += f"\n\n---\n\n## Turn Generation Feedback\n{feedback}" user += f"\n\n---\n\n## Your Previous Response\n\n```\n{prev_raw}\n```\n\n---\n\n## Feedback\n{feedback}"
state.append_llm_log(f"\n[TOOL] Attempt {attempt + 1}/{MAX_RETRIES + 1}{len(system)} chars system, {len(user)} chars user") state.append_llm_log(f"\n[TOOL] Attempt {attempt + 1}/{MAX_RETRIES + 1}{len(system)} chars system, {len(user)} chars user")
@ -91,13 +133,16 @@ class GameEngine:
if not text or not text.strip(): if not text or not text.strip():
if attempt < MAX_RETRIES: if attempt < MAX_RETRIES:
feedback = "Your response was empty. Generate a complete turn with narrative and state changes." feedback = f"Your response was empty. Generate a complete turn with narrative and state changes."
state.append_llm_log("\n[RETRY] empty response") state.append_llm_log("\n[RETRY] empty response")
if on_action:
on_action("DM is weaving the tale...")
continue continue
return TurnResult(error="LLM returned empty response after retries") return TurnResult(error="LLM returned empty response after retries")
raw = text.strip() raw = text.strip()
state.append_llm_log(f"\n[TOOL] got {len(raw)} chars in {(datetime.now() - start_time).total_seconds() * 1000:.1f}ms") state.append_llm_log(f"\n[TOOL] got {len(raw)} chars in {(datetime.now() - start_time).total_seconds() * 1000:.1f}ms")
prev_raw = raw
tool_calls = extract_tool_calls(raw) tool_calls = extract_tool_calls(raw)
if not tool_calls: if not tool_calls:
@ -107,14 +152,15 @@ class GameEngine:
book_log = "" book_log = ""
ambience = None ambience = None
log_entry = None log_entry = None
meta_log = ""
state_changes: list[dict] = [] state_changes: list[dict] = []
for tc in tool_calls: for tc in tool_calls:
name = tc.get("tool", "") name = tc.get("tool", "")
args = tc.get("args", {}) args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
if name == "narrative": if name == "narrative":
text = args.get("text", "") text = args.get("text", "") or ""
if text: if text:
book_log = (book_log + "\n\n" + text) if book_log else text book_log = (book_log + "\n\n" + text) if book_log else text
elif name == "finalize_turn": elif name == "finalize_turn":
@ -128,13 +174,53 @@ class GameEngine:
ambience = None ambience = None
if args.get("log_entry"): if args.get("log_entry"):
log_entry = args["log_entry"] log_entry = args["log_entry"]
elif name == "player_roll": if args.get("meta_log"):
pass meta_log = args["meta_log"]
elif name not in ("roll",): elif name == "read_rules":
cat = args.get("category", "mechanics")
result = execute_tool("read_rules", {"category": cat})
state.append_llm_log(f"\n[READ RULES] loaded {len(result)} chars")
RULES_INJECTION_PATH.parent.mkdir(parents=True, exist_ok=True)
RULES_INJECTION_PATH.write_text(result)
else:
state_changes.append(tc) state_changes.append(tc)
# Required args check — reject if any state-changing tool is missing required arguments
missing_args = self._check_required_tool_args(state_changes)
if missing_args:
state.append_llm_log(f"\n[TURN MISSING ARGS] {missing_args}")
if attempt < MAX_RETRIES:
feedback = f"The following tool calls are missing required arguments: {missing_args}. Include all required fields for each tool and regenerate."
state.append_llm_log(f"\n[TURN REGENERATE] (missing args) attempt {attempt + 2}")
if on_action:
on_action("DM is consulting the fates...")
continue
state.append_llm_log(f"\n[TURN MISSING ARGS EXCEEDED] accepting despite missing args")
# Meta check — reject if state changes produced for a meta action
if is_meta and state_changes:
state.append_llm_log(f"\n[TURN META REJECTED] state changes not allowed for meta action")
if attempt < MAX_RETRIES:
feedback = f"This is a meta action. Do NOT call any state-changing tools. Respond only with meta text (starting with `>`) and no tool calls beyond a finalize_turn."
state.append_llm_log(f"\n[TURN REGENERATE] (meta) attempt {attempt + 2}")
if on_action:
on_action("DM is consulting the fates...")
continue
state.append_llm_log(f"\n[TURN META EXCEEDED] accepting despite state changes")
# Narrative check — reject if finalized with log_entry but no narrative
if not is_meta and log_entry and not book_log:
state.append_llm_log(f"\n[TURN NO NARRATIVE] finalized with log_entry but no narrative")
if attempt < MAX_RETRIES:
feedback = f"You called finalize_turn with a log_entry but produced no narrative. Every turn must include a `narrative` tool block with the story. Regenerate with both narrative and log_entry."
state.append_llm_log(f"\n[TURN REGENERATE] (no narrative) attempt {attempt + 2}")
if on_action:
on_action("DM is weaving the tale...")
continue
state.append_llm_log(f"\n[TURN NO NARRATIVE EXCEEDED] accepting despite missing narrative")
# Duplicate check — reject if narrative is 80%+ similar to last book entry # Duplicate check — reject if narrative is 80%+ similar to last book entry
if book_log: if not is_meta and book_log:
prev = state.read_recent_book(1) prev = state.read_recent_book(1)
if prev and prev not in ("*No prior story.*",): if prev and prev not in ("*No prior story.*",):
prev_text = re.sub(r"^## Turn \d+\n\n", "", prev, flags=re.MULTILINE).strip() prev_text = re.sub(r"^## Turn \d+\n\n", "", prev, flags=re.MULTILINE).strip()
@ -142,8 +228,10 @@ class GameEngine:
if ratio >= 0.8: if ratio >= 0.8:
state.append_llm_log(f"\n[TURN DUPLICATE] {ratio:.0%} match with previous turn") state.append_llm_log(f"\n[TURN DUPLICATE] {ratio:.0%} match with previous turn")
if attempt < MAX_RETRIES: if attempt < MAX_RETRIES:
feedback = "The narrative is nearly identical to the previous turn. Generate something new and different." feedback = f"The narrative is nearly identical to the previous turn. Generate something new and different."
state.append_llm_log(f"\n[TURN REGENERATE] (duplicate) attempt {attempt + 2}") state.append_llm_log(f"\n[TURN REGENERATE] (duplicate) attempt {attempt + 2}")
if on_action:
on_action("DM is weaving the tale...")
continue continue
state.append_llm_log(f"\n[TURN DUPLICATE EXCEEDED] cannot generate unique narrative") state.append_llm_log(f"\n[TURN DUPLICATE EXCEEDED] cannot generate unique narrative")
return TurnResult( return TurnResult(
@ -163,14 +251,17 @@ class GameEngine:
changes=state_changes, changes=state_changes,
story=recent_narrative, story=recent_narrative,
log=session_log, log=session_log,
meta=is_meta,
) )
if valid: if valid:
state.append_llm_log(f"\n[TURN VALID] {reason}") state.append_llm_log(f"\n[TURN VALID] {reason}")
elif reason == "Unrecognized": elif reason == "Unrecognized":
if attempt < MAX_RETRIES: if attempt < MAX_RETRIES:
feedback = "The validation system could not process the previous turn. Please regenerate." feedback = f"The validation system could not process the previous turn. Please regenerate."
state.append_llm_log(f"\n[TURN REGENERATE] (unrecognized) attempt {attempt + 2}") state.append_llm_log(f"\n[TURN REGENERATE] (unrecognized) attempt {attempt + 2}")
if on_action:
on_action("DM is consulting the fates...")
continue continue
state.append_llm_log(f"\n[TURN UNRECOGNIZED] cannot validate turn") state.append_llm_log(f"\n[TURN UNRECOGNIZED] cannot validate turn")
return TurnResult( return TurnResult(
@ -189,6 +280,8 @@ class GameEngine:
validator_tool = json.dumps({"tool": "validate", "args": {"valid": False, "reason": reason, "action": "regenerate"}}) validator_tool = json.dumps({"tool": "validate", "args": {"valid": False, "reason": reason, "action": "regenerate"}})
feedback = f"The validation tool returned:\n```tool\n{validator_tool}\n```\n\nPlease regenerate the turn addressing the issues above. Keep the same player action but fix the problems described." feedback = f"The validation tool returned:\n```tool\n{validator_tool}\n```\n\nPlease regenerate the turn addressing the issues above. Keep the same player action but fix the problems described."
state.append_llm_log(f"\n[TURN REGENERATE] attempt {attempt + 2}: {reason}") state.append_llm_log(f"\n[TURN REGENERATE] attempt {attempt + 2}: {reason}")
if on_action:
on_action("DM is searching for inspiration...")
continue continue
else: else:
state.append_llm_log(f"\n[TURN REGENERATE EXCEEDED] accepting despite: {reason}") state.append_llm_log(f"\n[TURN REGENERATE EXCEEDED] accepting despite: {reason}")
@ -198,24 +291,22 @@ class GameEngine:
# Accept this turn — execute all tool calls # Accept this turn — execute all tool calls
break break
if is_meta:
tool_calls = [tc for tc in tool_calls if tc.get("tool") == "narrative"]
# Second pass — execute all tool calls # Second pass — execute all tool calls
extr_start = datetime.now() extr_start = datetime.now()
for tc in tool_calls: for tc in tool_calls:
name = tc.get("tool", "") name = tc.get("tool", "")
args = tc.get("args", {}) args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
if name == "narrative": if name in ("narrative", "read_rules"):
pass pass
elif name == "player_roll" and on_player_roll:
dice = args.get("dice", "1d6")
reason = args.get("reason", "a check")
roll_val = on_player_roll(dice, reason)
result = f"Player rolled {dice} for '{reason}': {roll_val}"
else: else:
result = execute_tool(name, args) result = execute_tool(name, args)
if name not in ("narrative", "finalize_turn", "player_roll"): if name not in ("narrative", "finalize_turn", "player_roll", "read_rules"):
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
errors.append(f"{name}: {result}") errors.append(f"{name}: {result}")
else: else:
@ -233,6 +324,8 @@ class GameEngine:
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000 total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
game_over = END_MARKER in book_log
if on_action: if on_action:
on_action("Turn complete") on_action("Turn complete")
@ -248,6 +341,7 @@ class GameEngine:
log_entry=log_entry or "", log_entry=log_entry or "",
ambience=ambience, ambience=ambience,
tool_calls=tool_calls, tool_calls=tool_calls,
meta_log=meta_log,
) )
return TurnResult( return TurnResult(
@ -256,6 +350,9 @@ class GameEngine:
ambience=ambience, ambience=ambience,
debug_info="; ".join(errors) if errors else "", debug_info="; ".join(errors) if errors else "",
changes=changes, changes=changes,
is_meta=is_meta,
game_over=game_over,
meta_log=meta_log,
) )

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH, CORE_RULES_PATH, RULES_INJECTION_PATH
from .prompts import SYSTEM_PROMPT from .prompts import SYSTEM_PROMPT
from . import state from . import state
@ -12,6 +12,9 @@ def build_system_prompt(recent_narrative: str | None = None, recent_log: str | N
log = recent_log if recent_log is not None else state.read_recent_log() log = recent_log if recent_log is not None else state.read_recent_log()
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*" journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
story = recent_narrative if recent_narrative is not None else state.read_recent_book(2) story = recent_narrative if recent_narrative is not None else state.read_recent_book(2)
core_rules = state.read_file(CORE_RULES_PATH) or "*No core rules file.*"
extra = state.read_file(RULES_INJECTION_PATH)
extra_section = f"\n\n## Full Mechanics Reference\n{extra}" if extra else ""
return SYSTEM_PROMPT.substitute( return SYSTEM_PROMPT.substitute(
character=char, world=world, log=log, journal=journal, story=story, character=char, world=world, log=log, journal=journal, story=story, core_rules=core_rules,
) ) + extra_section

View File

@ -4,6 +4,8 @@ from dataclasses import dataclass, field
from typing import Optional from typing import Optional
END_MARKER = "### THE END"
@dataclass @dataclass
class TurnResult: class TurnResult:
"""Output of a complete turn.""" """Output of a complete turn."""
@ -14,3 +16,6 @@ class TurnResult:
error: Optional[str] = None error: Optional[str] = None
debug_info: str = "" debug_info: str = ""
changes: list[str] = field(default_factory=list) changes: list[str] = field(default_factory=list)
is_meta: bool = False
game_over: bool = False
meta_log: str = ""

View File

@ -18,6 +18,7 @@ def log_turn_details(
log_entry: str, log_entry: str,
ambience: Optional[str], ambience: Optional[str],
tool_calls: list, tool_calls: list,
meta_log: str = "",
) -> None: ) -> None:
"""Write structured turn summary to llm.log and fire TUI debug event.""" """Write structured turn summary to llm.log and fire TUI debug event."""
ts = datetime.now().isoformat() ts = datetime.now().isoformat()
@ -34,6 +35,7 @@ def log_turn_details(
state.append_llm_log(f"├─ Output: {output_chars} chars ({output_words} words)") state.append_llm_log(f"├─ Output: {output_chars} chars ({output_words} words)")
state.append_llm_log(f"├─ Log Entry: {log_entry}") state.append_llm_log(f"├─ Log Entry: {log_entry}")
state.append_llm_log(f"├─ Ambience: {ambience or 'None'}") state.append_llm_log(f"├─ Ambience: {ambience or 'None'}")
state.append_llm_log(f"├─ Meta Log: {(meta_log or '')[:80]}")
tools_preview = ", ".join(tc.get("tool", "?") for tc in tool_calls) tools_preview = ", ".join(tc.get("tool", "?") for tc in tool_calls)
state.append_llm_log(f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})") state.append_llm_log(f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})")
state.append_llm_log( state.append_llm_log(

View File

@ -9,6 +9,10 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
RULES_DIR = BASE_DIR / 'rules'
CORE_RULES_PATH = RULES_DIR / 'core_mechanics.md'
MECHANICS_PATH = RULES_DIR / 'mechanics.md'
CHARACTER_CREATION_PATH = RULES_DIR / 'character_creation.md'
SESSION_DIR = BASE_DIR / 'session' SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json' CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md' CHAR_PATH = SESSION_DIR / 'character.md'
@ -20,4 +24,9 @@ LOG_PATH = SESSION_DIR / 'session_log.md'
LLM_LOG_PATH = SESSION_DIR / 'llm.log' LLM_LOG_PATH = SESSION_DIR / 'llm.log'
AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md" AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
CHANGES_PATH = SESSION_DIR / "changes.md" CHANGES_PATH = SESSION_DIR / "changes.md"
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
META_LOG_PATH = SESSION_DIR / "meta_log.md"
AUDIO_DIR = SESSION_DIR / "audio" AUDIO_DIR = SESSION_DIR / "audio"
END_GAME_PATH = RULES_DIR / 'end_game.md'
ARCHIVE_DIR = BASE_DIR / 'archive'

View File

@ -1,14 +1,12 @@
from __future__ import annotations from __future__ import annotations
from string import Template from string import Template
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects. SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
## Rules ## Core Rules
- **Odds**: 1d6, 4+ favourable, 3- trouble. $core_rules
- **Traits**: 3d6, roll UNDER trait.
- **Combat**: 1d6, 4+ hits. Damage: 1d6 + mod - armour. If you need the full mechanics reference (exploration, deck tables, grit tables, etc.), call the `read_rules` tool to load it.
- **Wounds at 0 HP**: 1d6 1-2 die, 3-4 -1 max HP, 5-6 -1 all rolls until healed.
- **Modifiers**: Favourable +1, Risky -1, Desperate -2.
## Output Format ## Output Format
Output ONLY ```tool blocks no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block. Output ONLY ```tool blocks no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block.
@ -20,47 +18,67 @@ Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside to
Wrap each action in its own ```tool block: Wrap each action in its own ```tool block:
```tool ```tool
{"tool": "roll", "args": {"dice": "1d6"}} {"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}
``` ```
```tool ```tool
{"tool": "player_roll", "args": {"dice": "1d6", "reason": "a check"}} {"tool": "modify_vitals", "args": {"stat": "HP", "operation": "diff", "value": -3}}
``` ```
```tool ```tool
{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}} {"tool": "modify_cash", "args": {"operation": "diff", "value": -5}}
``` ```
```tool ```tool
{"tool": "modify_traits", "args": {"dex": 15}} {"tool": "modify_traits", "args": {"str": 12, "dex": 15}}
``` ```
```tool ```tool
{"tool": "add_to_inventory", "args": {"item": "Silver key"}} {"tool": "modify_inventory", "args": {"operation": "add", "item": "Torch", "value": 1}}
``` ```
```tool ```tool
{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}} {"tool": "modify_inventory", "args": {"operation": "remove", "item": "Torch", "value": 1}}
``` ```
```tool ```tool
{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}} {"tool": "modify_inventory", "args": {"operation": "replace", "item": "Mace (1d6+1)", "value": "Mace (1d6+2, sharpened)"}}
``` ```
```tool ```tool
{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}} {"tool": "modify_note", "args": {"operation": "add", "value": "Found a hidden passage under the temple"}}
``` ```
```tool ```tool
{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}} {"tool": "modify_world", "args": {"operation": "set", "value": "# The World\\n\\n...full new world state..."}}
``` ```
```tool ```tool
{"tool": "world_update", "args": {"content": "# The World\\n\\n...full new world state..."}} {"tool": "modify_journal", "args": {"operation": "add", "value": "Investigate the mine"}}
``` ```
```tool ```tool
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}} {"tool": "modify_journal", "args": {"operation": "done", "value": "Defeat the demon"}}
``` ```
```tool ```tool
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Dillion explored the dungeon, found a hidden passage, and was ambushed by goblins."}} {"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins.", "meta_log": "Kael rolled a 4 (1d6) for perception — success, spotted the hidden door. HP lowered to 5 after goblin ambush."}}
``` ```
```tool
{"tool": "read_rules", "args": {}}
```
or with a category:
```tool
{"tool": "read_rules", "args": {"category": "end_game"}}
```
(Categories: mechanics, core, character_creation, end_game)
**log_entry**: Provide a short, dense summary (1-2 sentences) of the turn's main events. This becomes the session log — be specific, factual, and concise. **log_entry**: Provide a short, dense summary (1-2 sentences) of the turn's main events. This becomes the session log — be specific, factual, and concise.
**meta_log**: (Optional) Provide a behind-the-scenes explanation of the mechanics what dice were rolled, what rules triggered, what changed and why. This is shown to the player at the bottom of the screen for insight into the game mechanics. Be specific: "rolled X (1d6) for Y — result: Z".
You are the sole authority over the game state. The player's action is a **proposal**, not a fact. If their action contradicts the character sheet (e.g. using an item they don't have, spending cash they don't have, claiming stats they don't have), narrate the failure and do NOT call any state-changing tools. You are the sole authority over the game state. The player's action is a **proposal**, not a fact. If their action contradicts the character sheet (e.g. using an item they don't have, spending cash they don't have, claiming stats they don't have), narrate the failure and do NOT call any state-changing tools.
**Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `remove_from_inventory` for that item AND apply the effects (e.g. `modify_vitals` for HP potions). If it's not on the sheet, reject the action do not let them use items they don't have. **Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `modify_inventory` with operation `remove` for that item AND apply the effects (e.g. `modify_vitals` for HP potions, `modify_cash` for cash changes). If it's not on the sheet, reject the action do not let them use items they don't have.
## Ending the Game
When the story reaches a definitive end (character death, quest completion, or the player chooses to retire), output the exact marker `### THE END` as a heading in the narrative, then provide:
1. **Why** why the story ended
2. **What Happened** summary of final events
3. **The World After** 2-3 paragraphs describing how the world and characters evolved
After the `### THE END` marker, do NOT call any state-changing tools. The epilogue is narrative-only. Call `read_rules` with `category: "end_game"` for full details.
## State ## State
@ -76,7 +94,7 @@ $log
### Journal (TODO / DONE) ### Journal (TODO / DONE)
$journal $journal
**journal_update rule**: When calling `journal_update`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions match the TODO text character-for-character. Mark items as `done` exactly as they appear in TODO. Add new items with exact wording matching their entry in the list. **modify_journal rule**: When calling `modify_journal`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions match the TODO text character-for-character. Use operation `done` for items exactly as they appear in TODO. Use operation `add` for new items with exact wording matching their entry in the list.
### Story ### Story
$story""") $story""")

View File

@ -9,13 +9,15 @@ GameEngine or other modules besides paths.
from __future__ import annotations from __future__ import annotations
import re import re
import shutil
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from .paths import ( from .paths import (
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH, LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
AUDIO_DIR, META_LOG_PATH, AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
) )
from .models import TurnResult from .models import TurnResult
@ -166,6 +168,21 @@ def append_llm_log(text: str) -> None:
f.write(text + "\n") f.write(text + "\n")
def append_meta_log(turn_num: int, entry: str) -> None:
"""Append a meta_log entry to meta_log.md with turn number."""
META_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(META_LOG_PATH, "a") as f:
f.write(f"- **Turn {turn_num}** — {entry.strip()}\n")
def read_last_meta_log() -> str:
"""Return the last meta_log entry, or empty string if none."""
if not META_LOG_PATH.exists():
return ""
lines = [l.strip() for l in META_LOG_PATH.read_text().splitlines() if l.strip()]
return lines[-1] if lines else ""
def update_journal(add: list[str] | None = None, done: list[str] | None = None) -> None: def update_journal(add: list[str] | None = None, done: list[str] | None = None) -> None:
"""Add or complete TODO items in journal.md.""" """Add or complete TODO items in journal.md."""
if not JOURNAL_PATH.exists(): if not JOURNAL_PATH.exists():
@ -227,3 +244,36 @@ def update_journal(add: list[str] | None = None, done: list[str] | None = None)
cleaned.append(line) cleaned.append(line)
prev_blank = is_blank prev_blank = is_blank
JOURNAL_PATH.write_text("\n".join(cleaned) + "\n") JOURNAL_PATH.write_text("\n".join(cleaned) + "\n")
def extract_hero_name() -> str:
"""Extract the hero's name from character.md."""
text = read_file(CHAR_PATH)
if not text:
return "unknown-hero"
for line in text.splitlines():
if line.strip().startswith("**Name:**"):
name = line.split(":", 1)[1].strip().strip("*_ \t")
return name.lower().replace(" ", "-") or "unknown-hero"
return "unknown-hero"
def archive_session() -> str:
"""Archive the current session folder to archive/<hero-name>-<timestamp>/ and clear session state for a fresh start.
Returns the archive path as a string."""
hero = extract_hero_name()
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
archive_dir = ARCHIVE_DIR / f"{hero}-{ts}"
archive_dir.parent.mkdir(parents=True, exist_ok=True)
if SESSION_DIR.exists():
shutil.copytree(SESSION_DIR, archive_dir, dirs_exist_ok=True)
# Clear session state
for child in SESSION_DIR.iterdir():
if child.is_file():
child.unlink()
elif child.is_dir() and child.name != "__pycache__":
shutil.rmtree(child)
return str(archive_dir)

View File

@ -1,26 +1,25 @@
from __future__ import annotations from __future__ import annotations
import json import json
import random
import re import re
from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH from .paths import (
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH,
CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH,
)
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
TOOL_REGISTRY: dict[str, dict] = { TOOL_REGISTRY: dict[str, dict] = {
"roll": {"description": "Roll dice.", "args": {"dice": "1d6", "modifier": "+1"}}, "modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"stat": "str/dex/wil", "operation": "set|diff", "value": "new value"}},
"player_roll": {"description": "Ask player to roll.", "args": {"dice": "1d6", "reason": "why"}}, "modify_vitals": {"description": "Change HP, max_hp, weapon, armour.", "args": {"stat": "HP|max_hp|weapon|armour", "operation": "set|diff", "value": "number or string"}},
"modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"str": "optional", "dex": "optional", "wil": "optional"}}, "modify_cash": {"description": "Change cash amount.", "args": {"operation": "set|diff", "value": "number"}},
"modify_vitals": {"description": "Change HP, cash, weapon, armour.", "args": {"current_hp": "optional", "max_hp": "optional", "cash": "optional", "weapon": "optional", "armour": "optional"}}, "modify_inventory": {"description": "Add, remove, or replace gear. For add with quantity, item name prefix matches existing stack.", "args": {"operation": "add|remove|replace", "item": "item name", "value": "quantity (int) for add/remove, new text for replace"}},
"add_to_inventory": {"description": "Add item to gear.", "args": {"item": "item name and stats"}}, "modify_note": {"description": "Add, remove, or replace a note.", "args": {"operation": "add|remove|replace", "stat": "existing text (for remove/replace)", "value": "note text"}},
"remove_from_inventory": {"description": "Remove item from gear.", "args": {"item": "exact item text"}}, "modify_world": {"description": "Replace full world state.", "args": {"operation": "set", "value": "full world markdown"}},
"replace_gear": {"description": "Replace gear by exact match.", "args": {"before": "exact text", "after": "new text"}}, "modify_journal": {"description": "Update TODO/DONE list.", "args": {"operation": "add|done|remove", "value": "entry text"}},
"add_note": {"description": "Add note to sheet.", "args": {"note": "note content"}}, "finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened", "meta_log": "optional behind-the-scenes mechanics explanation"}},
"replace_note": {"description": "Replace note by exact match.", "args": {"before": "exact text", "after": "new text"}}, "read_rules": {"description": "Read a rules file by category. Categories: mechanics (full mechanics reference), core (core mechanics), character_creation, end_game (end-game closure rules). Call when you need details beyond the Core Rules in the prompt.", "args": {"category": "optional — one of: mechanics, core, character_creation, end_game (default: mechanics)"}},
"world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
"journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}},
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened"}},
} }
@ -34,25 +33,35 @@ def patch_character(pattern: str, repl: str, count: int = 1, flags: int = 0) ->
return "" return ""
def tool_roll(args: dict) -> str: VITAL_LABELS = {
dice_str = (args or {}).get("dice", "1d6") "HP": "Current Health",
modifier_str = (args or {}).get("modifier", "0") "max_hp": "Max Health",
try: "weapon": "Weapon",
count, sides = dice_str.lower().split("d") "armour": "Armour",
count = int(count) if count else 1 }
sides = int(sides)
except (ValueError, TypeError):
return f"Invalid dice: {dice_str}. Use format like '2d6'." def _parse_current_int(label: str) -> int | None:
mod = 0 """Read a numeric value from character.md by label."""
if modifier_str: text = CHAR_PATH.read_text()
try: m = re.search(rf"^- \*\*{re.escape(label)}:\*\*\s*(\d+)", text, re.MULTILINE)
mod = int(modifier_str) return int(m.group(1)) if m else None
except ValueError:
pass
rolls = [random.randint(1, sides) for _ in range(count)] def _apply_diff(label: str, diff: int) -> str:
total = sum(rolls) + mod """Apply a numeric diff to a label in character.md. Returns result string."""
mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else "" current = _parse_current_int(label)
return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}" if current is None:
return f"**Error:** cannot find numeric {label} in character sheet"
new_val = current + diff
err = patch_character(
rf"^(- \*\*{re.escape(label)}:\*\*\s*)\d+",
rf"\g<1>{new_val}",
count=1, flags=re.MULTILINE,
)
if err:
return err
return f"{label}: {current}{new_val} ({diff:+d})"
def tool_modify_traits(args: dict) -> str: def tool_modify_traits(args: dict) -> str:
@ -69,103 +78,212 @@ def tool_modify_traits(args: dict) -> str:
def tool_modify_vitals(args: dict) -> str: def tool_modify_vitals(args: dict) -> str:
errors = [] stat = args.get("stat", "")
for field, label in [("current_hp", "Current Health"), ("max_hp", "Max Health"), op = args.get("operation", "set")
("cash", "Cash"), ("weapon", "Weapon"), ("armour", "Armour")]: value = args.get("value")
val = args.get(field)
if val is not None: label = VITAL_LABELS.get(stat)
if not label:
return f"**Error:** unknown stat '{stat}'. Use: HP, max_hp, weapon, armour."
if op == "set":
err = patch_character( err = patch_character(
rf"^(- \*\*{label}:\*\*\s*).*", rf"\g<1>{val}", count=1, flags=re.MULTILINE rf"^(- \*\*{re.escape(label)}:\*\*\s*).*", rf"\g<1>{value}",
count=1, flags=re.MULTILINE,
) )
if err: return f"{label} set to {value}." if not err else err
errors.append(err) elif op == "diff":
return "; ".join(errors) if errors else "Vitals updated." if stat in ("weapon", "armour"):
return f"**Error:** diff not supported for {stat}. Use set."
try:
def tool_add_to_inventory(args: dict) -> str: diff = int(value)
item = (args or {}).get("item", "") except (TypeError, ValueError):
if not item: return f"**Error:** value must be a number for diff, got {value!r}"
return "**Error:** `item` is required." return _apply_diff(label, diff)
text = CHAR_PATH.read_text()
if item in text:
return f"Item already in inventory: {item}"
gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
if gear_section:
insert_at = gear_section.end()
text = text[:insert_at] + f"- {item}\n" + text[insert_at:]
else: else:
text += f"\n## Gear\n- {item}\n" return f"**Error:** unknown operation '{op}'. Use set or diff."
CHAR_PATH.write_text(text)
return f"Added to inventory: {item}"
def tool_remove_from_inventory(args: dict) -> str: def tool_modify_cash(args: dict) -> str:
item = (args or {}).get("item", "") op = args.get("operation", "set")
value = args.get("value")
if op == "set":
err = patch_character(
rf"^(- \*\*Cash:\*\*\s*).*", rf"\g<1>{value}",
count=1, flags=re.MULTILINE,
)
return f"Cash set to {value}." if not err else err
elif op == "diff":
try:
diff = int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for diff, got {value!r}"
return _apply_diff("Cash", diff)
else:
return f"**Error:** unknown operation '{op}'. Use set or diff."
def _find_gear_line(text: str, item_name: str) -> tuple[str, int, str] | None:
"""Find a gear line matching item_name (prefix match). Returns (full_line, line_start, quantity_str) or None."""
for m in re.finditer(r"^- (.+)$", text, re.MULTILINE):
line = m.group(1)
line_start = m.start(1) - 2 # start of "- "
line_lower = line.lower().strip()
name_lower = item_name.lower().strip()
if line_lower.startswith(name_lower):
qty_match = re.search(r"\((\d+)\)\s*$", line)
qty = qty_match.group(1) if qty_match else None
return line, line_start, qty
return None
def tool_modify_inventory(args: dict) -> str:
op = args.get("operation", "")
item = args.get("item", "")
value = args.get("value")
if not item: if not item:
return "**Error:** `item` is required." return "**Error:** `item` is required."
err = patch_character(rf"^- {re.escape(item)}\n?", "", count=1, flags=re.MULTILINE)
text = CHAR_PATH.read_text()
if op == "add":
existing = _find_gear_line(text, item)
if existing:
full_line, line_start, qty_str = existing
if qty_str is not None and value is not None:
try:
new_qty = int(qty_str) + int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for add with quantity, got {value!r}"
if new_qty <= 0:
new_text = text[:line_start] + text[line_start + len(full_line) + 2:]
CHAR_PATH.write_text(new_text)
return f"Removed all {item} from inventory (quantity reached 0)."
new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1)
CHAR_PATH.write_text(text.replace(full_line, new_line, 1))
return f"{item}: {qty_str}{new_qty}"
if qty_str is None:
return f"Item '{full_line}' exists but has no quantity to stack. Use replace to update it."
gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
insert_at = gear_section.end() if gear_section else len(text)
if not gear_section:
text += "\n## Gear\n"
insert_at = len(text)
val_str = f" ({value})" if value is not None else ""
new_entry = f"- {item}{val_str}\n"
text = text[:insert_at] + new_entry + text[insert_at:]
CHAR_PATH.write_text(text)
return f"Added to inventory: {item}{val_str}"
elif op == "remove":
existing = _find_gear_line(text, item)
if not existing:
return f"**Error:** item not found: {item}"
full_line, line_start, qty_str = existing
if qty_str is not None and value is not None:
try:
new_qty = int(qty_str) - int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for remove with quantity, got {value!r}"
if new_qty <= 0:
new_text = text[:line_start] + text[line_start + len(full_line) + 2:]
CHAR_PATH.write_text(new_text)
return f"Removed all {item} from inventory (quantity reached 0)."
new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1)
CHAR_PATH.write_text(text.replace(full_line, new_line, 1))
return f"{item}: {qty_str}{new_qty}"
err = patch_character(rf"^- {re.escape(full_line)}\n?", "", count=1, flags=re.MULTILINE)
if err: if err:
return f"**Error:** item not found: {item}" return f"**Error:** item not found: {item}"
return f"Removed from inventory: {item}" return f"Removed from inventory: {full_line}"
elif op == "replace":
def tool_replace_gear(args: dict) -> str: after = value or ""
before = (args or {}).get("before", "") if not after:
after = (args or {}).get("after", "") return "**Error:** `value` (new text) is required for replace."
if not before or not after: existing = _find_gear_line(text, item)
return "**Error:** `before` and `after` are required." if not existing:
err = patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE) return f"**Error:** item not found: {item}"
full_line = existing[0]
err = patch_character(rf"^- {re.escape(full_line)}", f"- {after}", count=1, flags=re.MULTILINE)
if err: if err:
return f"**Error:** gear not found: {before}" return f"**Error:** item not found: {item}"
return f"Gear replaced: {before}{after}" return f"Gear replaced: {full_line}{after}"
return "**Error:** unknown operation. Use add, remove, or replace."
def tool_add_note(args: dict) -> str: def tool_modify_note(args: dict) -> str:
note = (args or {}).get("note", "") op = args.get("operation", "")
if not note: stat = args.get("stat", "")
return "**Error:** `note` is required." value = args.get("value", "")
if op == "add":
if not value:
return "**Error:** `value` (note text) is required for add."
text = CHAR_PATH.read_text() text = CHAR_PATH.read_text()
notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE) notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE)
if notes_section: if notes_section:
text = text[:notes_section.end()] + f"- {note}\n" + text[notes_section.end():] text = text[:notes_section.end()] + f"- {value}\n" + text[notes_section.end():]
else: else:
text += f"\n## Notes & Scribbles\n- {note}\n" text += f"\n## Notes & Scribbles\n- {value}\n"
CHAR_PATH.write_text(text) CHAR_PATH.write_text(text)
return f"Note added: {note}" return f"Note added: {value}"
elif op == "remove":
def tool_replace_note(args: dict) -> str: if not stat:
before = (args or {}).get("before", "") return "**Error:** `stat` (exact note text) is required for remove."
after = (args or {}).get("after", "") err = patch_character(rf"^- {re.escape(stat)}\n?", "", count=1, flags=re.MULTILINE)
if not before or not after:
return "**Error:** `before` and `after` are required."
err = patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE)
if err: if err:
return f"**Error:** note not found: {before}" return f"**Error:** note not found: {stat}"
return f"Note removed."
elif op == "replace":
if not stat or not value:
return "**Error:** `stat` (old text) and `value` (new text) are required for replace."
err = patch_character(rf"^- {re.escape(stat)}", f"- {value}", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** note not found: {stat}"
return f"Note replaced." return f"Note replaced."
return "**Error:** unknown operation. Use add, remove, or replace."
def tool_world_update(args: dict) -> str:
content = (args or {}).get("content", "") def tool_modify_world(args: dict) -> str:
if not content: op = args.get("operation", "set")
return "**Error:** `content` is required." value = args.get("value", "")
if not validate_update_size("world", content, WORLD_PATH):
if op != "set":
return f"**Error:** only 'set' operation is supported for world, got '{op}'."
if not value:
return "**Error:** `value` (full world markdown) is required."
if not validate_update_size("world", value, WORLD_PATH):
return "**Error:** Update rejected — content is too short (likely a partial paste)." return "**Error:** Update rejected — content is too short (likely a partial paste)."
WORLD_PATH.write_text(content.strip() + "\n") WORLD_PATH.write_text(value.strip() + "\n")
return "World state updated." return "World state updated."
def tool_journal_update(args: dict) -> str: def tool_modify_journal(args: dict) -> str:
add = (args or {}).get("add", []) op = args.get("operation", "")
done = (args or {}).get("done", []) value = args.get("value", "")
if isinstance(add, str):
add = [add] if not value:
if isinstance(done, str): return "**Error:** `value` (entry text) is required."
done = [done]
if not add and not done: if op == "add":
return "**Error:** Provide at least one of `add` or `done`." update_journal(add=[value])
update_journal(add=add, done=done) return f"Journal entry added: {value}"
return "Journal updated." elif op == "done":
update_journal(done=[value])
return f"Journal entry done: {value}"
elif op == "remove":
update_journal(done=[value])
return f"Journal entry removed: {value}"
else:
return f"**Error:** unknown operation '{op}'. Use add, done, or remove."
def tool_finalize_turn(args: dict) -> str: def tool_finalize_turn(args: dict) -> str:
@ -182,20 +300,38 @@ def tool_finalize_turn(args: dict) -> str:
return f"Ambience set to {raw}." return f"Ambience set to {raw}."
RULES_CATEGORIES = {
"mechanics": MECHANICS_PATH,
"core": CORE_RULES_PATH,
"character_creation": CHARACTER_CREATION_PATH,
"end_game": END_GAME_PATH,
}
def tool_read_rules(args: dict) -> str:
"""Read a rules file by category and return its content."""
category = (args or {}).get("category", "mechanics")
path = RULES_CATEGORIES.get(category)
if not path:
allowed = ", ".join(RULES_CATEGORIES)
return f"**Error:** unknown category '{category}'. Allowed: {allowed}."
content = read_file(path)
if not content:
return f"**Error:** {path.name} not found."
return content
def execute_tool(tool_name: str, args: dict) -> str: def execute_tool(tool_name: str, args: dict) -> str:
"""Execute a tool by name. Returns result string.""" """Execute a tool by name. Returns result string."""
fn_map = { fn_map = {
"roll": tool_roll,
"modify_traits": tool_modify_traits, "modify_traits": tool_modify_traits,
"modify_vitals": tool_modify_vitals, "modify_vitals": tool_modify_vitals,
"add_to_inventory": tool_add_to_inventory, "modify_cash": tool_modify_cash,
"remove_from_inventory": tool_remove_from_inventory, "modify_inventory": tool_modify_inventory,
"replace_gear": tool_replace_gear, "modify_note": tool_modify_note,
"add_note": tool_add_note, "modify_world": tool_modify_world,
"replace_note": tool_replace_note, "modify_journal": tool_modify_journal,
"world_update": tool_world_update,
"journal_update": tool_journal_update,
"finalize_turn": tool_finalize_turn, "finalize_turn": tool_finalize_turn,
"read_rules": tool_read_rules,
} }
fn = fn_map.get(tool_name) fn = fn_map.get(tool_name)
if not fn: if not fn:
@ -212,36 +348,31 @@ def execute_tool(tool_name: str, args: dict) -> str:
def describe_change(tool_name: str, args: dict) -> str: def describe_change(tool_name: str, args: dict) -> str:
"""Build a compact human-readable change description from a tool call.""" """Build a compact human-readable change description from a tool call."""
if tool_name == "modify_vitals": if tool_name == "modify_vitals":
parts = [] return f"{args.get('stat', '?')}: {args.get('operation', '?')} {args.get('value', '?')}"
for k, v in args.items(): elif tool_name == "modify_cash":
label = k.replace("_", " ").title() op = args.get("operation", "?")
parts.append(f"{label}: {v}") val = args.get("value", "?")
return f"{', '.join(parts)}" if parts else "" return f"💰 Cash {op} {val}"
elif tool_name == "modify_traits": elif tool_name == "modify_traits":
parts = [] parts = []
for k, v in args.items(): for k, v in args.items():
parts.append(f"{k.upper()}: {v}") parts.append(f"{k.upper()}: {v}")
return f"{', '.join(parts)}" return f"{', '.join(parts)}"
elif tool_name == "add_to_inventory": elif tool_name == "modify_inventory":
return f"+ {args.get('item', '?')}" op = args.get("operation", "?")
elif tool_name == "remove_from_inventory": item = args.get("item", "?")
return f" {args.get('item', '?')}" val = args.get("value", "")
elif tool_name == "replace_gear": return f"{'' if op == 'replace' else '+' if op == 'add' else ''} {item}{f' ({val})' if val else ''}"
return f"{args.get('before', '?')}{args.get('after', '?')}" elif tool_name == "modify_note":
elif tool_name == "add_note": op = args.get("operation", "?")
note = args.get("note", "?") val = (args.get("value") or args.get("stat") or "?")[:60]
return f"📝 {note[:60]}{'' if len(note) > 60 else ''}" return f"📝 {op}: {val}{'' if len(val) >= 60 else ''}"
elif tool_name == "replace_note": elif tool_name == "modify_world":
return f"📝 {args.get('before', '?')[:40]}{args.get('after', '?')[:40]}"
elif tool_name == "world_update":
return "🌍 World updated" return "🌍 World updated"
elif tool_name == "journal_update": elif tool_name == "modify_journal":
parts = [] op = args.get("operation", "?")
for a in args.get("add", []): val = args.get("value", "?")
parts.append(f"📋 {a}") return f"{'' if op == 'done' else '📋'} {val}"
for d in args.get("done", []):
parts.append(f"{d}")
return "; ".join(parts) if parts else ""
elif tool_name == "finalize_turn": elif tool_name == "finalize_turn":
a = args.get("ambience", "") a = args.get("ambience", "")
return f"{a}" if a else "" return f"{a}" if a else ""

View File

@ -8,93 +8,6 @@ from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
from . import state from . import state
VALIDATION_PROMPT = """You are a strict RPG game master validating whether a player's action is possible given the game state. Be thorough — check inventory, stats, location, NPCs, story context, and story logic.
## Character
{character}
## World
{world}
## Session Log
*Written in 3rd person with explicit actor names.*
{log}
## Recent Story
*Written in 3rd person with explicit actor names.*
{story}
## Player Action
{action}
## Instructions
- Is the player trying to use an item they don't have? -> invalid
- Are they asserting something that contradicts the state? -> invalid
- Is the action nonsensical given the situation? -> invalid
- Does the action make sense given the character's abilities and resources? -> valid
- Pay close attention to the Recent Story section entities like monsters, NPCs, and hazards currently present in the scene ARE valid targets for action.
- If valid, also check: if they're using a consumable item, note that it must be removed from inventory.
Reply with ONLY the JSON object. Examples:
```
{{"valid": true, "reason": "ok"}}
```
or
```
{{"valid": false, "reason": "brief explanation of why the action is impossible"}}
```
"""
def validate_action(
player_action: str,
*,
story: str = "",
log: str = "",
) -> tuple[bool, str]:
"""Ask the LLM whether a player action is valid given the game state. Returns (valid, reason)."""
if not player_action:
return True, ""
char = state.read_file(CHAR_PATH) or "*No character sheet.*"
world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*"
recent = story.strip() or state.read_recent_book() or "*No prior story.*"
log_entries = log.strip() or state.read_recent_log() or "*No recent events.*"
prompt = VALIDATION_PROMPT.format(character=char, world=world, log=log_entries, story=recent, action=player_action)
messages = [{"role": "user", "content": prompt}]
for attempt in range(2):
text = call_llm(
messages,
max_tokens=1024,
temperature=0.2,
label="Action validation",
)
if not text:
return False, "Not sure"
cleaned = text.strip()
m = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL)
if m:
cleaned = m.group(1).strip()
try:
data = json.loads(cleaned)
valid = data.get("valid", True)
reason = data.get("reason", "")
return valid, reason
except (json.JSONDecodeError, ValueError):
if attempt == 0:
messages.append({
"role": "system",
"content": "Your previous response was not valid JSON. Reply with ONLY a JSON object in exactly this format, nothing else:\n\n```json\n{\"valid\": true, \"reason\": \"ok\"}\n```\nor\n```json\n{\"valid\": false, \"reason\": \"brief explanation\"}\n```"
})
return False, "Unrecognized"
TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a generated turn. Check: TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a generated turn. Check:
1. **Action Sense**: Did the player's request make sense given the character, inventory, and world state? 1. **Action Sense**: Did the player's request make sense given the character, inventory, and world state?
@ -102,7 +15,8 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state? 3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state?
4. **Self-Contained Narrative**: The narrative must read clearly on its own explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line. 4. **Self-Contained Narrative**: The narrative must read clearly on its own explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line.
5. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable. 5. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable.
6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `journal_update` to mark it done. Unchecked items left stale too long may need prompting. 6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `modify_journal` with operation `done` to mark it done. Unchecked items left stale too long may need prompting.
7. **Player Speech**: If the player action contains direct speech (quoted text like `"Hello"` or `'Hello'`), the narrative MUST include the player character speaking those words or equivalent dialogue. If the player's speech can be incorporated given the context, the turn should reflect it. Only skip if the speech is completely impossible given the situation.
## Character (before changes) ## Character (before changes)
{character} {character}
@ -134,20 +48,21 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
## Instructions ## Instructions
Check all criteria. **Completeness** is critical scan the narrative for every event that should change state and verify it has a corresponding tool call: Check all criteria. **Completeness** is critical scan the narrative for every event that should change state and verify it has a corresponding tool call:
- **Item used** must have `remove_from_inventory` - **Item used** must have `modify_inventory` with operation `remove`
- **Item acquired** must have `add_to_inventory` or `replace_gear` - **Item acquired** must have `modify_inventory` with operation `add` or `replace`
- **HP changed** must have `modify_vitals` - **HP changed** must have `modify_vitals`
- **Cash changed** must have `modify_vitals` - **Cash changed** must have `modify_cash`
- **World changed** must have `world_update` - **World changed** must have `modify_world`
- **NPC/location/thread changes** must have `world_update` or `add_note` - **NPC/location/thread changes** must have `modify_world` or `modify_note`
- **TODO resolved** must have `journal_update` with `done` - **TODO resolved** must have `modify_journal` with operation `done`
Missing tool calls = regenerate. Also check that: Missing tool calls = regenerate. Also check that:
- The narrative explicitly describes the character acting not just the world reacting - The narrative explicitly describes the character acting not just the world reacting
- A reader should understand what happened without seeing the "Player Action" line above - A reader should understand what happened without seeing the "Player Action" line above
- Items removed were actually in inventory - Items removed were actually in inventory
- Items added are reasonable and don't duplicate existing items - Items added are reasonable and don't duplicate existing items
- HP/cash changes follow logically from the narrative - HP changes follow logically from the narrative
- Cash changes follow logically from the narrative (spending deduct, earning add)
- No impossible modifications - No impossible modifications
For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate. For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate.
@ -170,6 +85,33 @@ Regenerate (turn had fixable issues like wrong state changes or minor inconsiste
``` ```
""" """
META_VALIDATION_PROMPT = """You are validating a meta (out-of-character) DM response. The player's action starts with `>` — they are talking to the DM, not to a character.
## Player Action (Meta)
{action}
## Generated Meta Response
{narrative}
## Instructions
1. **Meta Format**: The entire response must start with `>` and use meta language (DM addressing the player directly).
2. **State Changes**: There MUST be no state changes. This is a meta conversation, not story progression.
3. **Answer Quality**: The response should address the player's meta question and be helpful.
4. **No Story Advancement**: The response must not advance the game narrative.
Reply with ONLY a ```tool block. Examples:
Valid:
```tool
{{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}}
```
Regenerate (response didn't start with `>` or tried to change state):
```tool
{{"tool": "validate", "args": {{"valid": false, "reason": "describe the issue", "action": "regenerate"}}}}
```
"""
def _format_changes(changes: list[dict]) -> str: def _format_changes(changes: list[dict]) -> str:
"""Format tool calls into a readable change list for the validation prompt.""" """Format tool calls into a readable change list for the validation prompt."""
@ -192,6 +134,7 @@ def validate_turn(
changes: list[dict] | None = None, changes: list[dict] | None = None,
story: str = "", story: str = "",
log: str = "", log: str = "",
meta: bool = False,
) -> tuple[bool, str, str]: ) -> tuple[bool, str, str]:
"""Validate a complete generated turn. """Validate a complete generated turn.
@ -207,6 +150,12 @@ def validate_turn(
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*" journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
change_summary = _format_changes(changes or []) change_summary = _format_changes(changes or [])
if meta:
prompt = META_VALIDATION_PROMPT.format(
action=player_action,
narrative=narrative,
)
else:
prompt = TURN_VALIDATION_PROMPT.format( prompt = TURN_VALIDATION_PROMPT.format(
character=char, world=world, story=recent, character=char, world=world, story=recent,
log=log_entries, journal=journal, action=player_action, log=log_entries, journal=journal, action=player_action,
@ -214,7 +163,7 @@ def validate_turn(
changes=change_summary, changes=change_summary,
) )
messages = [{"role": "user", "content": prompt}] messages = [{"role": "system", "content": prompt}]
for attempt in range(2): for attempt in range(2):
text = call_llm( text = call_llm(
@ -247,7 +196,7 @@ def validate_turn(
if attempt == 0: if attempt == 0:
messages.append({ messages.append({
"role": "system", "role": "system",
"content": "Your previous response was not valid. Reply with ONLY a ```tool block:\n\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"reject\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"regenerate\"}}\n```" "content": f"Your previous response was NOT valid. Do NOT include any reasoning or explanation. Reply with EXACTLY ONE of these three ```tool blocks and nothing else:\n\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}}}\n```\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": false, \"reason\": \"explain why the action is impossible\", \"action\": \"reject\"}}}}\n```\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": false, \"reason\": \"describe what the LLM should fix\", \"action\": \"regenerate\"}}}}\n```"
}) })
return False, "Unrecognized", "reject" return False, "Unrecognized", "reject"

View File

@ -8,6 +8,7 @@ Owns the TUI and game loop. Layout:
from __future__ import annotations from __future__ import annotations
import json import json
import re
import threading import threading
from textual import on from textual import on
@ -18,7 +19,7 @@ from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme from rich.theme import Theme
from engine import GameEngine from engine import GameEngine
from engine_lib.models import TurnResult from engine_lib.models import TurnResult, END_MARKER
from engine_lib import state from engine_lib import state
from run_utils import ( from run_utils import (
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH, BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
@ -28,7 +29,7 @@ from run_utils import (
from run_ambience import AmbiencePlayer from run_ambience import AmbiencePlayer
from run_widgets import ( from run_widgets import (
app_ambience_player as _widget_player_ref, app_ambience_player as _widget_player_ref,
RollModal, CharPane, StatusBar, TodoPane, TranscriptPane, CharPane, StatusBar, TodoPane, TranscriptPane,
) )
@ -60,13 +61,16 @@ class ChaosTUI(App):
#banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; } #banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; }
#main { height: 100%; background: #111111; } #main { height: 100%; background: #111111; }
#todo-header { background: #3a2d23; color: #e0b060; padding: 0 1; height: 1; } #todo-header { background: #3a2d23; color: #e0b060; padding: 0 1; height: 1; }
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; height: 5; max-height:5; overflow-y:auto; scrollbar-size-vertical:2; } #todo-scroll { background: #1a1510; color: #d0b080; max-height: 4; }
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; }
#main-tabs { height: 1fr; } #main-tabs { height: 1fr; }
TabbedContent { background: #1a1a2a; } TabbedContent { background: #1a1a2a; }
VerticalScroll { overflow-y: auto; scrollbar-size-vertical:2; scrollbar-color:#555; scrollbar-color-hover:#777; scrollbar-color-active:#999; } VerticalScroll { overflow-y: auto; scrollbar-size-vertical:2; scrollbar-color:#555; scrollbar-color-hover:#777; scrollbar-color-active:#999; }
#char-content { background: #1e1e2a; color: #c0c0c0; padding: 0 1; } #char-content { background: #1e1e2a; color: #c0c0c0; padding: 0 1; }
#transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; } #transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; }
#play-narrative { background: #161616; color: #d8d8d8; padding: 1 2; height: auto; } #play-narrative { background: #161616; color: #d8d8d8; padding: 1 2; height: auto; }
#play-narrative.meta { background: #1a1a2e; color: #b0a0e0; border-top: solid #6b4fa0; border-bottom: solid #6b4fa0; }
#play-meta { background: #0d0d1a; color: #a0a0c0; padding: 0 2; height: auto; border-top: solid #2a2a3a; }
#play-status { background: #1a2a1a; color: #e0b060; padding: 0 2; height: 1; text-style: bold italic; text-align: center; } #play-status { background: #1a2a1a; color: #e0b060; padding: 0 2; height: 1; text-style: bold italic; text-align: center; }
#play-status.processing { background: #2a1a0a; color: #ffd93d; } #play-status.processing { background: #2a1a0a; color: #ffd93d; }
#play-input { height: 3; background: #222; color: #e0d0c0; border: solid #555; padding: 0 1; } #play-input { height: 3; background: #222; color: #e0d0c0; border: solid #555; padding: 0 1; }
@ -86,6 +90,9 @@ class ChaosTUI(App):
#mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; } #mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; }
#mute-btn:hover { background: #3a3a3a; color: #ccc; } #mute-btn:hover { background: #3a3a3a; color: #ccc; }
#mute-btn.muted { color: #ff6b6b; text-style: bold; } #mute-btn.muted { color: #ff6b6b; text-style: bold; }
#end-game-btn { display: none; margin: 1 2; height: 3; background: #5a3a00; color: #ffd93d; border: solid #8a5a00; }
#end-game-btn.visible { display: block; }
#end-game-btn:hover { background: #7a4a00; }
""" """
BINDINGS = [ BINDINGS = [
@ -107,12 +114,11 @@ class ChaosTUI(App):
self._thinking_frame = 0 self._thinking_frame = 0
self._thinking_timer_handle = None self._thinking_timer_handle = None
self._dm_action = "DM is preparing a response" self._dm_action = "DM is preparing a response"
self._roll_event = threading.Event()
self._roll_result: str | None = None
self._book_page = 0 self._book_page = 0
self._book_pages: list[str] = [] self._book_pages: list[str] = []
self._prev_page_count = 0 self._prev_page_count = 0
self._settings_loaded = False self._settings_loaded = False
self._game_over = False
def _load_settings(self) -> dict: def _load_settings(self) -> dict:
defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0} defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0}
@ -143,13 +149,16 @@ class ChaosTUI(App):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Vertical(id="main"): with Vertical(id="main"):
yield Static("TODO", id="todo-header") yield Static("TODO", id="todo-header")
with VerticalScroll(id="todo-scroll"):
yield TodoPane(id="todo-content") yield TodoPane(id="todo-content")
with TabbedContent(initial="play-tab", id="main-tabs"): with TabbedContent(initial="play-tab", id="main-tabs"):
with TabPane("PLAY", id="play-tab"): with TabPane("PLAY", id="play-tab"):
with VerticalScroll(id="play-scroll"): with VerticalScroll(id="play-scroll"):
yield Static("*Awaiting the fates...*", id="play-narrative") yield Static("*Awaiting the fates...*", id="play-narrative")
yield Static("", id="play-meta")
yield Static("", id="play-status") yield Static("", id="play-status")
yield Input(placeholder="Type your action and press Enter...", id="play-input") yield Input(placeholder="Type your action and press Enter...", id="play-input")
yield Button("Close the Book and Start a New One", id="end-game-btn", variant="warning")
with TabPane("CHARACTER", id="char-tab"): with TabPane("CHARACTER", id="char-tab"):
with VerticalScroll(): with VerticalScroll():
yield CharPane(id="char-content") yield CharPane(id="char-content")
@ -196,20 +205,35 @@ class ChaosTUI(App):
self._settings_loaded = True self._settings_loaded = True
def _begin_game(self): def _begin_game(self):
self._game_over = False
self._last_narrative: str = "" self._last_narrative: str = ""
self.query_one("#play-meta", Static).update("")
pages = load_book_pages() pages = load_book_pages()
if pages and pages != ["*The story has not begun.*"]: if pages and pages != ["*The story has not begun.*"]:
parts = [] parts = []
parts.append(pages[-1]) parts.append(pages[-1])
changes: list[str] = []
if CHANGES_PATH.exists(): if CHANGES_PATH.exists():
saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()] changes = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()]
if saved: last_meta = self._strip_meta_prefix(state.read_last_meta_log())
parts.append(self._render_changes(saved))
self._set_narrative("\n\n".join(parts)) self._set_narrative("\n\n".join(parts))
self._update_meta(changes, last_meta)
self._enable_input() self._enable_input()
return return
self._call_llm() self._call_llm()
def _archive_and_reset(self) -> None:
"""Archive the session and reset for a new game."""
self._game_over = False
btn = self.query_one("#end-game-btn", Button)
btn.remove_class("visible")
archive_path = state.archive_session()
self._book_pages = ["*The story has not begun.*"]
self._book_page = 0
self._render_book_page()
self._begin_game()
self._save_settings()
def _check_ambience(self): def _check_ambience(self):
if app_ambience_player: if app_ambience_player:
app_ambience_player.poll() app_ambience_player.poll()
@ -220,6 +244,8 @@ class ChaosTUI(App):
app_ambience_player.toggle_mute() app_ambience_player.toggle_mute()
self._update_mute_button() self._update_mute_button()
self._save_settings() self._save_settings()
elif event.button.id == "end-game-btn":
self._archive_and_reset()
def _update_mute_button(self) -> None: def _update_mute_button(self) -> None:
btn = self.query_one("#mute-btn", Button) btn = self.query_one("#mute-btn", Button)
@ -256,7 +282,6 @@ class ChaosTUI(App):
player_action=player_action, player_action=player_action,
on_thought=on_thought, on_thought=on_thought,
on_action=on_action, on_action=on_action,
on_player_roll=self._on_player_roll,
) )
except Exception as e: except Exception as e:
self.call_from_thread(self._on_generation_error, e, traceback.format_exc()) self.call_from_thread(self._on_generation_error, e, traceback.format_exc())
@ -294,24 +319,6 @@ class ChaosTUI(App):
status.add_class("processing") status.add_class("processing")
status.update(f"{self._spinner_frames[0]} {action}") status.update(f"{self._spinner_frames[0]} {action}")
def _on_player_roll(self, dice: str, reason: str) -> str:
self.call_from_thread(self._show_roll_modal, dice, reason)
self._roll_event.wait()
self._roll_event.clear()
result = self._roll_result
self._roll_result = None
return result or "0"
def _show_roll_modal(self, dice: str, reason: str) -> None:
self._roll_event.clear()
self._roll_result = None
def on_dismiss(value: str) -> None:
self._roll_result = value
self._roll_event.set()
self.push_screen(RollModal(dice, reason), on_dismiss)
def _tick_thinking(self) -> None: def _tick_thinking(self) -> None:
if not self._is_processing: if not self._is_processing:
return return
@ -325,6 +332,12 @@ class ChaosTUI(App):
if result.error: if result.error:
self._show_error(result.error, result.debug_info) self._show_error(result.error, result.debug_info)
return return
if result.is_meta:
self._display_scene(result)
self._enable_input()
return
if result.book_log: if result.book_log:
turn_num = state.archive_turn(result.book_log) turn_num = state.archive_turn(result.book_log)
if result.log_entry: if result.log_entry:
@ -332,10 +345,23 @@ class ChaosTUI(App):
else: else:
summary = result.book_log.strip().split(chr(10))[0][:80] summary = result.book_log.strip().split(chr(10))[0][:80]
state.append_log(f"- **Turn {turn_num}** — {summary}") state.append_log(f"- **Turn {turn_num}** — {summary}")
if result.meta_log:
state.append_meta_log(turn_num, result.meta_log)
result.book_log = load_book_pages()[-1] result.book_log = load_book_pages()[-1]
elif result.log_entry: elif result.log_entry:
state.append_log(f"- {result.log_entry}") state.append_log(f"- {result.log_entry}")
state.apply_state(result) state.apply_state(result)
if result.game_over:
self._game_over = True
self._set_narrative(result.book_log if result.book_log else "(The story has ended.)")
inp = self.query_one("#play-input", Input)
inp.disabled = True
inp.placeholder = "The story has ended."
btn = self.query_one("#end-game-btn", Button)
btn.add_class("visible")
return
if result.book_log or not result.user_prompt: if result.book_log or not result.user_prompt:
self._display_scene(result) self._display_scene(result)
else: else:
@ -354,18 +380,32 @@ class ChaosTUI(App):
self._show_error(err_msg, traceback_str) self._show_error(err_msg, traceback_str)
@staticmethod @staticmethod
def _render_changes(changes: list[str]) -> str: def _strip_meta_prefix(entry: str) -> str:
return "**Changes:**\n" + "\n".join(f"- {c}" for c in changes) return re.sub(r"^- \*\*Turn \d+\*\* — ", "", entry)
@staticmethod
def _render_meta(changes: list[str], meta_log: str) -> str:
lines = []
if changes:
lines.append("**Changes:**")
lines.extend(f"- {c}" for c in changes)
if meta_log:
lines.append(meta_log)
return "\n\n".join(lines) if lines else ""
def _update_meta(self, changes: list[str], meta_log: str) -> None:
meta = self._render_meta(changes, meta_log)
widget = self.query_one("#play-meta", Static)
widget.update(RichMarkdown(meta) if meta else "")
def _display_scene(self, result: TurnResult) -> None: def _display_scene(self, result: TurnResult) -> None:
parts = [] parts = []
if result.book_log: if result.book_log:
parts.append(result.book_log) parts.append(result.book_log)
if result.changes:
parts.append(self._render_changes(result.changes))
if result.user_prompt: if result.user_prompt:
parts.append(f"---\n\n{result.user_prompt}") parts.append(f"---\n\n{result.user_prompt}")
self._set_narrative("\n\n".join(parts) if parts else "") self._set_narrative("\n\n".join(parts) if parts else "", meta=result.is_meta)
self._update_meta(result.changes, result.meta_log)
self._enable_input() self._enable_input()
def _enable_input(self, value: str = "") -> None: def _enable_input(self, value: str = "") -> None:
@ -375,9 +415,11 @@ class ChaosTUI(App):
inp.value = value inp.value = value
inp.focus() inp.focus()
def _set_narrative(self, text: str) -> None: def _set_narrative(self, text: str, meta: bool = False) -> None:
self._last_narrative = text self._last_narrative = text
self.query_one("#play-narrative", Static).update(RichMarkdown(text)) widget = self.query_one("#play-narrative", Static)
widget.set_class(meta, "meta")
widget.update(RichMarkdown(text))
self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False) self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False)
def _show_error(self, error: str, debug_info: str = "") -> None: def _show_error(self, error: str, debug_info: str = "") -> None:

View File

@ -1,9 +1,6 @@
from __future__ import annotations from __future__ import annotations
from textual.app import ComposeResult from textual.widgets import Static
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Button, Input, Static
from rich.markdown import Markdown as RichMarkdown from rich.markdown import Markdown as RichMarkdown
from run_utils import ( from run_utils import (
@ -18,69 +15,6 @@ from run_ambience import HAS_AUDIO
app_ambience_player: object | None = None app_ambience_player: object | None = None
class RollModal(Screen):
CSS = """
RollModal {
align: center middle;
background: rgba(0, 0, 0, 0.75);
}
#roll-dialog {
width: 44;
height: auto;
padding: 2 3;
background: #2a2a3a;
border: thick #e0ad4c;
}
#roll-title {
text-style: bold;
color: #ffd93d;
text-align: center;
height: 3;
}
#roll-reason {
color: #c0b090;
text-align: center;
height: 3;
}
#roll-input {
margin: 1 0;
}
#roll-submit {
width: 100%;
}
#roll-hint {
color: #888888;
text-align: center;
height: 1;
}
"""
def __init__(self, dice: str, reason: str) -> None:
super().__init__()
self.dice = dice
self.reason = reason
def compose(self) -> ComposeResult:
with Vertical(id="roll-dialog"):
yield Static(f"[bold]🎲 ROLL {self.dice}[/bold]", id="roll-title")
yield Static(f"Reason: {self.reason}", id="roll-reason")
yield Input(placeholder="Enter the number you rolled...", id="roll-input")
yield Button("Submit", id="roll-submit", variant="primary")
yield Static("(or press Enter)", id="roll-hint")
def on_input_submitted(self, event: Input.Submitted) -> None:
self._submit(event.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "roll-submit":
self._submit(self.query_one("#roll-input", Input).value)
def _submit(self, value: str) -> None:
val = value.strip()
if val:
self.dismiss(val)
class AutoStatic(Static): class AutoStatic(Static):
def load(self): def load(self):
raise NotImplementedError raise NotImplementedError

View File

@ -1,93 +0,0 @@
#!/usr/bin/env python3
"""End-to-end validation tests using the real configured LLM.
Tests that validate_action handles real LLM responses correctly with
the actual character sheet and world state. Requires a running LLM.
Usage:
python3 tools/test_llm_validation.py
"""
import sys
import os
import json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from engine_lib.validation import validate_action
PASS = 0
FAIL = 0
def check(label: str, valid: bool, reason: str, expected_valid: bool):
global PASS, FAIL
status = "" if valid == expected_valid else ""
if valid == expected_valid:
PASS += 1
else:
FAIL += 1
print(f" {status} {label}: valid={valid}, reason=\"{reason}\"")
def section(name: str):
print(f"\n{'=' * 60}")
print(f" {name}")
print(f"{'=' * 60}")
def main():
section("Valid actions — should pass")
check("Buy a drink",
*validate_action("I buy a mug of weak ale at the Splintered Tankard"),
expected_valid=True)
check("Use healing salve",
*validate_action("I use my healing salve to restore 1 HP"),
expected_valid=True)
check("Talk to Otta",
*validate_action("I ask Mistress Otta about recent news in the Keep"),
expected_valid=True)
check("Visit the market",
*validate_action("I head to the Market Square to browse stalls"),
expected_valid=True)
section("Invalid actions — should fail")
check("Use non-existent item",
*validate_action("I drink a potion of invisibility"),
expected_valid=False)
check("Cast a spell (not a weaver)",
*validate_action("I cast a fireball spell at the tavern"),
expected_valid=False)
check("Buy impossible item",
*validate_action("I buy a horse for a broken copper coin"),
expected_valid=False)
check("Assert false state",
*validate_action("I fly to the moon"),
expected_valid=False)
section("Edge cases")
check("Empty action",
*validate_action(""),
expected_valid=True)
check("Garbled nonsense",
*validate_action("qwxz jabberwocky flargle bargle"),
expected_valid=False)
print(f"\n{'=' * 60}")
print(f" Results: {PASS} passed, {FAIL} failed")
print(f"{'=' * 60}")
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -30,7 +30,7 @@ def test_engine_import():
('engine_lib.state', ['read_file', 'apply_state', 'append_log', 'append_llm_log', 'next_turn_number']), ('engine_lib.state', ['read_file', 'apply_state', 'append_log', 'append_llm_log', 'next_turn_number']),
('engine_lib.tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']), ('engine_lib.tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']),
('engine_lib.llm', ['call_llm']), ('engine_lib.llm', ['call_llm']),
('engine_lib.validation', ['validate_action', 'validate_turn']), ('engine_lib.validation', ['validate_turn']),
('engine_lib.parsing', ['log_turn_details']), ('engine_lib.parsing', ['log_turn_details']),
('engine', ['GameEngine']), ('engine', ['GameEngine']),
] ]

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for engine_lib/validation.py.""" """Tests for engine_lib/validation.py — validate_turn only."""
import sys import sys
import os import os
@ -10,103 +10,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
def test_empty_action():
"""Empty action should return (True, '')."""
from engine_lib.validation import validate_action
valid, reason = validate_action("")
assert valid is True
assert reason == ""
print("✓ empty action returns (True, '')")
@patch("engine_lib.validation.state.read_file")
@patch("engine_lib.validation.state.truncate_world")
@patch("engine_lib.validation.call_llm")
def test_valid_action(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_action
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 5" if "character" in str(p).lower() else "## Location\nTavern"
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = json.dumps({"valid": True, "reason": "ok"})
valid, reason = validate_action("I buy a drink", story="At the tavern", log="- Entered the tavern")
assert valid is True
assert reason == "ok"
mock_call_llm.assert_called_once()
print("✓ valid action returns (True, reason)")
@patch("engine_lib.validation.state.read_file")
@patch("engine_lib.validation.state.truncate_world")
@patch("engine_lib.validation.call_llm")
def test_invalid_action(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_action
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 0" if "character" in str(p).lower() else "## Location\nTavern"
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = json.dumps({"valid": False, "reason": "Not enough gold"})
valid, reason = validate_action("I buy a drink", story="At the tavern", log="- Entered the tavern")
assert valid is False
assert reason == "Not enough gold"
print("✓ invalid action returns (False, reason)")
@patch("engine_lib.validation.state.read_file")
@patch("engine_lib.validation.state.truncate_world")
@patch("engine_lib.validation.call_llm")
def test_llm_returns_none(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_action
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = None
valid, reason = validate_action("I attack the dragon", story="A dragon appears!", log="- Dragon spotted")
assert valid is False
assert reason == "Not sure"
print("✓ LLM returning None gives (False, 'Not sure')")
@patch("engine_lib.validation.state.read_file")
@patch("engine_lib.validation.state.truncate_world")
@patch("engine_lib.validation.call_llm")
def test_llm_returns_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_action
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = "not valid json at all"
valid, reason = validate_action("I cast a spell", story="In a dungeon", log="- Found a weird altar")
assert valid is False
assert reason == "Unrecognized"
print("✓ bad JSON from LLM gives (False, 'Unrecognized')")
@patch("engine_lib.validation.state.read_file")
@patch("engine_lib.validation.state.truncate_world")
def test_missing_character_sheet(mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_action
mock_read_file.return_value = ""
mock_truncate_world.return_value = "*No world state.*"
with patch("engine_lib.validation.call_llm") as mock_call_llm:
mock_call_llm.return_value = json.dumps({"valid": True, "reason": "ok"})
valid, reason = validate_action("I look around", story="In a dark room", log="- Entered the room")
assert valid is True
print("✓ handles missing character sheet gracefully")
# ── validate_turn tests ────────────────────────────────────
def test_turn_empty_inputs(): def test_turn_empty_inputs():
"""No action and no narrative should return (True, '', 'ok').""" """No action and no narrative should return (True, '', 'ok')."""
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
@ -118,7 +21,6 @@ def test_turn_empty_inputs():
def _mock_read(p: str) -> str: def _mock_read(p: str) -> str:
"""Helper for mock_read_file side_effect handling char/world/journal."""
low = str(p).lower() low = str(p).lower()
if "character" in low: if "character" in low:
return "HP: 10\nGold: 5\nInventory:\n- Healing Salve" return "HP: 10\nGold: 5\nInventory:\n- Healing Salve"
@ -161,10 +63,10 @@ def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I use my healing salve", "I use my healing salve",
narrative="Dillion applies the salve to his wound.", narrative="Kael applies the salve to his wound.",
log_entry="Dillion used his healing salve to restore 2 HP.", log_entry="Kael used his healing salve to restore 2 HP.",
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}}, changes=[{"tool": "modify_inventory", "args": {"operation": "remove", "item": "Healing Salve"}},
{"tool": "modify_vitals", "args": {"current_hp": 8}}], {"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}],
story="At the tavern", story="At the tavern",
log="- Entered the tavern", log="- Entered the tavern",
) )
@ -187,9 +89,9 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I buy a round for the house", "I buy a round for the house",
narrative="Dillion orders drinks for everyone.", narrative="Kael orders drinks for everyone.",
log_entry="Dillion bought a round at the tavern.", log_entry="Kael bought a round at the tavern.",
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}], changes=[{"tool": "modify_cash", "args": {"cash": 0}}],
story="At the tavern", story="At the tavern",
log="- Entered the tavern", log="- Entered the tavern",
) )
@ -208,19 +110,19 @@ def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
mock_read_file.side_effect = _mock_read mock_read_file.side_effect = _mock_read
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no remove_from_inventory", "regenerate") mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no modify_inventory with operation remove", "regenerate")
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I use my healing salve", "I use my healing salve",
narrative="Dillion applies the salve to his wound.", narrative="Kael applies the salve to his wound.",
log_entry="Dillion used his healing salve.", log_entry="Kael used his healing salve.",
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}], changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}],
story="At the tavern", story="At the tavern",
log="- Entered the tavern", log="- Entered the tavern",
) )
assert valid is False assert valid is False
assert reason == "Narrative says salve used but no remove_from_inventory" assert reason == "Narrative says salve used but no modify_inventory with operation remove"
assert action == "regenerate" assert action == "regenerate"
print("✓ turn validation returns (False, reason, 'regenerate')") print("✓ turn validation returns (False, reason, 'regenerate')")
@ -237,9 +139,9 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I attack the dragon", "I attack the dragon",
narrative="Dillion swings his sword.", narrative="Kael swings his sword.",
log_entry="Dillion attacked the dragon.", log_entry="Kael attacked the dragon.",
changes=[{"tool": "roll", "args": {"dice": "1d6"}}], changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 10}}],
story="A dragon appears!", story="A dragon appears!",
log="- Dragon spotted", log="- Dragon spotted",
) )
@ -251,12 +153,6 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
if __name__ == "__main__": if __name__ == "__main__":
test_empty_action()
test_valid_action()
test_invalid_action()
test_llm_returns_none()
test_llm_returns_bad_json()
test_missing_character_sheet()
test_turn_empty_inputs() test_turn_empty_inputs()
test_turn_valid() test_turn_valid()
test_turn_reject() test_turn_reject()