Compare commits

..

No commits in common. "bd0582c78b741e906166c0d3c21c3a3dc7a24041" and "527369f28022f62bdfc244bfa3ce7fe4b02413bb" have entirely different histories.

19 changed files with 621 additions and 1162 deletions

View File

@ -22,10 +22,7 @@ in that process.
the-chaos/
├── rules/ # LOCKED — game rules, do not modify
│ ├── deck/ # Card tables
│ ├── 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
│ └── mechanics.md # Core mechanics reference
├── tools/ # Game system code
│ ├── __init__.py
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
@ -204,7 +201,7 @@ Default is "tools" for faster single-call generation.
## Action Validation
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.
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.
- Uses `VALIDATION_PROMPT` template with character + world state
- Low temperature (0.2), low max tokens (256)
@ -355,7 +352,6 @@ This allows compatibility with OpenAI-compatible servers that return content in
- `session/ambience.md` — Current ambience (written by engine)
- `session/log/<date>.md` — Session logs (written by engine)
- `session/tweaks.md` — House rules (manual edit)
- `archive/<hero>-<timestamp>/` — Archived completed games
## LLM Strategies Explained
@ -384,30 +380,3 @@ Uses single LLM call with all tools available:
4. Check config.json for LLM settings
5. Look for missing imports in the engine.py file
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

View File

@ -1,157 +0,0 @@
# 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

View File

@ -1,63 +0,0 @@
## 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.

View File

@ -1,56 +0,0 @@
# 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 — Full Mechanics Reference
# The Chaos — Core Mechanics Reference
## Core Dice
@ -11,49 +11,7 @@
- **Traits** (STR, DEX, WIL): Roll 3d6 for each. Lower is better.
- **Max Health**: 2d6 + Thing modifier (min 2).
- **Starting Cash**: 3d6 × 5 silver.
- **Damage**: 1d6 + weapon 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 |
- **Damage**: 1d6 ± weapon modifier ± armour reduction.
## Conflict (Combat)
@ -67,7 +25,7 @@
2. **Grit** (creatures/NPCs) — Roll 2d6, check grit score/tables.
3. **Initiative** — Both sides roll 1d6. Winner acts first. Same = simultaneous.
4. **Turns** — Roll 1d6 ± modifiers. 4+ = success. 3- = take a hit.
5. **Damage** — 1d6 + weapon mod - target armour.
5. **Damage** — 1d6 ± weapon mod ± armour reduction.
### New Paths (Alternatives to Fighting)
- **Run off** — Check conditions and surprise.
@ -88,38 +46,6 @@ Each meaningful action (search, make noise, deal with trap, 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
When reduced to 0 HP, roll 1d6:
@ -159,126 +85,9 @@ When the dice say "yes, but...":
- A secondary problem emerges
- 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
- **Short rest** (few hours in relative safety): Recover 1d6 HP, reduce stress by 1.
- **Long rest** (full night in safe haven): Recover all HP, reduce stress by 1d6.
- **Short rest** (few hours in relative safety): Recover 1d6 HP.
- **Long rest** (full night in safe haven): Recover all HP.
- **Healing salve**: Restores 1 HP when applied.
- **Antitoxin**: Cures one poisoning.

View File

@ -8,7 +8,7 @@ from datetime import datetime
from difflib import SequenceMatcher
from pathlib import Path
from engine_lib.models import TurnResult, END_MARKER
from engine_lib.models import TurnResult
from engine_lib import config
from engine_lib.context import build_system_prompt
from engine_lib.validation import validate_turn
@ -16,41 +16,19 @@ from engine_lib.tools_handler import execute_tool, describe_change, extract_tool
from engine_lib.parsing import log_turn_details
from engine_lib import state
from engine_lib.llm import call_llm
from engine_lib.paths import CHARACTER_CREATION_PATH, RULES_INJECTION_PATH
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):
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(
self,
player_action: str | None = None,
recent_narrative: str | None = None,
on_thought: callable = None,
on_action: callable = None,
on_player_roll: callable = None,
) -> TurnResult:
now = datetime.now()
state.append_llm_log(f"\n{'='*60}")
@ -71,27 +49,11 @@ class GameEngine:
if on_action:
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)
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 = []
if player_action:
base_parts.append(f"## Player's Request\n{player_action}")
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:
if not player_action and not recent_narrative:
base_parts.append(
"## Instructions\n"
"This is a new story. Welcome the player and guide them through the game setup."
@ -102,8 +64,7 @@ class GameEngine:
"Advance the story based on the player's request. "
"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)
MAX_RETRIES = 2
@ -114,15 +75,12 @@ class GameEngine:
changes: list[str] = []
start_time = datetime.now()
total_attempts = 0
prev_raw = ""
_ = None # placeholder
for attempt in range(MAX_RETRIES + 1):
total_attempts = attempt + 1
user = base_user
if attempt > 0:
user += f"\n\n---\n\n## Your Previous Response\n\n```\n{prev_raw}\n```\n\n---\n\n## Feedback\n{feedback}"
user += f"\n\n---\n\n## Turn Generation Feedback\n{feedback}"
state.append_llm_log(f"\n[TOOL] Attempt {attempt + 1}/{MAX_RETRIES + 1}{len(system)} chars system, {len(user)} chars user")
@ -133,16 +91,13 @@ class GameEngine:
if not text or not text.strip():
if attempt < MAX_RETRIES:
feedback = f"Your response was empty. Generate a complete turn with narrative and state changes."
feedback = "Your response was empty. Generate a complete turn with narrative and state changes."
state.append_llm_log("\n[RETRY] empty response")
if on_action:
on_action("DM is weaving the tale...")
continue
return TurnResult(error="LLM returned empty response after retries")
raw = text.strip()
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)
if not tool_calls:
@ -152,15 +107,14 @@ class GameEngine:
book_log = ""
ambience = None
log_entry = None
meta_log = ""
state_changes: list[dict] = []
for tc in tool_calls:
name = tc.get("tool", "")
args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
args = tc.get("args", {})
if name == "narrative":
text = args.get("text", "") or ""
text = args.get("text", "")
if text:
book_log = (book_log + "\n\n" + text) if book_log else text
elif name == "finalize_turn":
@ -174,53 +128,13 @@ class GameEngine:
ambience = None
if args.get("log_entry"):
log_entry = args["log_entry"]
if args.get("meta_log"):
meta_log = args["meta_log"]
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:
elif name == "player_roll":
pass
elif name not in ("roll",):
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
if not is_meta and book_log:
if book_log:
prev = state.read_recent_book(1)
if prev and prev not in ("*No prior story.*",):
prev_text = re.sub(r"^## Turn \d+\n\n", "", prev, flags=re.MULTILINE).strip()
@ -228,10 +142,8 @@ class GameEngine:
if ratio >= 0.8:
state.append_llm_log(f"\n[TURN DUPLICATE] {ratio:.0%} match with previous turn")
if attempt < MAX_RETRIES:
feedback = f"The narrative is nearly identical to the previous turn. Generate something new and different."
feedback = "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}")
if on_action:
on_action("DM is weaving the tale...")
continue
state.append_llm_log(f"\n[TURN DUPLICATE EXCEEDED] cannot generate unique narrative")
return TurnResult(
@ -251,17 +163,14 @@ class GameEngine:
changes=state_changes,
story=recent_narrative,
log=session_log,
meta=is_meta,
)
if valid:
state.append_llm_log(f"\n[TURN VALID] {reason}")
elif reason == "Unrecognized":
if attempt < MAX_RETRIES:
feedback = f"The validation system could not process the previous turn. Please regenerate."
feedback = "The validation system could not process the previous turn. Please regenerate."
state.append_llm_log(f"\n[TURN REGENERATE] (unrecognized) attempt {attempt + 2}")
if on_action:
on_action("DM is consulting the fates...")
continue
state.append_llm_log(f"\n[TURN UNRECOGNIZED] cannot validate turn")
return TurnResult(
@ -280,8 +189,6 @@ class GameEngine:
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."
state.append_llm_log(f"\n[TURN REGENERATE] attempt {attempt + 2}: {reason}")
if on_action:
on_action("DM is searching for inspiration...")
continue
else:
state.append_llm_log(f"\n[TURN REGENERATE EXCEEDED] accepting despite: {reason}")
@ -291,22 +198,24 @@ class GameEngine:
# Accept this turn — execute all tool calls
break
if is_meta:
tool_calls = [tc for tc in tool_calls if tc.get("tool") == "narrative"]
# Second pass — execute all tool calls
extr_start = datetime.now()
for tc in tool_calls:
name = tc.get("tool", "")
args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
args = tc.get("args", {})
if name in ("narrative", "read_rules"):
if name == "narrative":
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:
result = execute_tool(name, args)
if name not in ("narrative", "finalize_turn", "player_roll", "read_rules"):
if name not in ("narrative", "finalize_turn", "player_roll"):
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
errors.append(f"{name}: {result}")
else:
@ -324,8 +233,6 @@ class GameEngine:
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
game_over = END_MARKER in book_log
if on_action:
on_action("Turn complete")
@ -341,7 +248,6 @@ class GameEngine:
log_entry=log_entry or "",
ambience=ambience,
tool_calls=tool_calls,
meta_log=meta_log,
)
return TurnResult(
@ -350,9 +256,6 @@ class GameEngine:
ambience=ambience,
debug_info="; ".join(errors) if errors else "",
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 .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH, CORE_RULES_PATH, RULES_INJECTION_PATH
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
from .prompts import SYSTEM_PROMPT
from . import state
@ -12,9 +12,6 @@ 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()
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)
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(
character=char, world=world, log=log, journal=journal, story=story, core_rules=core_rules,
) + extra_section
character=char, world=world, log=log, journal=journal, story=story,
)

View File

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

View File

@ -18,7 +18,6 @@ def log_turn_details(
log_entry: str,
ambience: Optional[str],
tool_calls: list,
meta_log: str = "",
) -> None:
"""Write structured turn summary to llm.log and fire TUI debug event."""
ts = datetime.now().isoformat()
@ -35,7 +34,6 @@ def log_turn_details(
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"├─ 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)
state.append_llm_log(f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})")
state.append_llm_log(

View File

@ -9,10 +9,6 @@ from pathlib import Path
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'
CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md'
@ -24,9 +20,4 @@ LOG_PATH = SESSION_DIR / 'session_log.md'
LLM_LOG_PATH = SESSION_DIR / 'llm.log'
AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.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"
END_GAME_PATH = RULES_DIR / 'end_game.md'
ARCHIVE_DIR = BASE_DIR / 'archive'

View File

@ -1,12 +1,14 @@
from __future__ import annotations
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 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 (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
## Core Rules
$core_rules
If you need the full mechanics reference (exploration, deck tables, grit tables, etc.), call the `read_rules` tool to load it.
## Rules
- **Odds**: 1d6, 4+ favourable, 3- trouble.
- **Traits**: 3d6, roll UNDER trait.
- **Combat**: 1d6, 4+ hits. Damage: 1d6 + mod - armour.
- **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 ONLY ```tool blocks no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block.
@ -18,67 +20,47 @@ Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside to
Wrap each action in its own ```tool block:
```tool
{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}
{"tool": "roll", "args": {"dice": "1d6"}}
```
```tool
{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "diff", "value": -3}}
{"tool": "player_roll", "args": {"dice": "1d6", "reason": "a check"}}
```
```tool
{"tool": "modify_cash", "args": {"operation": "diff", "value": -5}}
{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}}
```
```tool
{"tool": "modify_traits", "args": {"str": 12, "dex": 15}}
{"tool": "modify_traits", "args": {"dex": 15}}
```
```tool
{"tool": "modify_inventory", "args": {"operation": "add", "item": "Torch", "value": 1}}
{"tool": "add_to_inventory", "args": {"item": "Silver key"}}
```
```tool
{"tool": "modify_inventory", "args": {"operation": "remove", "item": "Torch", "value": 1}}
{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}}
```
```tool
{"tool": "modify_inventory", "args": {"operation": "replace", "item": "Mace (1d6+1)", "value": "Mace (1d6+2, sharpened)"}}
{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}}
```
```tool
{"tool": "modify_note", "args": {"operation": "add", "value": "Found a hidden passage under the temple"}}
{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}}
```
```tool
{"tool": "modify_world", "args": {"operation": "set", "value": "# The World\\n\\n...full new world state..."}}
{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}}
```
```tool
{"tool": "modify_journal", "args": {"operation": "add", "value": "Investigate the mine"}}
{"tool": "world_update", "args": {"content": "# The World\\n\\n...full new world state..."}}
```
```tool
{"tool": "modify_journal", "args": {"operation": "done", "value": "Defeat the demon"}}
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}}
```
```tool
{"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": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Dillion explored the dungeon, found a hidden passage, and was ambushed by goblins."}}
```
```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.
**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.
**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.
**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.
## State
@ -94,7 +76,7 @@ $log
### Journal (TODO / DONE)
$journal
**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.
**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.
### Story
$story""")

View File

@ -9,15 +9,13 @@ GameEngine or other modules besides paths.
from __future__ import annotations
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from .paths import (
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
META_LOG_PATH, AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
AUDIO_DIR,
)
from .models import TurnResult
@ -168,21 +166,6 @@ def append_llm_log(text: str) -> None:
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:
"""Add or complete TODO items in journal.md."""
if not JOURNAL_PATH.exists():
@ -244,36 +227,3 @@ def update_journal(add: list[str] | None = None, done: list[str] | None = None)
cleaned.append(line)
prev_blank = is_blank
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,25 +1,26 @@
from __future__ import annotations
import json
import random
import re
from .paths import (
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH,
CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH,
)
from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
TOOL_REGISTRY: dict[str, dict] = {
"modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"stat": "str/dex/wil", "operation": "set|diff", "value": "new value"}},
"modify_vitals": {"description": "Change HP, max_hp, weapon, armour.", "args": {"stat": "HP|max_hp|weapon|armour", "operation": "set|diff", "value": "number or string"}},
"modify_cash": {"description": "Change cash amount.", "args": {"operation": "set|diff", "value": "number"}},
"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"}},
"modify_note": {"description": "Add, remove, or replace a note.", "args": {"operation": "add|remove|replace", "stat": "existing text (for remove/replace)", "value": "note text"}},
"modify_world": {"description": "Replace full world state.", "args": {"operation": "set", "value": "full world markdown"}},
"modify_journal": {"description": "Update TODO/DONE list.", "args": {"operation": "add|done|remove", "value": "entry text"}},
"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"}},
"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)"}},
"roll": {"description": "Roll dice.", "args": {"dice": "1d6", "modifier": "+1"}},
"player_roll": {"description": "Ask player to roll.", "args": {"dice": "1d6", "reason": "why"}},
"modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"str": "optional", "dex": "optional", "wil": "optional"}},
"modify_vitals": {"description": "Change HP, cash, weapon, armour.", "args": {"current_hp": "optional", "max_hp": "optional", "cash": "optional", "weapon": "optional", "armour": "optional"}},
"add_to_inventory": {"description": "Add item to gear.", "args": {"item": "item name and stats"}},
"remove_from_inventory": {"description": "Remove item from gear.", "args": {"item": "exact item text"}},
"replace_gear": {"description": "Replace gear by exact match.", "args": {"before": "exact text", "after": "new text"}},
"add_note": {"description": "Add note to sheet.", "args": {"note": "note content"}},
"replace_note": {"description": "Replace note by exact match.", "args": {"before": "exact text", "after": "new text"}},
"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"}},
}
@ -33,35 +34,25 @@ def patch_character(pattern: str, repl: str, count: int = 1, flags: int = 0) ->
return ""
VITAL_LABELS = {
"HP": "Current Health",
"max_hp": "Max Health",
"weapon": "Weapon",
"armour": "Armour",
}
def _parse_current_int(label: str) -> int | None:
"""Read a numeric value from character.md by label."""
text = CHAR_PATH.read_text()
m = re.search(rf"^- \*\*{re.escape(label)}:\*\*\s*(\d+)", text, re.MULTILINE)
return int(m.group(1)) if m else None
def _apply_diff(label: str, diff: int) -> str:
"""Apply a numeric diff to a label in character.md. Returns result string."""
current = _parse_current_int(label)
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_roll(args: dict) -> str:
dice_str = (args or {}).get("dice", "1d6")
modifier_str = (args or {}).get("modifier", "0")
try:
count, sides = dice_str.lower().split("d")
count = int(count) if count else 1
sides = int(sides)
except (ValueError, TypeError):
return f"Invalid dice: {dice_str}. Use format like '2d6'."
mod = 0
if modifier_str:
try:
mod = int(modifier_str)
except ValueError:
pass
rolls = [random.randint(1, sides) for _ in range(count)]
total = sum(rolls) + mod
mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else ""
return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}"
def tool_modify_traits(args: dict) -> str:
@ -78,212 +69,103 @@ def tool_modify_traits(args: dict) -> str:
def tool_modify_vitals(args: dict) -> str:
stat = args.get("stat", "")
op = args.get("operation", "set")
value = args.get("value")
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(
rf"^(- \*\*{re.escape(label)}:\*\*\s*).*", rf"\g<1>{value}",
count=1, flags=re.MULTILINE,
)
return f"{label} set to {value}." if not err else err
elif op == "diff":
if stat in ("weapon", "armour"):
return f"**Error:** diff not supported for {stat}. Use set."
try:
diff = int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for diff, got {value!r}"
return _apply_diff(label, diff)
else:
return f"**Error:** unknown operation '{op}'. Use set or diff."
errors = []
for field, label in [("current_hp", "Current Health"), ("max_hp", "Max Health"),
("cash", "Cash"), ("weapon", "Weapon"), ("armour", "Armour")]:
val = args.get(field)
if val is not None:
err = patch_character(
rf"^(- \*\*{label}:\*\*\s*).*", rf"\g<1>{val}", count=1, flags=re.MULTILINE
)
if err:
errors.append(err)
return "; ".join(errors) if errors else "Vitals updated."
def tool_modify_cash(args: dict) -> str:
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")
def tool_add_to_inventory(args: dict) -> str:
item = (args or {}).get("item", "")
if not item:
return "**Error:** `item` is required."
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:
return f"**Error:** item not found: {item}"
return f"Removed from inventory: {full_line}"
elif op == "replace":
after = value or ""
if not after:
return "**Error:** `value` (new text) is required for replace."
existing = _find_gear_line(text, item)
if not existing:
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:
return f"**Error:** item not found: {item}"
return f"Gear replaced: {full_line}{after}"
return "**Error:** unknown operation. Use add, remove, or replace."
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:
text += f"\n## Gear\n- {item}\n"
CHAR_PATH.write_text(text)
return f"Added to inventory: {item}"
def tool_modify_note(args: dict) -> str:
op = args.get("operation", "")
stat = args.get("stat", "")
value = args.get("value", "")
if op == "add":
if not value:
return "**Error:** `value` (note text) is required for add."
text = CHAR_PATH.read_text()
notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE)
if notes_section:
text = text[:notes_section.end()] + f"- {value}\n" + text[notes_section.end():]
else:
text += f"\n## Notes & Scribbles\n- {value}\n"
CHAR_PATH.write_text(text)
return f"Note added: {value}"
elif op == "remove":
if not stat:
return "**Error:** `stat` (exact note text) is required for remove."
err = patch_character(rf"^- {re.escape(stat)}\n?", "", count=1, flags=re.MULTILINE)
if err:
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 "**Error:** unknown operation. Use add, remove, or replace."
def tool_remove_from_inventory(args: dict) -> str:
item = (args or {}).get("item", "")
if not item:
return "**Error:** `item` is required."
err = patch_character(rf"^- {re.escape(item)}\n?", "", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** item not found: {item}"
return f"Removed from inventory: {item}"
def tool_modify_world(args: dict) -> str:
op = args.get("operation", "set")
value = args.get("value", "")
def tool_replace_gear(args: dict) -> str:
before = (args or {}).get("before", "")
after = (args or {}).get("after", "")
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:
return f"**Error:** gear not found: {before}"
return f"Gear replaced: {before}{after}"
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):
def tool_add_note(args: dict) -> str:
note = (args or {}).get("note", "")
if not note:
return "**Error:** `note` is required."
text = CHAR_PATH.read_text()
notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE)
if notes_section:
text = text[:notes_section.end()] + f"- {note}\n" + text[notes_section.end():]
else:
text += f"\n## Notes & Scribbles\n- {note}\n"
CHAR_PATH.write_text(text)
return f"Note added: {note}"
def tool_replace_note(args: dict) -> str:
before = (args or {}).get("before", "")
after = (args or {}).get("after", "")
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:
return f"**Error:** note not found: {before}"
return f"Note replaced."
def tool_world_update(args: dict) -> str:
content = (args or {}).get("content", "")
if not content:
return "**Error:** `content` is required."
if not validate_update_size("world", content, WORLD_PATH):
return "**Error:** Update rejected — content is too short (likely a partial paste)."
WORLD_PATH.write_text(value.strip() + "\n")
WORLD_PATH.write_text(content.strip() + "\n")
return "World state updated."
def tool_modify_journal(args: dict) -> str:
op = args.get("operation", "")
value = args.get("value", "")
if not value:
return "**Error:** `value` (entry text) is required."
if op == "add":
update_journal(add=[value])
return f"Journal entry added: {value}"
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_journal_update(args: dict) -> str:
add = (args or {}).get("add", [])
done = (args or {}).get("done", [])
if isinstance(add, str):
add = [add]
if isinstance(done, str):
done = [done]
if not add and not done:
return "**Error:** Provide at least one of `add` or `done`."
update_journal(add=add, done=done)
return "Journal updated."
def tool_finalize_turn(args: dict) -> str:
@ -300,38 +182,20 @@ def tool_finalize_turn(args: dict) -> str:
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:
"""Execute a tool by name. Returns result string."""
fn_map = {
"roll": tool_roll,
"modify_traits": tool_modify_traits,
"modify_vitals": tool_modify_vitals,
"modify_cash": tool_modify_cash,
"modify_inventory": tool_modify_inventory,
"modify_note": tool_modify_note,
"modify_world": tool_modify_world,
"modify_journal": tool_modify_journal,
"add_to_inventory": tool_add_to_inventory,
"remove_from_inventory": tool_remove_from_inventory,
"replace_gear": tool_replace_gear,
"add_note": tool_add_note,
"replace_note": tool_replace_note,
"world_update": tool_world_update,
"journal_update": tool_journal_update,
"finalize_turn": tool_finalize_turn,
"read_rules": tool_read_rules,
}
fn = fn_map.get(tool_name)
if not fn:
@ -348,31 +212,36 @@ def execute_tool(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."""
if tool_name == "modify_vitals":
return f"{args.get('stat', '?')}: {args.get('operation', '?')} {args.get('value', '?')}"
elif tool_name == "modify_cash":
op = args.get("operation", "?")
val = args.get("value", "?")
return f"💰 Cash {op} {val}"
parts = []
for k, v in args.items():
label = k.replace("_", " ").title()
parts.append(f"{label}: {v}")
return f"{', '.join(parts)}" if parts else ""
elif tool_name == "modify_traits":
parts = []
for k, v in args.items():
parts.append(f"{k.upper()}: {v}")
return f"{', '.join(parts)}"
elif tool_name == "modify_inventory":
op = args.get("operation", "?")
item = args.get("item", "?")
val = args.get("value", "")
return f"{'' if op == 'replace' else '+' if op == 'add' else ''} {item}{f' ({val})' if val else ''}"
elif tool_name == "modify_note":
op = args.get("operation", "?")
val = (args.get("value") or args.get("stat") or "?")[:60]
return f"📝 {op}: {val}{'' if len(val) >= 60 else ''}"
elif tool_name == "modify_world":
elif tool_name == "add_to_inventory":
return f"+ {args.get('item', '?')}"
elif tool_name == "remove_from_inventory":
return f" {args.get('item', '?')}"
elif tool_name == "replace_gear":
return f"{args.get('before', '?')}{args.get('after', '?')}"
elif tool_name == "add_note":
note = args.get("note", "?")
return f"📝 {note[:60]}{'' if len(note) > 60 else ''}"
elif tool_name == "replace_note":
return f"📝 {args.get('before', '?')[:40]}{args.get('after', '?')[:40]}"
elif tool_name == "world_update":
return "🌍 World updated"
elif tool_name == "modify_journal":
op = args.get("operation", "?")
val = args.get("value", "?")
return f"{'' if op == 'done' else '📋'} {val}"
elif tool_name == "journal_update":
parts = []
for a in args.get("add", []):
parts.append(f"📋 {a}")
for d in args.get("done", []):
parts.append(f"{d}")
return "; ".join(parts) if parts else ""
elif tool_name == "finalize_turn":
a = args.get("ambience", "")
return f"{a}" if a else ""

View File

@ -8,6 +8,93 @@ from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
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:
1. **Action Sense**: Did the player's request make sense given the character, inventory, and world state?
@ -15,8 +102,7 @@ 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?
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.
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.
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.
## Character (before changes)
{character}
@ -48,21 +134,20 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
## 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:
- **Item used** must have `modify_inventory` with operation `remove`
- **Item acquired** must have `modify_inventory` with operation `add` or `replace`
- **Item used** must have `remove_from_inventory`
- **Item acquired** must have `add_to_inventory` or `replace_gear`
- **HP changed** must have `modify_vitals`
- **Cash changed** must have `modify_cash`
- **World changed** must have `modify_world`
- **NPC/location/thread changes** must have `modify_world` or `modify_note`
- **TODO resolved** must have `modify_journal` with operation `done`
- **Cash changed** must have `modify_vitals`
- **World changed** must have `world_update`
- **NPC/location/thread changes** must have `world_update` or `add_note`
- **TODO resolved** must have `journal_update` with `done`
Missing tool calls = regenerate. Also check that:
- 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
- Items removed were actually in inventory
- Items added are reasonable and don't duplicate existing items
- HP changes follow logically from the narrative
- Cash changes follow logically from the narrative (spending deduct, earning add)
- HP/cash changes follow logically from the narrative
- 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.
@ -85,33 +170,6 @@ 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:
"""Format tool calls into a readable change list for the validation prompt."""
@ -134,7 +192,6 @@ def validate_turn(
changes: list[dict] | None = None,
story: str = "",
log: str = "",
meta: bool = False,
) -> tuple[bool, str, str]:
"""Validate a complete generated turn.
@ -150,20 +207,14 @@ def validate_turn(
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
change_summary = _format_changes(changes or [])
if meta:
prompt = META_VALIDATION_PROMPT.format(
action=player_action,
narrative=narrative,
)
else:
prompt = TURN_VALIDATION_PROMPT.format(
character=char, world=world, story=recent,
log=log_entries, journal=journal, action=player_action,
narrative=narrative, log_entry=log_entry or "*No log entry provided.*",
changes=change_summary,
)
prompt = TURN_VALIDATION_PROMPT.format(
character=char, world=world, story=recent,
log=log_entries, journal=journal, action=player_action,
narrative=narrative, log_entry=log_entry or "*No log entry provided.*",
changes=change_summary,
)
messages = [{"role": "system", "content": prompt}]
messages = [{"role": "user", "content": prompt}]
for attempt in range(2):
text = call_llm(
@ -196,7 +247,7 @@ def validate_turn(
if attempt == 0:
messages.append({
"role": "system",
"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```"
"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```"
})
return False, "Unrecognized", "reject"

View File

@ -8,7 +8,6 @@ Owns the TUI and game loop. Layout:
from __future__ import annotations
import json
import re
import threading
from textual import on
@ -19,7 +18,7 @@ from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme
from engine import GameEngine
from engine_lib.models import TurnResult, END_MARKER
from engine_lib.models import TurnResult
from engine_lib import state
from run_utils import (
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
@ -29,7 +28,7 @@ from run_utils import (
from run_ambience import AmbiencePlayer
from run_widgets import (
app_ambience_player as _widget_player_ref,
CharPane, StatusBar, TodoPane, TranscriptPane,
RollModal, CharPane, StatusBar, TodoPane, TranscriptPane,
)
@ -61,16 +60,13 @@ class ChaosTUI(App):
#banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; }
#main { height: 100%; background: #111111; }
#todo-header { background: #3a2d23; color: #e0b060; padding: 0 1; height: 1; }
#todo-scroll { background: #1a1510; color: #d0b080; max-height: 4; }
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; }
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; height: 5; max-height:5; overflow-y:auto; scrollbar-size-vertical:2; }
#main-tabs { height: 1fr; }
TabbedContent { background: #1a1a2a; }
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; }
#transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; }
#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.processing { background: #2a1a0a; color: #ffd93d; }
#play-input { height: 3; background: #222; color: #e0d0c0; border: solid #555; padding: 0 1; }
@ -90,9 +86,6 @@ 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:hover { background: #3a3a3a; color: #ccc; }
#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 = [
@ -114,11 +107,12 @@ class ChaosTUI(App):
self._thinking_frame = 0
self._thinking_timer_handle = None
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_pages: list[str] = []
self._prev_page_count = 0
self._settings_loaded = False
self._game_over = False
def _load_settings(self) -> dict:
defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0}
@ -149,16 +143,13 @@ class ChaosTUI(App):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Vertical(id="main"):
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 TabPane("PLAY", id="play-tab"):
with VerticalScroll(id="play-scroll"):
yield Static("*Awaiting the fates...*", id="play-narrative")
yield Static("", id="play-meta")
yield Static("", id="play-status")
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 VerticalScroll():
yield CharPane(id="char-content")
@ -205,35 +196,20 @@ class ChaosTUI(App):
self._settings_loaded = True
def _begin_game(self):
self._game_over = False
self._last_narrative: str = ""
self.query_one("#play-meta", Static).update("")
pages = load_book_pages()
if pages and pages != ["*The story has not begun.*"]:
parts = []
parts.append(pages[-1])
changes: list[str] = []
if CHANGES_PATH.exists():
changes = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()]
last_meta = self._strip_meta_prefix(state.read_last_meta_log())
saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()]
if saved:
parts.append(self._render_changes(saved))
self._set_narrative("\n\n".join(parts))
self._update_meta(changes, last_meta)
self._enable_input()
return
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):
if app_ambience_player:
app_ambience_player.poll()
@ -244,8 +220,6 @@ class ChaosTUI(App):
app_ambience_player.toggle_mute()
self._update_mute_button()
self._save_settings()
elif event.button.id == "end-game-btn":
self._archive_and_reset()
def _update_mute_button(self) -> None:
btn = self.query_one("#mute-btn", Button)
@ -282,6 +256,7 @@ class ChaosTUI(App):
player_action=player_action,
on_thought=on_thought,
on_action=on_action,
on_player_roll=self._on_player_roll,
)
except Exception as e:
self.call_from_thread(self._on_generation_error, e, traceback.format_exc())
@ -319,6 +294,24 @@ class ChaosTUI(App):
status.add_class("processing")
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:
if not self._is_processing:
return
@ -332,12 +325,6 @@ class ChaosTUI(App):
if result.error:
self._show_error(result.error, result.debug_info)
return
if result.is_meta:
self._display_scene(result)
self._enable_input()
return
if result.book_log:
turn_num = state.archive_turn(result.book_log)
if result.log_entry:
@ -345,23 +332,10 @@ class ChaosTUI(App):
else:
summary = result.book_log.strip().split(chr(10))[0][:80]
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]
elif result.log_entry:
state.append_log(f"- {result.log_entry}")
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:
self._display_scene(result)
else:
@ -380,32 +354,18 @@ class ChaosTUI(App):
self._show_error(err_msg, traceback_str)
@staticmethod
def _strip_meta_prefix(entry: str) -> str:
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 _render_changes(changes: list[str]) -> str:
return "**Changes:**\n" + "\n".join(f"- {c}" for c in changes)
def _display_scene(self, result: TurnResult) -> None:
parts = []
if result.book_log:
parts.append(result.book_log)
if result.changes:
parts.append(self._render_changes(result.changes))
if result.user_prompt:
parts.append(f"---\n\n{result.user_prompt}")
self._set_narrative("\n\n".join(parts) if parts else "", meta=result.is_meta)
self._update_meta(result.changes, result.meta_log)
self._set_narrative("\n\n".join(parts) if parts else "")
self._enable_input()
def _enable_input(self, value: str = "") -> None:
@ -415,11 +375,9 @@ class ChaosTUI(App):
inp.value = value
inp.focus()
def _set_narrative(self, text: str, meta: bool = False) -> None:
def _set_narrative(self, text: str) -> None:
self._last_narrative = text
widget = self.query_one("#play-narrative", Static)
widget.set_class(meta, "meta")
widget.update(RichMarkdown(text))
self.query_one("#play-narrative", Static).update(RichMarkdown(text))
self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False)
def _show_error(self, error: str, debug_info: str = "") -> None:

View File

@ -1,6 +1,9 @@
from __future__ import annotations
from textual.widgets import Static
from textual.app import ComposeResult
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 run_utils import (
@ -15,6 +18,69 @@ from run_ambience import HAS_AUDIO
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):
def load(self):
raise NotImplementedError

View File

@ -0,0 +1,93 @@
#!/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.tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']),
('engine_lib.llm', ['call_llm']),
('engine_lib.validation', ['validate_turn']),
('engine_lib.validation', ['validate_action', 'validate_turn']),
('engine_lib.parsing', ['log_turn_details']),
('engine', ['GameEngine']),
]

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Tests for engine_lib/validation.py — validate_turn only."""
"""Tests for engine_lib/validation.py."""
import sys
import os
@ -10,6 +10,103 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
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():
"""No action and no narrative should return (True, '', 'ok')."""
from engine_lib.validation import validate_turn
@ -21,6 +118,7 @@ def test_turn_empty_inputs():
def _mock_read(p: str) -> str:
"""Helper for mock_read_file side_effect handling char/world/journal."""
low = str(p).lower()
if "character" in low:
return "HP: 10\nGold: 5\nInventory:\n- Healing Salve"
@ -63,10 +161,10 @@ def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn(
"I use my healing salve",
narrative="Kael applies the salve to his wound.",
log_entry="Kael used his healing salve to restore 2 HP.",
changes=[{"tool": "modify_inventory", "args": {"operation": "remove", "item": "Healing Salve"}},
{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}],
narrative="Dillion applies the salve to his wound.",
log_entry="Dillion used his healing salve to restore 2 HP.",
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}},
{"tool": "modify_vitals", "args": {"current_hp": 8}}],
story="At the tavern",
log="- Entered the tavern",
)
@ -89,9 +187,9 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn(
"I buy a round for the house",
narrative="Kael orders drinks for everyone.",
log_entry="Kael bought a round at the tavern.",
changes=[{"tool": "modify_cash", "args": {"cash": 0}}],
narrative="Dillion orders drinks for everyone.",
log_entry="Dillion bought a round at the tavern.",
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}],
story="At the tavern",
log="- Entered the tavern",
)
@ -110,19 +208,19 @@ def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
mock_read_file.side_effect = _mock_read
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no modify_inventory with operation remove", "regenerate")
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no remove_from_inventory", "regenerate")
valid, reason, action = validate_turn(
"I use my healing salve",
narrative="Kael applies the salve to his wound.",
log_entry="Kael used his healing salve.",
changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}],
narrative="Dillion applies the salve to his wound.",
log_entry="Dillion used his healing salve.",
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}],
story="At the tavern",
log="- Entered the tavern",
)
assert valid is False
assert reason == "Narrative says salve used but no modify_inventory with operation remove"
assert reason == "Narrative says salve used but no remove_from_inventory"
assert action == "regenerate"
print("✓ turn validation returns (False, reason, 'regenerate')")
@ -139,9 +237,9 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn(
"I attack the dragon",
narrative="Kael swings his sword.",
log_entry="Kael attacked the dragon.",
changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 10}}],
narrative="Dillion swings his sword.",
log_entry="Dillion attacked the dragon.",
changes=[{"tool": "roll", "args": {"dice": "1d6"}}],
story="A dragon appears!",
log="- Dragon spotted",
)
@ -153,6 +251,12 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
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_valid()
test_turn_reject()