TUI now owns the full game loop with embedded LLM engine

The game is now self-contained: run.sh starts the TUI, which calls the
LLM directly via engine.py. No external agent (OpenCode) needed.

- tools/engine.py: Game engine with prompt builder, litellm client,
  response parser (JSON block extraction), and state persistence
- tools/run.py: Refactored TUI with PLAY/CHAR/LOG/BOOK tabs. PLAY tab
  has streaming narrative pane, dynamic choice buttons, and text input.
  Game loop: scene -> input -> resolve -> archive -> apply -> scene
- session/config.json: LLM provider configuration (model, api_key, etc.)
- AGENTS.md: Updated to document the new architecture
- tools/__init__.py: Package marker for clean imports
- session/turn_description.md, turn_reaction.md: Deprecated - no longer
  needed now that the TUI drives the game loop internally
This commit is contained in:
Dejvino 2026-06-25 12:12:04 +02:00
parent 9f8db6e64c
commit 4b9078d41f
10 changed files with 1014 additions and 181 deletions

190
AGENTS.md
View File

@ -1,90 +1,148 @@
# The Chaos — DM Guide (for the AI)
# The Chaos — Game Architecture
You are the DM for a solo TTRPG session of **The Chaos**, a card-based rules-light fantasy RPG. Your job is to narrate, set scenes, run NPCs/creatures, apply mechanics fairly, and maintain all game files.
This document describes the system architecture for developers and AI agents
working on the codebase.
## Design Principle
The Chaos is a **self-contained terminal game**. The TUI owns the full game
loop — including LLM calls — so there is no split between a "DM agent" in chat
and a "dashboard" in the terminal. The player runs one command:
```bash
python3 tools/run.py
```
Everything — narrative, choices, character sheet, log, archive, ambience — lives
in that process.
## Project Layout
```
the-chaos/
├── rules/ # LOCKED — the game itself, do not modify
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
│ └── mechanics.md # Core rules reference
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py, store_turn.py)
├── scripts/ # UNLOCKED — DM helper scripts
└── session/ # UNLOCKED — our campaign
├── book.md # Story book (append-only turn narrative)
├── rules/ # LOCKED — game rules, do not modify
│ ├── deck/ # Card tables
│ └── mechanics.md # Core mechanics reference
├── tools/ # Game system code
│ ├── __init__.py
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
│ ├── run.py # TUI (Textual app, game loop, narrative, input)
│ ├── ambience.py # CLI shortcut for ambience switching
│ ├── draw.py # Card drawing tool
│ ├── music-fetch.py # YouTube audio downloader
│ ├── roll.py # Dice rolling tool
│ └── store_turn.py # DEPRECATED — use engine.py archive_turn instead
├── scripts/ # UNLOCKED — helper scripts
├── run.sh # Entry point (just calls tools/run.py)
└── session/ # Game state (read/write by engine)
├── config.json # LLM provider config
├── character.md # Player character sheet
├── world.md # Keep & Realm state (NPCs, locations, threads)
├── journal.md # TODO / DONE task tracking
├── world.md # Keep & Realm state
├── book.md # Story book (append-only turn archive)
├── journal.md # TODO / DONE tracking
├── ambience.md # Current ambience name
├── ambience_options.md # Ambience → file mapping
├── ambience_sources.md # Track source URLs
├── tweaks.md # House rules log
├── ambience.md # Current ambience (written by DM, read by TUI)
├── ambience_options.md # Ambience → track file mapping
├── ambience_sources.md # Track source URLs (for re-download)
├── audio/ # Music files go here
├── log/ # Raw session logs by date
├── turn_description.md # DM narrative for current turn
├── audio/ # Music files
└── log/ # Session logs by date
```
## First Steps (Fresh Session)
## How It Works
When starting a fresh session, immediately:
1. **Read** `session/character.md` — current PC state (HP, gear, cash, stats)
2. **Read** `session/world.md` — active locations, NPCs, threads
3. **Read** `session/tweaks.md` — any house rules in play
4. **Check** `session/log/<today>.md` — recent events to pick up from
### Tools
Then begin narrating from where things left off.
| Tool | Role |
|------|------|
| `tools/engine.py` | Game engine. Owns the LLM interaction, prompt assembly, response parsing, and state persistence. Can be used standalone from the CLI for debugging. |
| `tools/run.py` | TUI (Textual app). Owns the game loop: display narrative → get player input → call engine → display result. |
## Core Mechanics (Quick Reference)
### The Game Loop (run.py)
### Dice
- **Odds roll**: 1d6, 4+ favours character, 3- is trouble
- **Trait roll**: 3d6, must roll UNDER the trait score to succeed
- **Combat hit**: 1d6 ± mods, 4+ hits
- **Damage**: 1d6 ± weapon mod - armour reduction
- **Initiative**: both sides roll 1d6, higher acts first
1. **Mount**: Load engine, build system prompt (rules + character + world + log).
2. **Scene**: Call `engine.generate()` → receive narrative + choices.
3. **Display**: Show narrative in main pane, render choice buttons.
4. **Input**: Player clicks a choice or types free text, presses Enter.
5. **Resolve**: Call `engine.generate(player_action)` → receive outcome + state changes.
6. **Archive**: Append the full turn (scene + action + outcome) to `book.md`.
7. **Apply**: Write state changes to `character.md`, `world.md`, `log/`, `ambience.md`, `journal.md`.
8. **Loop**: Display the next scene → go to step 3.
### Combat Flow
1. Distance: 2d6 × 10 (metres/feet)
2. Surprise: 1d6 (1-2 chars surprised, 3-4 creatures, 5 both, 6 neither)
3. Grit: 2d6 for creatures (higher = more determined)
4. Initiative: 1d6
5. Turns: state intent → roll 1d6 ± mods → 4+ success, 3- take hit
### The Engine (engine.py)
### Wounds (0 HP)
1d6: 1-2 die, 3-4 lasting wound (-1 max HP), 5-6 -1 all rolls until healed
- `GameEngine` class loads config from `session/config.json`.
- `build_system_prompt()` assembles the DM prompt from game rules + current state.
- `build_user_message()` builds the per-turn message with player action context.
- `generate()` calls litellm, returns parsed `GenerationResult`.
- `parse_response()` extracts the JSON block from the LLM response.
- `apply_state()` writes state changes to session files.
- `archive_turn()` appends the narrative to `book.md`.
### Exploration
6 ten-minute watches per hour. Each meaningful action advances a watch. After 6 watches, situation changes.
### LLM Output Format
## The Game Loop
The LLM must end every response with a JSON fenced code block:
The core loop for every turn:
```json
{
"choices": ["Choice 1", "Choice 2"],
"log_entry": "- **time** — description.",
"ambience": "ambience_name_or_null",
"character_updates": null,
"world_updates": null,
"journal_add": [],
"journal_done": []
}
```
1. **Write** `session/turn_description.md` with the current scene.
2. **Ask the player** in the chat to act — they read the scene in the TUI, type their action, and come back here with the result.
3. **Process the turn:**
a. Resolve outcomes mechanically — update `character.md`, `world.md`, `journal.md`, and append to `session/log/<today>.md`.
b. Run `python3 tools/store_turn.py` to archive the turn description to `session/book.md` and clear turn temp files.
4. **Generate a new turn:**
a. Populate `session/turn_description.md` with the next scene's narrative using full markdown — naturally narrating what happened in the previous turn as context. Use **bold** for emphasis, *italic* for thoughts or sounds, `---` for scene breaks, lists for options or details, and quotes for dialogue.
b. Update `session/ambience.md` if the mood has changed.
- `choices`: 2-4 action options for the player.
- `log_entry`: Single-line summary appended to today's log.
- `ambience`: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
- `character_updates`: Full character sheet markdown only if HP/cash/gear/stats changed.
- `world_updates`: Full world markdown only if NPCs/locations/threads changed.
- `journal_add` / `journal_done`: TODO list management.
## How to Operate
### Session Config
1. **Draw cards when needed** — use `python3 tools/draw.py <deck> <table>` for random results
2. **Player rolls dice physically** — they report results, you narrate outcomes
3. **Log before narrating** — After every meaningful beat (conversation, travel, roll, combat round, decision), append the beat to `session/log/<today>.md` **before** describing the next scene. The log comes first, always. Format: `- **time of day** — brief description.` Each beat gets its own line. World changes get `- *World Change:* ...` mixed into the timeline.
4. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed.
5. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration.
6. **Set the ambience** — When the scene's mood changes, write the ambience name:
```
echo "tavern" > session/ambience.md
```
Available names are listed in `session/ambience_options.md`. Use `silence` to stop music.
7. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`.
8. **Death is real** — if the PC dies, help the player roll a new character. That's the game.
```json
{
"llm": {
"model": "ollama/llama3.1",
"api_key": null,
"api_base": null,
"temperature": 0.8
}
}
```
## The TUI
The `model` field accepts any litellm provider string: `openai/gpt-4`,
`anthropic/claude-sonnet-4-20250514`, `ollama/llama3.1`, `groq/llama3-70b-8192`,
etc. Set `api_key` for remote providers.
The player may have `tools/run.py` open in another terminal. It displays a live dashboard: TODO (top), CHARACTER/LOG tabs (middle), BOOK viewer with ◀ ▶ page navigation (main), and a status bar. The BOOK pane shows the story book split by turns — use arrow keys or click ◀ ▶ to flip pages. Keep the session files accurate and the TUI reflects game state.
## Files Still Used By Tools
| File | Purpose | Written By |
|------|---------|------------|
| `session/config.json` | LLM provider config | Manual edit |
| `session/character.md` | PC state | engine.py |
| `session/world.md` | Realm state | engine.py |
| `session/book.md` | Story archive | engine.py |
| `session/journal.md` | TODO/DONE | engine.py |
| `session/ambience.md` | Current ambience | engine.py |
| `session/log/<date>.md` | Session log | engine.py |
| `session/tweaks.md` | House rules | Manual edit |
## Running
```bash
# Start the game
./run.sh
# Or directly
python3 tools/run.py
# No music
python3 tools/run.py --no-music
# Test a generation from CLI (no TUI)
python3 tools/engine.py --action "I head to the market"
```

View File

@ -1 +1 @@
dungeon
wilds

View File

@ -158,3 +158,80 @@ Rina whispers: *"That's blood magick. I've seen the signs before. He's not just
She looks at you, waiting.
Waits and deliberates, weighing the risk of waiting for the ritual to finish against the danger of interrupting a blood-weaver mid-chant.
## Turn — 2026-06-25
The weaver's voice rises to a shout — a single word, sharp and resonant. The candle flames all gutter at once, then flare back a deep, pulsing red. A low thrum vibrates through the stone under your boots.
Through the gap in the curtain, you see the dagger in the circle begin to glow — a dull, hungry orange, like iron pulled from a forge. The weaver's hands tremble as he holds them over the blade, chanting faster now, sweat gleaming on his face.
Rina's fingers brush your arm. She points — past the curtain, where the corridor curves right. A heavy wooden door, banded in iron. The same crest: a fist gripping a gear.
The weaver's chant is building toward its final note. The dagger pulses brighter. Whatever he's summoning is almost here.
You have seconds.
Decides to hide and wait. Slipping into the weaver's study behind shelves and furniture, Dillion and Rina watch as the ritual reaches its peak and something begins to take shape in the chalk circle.
## Turn — 2026-06-25
The light in the chalk circle collapses inward, then explodes outward in a wave of heat and pressure. The weaver stumbles back, arms shielding his face.
Where the dagger lay, a figure now stands.
It is man-shaped, but wrong — tall and gaunt, skin the colour of old ash. A jagged blade protrudes from its own chest, driven in at an angle, crusted with dried black blood. Deep wounds crater its torso and arms — some fresh, some scarred. Its eyes open. They have no pupil, only a dull orange glow, like embers guttering in a dead fire.
The temperature in the room drops. Frost spiderwebs across the stone floor from the demon's feet. The candles gutter blue again, then die. Only the orange glow of the demon's eyes lights the room.
The weaver stares, breathless, a grin spreading across his face. *"It worked... it actually worked..."*
The demon's head turns — slowly — toward the weaver. Then toward the shelf where Rina hides. Then toward the table.
*A damned thing. Summoned. Wounded. Looking for something to defend — or destroy.*
Rina's fingers tighten on her crossbow. She's frozen, waiting for your signal.
Signals Rina to hold steady. They watch as the weaver tries to command the summoned demon — it resists, kills him, and now stands in the dark, aware it is not alone.
## Turn — 2026-06-25
Silence. Just the drip of moisture somewhere in the tunnel, and the slow, wet breathing of the demon.
It takes a step toward the shelves — toward Rina. Its bare foot leaves a frozen print on the stone. The blade in its chest grinds with every movement. Its orange eyes sweep the jars, the scrolls, the shadows.
It stops. Tilts its head.
*A low, guttural voice, like stones grinding together:* **"I smell you."**
Rina's hand drifts to her crossbow.
The demon turns — slowly — toward the table where Dillion crouches. Its cracked lips peel back from teeth that are too long, too many. The cold intensifies. Your breath fogs.
**"Fresh meat. Wrapped in leather and fear."**
It takes another step. Then another. The frost crawls up the table legs.
## Turn — 2026-06-25
The demon stands over you, frost crackling across its scarred hide. The blade in its chest glints in the dim orange light of its eyes. It's close — too close — but it's wounded, slow, and you still have your mace.
Rina yanks the string of her crossbow, slotting a fresh bolt with trembling fingers. *"Buy me three seconds!"*
The demon's lips peel back. **"The warm one first. Then the other."** It raises a hand — the same hand that froze your chest — and the temperature in the room plummets again.
You have a split second. The mace is heavy in your grip — freshly sharpened, never used since Weber's stone. It wants to bite.
## Turn — 2026-06-25
The demon plants its feet, frost spiderwebbing across the floor. Its eyes fix on you — then dart to Rina, crossbow raised. It's weighing which of you to strike first.
Rina's voice, low and steady: *"Say the word. I'll put one through its eye."*
The demon's lips curl. **"The crossbow won't save you, little thing. I've been killed before. It didn't stick."** It takes a step toward Rina, testing.
You're between them. Mace in hand. Blood still cold where it touched you.

View File

@ -14,7 +14,7 @@
## Vitals
- **Max Health:** 10
- **Current Health:** 10
- **Current Health:** 6
- **Armour:** Leather (-1 reduction)
- **Weapon:** Mace (1d6+1, freshly sharpened — 1d6+2 for one job)
- **Cash:** 52 silver

8
session/config.json Normal file
View File

@ -0,0 +1,8 @@
{
"llm": {
"model": "ollama/llama3.1",
"api_key": null,
"api_base": null,
"temperature": 0.8
}
}

View File

@ -14,3 +14,9 @@
- **Late Afternoon** — Bodies hidden. Dillion searches the guards (19 silver, key ring, handbill). Rina finds delivery manifests and a note mentioning "The Weeper" and "H." Dillion takes the loot and moves to the iron-reinforced door. Listens. Opens it safely with a rope from behind crates. Beyond: a dark tunnel sloping downward, humid air, slow rhythmic breathing sound.
- **Late Afternoon** — Dillion and Rina descend into the tunnel. It opens into a chamber with a pit at the centre — chains descending into darkness, slow wet breathing from below. A second iron door on the far side. Dillion and Rina skirt the pit silently (DEX 7). Beyond the door: a hallway with a velvet curtain, warm light and a chanting voice beyond. Dillion peeks: a weaver's study, chalk circle with a dagger at the centre. He pulls back to consult Rina.
- **Late Afternoon** — Dillion weighs the options — wait for the ritual to finish and snatch the artifact, or strike now. Rina warns it's blood magick. The weaver's chant climbs toward its peak. Decision hangs in the air.
- **Late Afternoon** — Dillion decides to hide and wait. DEX check (11, success). Dillion and Rina slip into the weaver's study undetected, taking cover behind shelves and the table as the ritual reaches its climax.
- **Late Afternoon** — The ritual completes. A wounded demon emerges from the chalk circle — scarred, a blade in its chest, frost radiating from its feet. Dillion signals Rina to hold. The weaver tries to command the demon — it kills him instead. Now it stands in the dark, searching the room.
- **Late Afternoon** — Combat opens. Dillion wins initiative. He hurls a jar to distract — Rina fires but misses. The demon strikes Dillion with a freezing blast (4 HP, now 6/10). Dillion is on his feet, mace ready. Rina reloads.
- **Late Afternoon** — Dillion lands a solid blow on the demon's ribs (5+2=7 hits, 1+2-1=2 damage). The demon shrugs it off, wound icing over. Rina finishes reloading.
- **Late Afternoon** — Dillion shouts "Shoot!" Rina fires but misses (3). Dillion swings hard (5) — 6 damage. The demon staggers. Dillion and Rina flee through the hallway into the pit chamber. The demon gives chase.
- **Late Afternoon** — Ambush at the pit. Rina hits (5, 2 damage -1 = 1). Stones miss (1). Dillion tries again (4) — knocks the demon into the pit. Escape clean (5). Dillion and Rina flee the underground, back through the tunnel, and emerge at the mill at dusk.

View File

@ -1,9 +0,0 @@
The weaver's voice rises to a shout — a single word, sharp and resonant. The candle flames all gutter at once, then flare back a deep, pulsing red. A low thrum vibrates through the stone under your boots.
Through the gap in the curtain, you see the dagger in the circle begin to glow — a dull, hungry orange, like iron pulled from a forge. The weaver's hands tremble as he holds them over the blade, chanting faster now, sweat gleaming on his face.
Rina's fingers brush your arm. She points — past the curtain, where the corridor curves right. A heavy wooden door, banded in iron. The same crest: a fist gripping a gear.
The weaver's chant is building toward its final note. The dagger pulses brighter. Whatever he's summoning is almost here.
You have seconds.

0
tools/__init__.py Normal file
View File

530
tools/engine.py Normal file
View File

@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""
engine.py The Chaos Game Engine
Owns the LLM interaction, prompt assembly, response parsing, and game state
persistence. The TUI (run.py) calls this module they do not depend on each
other, only on the shared session/ file layout.
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from string import Template
from typing import Iterator, Optional
# ── Paths ──────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent.parent
SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md'
WORLD_PATH = SESSION_DIR / 'world.md'
BOOK_PATH = SESSION_DIR / 'book.md'
JOURNAL_PATH = SESSION_DIR / 'journal.md'
AMBIENCE_PATH = SESSION_DIR / 'ambience.md'
LOG_DIR = SESSION_DIR / 'log'
TODAY = date.today().isoformat()
# ── Structured output ──────────────────────────────────────────────────────
@dataclass
class GenerationResult:
narrative: str
choices: list[str] = field(default_factory=list)
log_entry: Optional[str] = None
ambience: Optional[str] = None
character_updates: Optional[str] = None
world_updates: Optional[str] = None
journal_add: list[str] = field(default_factory=list)
journal_done: list[str] = field(default_factory=list)
error: Optional[str] = None
# ── DM System Prompt Template ──────────────────────────────────────────────
SYSTEM_PROMPT = Template("""You are the Dungeon Master for **The Chaos**, a solo card-based rules-light fantasy TTRPG. Your job is to narrate an immersive, responsive story for one player character.
## Tone & Style
- Write in **second person** ("You", "Dillion") the player is Dillion.
- Use vivid sensory descriptions sight, sound, smell, touch.
- Keep narration tight and cinematic. No monologues.
- Use **bold** for emphasis, *italic* for thoughts/sounds.
- NPC dialogue goes in **"quotes with bold names."**
- Present **2-4 clear choices** at the end of each scene.
- Each turn should advance the story meaningfully.
## Game Rules (Quick Reference)
### Core Dice
- **Odds**: 1d6, 4+ favours character, 3- is trouble.
- **Traits**: 3d6, must roll UNDER the trait score.
- **Combat hit**: 1d6 ± mods, 4+ hits.
- **Damage**: 1d6 ± weapon mod - armour reduction.
- **Initiative**: both sides roll 1d6, higher acts first.
### Combat Flow
1. Distance: 2d6 × 10 (metres/feet)
2. Surprise: 1d6
3. Grit: 2d6 for creatures (higher = more determined)
4. Initiative: 1d6
5. Turns: state intent roll 1d6 ± mods 4+ success, 3- take hit
### Wounds (0 HP)
1d6: 1-2 die, 3-4 lasting wound (-1 max HP), 5-6 -1 all rolls until healed
### Roll Modifiers
Favourable +1, Risky -1, Desperate -2, Well-prepared +1, Poor visibility -1, Relevant trait +1
### Exploration
6 ten-minute watches per hour. Each meaningful action advances a watch.
## Output Format
IMPORTANT: End every response with a JSON fenced code block:
```json
{
"choices": ["Choice 1", "Choice 2", "Choice 3"],
"log_entry": "- **time of day** — brief description of what happened.",
"ambience": "ambience_name_or_null",
"character_updates": null,
"world_updates": null,
"journal_add": [],
"journal_done": []
}
```
Rules for the JSON block:
- **choices**: 2-4 brief action options presented to the player.
- **log_entry**: One-line log entry summarizing this turn's action.
- **ambience**: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. Set to null to keep current.
- **character_updates**: ONLY include if HP, cash, gear, or stats changed. Provide the FULL updated character sheet markdown. Otherwise null.
- **world_updates**: ONLY include if NPCs, locations, or world state changed. Provide the FULL updated world markdown. Otherwise null.
- **journal_add**: New TODO items to add.
- **journal_done**: TODO items that are now completed.
When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state.
## Current Game State
### Character
$character
### World
$world
### Recent Events
$log""")
# trailing """ is intentional — the template ends here
# ── Game Engine ────────────────────────────────────────────────────────────
class GameEngine:
"""Owns the LLM interaction and game state persistence."""
def __init__(self, session_dir: str | Path = SESSION_DIR):
self.session_dir = Path(session_dir)
self.config: dict = {}
self._load_config()
# ── Config ──────────────────────────────────────────────────────────
def _load_config(self) -> None:
if not CONFIG_PATH.exists():
print(
"No session/config.json found. Creating default.\n"
"Edit the model field (e.g. 'ollama/llama3.1', 'openai/gpt-4', "
"'anthropic/claude-sonnet-4-20250514') and set api_key if needed.",
file=sys.stderr,
)
self.config = {
"llm": {
"model": "ollama/llama3.1",
"api_key": None,
"api_base": None,
"temperature": 0.8,
}
}
self._save_config()
else:
raw = CONFIG_PATH.read_text()
self.config = json.loads(raw)
# Ensure api_key is None not empty string
llm = self.config.get("llm", {})
if not llm.get("api_key"):
llm["api_key"] = None
def _save_config(self) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(self.config, indent=2) + "\n")
@property
def model(self) -> str:
return self.config.get("llm", {}).get("model", "ollama/llama3.1")
@property
def api_key(self) -> str | None:
return self.config.get("llm", {}).get("api_key")
@property
def api_base(self) -> str | None:
return self.config.get("llm", {}).get("api_base")
@property
def temperature(self) -> float:
return self.config.get("llm", {}).get("temperature", 0.8)
# ── Context Assembly ────────────────────────────────────────────────
def _read_file(self, path: Path) -> str:
return path.read_text().strip() if path.exists() else ""
def _read_recent_log(self, max_entries: int = 15) -> str:
"""Read the latest log file and return the last N entries."""
log_path = LOG_DIR / f"{TODAY}.md"
if not log_path.exists():
# Check yesterday's log
from datetime import timedelta
yesterday = (date.today() - timedelta(days=1)).isoformat()
log_path = LOG_DIR / f"{yesterday}.md"
if not log_path.exists():
return "*No recent events.*"
lines = log_path.read_text().splitlines()
entries = [l for l in lines if l.strip().startswith("- ")]
return "\n".join(entries[-max_entries:]) or "*No recent events.*"
def _read_recent_book(self, max_turns: int = 3) -> str:
"""Return the last N turns from the book as context."""
text = self._read_file(BOOK_PATH)
if not text:
return "*No prior story.*"
turns = text.split("\n## ")
recent = turns[-max_turns:]
return "\n## ".join(recent) if len(turns) > 1 else recent[0]
def build_system_prompt(self) -> str:
"""Assemble the system prompt with current game state."""
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
world = self._read_file(WORLD_PATH) or "*No world state.*"
log = self._read_recent_log()
return SYSTEM_PROMPT.substitute(character=char, world=world, log=log)
def build_user_message(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> str:
"""Build the user message for this turn's LLM call."""
parts = []
if last_narrative:
parts.append(f"## Previously\n{last_narrative}")
if player_action:
parts.append(f"## Player Action\n{player_action}")
if not player_action and not last_narrative:
parts.append(
"## Instructions\n"
"Establish the opening scene. Dillion is at the Splintered "
"Tankard in the Keep. Describe the setting and present "
"choices for what he might do. End with a JSON block."
)
else:
parts.append(
"## Instructions\n"
"Describe the outcome of the player's action using game "
"mechanics where appropriate. Then present new choices. "
"End with a JSON block."
)
return "\n\n".join(parts)
# ── LLM Call ────────────────────────────────────────────────────────
def generate(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> GenerationResult:
"""
Synchronous generation. Calls the LLM, parses the response,
and returns a GenerationResult.
The TUI calls this from a worker thread see run.py.
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_narrative=last_narrative
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
import litellm
except ImportError:
return GenerationResult(
narrative="",
error=(
"litellm is not installed. Run: pip install litellm"
),
)
# Set API key / base if provided
if self.api_key:
# litellm reads env vars or we can pass via kwargs
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=False,
)
text = response.choices[0].message.content or ""
except Exception as e:
return GenerationResult(
narrative="",
error=f"LLM call failed: {e}",
)
return self.parse_response(text)
def generate_stream(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> Iterator[str]:
"""
Streaming generator. Yields text chunks as they arrive from the LLM.
On completion, the final yield is the FULL text (for parsing).
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_narrative=last_narrative
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
import litellm
except ImportError:
yield json.dumps({
"error": "litellm is not installed. Run: pip install litellm"
})
return
if self.api_key:
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
import os
os.environ[os_env_key] = self.api_key
if self.api_base:
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
import os
os.environ[os_env_base] = self.api_base
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=True,
)
full_text = ""
for chunk in response:
delta = chunk.choices[0].delta.content or ""
if delta:
full_text += delta
yield full_text # partial narrative for streaming display
# Final yield is the completed text
yield full_text
except Exception as e:
yield json.dumps({"error": f"LLM call failed: {e}"})
# ── Response Parsing ────────────────────────────────────────────────
@staticmethod
def parse_response(text: str) -> GenerationResult:
"""
Parse a full LLM response into a GenerationResult.
Extracts the JSON block and splits narrative from it.
"""
# Check for error JSON
if text.startswith('{"error":'):
try:
err = json.loads(text).get("error", "Unknown error")
except json.JSONDecodeError:
err = "Unknown error"
return GenerationResult(narrative="", error=err)
# Extract JSON block — find the last ```json ... ``` block
json_pattern = r"```json\s*\n?(.*?)\n?```"
matches = re.findall(json_pattern, text, re.DOTALL)
narrative = text
data = {}
if matches:
json_str = matches[-1].strip()
# Remove the json block from the narrative
narrative = text[: text.rfind("```json")]
narrative = narrative.strip()
try:
data = json.loads(json_str)
except json.JSONDecodeError:
# Try to salvage partial JSON
pass
return GenerationResult(
narrative=narrative or text,
choices=data.get("choices", []),
log_entry=data.get("log_entry"),
ambience=data.get("ambience"),
character_updates=data.get("character_updates"),
world_updates=data.get("world_updates"),
journal_add=data.get("journal_add", []),
journal_done=data.get("journal_done", []),
)
# ── State Persistence ───────────────────────────────────────────────
def apply_state(self, result: GenerationResult) -> None:
"""Write state changes from a GenerationResult to disk."""
if result.character_updates:
CHAR_PATH.write_text(result.character_updates.strip() + "\n")
if result.world_updates:
WORLD_PATH.write_text(result.world_updates.strip() + "\n")
if result.log_entry:
self.append_log(result.log_entry)
if result.ambience:
AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n")
if result.journal_add or result.journal_done:
self._update_journal(
add=result.journal_add, done=result.journal_done
)
def archive_turn(self, narrative: str) -> None:
"""Append the narrative as a new turn in book.md."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
heading = f"\n\n## Turn — {timestamp}\n\n"
BOOK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(BOOK_PATH, "a") as f:
f.write(heading + narrative.strip() + "\n")
def append_log(self, entry: str) -> None:
"""Append a log entry to today's log file."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{TODAY}.md"
if not log_path.exists():
log_path.write_text(f"# Session Log — {TODAY}\n\n")
with open(log_path, "a") as f:
f.write(entry.strip() + "\n")
def _update_journal(
self, add: list[str] | None = None, done: list[str] | None = None
) -> None:
"""Add or complete TODO items in journal.md."""
if not JOURNAL_PATH.exists():
JOURNAL_PATH.write_text("# Journal\n\n## TODO\n\n## DONE\n\n")
lines = JOURNAL_PATH.read_text().splitlines()
new_lines = []
in_todo = False
in_done = False
for line in lines:
stripped = line.strip()
if stripped.startswith("## TODO"):
in_todo = True
in_done = False
elif stripped.startswith("## DONE"):
in_todo = False
in_done = True
new_lines.append(line)
# Find insertion points
todo_idx = None
done_idx = None
for i, line in enumerate(lines):
stripped = line.strip()
if stripped == "## TODO":
todo_idx = i
elif stripped == "## DONE":
done_idx = i
if done:
for item in done:
# Remove from TODO if present
new_lines = [
l for l in new_lines
if l.strip().lstrip("- ").lstrip("") != item
]
# Find DONE section and add
if done_idx is not None:
done_entry = f"- {item}"
if done_idx + 1 < len(new_lines):
new_lines.insert(done_idx + 1, done_entry)
else:
new_lines.append(done_entry)
if add:
for item in add:
entry = f"- {item}"
if entry not in new_lines:
if todo_idx is not None:
new_lines.insert(todo_idx + 1, entry)
else:
new_lines.append(entry)
JOURNAL_PATH.write_text("\n".join(new_lines) + "\n")
# ── CLI entry point (for testing) ─────────────────────────────────────────
def main():
"""Generate a turn from the command line (debug/testing)."""
import argparse
parser = argparse.ArgumentParser(description="The Chaos Game Engine (CLI)")
parser.add_argument("--action", "-a", help="Player action text")
parser.add_argument("--last", "-l", help="Last narrative text")
args = parser.parse_args()
engine = GameEngine()
result = engine.generate(
player_action=args.action,
last_narrative=args.last,
)
if result.error:
print(f"ERROR: {result.error}", file=sys.stderr)
sys.exit(1)
print(result.narrative)
if result.choices:
print("\n--- Choices ---")
for c in result.choices:
print(f" [{c}]")
if result.log_entry:
print(f"\n[Log] {result.log_entry}")
if result.ambience:
print(f"[Ambience] {result.ambience}")
if __name__ == "__main__":
main()

View File

@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""
run.py The Chaos TTRPG Session Client
run.py The Chaos TTRPG Session Client (Game Mode)
Layout: banner | TODO | CHAR/LOG (tabs) | BOOK (paged) | status.
Music: polls session/ambience.md, plays via miniaudio.
Owns the TUI and game loop. Layout:
PLAY (narrative + choices + input) | CHAR | LOG | BOOK tabs
"""
from __future__ import annotations
import os
import random
@ -12,20 +13,24 @@ import sys
from datetime import date
from pathlib import Path
from textual import on
from textual import on, work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Static, TabbedContent, TabPane
from textual.widgets import Button, Input, Static, TabbedContent, TabPane
from textual.worker import Worker, WorkerState, get_current_worker
from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme
# ── Game engine ─────────────────────────────────────────
from engine import GameEngine, GenerationResult
# ── Optional miniaudio ────────────────────────────────────
try:
import miniaudio
HAS_AUDIO = True
except ImportError:
HAS_AUDIO = False
print("Note: miniaudio not installed — no ambience music. Install with: pip install miniaudio", file=sys.stderr)
print("Note: miniaudio not installed — no ambience music.", file=sys.stderr)
# ── Paths ────────────────────────────────────────────────
@ -38,7 +43,6 @@ JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
BOOK_PATH = SESSION / 'book.md'
TURN_DESC_PATH = SESSION / 'turn_description.md'
AUDIO_DIR = SESSION / 'audio'
TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md'
@ -61,7 +65,7 @@ MARKDOWN_THEME = Theme({
})
# ── Helpers ──────────────────────────────────────────────
# ── Helpers (file reading, status, book, ambience) ───────
def ensure_log():
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not LOG_PATH.exists():
@ -108,8 +112,6 @@ def read_log_tail(n=200):
lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
# ── Status summary ───────────────────────────────────────
def status_summary():
if not CHAR_PATH.exists():
return "no character"
@ -129,34 +131,20 @@ def status_summary():
health = m
return f"{name}{health}"
def log_count():
return len(read_log_tail())
# ── Book helpers ─────────────────────────────────────────
def load_book_pages():
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
pages = ["*The story has not begun.*"]
else:
text = BOOK_PATH.read_text().strip()
turns = text.split('\n## ')
pages = []
for i, t in enumerate(turns):
pages.append(t if i == 0 else '## ' + t)
if not pages:
pages = ["*The story has not begun.*"]
# Append current turn description as virtual last page
if TURN_DESC_PATH.exists():
desc = TURN_DESC_PATH.read_text().strip()
if desc:
pages.append(f"## ⚡ Current Turn\n\n{desc}")
return pages
return ["*The story has not begun.*"]
text = BOOK_PATH.read_text().strip()
turns = text.split('\n## ')
pages = []
for i, t in enumerate(turns):
pages.append(t if i == 0 else '## ' + t)
return pages or ["*The story has not begun.*"]
# ── Ambience subsystem ───────────────────────────────────
def parse_ambience_options():
"""Parse ambience_options.md into {name: [filepath, ...]}"""
if not AMBIENCE_OPTIONS_PATH.exists():
return {}
options = {}
@ -183,9 +171,8 @@ def parse_ambience_options():
return options
# ── Ambience subsystem ───────────────────────────────────
class AmbiencePlayer:
"""Monitors ambience.md and plays background music via miniaudio."""
def __init__(self):
self.current_ambience = 'silence'
self._last_mtime = 0
@ -250,10 +237,12 @@ class AmbiencePlayer:
self.current_ambience = None
# module-level ref
app_ambience_player = None
# ── Auto-refreshing panels ───────────────────────────────
class AutoStatic(Static):
"""A Static that reloads its content on an interval."""
def load(self):
raise NotImplementedError
@ -273,7 +262,7 @@ class TranscriptPane(AutoStatic):
lines = read_log_tail()
display = "\n".join(lines[-80:])
if lines:
display += "\n >>--- NOW --->"
display += "\n\n>>--- NOW --->"
self.update(display)
self.call_after_refresh(self._scroll_bottom)
@ -304,10 +293,6 @@ class StatusBar(AutoStatic):
self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}")
# module-level ref so StatusBar can reach it
app_ambience_player = None
# ── The App ──────────────────────────────────────────────
class ChaosTUI(App):
TITLE = "The Chaos"
@ -315,7 +300,6 @@ class ChaosTUI(App):
Screen {
background: #121212;
}
#banner {
dock: top;
height: 1;
@ -323,12 +307,10 @@ class ChaosTUI(App):
color: #e0ad4c;
text-align: center;
}
#main {
height: 100%;
background: #111111;
}
#todo-header {
background: #3a2a1a;
color: #e0b060;
@ -345,11 +327,6 @@ class ChaosTUI(App):
overflow-y: auto;
scrollbar-size-vertical: 2;
}
#middle-tabs {
height: 25%;
min-height: 8;
}
TabbedContent {
background: #1a1a2a;
}
@ -371,10 +348,43 @@ class ChaosTUI(App):
padding: 0 1;
}
#book-section {
height: 1fr;
layout: vertical;
/* Play tab */
#play-narrative {
background: #161616;
color: #d8d8d8;
padding: 1 2;
height: auto;
}
#play-choices {
height: auto;
min-height: 3;
background: #1e1e2a;
padding: 0 1;
align: center middle;
}
#play-choices Button {
margin: 0 1;
min-width: 12;
}
#play-input {
dock: bottom;
height: 3;
background: #222222;
color: #e0d0c0;
border: solid #555555;
padding: 0 1;
}
#play-input:focus {
border: solid #e0ad4c;
}
#play-processing {
background: #1a1a2a;
color: #888888;
padding: 1 2;
text-style: italic;
}
/* Book tab */
#book-header {
background: #2d2d2d;
color: #e0c080;
@ -408,12 +418,6 @@ class ChaosTUI(App):
padding: 0 2;
text-align: center;
}
#book-hint {
height: 1;
color: #808080;
padding: 0 2;
text-align: center;
}
#book-progress {
height: 1;
background: #1a1a1a;
@ -442,14 +446,6 @@ class ChaosTUI(App):
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("escape", "quit", "Quit"),
("h", "prev_page", "Previous turn"),
("l", "next_page", "Next turn"),
("[", "prev_page", "Previous turn"),
("]", "next_page", "Next turn"),
("j", "skip_fwd", "Skip 5 ahead"),
("k", "skip_bwd", "Skip 5 back"),
("g", "first_page", "First page"),
("G", "last_page", "Last page"),
]
def __init__(self, *args, no_music=False, **kwargs):
@ -459,47 +455,229 @@ class ChaosTUI(App):
app_ambience_player = AmbiencePlayer()
else:
app_ambience_player = None
# Game engine
self.engine = GameEngine()
# Game loop state
self._last_narrative: str = ""
self._last_result: GenerationResult | None = None
self._is_processing: bool = False
# Book viewer state
self._book_page = 0
self._book_pages = []
self._prev_page_count = 0
# ── Compose ──────────────────────────────────────────
def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Vertical(id="main"):
yield Static("TODO", id="todo-header")
yield TodoPane(id="todo-content")
with TabbedContent(initial="char-tab", id="middle-tabs"):
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 Horizontal(id="play-choices")
yield Input(
placeholder="Type your action and press Enter...",
id="play-input",
)
with TabPane("CHARACTER", id="char-tab"):
with VerticalScroll():
yield CharPane(id="char-content")
with TabPane("LOG", id="log-tab"):
with VerticalScroll():
yield TranscriptPane(id="transcript")
yield Static("BOOK", id="book-header")
with Vertical(id="book-section"):
with Horizontal(id="book-nav"):
yield Button("<< Prev", id="book-prev")
with Vertical(id="book-nav-center"):
yield Static("", id="book-page-label")
yield Static("", id="book-hint")
yield Static("", id="book-progress")
yield Button("Next >>", id="book-next")
with VerticalScroll(id="book-scroll"):
yield Static("", id="book-content")
with TabPane("BOOK", id="book-tab"):
yield Static("BOOK", id="book-header")
with Vertical(id="book-section"):
with Horizontal(id="book-nav"):
yield Button("<< Prev", id="book-prev")
with Vertical(id="book-nav-center"):
yield Static("", id="book-page-label")
yield Static("", id="book-progress")
yield Button("Next >>", id="book-next")
with VerticalScroll(id="book-scroll"):
yield Static("", id="book-content")
yield StatusBar(id="status-bar")
def on_mount(self):
ensure_log()
self.console._theme = MARKDOWN_THEME
self._reload_book()
self._render_book_page()
self._init_book()
self.set_interval(REFRESH_SECS, self._check_ambience)
self.set_interval(REFRESH_SECS, self._reload_book)
# Start the game
self.call_after_refresh(self._begin_game)
def _begin_game(self):
"""Generate the first scene of the game."""
self._call_llm()
# ── Ambience ─────────────────────────────────────────
def _check_ambience(self):
if app_ambience_player:
app_ambience_player.poll()
# ── Game Loop ─────────────────────────────────────────
def _call_llm(self, player_action: str | None = None):
"""Called when we need new content from the LLM (scene or resolution)."""
if self._is_processing:
return
self._is_processing = True
input_widget = self.query_one("#play-input", Input)
input_widget.disabled = True
self._set_narrative("✦ *The fates conspire...* ✦")
self._clear_choices()
self._run_generation(player_action)
@work(thread=True, exit_on_error=False)
def _run_generation(self, player_action: str | None) -> None:
"""Worker thread: calls engine.generate() and posts result back."""
worker = get_current_worker()
# Provide previous narrative as context on subsequent calls
last_narrative = self._last_narrative if self._last_narrative else None
result = self.engine.generate(
player_action=player_action,
last_narrative=last_narrative,
)
if worker.is_cancelled:
return
self.call_from_thread(self._on_generation_done, result, player_action)
def _on_generation_done(
self, result: GenerationResult, player_action: str | None
) -> None:
"""Handle the completed generation on the main thread."""
self._is_processing = False
if result.error:
self._show_error(result.error)
return
# If this was a resolution (player acted), archive the previous turn
if player_action and self._last_narrative:
archive_text = (
f"{self._last_narrative}\n\n"
f"---\n\n"
f"**Player chose:** {player_action}\n\n"
f"{result.narrative}"
)
self.engine.archive_turn(archive_text)
# Apply state changes
if result.character_updates or result.world_updates:
self.engine.apply_state(result)
# Display the scene
self._display_scene(result)
# Store for next turn
self._last_narrative = result.narrative
self._last_result = result
def _display_scene(self, result: GenerationResult) -> None:
"""Update the UI with a new scene."""
self._set_narrative(result.narrative)
self._set_choices(result.choices)
self._enable_input()
# Focus the input
input_widget = self.query_one("#play-input", Input)
input_widget.focus()
def _enable_input(self) -> None:
input_widget = self.query_one("#play-input", Input)
input_widget.disabled = False
input_widget.value = ""
input_widget.focus()
def _set_narrative(self, text: str) -> None:
widget = self.query_one("#play-narrative", Static)
widget.update(RichMarkdown(text))
# Scroll to top
scroll = self.query_one("#play-scroll", VerticalScroll)
scroll.scroll_home(animate=False)
def _clear_choices(self) -> None:
container = self.query_one("#play-choices", Horizontal)
container.remove_children()
def _set_choices(self, choices: list[str]) -> None:
container = self.query_one("#play-choices", Horizontal)
container.remove_children()
for choice in choices:
btn = Button(choice, classes="choice-btn")
container.mount(btn)
def _show_error(self, error: str) -> None:
self._set_narrative(
f"**Error:** {error}\n\n"
"Check your session/config.json and ensure your LLM provider is running."
)
self._enable_input()
# ── Input handling ────────────────────────────────────
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Player pressed Enter in the input widget."""
action = event.value.strip()
if not action or self._is_processing:
event.stop()
return
event.stop()
self._handle_player_action(action)
@on(Button.Pressed, ".choice-btn")
def on_choice_clicked(self, event: Button.Pressed) -> None:
"""Player clicked a choice button."""
if self._is_processing:
return
action = event.button.label
self._handle_player_action(str(action))
def _handle_player_action(self, action: str) -> None:
"""Common handler for player actions from input or buttons."""
# Log the action
from datetime import datetime
timestamp = datetime.now().strftime("%H:%M")
time_of_day = self._guess_time_of_day()
log_entry = f"- **{time_of_day}** — {action}"
self.engine.append_log(log_entry)
# Call LLM to resolve
self._call_llm(player_action=action)
def _guess_time_of_day(self) -> str:
"""Simple time-of-day label based on hour."""
from datetime import datetime
h = datetime.now().hour
if h < 6:
return "Night"
elif h < 12:
return "Morning"
elif h < 14:
return "Midday"
elif h < 18:
return "Afternoon"
elif h < 21:
return "Evening"
else:
return "Night"
# ── Book viewer ───────────────────────────────────────
def _init_book(self):
self._reload_book()
self._render_book_page()
def _reload_book(self):
self._book_pages = load_book_pages()
if len(self._book_pages) > self._prev_page_count:
@ -509,10 +687,15 @@ class ChaosTUI(App):
self._render_book_page()
def _render_book_page(self):
self.query_one("#book-content").update(RichMarkdown(self._book_pages[self._book_page]))
if not self._book_pages:
return
self.query_one("#book-content").update(
RichMarkdown(self._book_pages[self._book_page])
)
total = len(self._book_pages)
self.query_one("#book-page-label").update(f"Page {self._book_page + 1} of {total}")
self.query_one("#book-hint").update("h prev | l next | j +5 | k -5 | g first | G last")
self.query_one("#book-page-label").update(
f"Page {self._book_page + 1} of {total}"
)
pct = (self._book_page + 1) / total if total else 1
fill = round(pct * 20)
bar = "" * fill + "" * (20 - fill)
@ -532,32 +715,12 @@ class ChaosTUI(App):
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_skip_fwd(self):
self._book_page = min(len(self._book_pages) - 1, self._book_page + 5)
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_skip_bwd(self):
self._book_page = max(0, self._book_page - 5)
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_first_page(self):
self._book_page = 0
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
def action_last_page(self):
self._book_page = len(self._book_pages) - 1
self._render_book_page()
self.query_one("#book-scroll").scroll_home(animate=False)
@on(Button.Pressed, "#book-prev")
def on_prev(self):
def on_book_prev(self):
self.action_prev_page()
@on(Button.Pressed, "#book-next")
def on_next(self):
def on_book_next(self):
self.action_next_page()