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:
parent
9f8db6e64c
commit
4b9078d41f
190
AGENTS.md
190
AGENTS.md
@ -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
|
## Project Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
the-chaos/
|
the-chaos/
|
||||||
├── rules/ # LOCKED — the game itself, do not modify
|
├── rules/ # LOCKED — game rules, do not modify
|
||||||
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
|
│ ├── deck/ # Card tables
|
||||||
│ └── mechanics.md # Core rules reference
|
│ └── mechanics.md # Core mechanics reference
|
||||||
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py, store_turn.py)
|
├── tools/ # Game system code
|
||||||
├── scripts/ # UNLOCKED — DM helper scripts
|
│ ├── __init__.py
|
||||||
└── session/ # UNLOCKED — our campaign
|
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
|
||||||
├── book.md # Story book (append-only turn narrative)
|
│ ├── 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
|
├── character.md # Player character sheet
|
||||||
├── world.md # Keep & Realm state (NPCs, locations, threads)
|
├── world.md # Keep & Realm state
|
||||||
├── journal.md # TODO / DONE task tracking
|
├── 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
|
├── tweaks.md # House rules log
|
||||||
├── ambience.md # Current ambience (written by DM, read by TUI)
|
├── audio/ # Music files
|
||||||
├── ambience_options.md # Ambience → track file mapping
|
└── log/ # Session logs by date
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## First Steps (Fresh Session)
|
## How It Works
|
||||||
|
|
||||||
When starting a fresh session, immediately:
|
### Tools
|
||||||
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
|
|
||||||
|
|
||||||
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
|
1. **Mount**: Load engine, build system prompt (rules + character + world + log).
|
||||||
- **Odds roll**: 1d6, 4+ favours character, 3- is trouble
|
2. **Scene**: Call `engine.generate()` → receive narrative + choices.
|
||||||
- **Trait roll**: 3d6, must roll UNDER the trait score to succeed
|
3. **Display**: Show narrative in main pane, render choice buttons.
|
||||||
- **Combat hit**: 1d6 ± mods, 4+ hits
|
4. **Input**: Player clicks a choice or types free text, presses Enter.
|
||||||
- **Damage**: 1d6 ± weapon mod - armour reduction
|
5. **Resolve**: Call `engine.generate(player_action)` → receive outcome + state changes.
|
||||||
- **Initiative**: both sides roll 1d6, higher acts first
|
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
|
### The Engine (engine.py)
|
||||||
1. Distance: 2d6 × 10 (metres/feet)
|
|
||||||
2. Surprise: 1d6 (1-2 chars surprised, 3-4 creatures, 5 both, 6 neither)
|
|
||||||
3. Grit: 2d6 for creatures (higher = more determined)
|
|
||||||
4. Initiative: 1d6
|
|
||||||
5. Turns: state intent → roll 1d6 ± mods → 4+ success, 3- take hit
|
|
||||||
|
|
||||||
### Wounds (0 HP)
|
- `GameEngine` class loads config from `session/config.json`.
|
||||||
1d6: 1-2 die, 3-4 lasting wound (-1 max HP), 5-6 -1 all rolls until healed
|
- `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
|
### LLM Output Format
|
||||||
6 ten-minute watches per hour. Each meaningful action advances a watch. After 6 watches, situation changes.
|
|
||||||
|
|
||||||
## 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.
|
- `choices`: 2-4 action options for the player.
|
||||||
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.
|
- `log_entry`: Single-line summary appended to today's log.
|
||||||
3. **Process the turn:**
|
- `ambience`: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
|
||||||
a. Resolve outcomes mechanically — update `character.md`, `world.md`, `journal.md`, and append to `session/log/<today>.md`.
|
- `character_updates`: Full character sheet markdown only if HP/cash/gear/stats changed.
|
||||||
b. Run `python3 tools/store_turn.py` to archive the turn description to `session/book.md` and clear turn temp files.
|
- `world_updates`: Full world markdown only if NPCs/locations/threads changed.
|
||||||
4. **Generate a new turn:**
|
- `journal_add` / `journal_done`: TODO list management.
|
||||||
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.
|
|
||||||
|
|
||||||
## How to Operate
|
### Session Config
|
||||||
|
|
||||||
1. **Draw cards when needed** — use `python3 tools/draw.py <deck> <table>` for random results
|
```json
|
||||||
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.
|
"llm": {
|
||||||
4. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed.
|
"model": "ollama/llama3.1",
|
||||||
5. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration.
|
"api_key": null,
|
||||||
6. **Set the ambience** — When the scene's mood changes, write the ambience name:
|
"api_base": null,
|
||||||
```
|
"temperature": 0.8
|
||||||
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.
|
|
||||||
|
|
||||||
## 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"
|
||||||
|
```
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
dungeon
|
wilds
|
||||||
|
|||||||
@ -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.
|
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.
|
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.
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
## Vitals
|
## Vitals
|
||||||
|
|
||||||
- **Max Health:** 10
|
- **Max Health:** 10
|
||||||
- **Current Health:** 10
|
- **Current Health:** 6
|
||||||
- **Armour:** Leather (-1 reduction)
|
- **Armour:** Leather (-1 reduction)
|
||||||
- **Weapon:** Mace (1d6+1, freshly sharpened — 1d6+2 for one job)
|
- **Weapon:** Mace (1d6+1, freshly sharpened — 1d6+2 for one job)
|
||||||
- **Cash:** 52 silver
|
- **Cash:** 52 silver
|
||||||
|
|||||||
8
session/config.json
Normal file
8
session/config.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"llm": {
|
||||||
|
"model": "ollama/llama3.1",
|
||||||
|
"api_key": null,
|
||||||
|
"api_base": null,
|
||||||
|
"temperature": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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** — 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 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 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.
|
||||||
|
|||||||
@ -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
0
tools/__init__.py
Normal file
530
tools/engine.py
Normal file
530
tools/engine.py
Normal 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()
|
||||||
371
tools/run.py
371
tools/run.py
@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
Owns the TUI and game loop. Layout:
|
||||||
Music: polls session/ambience.md, plays via miniaudio.
|
PLAY (narrative + choices + input) | CHAR | LOG | BOOK tabs
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@ -12,20 +13,24 @@ import sys
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from textual import on
|
from textual import on, work
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
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.markdown import Markdown as RichMarkdown
|
||||||
from rich.theme import Theme
|
from rich.theme import Theme
|
||||||
|
|
||||||
|
# ── Game engine ─────────────────────────────────────────
|
||||||
|
from engine import GameEngine, GenerationResult
|
||||||
|
|
||||||
# ── Optional miniaudio ────────────────────────────────────
|
# ── Optional miniaudio ────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
import miniaudio
|
import miniaudio
|
||||||
HAS_AUDIO = True
|
HAS_AUDIO = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_AUDIO = False
|
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 ────────────────────────────────────────────────
|
# ── Paths ────────────────────────────────────────────────
|
||||||
@ -38,7 +43,6 @@ JOURNAL_PATH = SESSION / 'journal.md'
|
|||||||
AMBIENCE_PATH = SESSION / 'ambience.md'
|
AMBIENCE_PATH = SESSION / 'ambience.md'
|
||||||
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
|
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
|
||||||
BOOK_PATH = SESSION / 'book.md'
|
BOOK_PATH = SESSION / 'book.md'
|
||||||
TURN_DESC_PATH = SESSION / 'turn_description.md'
|
|
||||||
AUDIO_DIR = SESSION / 'audio'
|
AUDIO_DIR = SESSION / 'audio'
|
||||||
TODAY = date.today().isoformat()
|
TODAY = date.today().isoformat()
|
||||||
LOG_PATH = LOG_DIR / f'{TODAY}.md'
|
LOG_PATH = LOG_DIR / f'{TODAY}.md'
|
||||||
@ -61,7 +65,7 @@ MARKDOWN_THEME = Theme({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────
|
# ── Helpers (file reading, status, book, ambience) ───────
|
||||||
def ensure_log():
|
def ensure_log():
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
if not LOG_PATH.exists():
|
if not LOG_PATH.exists():
|
||||||
@ -108,8 +112,6 @@ def read_log_tail(n=200):
|
|||||||
lines = LOG_PATH.read_text().splitlines()
|
lines = LOG_PATH.read_text().splitlines()
|
||||||
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
|
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
|
||||||
|
|
||||||
|
|
||||||
# ── Status summary ───────────────────────────────────────
|
|
||||||
def status_summary():
|
def status_summary():
|
||||||
if not CHAR_PATH.exists():
|
if not CHAR_PATH.exists():
|
||||||
return "no character"
|
return "no character"
|
||||||
@ -129,34 +131,20 @@ def status_summary():
|
|||||||
health = m
|
health = m
|
||||||
return f"{name} ❤ {health}"
|
return f"{name} ❤ {health}"
|
||||||
|
|
||||||
|
|
||||||
def log_count():
|
def log_count():
|
||||||
return len(read_log_tail())
|
return len(read_log_tail())
|
||||||
|
|
||||||
|
|
||||||
# ── Book helpers ─────────────────────────────────────────
|
|
||||||
def load_book_pages():
|
def load_book_pages():
|
||||||
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
|
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
|
||||||
pages = ["*The story has not begun.*"]
|
return ["*The story has not begun.*"]
|
||||||
else:
|
text = BOOK_PATH.read_text().strip()
|
||||||
text = BOOK_PATH.read_text().strip()
|
turns = text.split('\n## ')
|
||||||
turns = text.split('\n## ')
|
pages = []
|
||||||
pages = []
|
for i, t in enumerate(turns):
|
||||||
for i, t in enumerate(turns):
|
pages.append(t if i == 0 else '## ' + t)
|
||||||
pages.append(t if i == 0 else '## ' + t)
|
return pages or ["*The story has not begun.*"]
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ── Ambience subsystem ───────────────────────────────────
|
|
||||||
def parse_ambience_options():
|
def parse_ambience_options():
|
||||||
"""Parse ambience_options.md into {name: [filepath, ...]}"""
|
|
||||||
if not AMBIENCE_OPTIONS_PATH.exists():
|
if not AMBIENCE_OPTIONS_PATH.exists():
|
||||||
return {}
|
return {}
|
||||||
options = {}
|
options = {}
|
||||||
@ -183,9 +171,8 @@ def parse_ambience_options():
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ambience subsystem ───────────────────────────────────
|
||||||
class AmbiencePlayer:
|
class AmbiencePlayer:
|
||||||
"""Monitors ambience.md and plays background music via miniaudio."""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.current_ambience = 'silence'
|
self.current_ambience = 'silence'
|
||||||
self._last_mtime = 0
|
self._last_mtime = 0
|
||||||
@ -250,10 +237,12 @@ class AmbiencePlayer:
|
|||||||
self.current_ambience = None
|
self.current_ambience = None
|
||||||
|
|
||||||
|
|
||||||
|
# module-level ref
|
||||||
|
app_ambience_player = None
|
||||||
|
|
||||||
|
|
||||||
# ── Auto-refreshing panels ───────────────────────────────
|
# ── Auto-refreshing panels ───────────────────────────────
|
||||||
class AutoStatic(Static):
|
class AutoStatic(Static):
|
||||||
"""A Static that reloads its content on an interval."""
|
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@ -273,7 +262,7 @@ class TranscriptPane(AutoStatic):
|
|||||||
lines = read_log_tail()
|
lines = read_log_tail()
|
||||||
display = "\n".join(lines[-80:])
|
display = "\n".join(lines[-80:])
|
||||||
if lines:
|
if lines:
|
||||||
display += "\n >>--- NOW --->"
|
display += "\n\n>>--- NOW --->"
|
||||||
self.update(display)
|
self.update(display)
|
||||||
self.call_after_refresh(self._scroll_bottom)
|
self.call_after_refresh(self._scroll_bottom)
|
||||||
|
|
||||||
@ -304,10 +293,6 @@ class StatusBar(AutoStatic):
|
|||||||
self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}")
|
self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}")
|
||||||
|
|
||||||
|
|
||||||
# module-level ref so StatusBar can reach it
|
|
||||||
app_ambience_player = None
|
|
||||||
|
|
||||||
|
|
||||||
# ── The App ──────────────────────────────────────────────
|
# ── The App ──────────────────────────────────────────────
|
||||||
class ChaosTUI(App):
|
class ChaosTUI(App):
|
||||||
TITLE = "The Chaos"
|
TITLE = "The Chaos"
|
||||||
@ -315,7 +300,6 @@ class ChaosTUI(App):
|
|||||||
Screen {
|
Screen {
|
||||||
background: #121212;
|
background: #121212;
|
||||||
}
|
}
|
||||||
|
|
||||||
#banner {
|
#banner {
|
||||||
dock: top;
|
dock: top;
|
||||||
height: 1;
|
height: 1;
|
||||||
@ -323,12 +307,10 @@ class ChaosTUI(App):
|
|||||||
color: #e0ad4c;
|
color: #e0ad4c;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #111111;
|
background: #111111;
|
||||||
}
|
}
|
||||||
|
|
||||||
#todo-header {
|
#todo-header {
|
||||||
background: #3a2a1a;
|
background: #3a2a1a;
|
||||||
color: #e0b060;
|
color: #e0b060;
|
||||||
@ -345,11 +327,6 @@ class ChaosTUI(App):
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-size-vertical: 2;
|
scrollbar-size-vertical: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#middle-tabs {
|
|
||||||
height: 25%;
|
|
||||||
min-height: 8;
|
|
||||||
}
|
|
||||||
TabbedContent {
|
TabbedContent {
|
||||||
background: #1a1a2a;
|
background: #1a1a2a;
|
||||||
}
|
}
|
||||||
@ -371,10 +348,43 @@ class ChaosTUI(App):
|
|||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#book-section {
|
/* Play tab */
|
||||||
height: 1fr;
|
#play-narrative {
|
||||||
layout: vertical;
|
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 {
|
#book-header {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
color: #e0c080;
|
color: #e0c080;
|
||||||
@ -408,12 +418,6 @@ class ChaosTUI(App):
|
|||||||
padding: 0 2;
|
padding: 0 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
#book-hint {
|
|
||||||
height: 1;
|
|
||||||
color: #808080;
|
|
||||||
padding: 0 2;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#book-progress {
|
#book-progress {
|
||||||
height: 1;
|
height: 1;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
@ -442,14 +446,6 @@ class ChaosTUI(App):
|
|||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("ctrl+c", "quit", "Quit"),
|
("ctrl+c", "quit", "Quit"),
|
||||||
("escape", "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):
|
def __init__(self, *args, no_music=False, **kwargs):
|
||||||
@ -459,47 +455,229 @@ class ChaosTUI(App):
|
|||||||
app_ambience_player = AmbiencePlayer()
|
app_ambience_player = AmbiencePlayer()
|
||||||
else:
|
else:
|
||||||
app_ambience_player = None
|
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_page = 0
|
||||||
self._book_pages = []
|
self._book_pages = []
|
||||||
self._prev_page_count = 0
|
self._prev_page_count = 0
|
||||||
|
|
||||||
|
# ── Compose ──────────────────────────────────────────
|
||||||
def compose(self):
|
def compose(self):
|
||||||
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
|
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
|
||||||
with Vertical(id="main"):
|
with Vertical(id="main"):
|
||||||
yield Static("TODO", id="todo-header")
|
yield Static("TODO", id="todo-header")
|
||||||
yield TodoPane(id="todo-content")
|
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 TabPane("CHARACTER", id="char-tab"):
|
||||||
with VerticalScroll():
|
with VerticalScroll():
|
||||||
yield CharPane(id="char-content")
|
yield CharPane(id="char-content")
|
||||||
with TabPane("LOG", id="log-tab"):
|
with TabPane("LOG", id="log-tab"):
|
||||||
with VerticalScroll():
|
with VerticalScroll():
|
||||||
yield TranscriptPane(id="transcript")
|
yield TranscriptPane(id="transcript")
|
||||||
yield Static("BOOK", id="book-header")
|
with TabPane("BOOK", id="book-tab"):
|
||||||
with Vertical(id="book-section"):
|
yield Static("BOOK", id="book-header")
|
||||||
with Horizontal(id="book-nav"):
|
with Vertical(id="book-section"):
|
||||||
yield Button("<< Prev", id="book-prev")
|
with Horizontal(id="book-nav"):
|
||||||
with Vertical(id="book-nav-center"):
|
yield Button("<< Prev", id="book-prev")
|
||||||
yield Static("", id="book-page-label")
|
with Vertical(id="book-nav-center"):
|
||||||
yield Static("", id="book-hint")
|
yield Static("", id="book-page-label")
|
||||||
yield Static("", id="book-progress")
|
yield Static("", id="book-progress")
|
||||||
yield Button("Next >>", id="book-next")
|
yield Button("Next >>", id="book-next")
|
||||||
with VerticalScroll(id="book-scroll"):
|
with VerticalScroll(id="book-scroll"):
|
||||||
yield Static("", id="book-content")
|
yield Static("", id="book-content")
|
||||||
yield StatusBar(id="status-bar")
|
yield StatusBar(id="status-bar")
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
ensure_log()
|
ensure_log()
|
||||||
self.console._theme = MARKDOWN_THEME
|
self.console._theme = MARKDOWN_THEME
|
||||||
self._reload_book()
|
self._init_book()
|
||||||
self._render_book_page()
|
|
||||||
self.set_interval(REFRESH_SECS, self._check_ambience)
|
self.set_interval(REFRESH_SECS, self._check_ambience)
|
||||||
self.set_interval(REFRESH_SECS, self._reload_book)
|
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):
|
def _check_ambience(self):
|
||||||
if app_ambience_player:
|
if app_ambience_player:
|
||||||
app_ambience_player.poll()
|
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):
|
def _reload_book(self):
|
||||||
self._book_pages = load_book_pages()
|
self._book_pages = load_book_pages()
|
||||||
if len(self._book_pages) > self._prev_page_count:
|
if len(self._book_pages) > self._prev_page_count:
|
||||||
@ -509,10 +687,15 @@ class ChaosTUI(App):
|
|||||||
self._render_book_page()
|
self._render_book_page()
|
||||||
|
|
||||||
def _render_book_page(self):
|
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)
|
total = len(self._book_pages)
|
||||||
self.query_one("#book-page-label").update(f"Page {self._book_page + 1} of {total}")
|
self.query_one("#book-page-label").update(
|
||||||
self.query_one("#book-hint").update("h prev | l next | j +5 | k -5 | g first | G last")
|
f"Page {self._book_page + 1} of {total}"
|
||||||
|
)
|
||||||
pct = (self._book_page + 1) / total if total else 1
|
pct = (self._book_page + 1) / total if total else 1
|
||||||
fill = round(pct * 20)
|
fill = round(pct * 20)
|
||||||
bar = "█" * fill + "░" * (20 - fill)
|
bar = "█" * fill + "░" * (20 - fill)
|
||||||
@ -532,32 +715,12 @@ class ChaosTUI(App):
|
|||||||
self._render_book_page()
|
self._render_book_page()
|
||||||
self.query_one("#book-scroll").scroll_home(animate=False)
|
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")
|
@on(Button.Pressed, "#book-prev")
|
||||||
def on_prev(self):
|
def on_book_prev(self):
|
||||||
self.action_prev_page()
|
self.action_prev_page()
|
||||||
|
|
||||||
@on(Button.Pressed, "#book-next")
|
@on(Button.Pressed, "#book-next")
|
||||||
def on_next(self):
|
def on_book_next(self):
|
||||||
self.action_next_page()
|
self.action_next_page()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user