Compare commits
10 Commits
527369f280
...
bd0582c78b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd0582c78b | ||
|
|
f0774fc946 | ||
|
|
3e1778d0d7 | ||
|
|
f98392478b | ||
|
|
f36387f1bf | ||
|
|
1c64ac79f4 | ||
|
|
49df5a67e8 | ||
|
|
e002bafbc8 | ||
|
|
83a83dd421 | ||
|
|
a0d24c9d44 |
35
AGENTS.md
35
AGENTS.md
@ -22,7 +22,10 @@ in that process.
|
||||
the-chaos/
|
||||
├── rules/ # LOCKED — game rules, do not modify
|
||||
│ ├── deck/ # Card tables
|
||||
│ └── mechanics.md # Core mechanics reference
|
||||
│ ├── mechanics.md # Core mechanics reference
|
||||
│ ├── core_mechanics.md # Condensed core rules (injected into system prompt)
|
||||
│ ├── character_creation.md # Character creation rules
|
||||
│ └── end_game.md # End-game closure rules
|
||||
├── tools/ # Game system code
|
||||
│ ├── __init__.py
|
||||
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
|
||||
@ -201,7 +204,7 @@ Default is "tools" for faster single-call generation.
|
||||
|
||||
## Action Validation
|
||||
|
||||
Before every turn, a separate lightweight LLM call (`validate_action` in `tools/engine_lib/validation.py`) checks whether the player's action is possible given the character sheet and world state. This catches impossible actions like using items not in inventory, asserting false facts, or attempting nonsensical actions.
|
||||
After every turn is generated, a separate lightweight LLM call (`validate_turn` in `tools/engine_lib/validation.py`) checks whether the turn is valid given the character sheet and world state. This catches impossible actions like using items not in inventory, asserting false facts, or attempting nonsensical actions.
|
||||
|
||||
- Uses `VALIDATION_PROMPT` template with character + world state
|
||||
- Low temperature (0.2), low max tokens (256)
|
||||
@ -352,6 +355,7 @@ This allows compatibility with OpenAI-compatible servers that return content in
|
||||
- `session/ambience.md` — Current ambience (written by engine)
|
||||
- `session/log/<date>.md` — Session logs (written by engine)
|
||||
- `session/tweaks.md` — House rules (manual edit)
|
||||
- `archive/<hero>-<timestamp>/` — Archived completed games
|
||||
|
||||
## LLM Strategies Explained
|
||||
|
||||
@ -380,3 +384,30 @@ 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
|
||||
|
||||
157
rules/character_creation.md
Normal file
157
rules/character_creation.md
Normal file
@ -0,0 +1,157 @@
|
||||
# Character Creation
|
||||
|
||||
## Step 1 — Traits
|
||||
Roll **3d6** for each trait (STR, DEX, WIL). **Lower is better.**
|
||||
|
||||
| Trait | Range |
|
||||
|-------|-------|
|
||||
| STR | 3d6 |
|
||||
| DEX | 3d6 |
|
||||
| WIL | 3d6 |
|
||||
|
||||
## Step 2 — Choose Your Thing
|
||||
Your "Thing" is your archetype. It determines your **max health modifier**.
|
||||
|
||||
| Thing | Health Mod | Hint |
|
||||
|-------|-----------|------|
|
||||
| The Brute | +2 | Strong, tough, direct |
|
||||
| The Cunning | +0 | Sly, quick, resourceful |
|
||||
| The Guardian | +2 | Protective, sturdy, watchful |
|
||||
| The Shadow | +0 | Stealthy, elusive, silent |
|
||||
| The Weaver | -1 | Arcane, perceptive, strange |
|
||||
| The Zealot | +1 | Driven, faithful, relentless |
|
||||
|
||||
## Step 3 — Max Health
|
||||
Roll **2d6 + Thing modifier**. Minimum 2.
|
||||
|
||||
## Step 4 — Starting Cash
|
||||
Roll **3d6 × 5** silver.
|
||||
|
||||
## Step 5 — Failed Career
|
||||
What you did before adventuring (and failed at). Roll 1d12 or choose.
|
||||
|
||||
| d12 | Failed Career |
|
||||
|-----|--------------|
|
||||
| 1 | Alchemist's assistant — your master's lab exploded |
|
||||
| 2 | Barber-surgeon — you lost more patients than you saved |
|
||||
| 3 | City watch — fired for taking bribes or sleeping on duty |
|
||||
| 4 | Coin counter — you embezzled and fled |
|
||||
| 5 | Ditch digger — hit a sewer main, flooded the street |
|
||||
| 6 | Farmhand — the farm was cursed / burned / eaten by beasts |
|
||||
| 7 | Grave keeper — the dead started walking, you walked faster |
|
||||
| 8 | Hedge knight — your employer died under your protection |
|
||||
| 9 | Inn keep — the inn was repossessed or burned down |
|
||||
| 10 | Jester — your jokes landed you in a dungeon |
|
||||
| 11 | Letter carrier — you lost the mail (all of it) |
|
||||
| 12 | Mercenary — your company was routed, sole survivor |
|
||||
|
||||
## Step 6 — Gear
|
||||
|
||||
### Weapons
|
||||
| Weapon | Damage Mod | Cost | Note |
|
||||
|--------|-----------|------|------|
|
||||
| Dagger | +0 | 3 | Small, concealable |
|
||||
| Short sword | +0 | 5 | Reliable |
|
||||
| Long sword | +1 | 8 | Versatile |
|
||||
| Axe | +1 | 7 | Can be thrown |
|
||||
| Mace | +1 | 6 | Bypasses some armour |
|
||||
| Spear | +0 | 4 | Reach, can be thrown |
|
||||
| Bow | +0 | 7 | Two-handed, ranged |
|
||||
| Crossbow | +1 | 10 | Two-handed, slow to reload |
|
||||
| Staff | -1 | 1 | Two-handed, can be focus |
|
||||
| Flail | +1 | 8 | Ignores shield bonus |
|
||||
| Hatchet | +0 | 4 | Light, off-hand |
|
||||
| Improvised | -1 | 0 | Bottle, chair leg, etc. |
|
||||
|
||||
### Armour
|
||||
| Armour | Reduction | Cost | Note |
|
||||
|--------|----------|------|------|
|
||||
| Padded | -1 | 5 | Quiet |
|
||||
| Leather | -1 | 8 | Flexible |
|
||||
| Studded leather | -2 | 15 | Light |
|
||||
| Chain shirt | -2 | 25 | Covers torso |
|
||||
| Scale mail | -3 | 35 | Noisy |
|
||||
| Plate | -4 | 50 | Heavy, noisy |
|
||||
| Shield | -1 | 10 | Can be used to shove |
|
||||
| Helmet | -1 | 8 | Only vs head shots |
|
||||
|
||||
### Equipment
|
||||
| Item | Cost |
|
||||
|------|------|
|
||||
| Rope (30ft) | 1 |
|
||||
| Torches (3) | 1 |
|
||||
| Rations (3 days) | 1 |
|
||||
| Waterskin | 1 |
|
||||
| Chalk (10 pieces) | 1 |
|
||||
| Candle | 1 |
|
||||
| Flint & steel | 1 |
|
||||
| Small sack | 1 |
|
||||
| Bedroll | 2 |
|
||||
| Crowbar | 2 |
|
||||
| Grappling hook | 3 |
|
||||
| Lantern | 3 |
|
||||
| Oil (flask) | 1 |
|
||||
| Tent | 3 |
|
||||
| Healing salve (restores 1 HP) | 5 |
|
||||
| Antitoxin | 5 |
|
||||
| Fine clothes | 10 |
|
||||
| Jewellery | 15 |
|
||||
| Musical instrument | 10 |
|
||||
| Writing kit | 8 |
|
||||
| Spyglass | 20 |
|
||||
|
||||
## Step 7 — Chains (Ties to the World)
|
||||
Who or what ties you to this world? Roll 1d12 or choose.
|
||||
|
||||
| d12 | Chain |
|
||||
|-----|-------|
|
||||
| 1 | A sibling who depends on you |
|
||||
| 2 | An oath you swore and cannot break |
|
||||
| 3 | A child you're protecting |
|
||||
| 4 | A mentor who taught you everything |
|
||||
| 5 | A pet or companion that follows you |
|
||||
| 6 | A debt of honour to a family |
|
||||
| 7 | A sacred duty to a forgotten god |
|
||||
| 8 | A bloodline you're the last of |
|
||||
| 9 | A promise to a dying friend |
|
||||
| 10 | A community that raised you |
|
||||
| 11 | A lover you're searching for |
|
||||
| 12 | A kingdom you're trying to restore |
|
||||
|
||||
## Step 8 — Discords (Complications)
|
||||
Who or what makes your life harder? Roll 1d12 or choose.
|
||||
|
||||
| d12 | Discord |
|
||||
|-----|---------|
|
||||
| 1 | A rival who wants what you have |
|
||||
| 2 | A faction that hunts your kind |
|
||||
| 3 | An ex-partner full of spite |
|
||||
| 4 | A religious order that condemns you |
|
||||
| 5 | A gang you crossed |
|
||||
| 6 | A noble whose name you sullied |
|
||||
| 7 | A monster that remembers you |
|
||||
| 8 | A liar who spreads rumours about you |
|
||||
| 9 | A creditor who never forgets |
|
||||
| 10 | A family feud that reignites |
|
||||
| 11 | A thief who stole your identity |
|
||||
| 12 | A corrupt official with a grudge |
|
||||
|
||||
## Optional — Arcane Weaving (Weaver only)
|
||||
If you play a Weaver, roll 1d6 for your arcane flavour.
|
||||
|
||||
| d6 | Arcane Flavour | Description |
|
||||
|----|---------------|-------------|
|
||||
| 1 | Wild magicks | Unpredictable, surges happen |
|
||||
| 2 | Blood magicks | Costs health to cast |
|
||||
| 3 | Bone magicks | Requires remains or tokens |
|
||||
| 4 | Storm magicks | Tied to weather and sky |
|
||||
| 5 | Veil magicks | Deals with spirits and the dead |
|
||||
| 6 | Rune magicks | Inscribed, prepared in advance |
|
||||
|
||||
## Damage Formula
|
||||
**Weapon damage**: 1d6 + weapon damage mod + other mods - target armour reduction.
|
||||
|
||||
## Final Steps
|
||||
- Choose a name
|
||||
- Write a brief description
|
||||
- Record starting gear on your character sheet
|
||||
63
rules/core_mechanics.md
Normal file
63
rules/core_mechanics.md
Normal file
@ -0,0 +1,63 @@
|
||||
## Core Mechanics
|
||||
|
||||
### Dice
|
||||
- **d6**: All rolls use d6.
|
||||
- **Odds**: 1d6, 4+ favourable, 3- trouble.
|
||||
- **Traits**: 3d6, roll UNDER trait score.
|
||||
|
||||
### Character
|
||||
- **Traits (STR/DEX/WIL)**: Roll 3d6 each. Lower is better.
|
||||
- **Max HP**: 2d6 + Thing modifier (min 2).
|
||||
- **Starting Cash**: 3d6 × 5 silver.
|
||||
- **Damage**: 1d6 ± weapon mod ± armour reduction.
|
||||
|
||||
### Combat (per round ≈10s)
|
||||
1. State intentions
|
||||
2. Grit (creatures): 2d6 — higher = more determined
|
||||
3. Initiative: both 1d6, winner acts first
|
||||
4. Turn: 1d6 ± mods, 4+ success, 3- take a hit
|
||||
5. Damage: 1d6 ± mods
|
||||
|
||||
#### Encounter Start
|
||||
- Distance: 2d6 × 10
|
||||
- Surprise: 1d6 — 1-2 PC, 3-4 creature, 5 both, 6 neither
|
||||
- Grit: 2d6
|
||||
|
||||
#### Alternatives to Fighting
|
||||
- Run off, Dodge & Parry (+1, 4+ move without hit), Parley, Innovate
|
||||
|
||||
### Exploration
|
||||
- 6 × 10-min watches per hour
|
||||
- Each action → roll (odds/traits) → outcome → mark watch
|
||||
- After 6 watches: scene shifts
|
||||
|
||||
### Wounds (0 HP)
|
||||
- 1d6: 1-2 die, 3-4 -1 max HP, 5-6 -1 all rolls until healed
|
||||
|
||||
### Grit (Creatures/NPCs)
|
||||
- 2d6, higher = more determined
|
||||
- > grit score → seek exit; ≤ grit score → press attack
|
||||
|
||||
### Modifiers
|
||||
| Situation | Mod |
|
||||
|---|---|
|
||||
| Favourable / Well-prepared | +1 |
|
||||
| Risky / Rushed / Poor visibility | -1 |
|
||||
| Desperate | -2 |
|
||||
|
||||
### Darkness
|
||||
- Most creatures see fine in dark
|
||||
- PCs without light: -1 actions, increased surprise, slower exploration
|
||||
|
||||
### Complications ("yes, but...")
|
||||
- Success costs (gear/time/HP), noise, secondary problem, escalation
|
||||
|
||||
### Rest & Healing
|
||||
- Short rest (few hours): 1d6 HP
|
||||
- Long rest (safe haven): full HP
|
||||
- Healing salve: +1 HP | Antitoxin: cures poison
|
||||
|
||||
### End Game Conditions
|
||||
The story ends when: the character dies, the main quest reaches a definitive conclusion, the player chooses to retire, or all party members have fallen.
|
||||
When the story ends, the LLM outputs `### THE END` in the narrative, followed by a reason, what happened, and an epilogue describing how the world evolved.
|
||||
Call `read_rules` with `category: "end_game"` for full details on ending the game.
|
||||
56
rules/end_game.md
Normal file
56
rules/end_game.md
Normal file
@ -0,0 +1,56 @@
|
||||
# End Game — Closure & Epilogue
|
||||
|
||||
## When the Story Ends
|
||||
|
||||
The game ends when one of these conditions is reached:
|
||||
|
||||
1. **Death** — The character dies and is not revived. The story ends with their final moments.
|
||||
2. **Quest Complete** — The main story arc reaches a definitive conclusion (the Darkmal is defeated, the Realm is saved or lost, etc.).
|
||||
3. **Player Retires** — The player decides their character's journey is over and invokes the end.
|
||||
4. **Mutual TPK** — All party members (if any) have fallen. The Realm's fate is sealed.
|
||||
|
||||
## The End Marker
|
||||
|
||||
When the story ends, the LLM must include this exact string as a level-3 heading in the narrative:
|
||||
|
||||
```
|
||||
### THE END
|
||||
```
|
||||
|
||||
Everything after this marker is the **epilogue**, which contains:
|
||||
|
||||
1. **Why** — A brief statement of why the story ended (death, quest completion, retirement, etc.).
|
||||
2. **What Happened** — A summary of the final events.
|
||||
3. **The World After** — At least 2-3 paragraphs describing how the world and its key characters evolved and moved on after the event. This is the closing of the book.
|
||||
|
||||
## Example Epilogue Structure
|
||||
|
||||
```
|
||||
### THE END
|
||||
|
||||
**Why:** Dillion fell in the final battle against the Darkmal.
|
||||
|
||||
**What Happened:** Dillion faced the Darkmal in the heart of the Spire. The battle was fierce — Dillion's blade found its mark, but the Darkmal's final curse was fatal. Both fell.
|
||||
|
||||
**The World After:**
|
||||
|
||||
The Realm mourned. In the years that followed, the scars of the Darkmal's reign slowly healed. The villages rebuilt, and the wilds grew quiet once more...
|
||||
|
||||
Rina took up Dillion's cause, traveling the Realm to protect those who could not protect themselves. She spoke often of the weaver who stood against the darkness...
|
||||
|
||||
The Reaper, denied his prize, vanished into the eastern wastes. Some say he still searches for a path to lichdom, but without the Darkmal's power, his reach is broken...
|
||||
```
|
||||
|
||||
## After the End
|
||||
|
||||
Once the marker is detected, the TUI shows a "Close the Book and Start a New One" button. Clicking it:
|
||||
|
||||
1. Archives the current session folder into `archive/<hero-name>/` with a timestamp.
|
||||
2. Clears all session state for a fresh start.
|
||||
3. Presents the character creation flow again.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- The LLM must output `### THE END` exactly as shown — case-sensitive, with a space before "THE".
|
||||
- Only the narrative tool block should contain the marker. No other tool blocks are needed for ending.
|
||||
- The LLM should **not** call any state-changing tools after the marker — the epilogue is narrative-only.
|
||||
@ -1,4 +1,4 @@
|
||||
# The Chaos — Core Mechanics Reference
|
||||
# The Chaos — Full Mechanics Reference
|
||||
|
||||
## Core Dice
|
||||
|
||||
@ -11,7 +11,49 @@
|
||||
- **Traits** (STR, DEX, WIL): Roll 3d6 for each. Lower is better.
|
||||
- **Max Health**: 2d6 + Thing modifier (min 2).
|
||||
- **Starting Cash**: 3d6 × 5 silver.
|
||||
- **Damage**: 1d6 ± weapon modifier ± armour reduction.
|
||||
- **Damage**: 1d6 + weapon damage mod + other mods - target armour reduction.
|
||||
|
||||
## Equipment
|
||||
|
||||
### Weapons
|
||||
| Weapon | Damage Mod | Cost | Note |
|
||||
|--------|-----------|------|------|
|
||||
| Dagger | +0 | 3 | Small, concealable |
|
||||
| Short sword | +0 | 5 | Reliable |
|
||||
| Long sword | +1 | 8 | Versatile |
|
||||
| Axe | +1 | 7 | Can be thrown |
|
||||
| Mace | +1 | 6 | Bypasses some armour |
|
||||
| Spear | +0 | 4 | Reach, can be thrown |
|
||||
| Bow | +0 | 7 | Two-handed, ranged |
|
||||
| Crossbow | +1 | 10 | Two-handed, slow to reload |
|
||||
| Staff | -1 | 1 | Two-handed, can be focus |
|
||||
| Flail | +1 | 8 | Ignores shield bonus |
|
||||
| Hatchet | +0 | 4 | Light, off-hand |
|
||||
| Improvised | -1 | 0 | Bottle, chair leg, etc. |
|
||||
|
||||
### Armour
|
||||
| Armour | Reduction | Cost | Note |
|
||||
|--------|----------|------|------|
|
||||
| Padded | -1 | 5 | Quiet |
|
||||
| Leather | -1 | 8 | Flexible |
|
||||
| Studded leather | -2 | 15 | Light |
|
||||
| Chain shirt | -2 | 25 | Covers torso |
|
||||
| Scale mail | -3 | 35 | Noisy |
|
||||
| Plate | -4 | 50 | Heavy, noisy |
|
||||
| Shield | -1 | 10 | Can be used to shove |
|
||||
| Helmet | -1 | 8 | Only vs head shots |
|
||||
|
||||
### Equipment Costs
|
||||
| Item | Cost | | Item | Cost |
|
||||
|------|------|---|------|------|
|
||||
| Rope (30ft) | 1 | | Healing salve | 5 |
|
||||
| Torches (3) | 1 | | Antitoxin | 5 |
|
||||
| Rations (3 days) | 1 | | Fine clothes | 10 |
|
||||
| Waterskin | 1 | | Jewellery | 15 |
|
||||
| Bedroll | 2 | | Musical instrument | 10 |
|
||||
| Crowbar | 2 | | Writing kit | 8 |
|
||||
| Grappling hook | 3 | | Spyglass | 20 |
|
||||
| Lantern | 3 | | Tent | 3 |
|
||||
|
||||
## Conflict (Combat)
|
||||
|
||||
@ -25,7 +67,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 ± armour reduction.
|
||||
5. **Damage** — 1d6 + weapon mod - target armour.
|
||||
|
||||
### New Paths (Alternatives to Fighting)
|
||||
- **Run off** — Check conditions and surprise.
|
||||
@ -46,6 +88,38 @@ Each meaningful action (search, make noise, deal with trap, etc.):
|
||||
|
||||
After 6 watches, the situation usually changes (random encounter, scene shift, etc.).
|
||||
|
||||
## 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:
|
||||
@ -85,9 +159,126 @@ 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.
|
||||
- **Long rest** (full night in safe haven): Recover all HP.
|
||||
- **Short rest** (few hours in relative safety): Recover 1d6 HP, reduce stress by 1.
|
||||
- **Long rest** (full night in safe haven): Recover all HP, reduce stress by 1d6.
|
||||
- **Healing salve**: Restores 1 HP when applied.
|
||||
- **Antitoxin**: Cures one poisoning.
|
||||
|
||||
141
tools/engine.py
141
tools/engine.py
@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
|
||||
from engine_lib.models import TurnResult
|
||||
from engine_lib.models import TurnResult, END_MARKER
|
||||
from engine_lib import config
|
||||
from engine_lib.context import build_system_prompt
|
||||
from engine_lib.validation import validate_turn
|
||||
@ -16,19 +16,41 @@ from engine_lib.tools_handler import execute_tool, describe_change, extract_tool
|
||||
from engine_lib.parsing import log_turn_details
|
||||
from engine_lib 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}")
|
||||
@ -49,11 +71,27 @@ 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 not player_action and not recent_narrative:
|
||||
if is_meta:
|
||||
base_parts.append(
|
||||
"## Instructions\n"
|
||||
"The player's message starts with `>` — this is a meta out-of-character question to the DM. "
|
||||
"Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. "
|
||||
"Use the `narrative` tool to output your meta response. Do NOT call any other tools (no modify_journal, no finalize_turn, no rolls, no state changes)."
|
||||
)
|
||||
elif is_new_game:
|
||||
base_parts.append(
|
||||
"## Instructions\n"
|
||||
"This is a new story. Welcome the player and guide them through the game setup."
|
||||
@ -64,7 +102,8 @@ class GameEngine:
|
||||
"Advance the story based on the player's request. "
|
||||
"All state is shown above — write the outcome directly."
|
||||
)
|
||||
base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*")
|
||||
if not is_meta:
|
||||
base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*")
|
||||
base_user = "\n\n".join(base_parts)
|
||||
|
||||
MAX_RETRIES = 2
|
||||
@ -75,12 +114,15 @@ 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## Turn Generation Feedback\n{feedback}"
|
||||
user += f"\n\n---\n\n## Your Previous Response\n\n```\n{prev_raw}\n```\n\n---\n\n## Feedback\n{feedback}"
|
||||
|
||||
state.append_llm_log(f"\n[TOOL] Attempt {attempt + 1}/{MAX_RETRIES + 1} — {len(system)} chars system, {len(user)} chars user")
|
||||
|
||||
@ -91,13 +133,16 @@ class GameEngine:
|
||||
|
||||
if not text or not text.strip():
|
||||
if attempt < MAX_RETRIES:
|
||||
feedback = "Your response was empty. Generate a complete turn with narrative and state changes."
|
||||
feedback = f"Your response was empty. Generate a complete turn with narrative and state changes."
|
||||
state.append_llm_log("\n[RETRY] empty response")
|
||||
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:
|
||||
@ -107,14 +152,15 @@ 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", {})
|
||||
args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
|
||||
|
||||
if name == "narrative":
|
||||
text = args.get("text", "")
|
||||
text = args.get("text", "") or ""
|
||||
if text:
|
||||
book_log = (book_log + "\n\n" + text) if book_log else text
|
||||
elif name == "finalize_turn":
|
||||
@ -128,13 +174,53 @@ class GameEngine:
|
||||
ambience = None
|
||||
if args.get("log_entry"):
|
||||
log_entry = args["log_entry"]
|
||||
elif name == "player_roll":
|
||||
pass
|
||||
elif name not in ("roll",):
|
||||
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:
|
||||
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 book_log:
|
||||
if not is_meta and 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()
|
||||
@ -142,8 +228,10 @@ 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 = "The narrative is nearly identical to the previous turn. Generate something new and different."
|
||||
feedback = f"The narrative is nearly identical to the previous turn. Generate something new and different."
|
||||
state.append_llm_log(f"\n[TURN REGENERATE] (duplicate) attempt {attempt + 2}")
|
||||
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(
|
||||
@ -163,14 +251,17 @@ 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 = "The validation system could not process the previous turn. Please regenerate."
|
||||
feedback = f"The validation system could not process the previous turn. Please regenerate."
|
||||
state.append_llm_log(f"\n[TURN REGENERATE] (unrecognized) attempt {attempt + 2}")
|
||||
if on_action:
|
||||
on_action("DM is consulting the fates...")
|
||||
continue
|
||||
state.append_llm_log(f"\n[TURN UNRECOGNIZED] cannot validate turn")
|
||||
return TurnResult(
|
||||
@ -189,6 +280,8 @@ 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}")
|
||||
@ -198,24 +291,22 @@ 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", {})
|
||||
args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
|
||||
|
||||
if name == "narrative":
|
||||
if name in ("narrative", "read_rules"):
|
||||
pass
|
||||
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"):
|
||||
if name not in ("narrative", "finalize_turn", "player_roll", "read_rules"):
|
||||
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
|
||||
errors.append(f"{name}: {result}")
|
||||
else:
|
||||
@ -233,6 +324,8 @@ 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")
|
||||
|
||||
@ -248,6 +341,7 @@ class GameEngine:
|
||||
log_entry=log_entry or "",
|
||||
ambience=ambience,
|
||||
tool_calls=tool_calls,
|
||||
meta_log=meta_log,
|
||||
)
|
||||
|
||||
return TurnResult(
|
||||
@ -256,6 +350,9 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
|
||||
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH, CORE_RULES_PATH, RULES_INJECTION_PATH
|
||||
from .prompts import SYSTEM_PROMPT
|
||||
from . import state
|
||||
|
||||
@ -12,6 +12,9 @@ def build_system_prompt(recent_narrative: str | None = None, recent_log: str | N
|
||||
log = recent_log if recent_log is not None else state.read_recent_log()
|
||||
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,
|
||||
)
|
||||
character=char, world=world, log=log, journal=journal, story=story, core_rules=core_rules,
|
||||
) + extra_section
|
||||
|
||||
@ -4,6 +4,8 @@ from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
END_MARKER = "### THE END"
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
"""Output of a complete turn."""
|
||||
@ -14,3 +16,6 @@ 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 = ""
|
||||
|
||||
@ -18,6 +18,7 @@ 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()
|
||||
@ -34,6 +35,7 @@ def log_turn_details(
|
||||
state.append_llm_log(f"├─ Output: {output_chars} chars ({output_words} words)")
|
||||
state.append_llm_log(f"├─ 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(
|
||||
|
||||
@ -9,6 +9,10 @@ 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'
|
||||
@ -20,4 +24,9 @@ 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'
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
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 (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
|
||||
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
|
||||
|
||||
## Rules
|
||||
- **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.
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@ -20,47 +18,67 @@ Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside to
|
||||
Wrap each action in its own ```tool block:
|
||||
|
||||
```tool
|
||||
{"tool": "roll", "args": {"dice": "1d6"}}
|
||||
{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 8}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "player_roll", "args": {"dice": "1d6", "reason": "a check"}}
|
||||
{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "diff", "value": -3}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}}
|
||||
{"tool": "modify_cash", "args": {"operation": "diff", "value": -5}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "modify_traits", "args": {"dex": 15}}
|
||||
{"tool": "modify_traits", "args": {"str": 12, "dex": 15}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "add_to_inventory", "args": {"item": "Silver key"}}
|
||||
{"tool": "modify_inventory", "args": {"operation": "add", "item": "Torch", "value": 1}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}}
|
||||
{"tool": "modify_inventory", "args": {"operation": "remove", "item": "Torch", "value": 1}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}}
|
||||
{"tool": "modify_inventory", "args": {"operation": "replace", "item": "Mace (1d6+1)", "value": "Mace (1d6+2, sharpened)"}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}}
|
||||
{"tool": "modify_note", "args": {"operation": "add", "value": "Found a hidden passage under the temple"}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}}
|
||||
{"tool": "modify_world", "args": {"operation": "set", "value": "# The World\\n\\n...full new world state..."}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "world_update", "args": {"content": "# The World\\n\\n...full new world state..."}}
|
||||
{"tool": "modify_journal", "args": {"operation": "add", "value": "Investigate the mine"}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}}
|
||||
{"tool": "modify_journal", "args": {"operation": "done", "value": "Defeat the demon"}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Dillion explored the dungeon, found a hidden passage, and was ambushed by goblins."}}
|
||||
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins.", "meta_log": "Kael rolled a 4 (1d6) for perception — success, spotted the hidden door. HP lowered to 5 after goblin ambush."}}
|
||||
```
|
||||
|
||||
```tool
|
||||
{"tool": "read_rules", "args": {}}
|
||||
```
|
||||
or with a category:
|
||||
```tool
|
||||
{"tool": "read_rules", "args": {"category": "end_game"}}
|
||||
```
|
||||
(Categories: mechanics, core, character_creation, end_game)
|
||||
|
||||
**log_entry**: Provide a short, dense summary (1-2 sentences) of the turn's main events. This becomes the session log — be specific, factual, and concise.
|
||||
|
||||
**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 `remove_from_inventory` for that item AND apply the effects (e.g. `modify_vitals` for HP potions). If it's not on the sheet, reject the action — do not let them use items they don't have.
|
||||
**Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `modify_inventory` with operation `remove` for that item AND apply the effects (e.g. `modify_vitals` for HP potions, `modify_cash` for cash changes). If it's not on the sheet, reject the action — do not let them use items they don't have.
|
||||
|
||||
## Ending the Game
|
||||
|
||||
When the story reaches a definitive end (character death, quest completion, or the player chooses to retire), output the exact marker `### THE END` as a heading in the narrative, then provide:
|
||||
1. **Why** — why the story ended
|
||||
2. **What Happened** — summary of final events
|
||||
3. **The World After** — 2-3 paragraphs describing how the world and characters evolved
|
||||
|
||||
After the `### THE END` marker, do NOT call any state-changing tools. The epilogue is narrative-only. Call `read_rules` with `category: "end_game"` for full details.
|
||||
|
||||
## State
|
||||
|
||||
@ -76,7 +94,7 @@ $log
|
||||
### Journal (TODO / DONE)
|
||||
$journal
|
||||
|
||||
**journal_update rule**: When calling `journal_update`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions — match the TODO text character-for-character. Mark items as `done` exactly as they appear in TODO. Add new items with exact wording matching their entry in the list.
|
||||
**modify_journal rule**: When calling `modify_journal`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions — match the TODO text character-for-character. Use operation `done` for items exactly as they appear in TODO. Use operation `add` for new items with exact wording matching their entry in the list.
|
||||
|
||||
### Story
|
||||
$story""")
|
||||
|
||||
@ -9,13 +9,15 @@ 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,
|
||||
AUDIO_DIR,
|
||||
META_LOG_PATH, AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
|
||||
)
|
||||
from .models import TurnResult
|
||||
|
||||
@ -166,6 +168,21 @@ 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():
|
||||
@ -227,3 +244,36 @@ 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)
|
||||
|
||||
@ -1,26 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH
|
||||
from .paths import (
|
||||
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH,
|
||||
CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH,
|
||||
)
|
||||
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
|
||||
|
||||
|
||||
TOOL_REGISTRY: dict[str, dict] = {
|
||||
"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"}},
|
||||
"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)"}},
|
||||
}
|
||||
|
||||
|
||||
@ -34,25 +33,35 @@ def patch_character(pattern: str, repl: str, count: int = 1, flags: int = 0) ->
|
||||
return ""
|
||||
|
||||
|
||||
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}"
|
||||
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_modify_traits(args: dict) -> str:
|
||||
@ -69,103 +78,212 @@ def tool_modify_traits(args: dict) -> str:
|
||||
|
||||
|
||||
def tool_modify_vitals(args: dict) -> str:
|
||||
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."
|
||||
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."
|
||||
|
||||
|
||||
def tool_add_to_inventory(args: dict) -> str:
|
||||
item = (args or {}).get("item", "")
|
||||
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")
|
||||
|
||||
if not item:
|
||||
return "**Error:** `item` is required."
|
||||
|
||||
text = CHAR_PATH.read_text()
|
||||
if item in text:
|
||||
return f"Item already in inventory: {item}"
|
||||
gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
|
||||
if gear_section:
|
||||
insert_at = gear_section.end()
|
||||
text = text[:insert_at] + f"- {item}\n" + text[insert_at:]
|
||||
else:
|
||||
text += f"\n## Gear\n- {item}\n"
|
||||
CHAR_PATH.write_text(text)
|
||||
return f"Added to inventory: {item}"
|
||||
|
||||
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."
|
||||
|
||||
|
||||
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_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_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}"
|
||||
def tool_modify_world(args: dict) -> str:
|
||||
op = args.get("operation", "set")
|
||||
value = args.get("value", "")
|
||||
|
||||
|
||||
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):
|
||||
if op != "set":
|
||||
return f"**Error:** only 'set' operation is supported for world, got '{op}'."
|
||||
if not value:
|
||||
return "**Error:** `value` (full world markdown) is required."
|
||||
if not validate_update_size("world", value, WORLD_PATH):
|
||||
return "**Error:** Update rejected — content is too short (likely a partial paste)."
|
||||
WORLD_PATH.write_text(content.strip() + "\n")
|
||||
WORLD_PATH.write_text(value.strip() + "\n")
|
||||
return "World state updated."
|
||||
|
||||
|
||||
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_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_finalize_turn(args: dict) -> str:
|
||||
@ -182,20 +300,38 @@ 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,
|
||||
"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,
|
||||
"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,
|
||||
"finalize_turn": tool_finalize_turn,
|
||||
"read_rules": tool_read_rules,
|
||||
}
|
||||
fn = fn_map.get(tool_name)
|
||||
if not fn:
|
||||
@ -212,36 +348,31 @@ def execute_tool(tool_name: str, args: dict) -> str:
|
||||
def describe_change(tool_name: str, args: dict) -> str:
|
||||
"""Build a compact human-readable change description from a tool call."""
|
||||
if tool_name == "modify_vitals":
|
||||
parts = []
|
||||
for k, v in args.items():
|
||||
label = k.replace("_", " ").title()
|
||||
parts.append(f"{label}: {v}")
|
||||
return f"⚡ {', '.join(parts)}" if parts else ""
|
||||
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}"
|
||||
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 == "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":
|
||||
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":
|
||||
return "🌍 World updated"
|
||||
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 == "modify_journal":
|
||||
op = args.get("operation", "?")
|
||||
val = args.get("value", "?")
|
||||
return f"{'✅' if op == 'done' else '📋'} {val}"
|
||||
elif tool_name == "finalize_turn":
|
||||
a = args.get("ambience", "")
|
||||
return f"♫ {a}" if a else ""
|
||||
|
||||
@ -8,93 +8,6 @@ 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?
|
||||
@ -102,7 +15,8 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
|
||||
3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state?
|
||||
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 `journal_update` to mark it done. Unchecked items left stale too long may need prompting.
|
||||
6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `modify_journal` with operation `done` to mark it done. Unchecked items left stale too long may need prompting.
|
||||
7. **Player Speech**: If the player action contains direct speech (quoted text like `"Hello"` or `'Hello'`), the narrative MUST include the player character speaking those words or equivalent dialogue. If the player's speech can be incorporated given the context, the turn should reflect it. Only skip if the speech is completely impossible given the situation.
|
||||
|
||||
## Character (before changes)
|
||||
{character}
|
||||
@ -134,20 +48,21 @@ 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 `remove_from_inventory`
|
||||
- **Item acquired** → must have `add_to_inventory` or `replace_gear`
|
||||
- **Item used** → must have `modify_inventory` with operation `remove`
|
||||
- **Item acquired** → must have `modify_inventory` with operation `add` or `replace`
|
||||
- **HP changed** → must have `modify_vitals`
|
||||
- **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`
|
||||
- **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`
|
||||
|
||||
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/cash changes follow logically from the narrative
|
||||
- HP changes follow logically from the narrative
|
||||
- Cash changes follow logically from the narrative (spending → deduct, earning → add)
|
||||
- No impossible modifications
|
||||
|
||||
For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate.
|
||||
@ -170,6 +85,33 @@ Regenerate (turn had fixable issues like wrong state changes or minor inconsiste
|
||||
```
|
||||
"""
|
||||
|
||||
META_VALIDATION_PROMPT = """You are validating a meta (out-of-character) DM response. The player's action starts with `>` — they are talking to the DM, not to a character.
|
||||
|
||||
## Player Action (Meta)
|
||||
{action}
|
||||
|
||||
## Generated Meta Response
|
||||
{narrative}
|
||||
|
||||
## Instructions
|
||||
1. **Meta Format**: The entire response must start with `>` and use meta language (DM addressing the player directly).
|
||||
2. **State Changes**: There MUST be no state changes. This is a meta conversation, not story progression.
|
||||
3. **Answer Quality**: The response should address the player's meta question and be helpful.
|
||||
4. **No Story Advancement**: The response must not advance the game narrative.
|
||||
|
||||
Reply with ONLY a ```tool block. Examples:
|
||||
|
||||
Valid:
|
||||
```tool
|
||||
{{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}}
|
||||
```
|
||||
|
||||
Regenerate (response didn't start with `>` or tried to change state):
|
||||
```tool
|
||||
{{"tool": "validate", "args": {{"valid": false, "reason": "describe the issue", "action": "regenerate"}}}}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def _format_changes(changes: list[dict]) -> str:
|
||||
"""Format tool calls into a readable change list for the validation prompt."""
|
||||
@ -192,6 +134,7 @@ def validate_turn(
|
||||
changes: list[dict] | None = None,
|
||||
story: str = "",
|
||||
log: str = "",
|
||||
meta: bool = False,
|
||||
) -> tuple[bool, str, str]:
|
||||
"""Validate a complete generated turn.
|
||||
|
||||
@ -207,14 +150,20 @@ def validate_turn(
|
||||
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
|
||||
change_summary = _format_changes(changes or [])
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
messages = [{"role": "system", "content": prompt}]
|
||||
|
||||
for attempt in range(2):
|
||||
text = call_llm(
|
||||
@ -247,7 +196,7 @@ def validate_turn(
|
||||
if attempt == 0:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": "Your previous response was not valid. Reply with ONLY a ```tool block:\n\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"reject\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"regenerate\"}}\n```"
|
||||
"content": f"Your previous response was NOT valid. Do NOT include any reasoning or explanation. Reply with EXACTLY ONE of these three ```tool blocks and nothing else:\n\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}}}\n```\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": false, \"reason\": \"explain why the action is impossible\", \"action\": \"reject\"}}}}\n```\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": false, \"reason\": \"describe what the LLM should fix\", \"action\": \"regenerate\"}}}}\n```"
|
||||
})
|
||||
|
||||
return False, "Unrecognized", "reject"
|
||||
|
||||
112
tools/run.py
112
tools/run.py
@ -8,6 +8,7 @@ Owns the TUI and game loop. Layout:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
|
||||
from textual import on
|
||||
@ -18,7 +19,7 @@ from rich.markdown import Markdown as RichMarkdown
|
||||
from rich.theme import Theme
|
||||
|
||||
from engine import GameEngine
|
||||
from engine_lib.models import TurnResult
|
||||
from engine_lib.models import TurnResult, END_MARKER
|
||||
from engine_lib import state
|
||||
from run_utils import (
|
||||
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
|
||||
@ -28,7 +29,7 @@ from run_utils import (
|
||||
from run_ambience import AmbiencePlayer
|
||||
from run_widgets import (
|
||||
app_ambience_player as _widget_player_ref,
|
||||
RollModal, CharPane, StatusBar, TodoPane, TranscriptPane,
|
||||
CharPane, StatusBar, TodoPane, TranscriptPane,
|
||||
)
|
||||
|
||||
|
||||
@ -60,13 +61,16 @@ class ChaosTUI(App):
|
||||
#banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; }
|
||||
#main { height: 100%; background: #111111; }
|
||||
#todo-header { background: #3a2d23; color: #e0b060; padding: 0 1; height: 1; }
|
||||
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; height: 5; max-height:5; overflow-y:auto; scrollbar-size-vertical:2; }
|
||||
#todo-scroll { background: #1a1510; color: #d0b080; max-height: 4; }
|
||||
#todo-content { background: #1a1510; color: #d0b080; padding: 0 1; }
|
||||
#main-tabs { height: 1fr; }
|
||||
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; }
|
||||
@ -86,6 +90,9 @@ class ChaosTUI(App):
|
||||
#mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; }
|
||||
#mute-btn: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 = [
|
||||
@ -107,12 +114,11 @@ 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}
|
||||
@ -143,13 +149,16 @@ class ChaosTUI(App):
|
||||
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
|
||||
with Vertical(id="main"):
|
||||
yield Static("TODO", id="todo-header")
|
||||
yield TodoPane(id="todo-content")
|
||||
with VerticalScroll(id="todo-scroll"):
|
||||
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")
|
||||
@ -196,20 +205,35 @@ 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():
|
||||
saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()]
|
||||
if saved:
|
||||
parts.append(self._render_changes(saved))
|
||||
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())
|
||||
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()
|
||||
@ -220,6 +244,8 @@ 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)
|
||||
@ -256,7 +282,6 @@ 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())
|
||||
@ -294,24 +319,6 @@ 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
|
||||
@ -325,6 +332,12 @@ 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:
|
||||
@ -332,10 +345,23 @@ 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:
|
||||
@ -354,18 +380,32 @@ class ChaosTUI(App):
|
||||
self._show_error(err_msg, traceback_str)
|
||||
|
||||
@staticmethod
|
||||
def _render_changes(changes: list[str]) -> str:
|
||||
return "**Changes:**\n" + "\n".join(f"- {c}" for c in changes)
|
||||
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 _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 "")
|
||||
self._set_narrative("\n\n".join(parts) if parts else "", meta=result.is_meta)
|
||||
self._update_meta(result.changes, result.meta_log)
|
||||
self._enable_input()
|
||||
|
||||
def _enable_input(self, value: str = "") -> None:
|
||||
@ -375,9 +415,11 @@ class ChaosTUI(App):
|
||||
inp.value = value
|
||||
inp.focus()
|
||||
|
||||
def _set_narrative(self, text: str) -> None:
|
||||
def _set_narrative(self, text: str, meta: bool = False) -> None:
|
||||
self._last_narrative = text
|
||||
self.query_one("#play-narrative", Static).update(RichMarkdown(text))
|
||||
widget = self.query_one("#play-narrative", Static)
|
||||
widget.set_class(meta, "meta")
|
||||
widget.update(RichMarkdown(text))
|
||||
self.query_one("#play-scroll", VerticalScroll).scroll_home(animate=False)
|
||||
|
||||
def _show_error(self, error: str, debug_info: str = "") -> None:
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Input, Static
|
||||
from textual.widgets import Static
|
||||
from rich.markdown import Markdown as RichMarkdown
|
||||
|
||||
from run_utils import (
|
||||
@ -18,69 +15,6 @@ 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
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end validation tests using the real configured LLM.
|
||||
|
||||
Tests that validate_action handles real LLM responses correctly with
|
||||
the actual character sheet and world state. Requires a running LLM.
|
||||
|
||||
Usage:
|
||||
python3 tools/test_llm_validation.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from engine_lib.validation import validate_action
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def check(label: str, valid: bool, reason: str, expected_valid: bool):
|
||||
global PASS, FAIL
|
||||
status = "✓" if valid == expected_valid else "✗"
|
||||
if valid == expected_valid:
|
||||
PASS += 1
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" {status} {label}: valid={valid}, reason=\"{reason}\"")
|
||||
|
||||
|
||||
def section(name: str):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" {name}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
|
||||
def main():
|
||||
section("Valid actions — should pass")
|
||||
|
||||
check("Buy a drink",
|
||||
*validate_action("I buy a mug of weak ale at the Splintered Tankard"),
|
||||
expected_valid=True)
|
||||
|
||||
check("Use healing salve",
|
||||
*validate_action("I use my healing salve to restore 1 HP"),
|
||||
expected_valid=True)
|
||||
|
||||
check("Talk to Otta",
|
||||
*validate_action("I ask Mistress Otta about recent news in the Keep"),
|
||||
expected_valid=True)
|
||||
|
||||
check("Visit the market",
|
||||
*validate_action("I head to the Market Square to browse stalls"),
|
||||
expected_valid=True)
|
||||
|
||||
section("Invalid actions — should fail")
|
||||
|
||||
check("Use non-existent item",
|
||||
*validate_action("I drink a potion of invisibility"),
|
||||
expected_valid=False)
|
||||
|
||||
check("Cast a spell (not a weaver)",
|
||||
*validate_action("I cast a fireball spell at the tavern"),
|
||||
expected_valid=False)
|
||||
|
||||
check("Buy impossible item",
|
||||
*validate_action("I buy a horse for a broken copper coin"),
|
||||
expected_valid=False)
|
||||
|
||||
check("Assert false state",
|
||||
*validate_action("I fly to the moon"),
|
||||
expected_valid=False)
|
||||
|
||||
section("Edge cases")
|
||||
|
||||
check("Empty action",
|
||||
*validate_action(""),
|
||||
expected_valid=True)
|
||||
|
||||
check("Garbled nonsense",
|
||||
*validate_action("qwxz jabberwocky flargle bargle"),
|
||||
expected_valid=False)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Results: {PASS} passed, {FAIL} failed")
|
||||
print(f"{'=' * 60}")
|
||||
return 0 if FAIL == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -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_action', 'validate_turn']),
|
||||
('engine_lib.validation', ['validate_turn']),
|
||||
('engine_lib.parsing', ['log_turn_details']),
|
||||
('engine', ['GameEngine']),
|
||||
]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for engine_lib/validation.py."""
|
||||
"""Tests for engine_lib/validation.py — validate_turn only."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
@ -10,103 +10,6 @@ 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
|
||||
@ -118,7 +21,6 @@ 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"
|
||||
@ -161,10 +63,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="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}}],
|
||||
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}}],
|
||||
story="At the tavern",
|
||||
log="- Entered the tavern",
|
||||
)
|
||||
@ -187,9 +89,9 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
|
||||
|
||||
valid, reason, action = validate_turn(
|
||||
"I buy a round for the house",
|
||||
narrative="Dillion orders drinks for everyone.",
|
||||
log_entry="Dillion bought a round at the tavern.",
|
||||
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}],
|
||||
narrative="Kael orders drinks for everyone.",
|
||||
log_entry="Kael bought a round at the tavern.",
|
||||
changes=[{"tool": "modify_cash", "args": {"cash": 0}}],
|
||||
story="At the tavern",
|
||||
log="- Entered the tavern",
|
||||
)
|
||||
@ -208,19 +110,19 @@ def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
|
||||
|
||||
mock_read_file.side_effect = _mock_read
|
||||
mock_truncate_world.return_value = "## Location\nTavern"
|
||||
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no remove_from_inventory", "regenerate")
|
||||
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no modify_inventory with operation remove", "regenerate")
|
||||
|
||||
valid, reason, action = validate_turn(
|
||||
"I use my healing salve",
|
||||
narrative="Dillion applies the salve to his wound.",
|
||||
log_entry="Dillion used his healing salve.",
|
||||
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
||||
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}}],
|
||||
story="At the tavern",
|
||||
log="- Entered the tavern",
|
||||
)
|
||||
|
||||
assert valid is False
|
||||
assert reason == "Narrative says salve used but no remove_from_inventory"
|
||||
assert reason == "Narrative says salve used but no modify_inventory with operation remove"
|
||||
assert action == "regenerate"
|
||||
print("✓ turn validation returns (False, reason, 'regenerate')")
|
||||
|
||||
@ -237,9 +139,9 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
|
||||
|
||||
valid, reason, action = validate_turn(
|
||||
"I attack the dragon",
|
||||
narrative="Dillion swings his sword.",
|
||||
log_entry="Dillion attacked the dragon.",
|
||||
changes=[{"tool": "roll", "args": {"dice": "1d6"}}],
|
||||
narrative="Kael swings his sword.",
|
||||
log_entry="Kael attacked the dragon.",
|
||||
changes=[{"tool": "modify_vitals", "args": {"stat": "HP", "operation": "set", "value": 10}}],
|
||||
story="A dragon appears!",
|
||||
log="- Dragon spotted",
|
||||
)
|
||||
@ -251,12 +153,6 @@ 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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user