End game rules and player name extraction
This commit is contained in:
parent
1c64ac79f4
commit
f36387f1bf
33
AGENTS.md
33
AGENTS.md
@ -22,7 +22,10 @@ in that process.
|
|||||||
the-chaos/
|
the-chaos/
|
||||||
├── rules/ # LOCKED — game rules, do not modify
|
├── rules/ # LOCKED — game rules, do not modify
|
||||||
│ ├── deck/ # Card tables
|
│ ├── deck/ # Card tables
|
||||||
│ └── mechanics.md # Core mechanics reference
|
│ ├── mechanics.md # Core mechanics reference
|
||||||
|
│ ├── core_mechanics.md # Condensed core rules (injected into system prompt)
|
||||||
|
│ ├── character_creation.md # Character creation rules
|
||||||
|
│ └── end_game.md # End-game closure rules
|
||||||
├── tools/ # Game system code
|
├── tools/ # Game system code
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
|
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
|
||||||
@ -352,6 +355,7 @@ This allows compatibility with OpenAI-compatible servers that return content in
|
|||||||
- `session/ambience.md` — Current ambience (written by engine)
|
- `session/ambience.md` — Current ambience (written by engine)
|
||||||
- `session/log/<date>.md` — Session logs (written by engine)
|
- `session/log/<date>.md` — Session logs (written by engine)
|
||||||
- `session/tweaks.md` — House rules (manual edit)
|
- `session/tweaks.md` — House rules (manual edit)
|
||||||
|
- `archive/<hero>-<timestamp>/` — Archived completed games
|
||||||
|
|
||||||
## LLM Strategies Explained
|
## LLM Strategies Explained
|
||||||
|
|
||||||
@ -380,3 +384,30 @@ Uses single LLM call with all tools available:
|
|||||||
4. Check config.json for LLM settings
|
4. Check config.json for LLM settings
|
||||||
5. Look for missing imports in the engine.py file
|
5. Look for missing imports in the engine.py file
|
||||||
6. Verify that the LLM provider is correctly configured in config.json
|
6. Verify that the LLM provider is correctly configured in config.json
|
||||||
|
|
||||||
|
## End Game Mechanics
|
||||||
|
|
||||||
|
The game ends when the LLM outputs the marker `### THE END` in the narrative. Detection is done by checking `END_MARKER in book_log` in `engine.py`.
|
||||||
|
|
||||||
|
### End Game Flow
|
||||||
|
|
||||||
|
1. LLM outputs narrative containing `### THE END`
|
||||||
|
2. Engine detects the marker and sets `TurnResult.game_over = True`
|
||||||
|
3. TUI shows the epilogue and disables input
|
||||||
|
4. A "Close the Book and Start a New One" button appears
|
||||||
|
5. On click: `state.archive_session()` copies `session/` to `archive/<hero-name>-<timestamp>/`, clears session state, and restarts the game loop
|
||||||
|
|
||||||
|
### Rules for the LLM
|
||||||
|
|
||||||
|
- The marker must be exactly `### THE END` (level-3 heading)
|
||||||
|
- After the marker: provide **Why**, **What Happened**, and **The World After** (2-3 paragraphs)
|
||||||
|
- Do NOT call state-changing tools after the marker
|
||||||
|
- Use `read_rules` with `category: "end_game"` for full details
|
||||||
|
|
||||||
|
### read_rules Categories
|
||||||
|
|
||||||
|
The `read_rules` tool accepts a `category` parameter:
|
||||||
|
- `mechanics` — Full mechanics reference (default)
|
||||||
|
- `core` — Condensed core rules
|
||||||
|
- `character_creation` — Character creation rules
|
||||||
|
- `end_game` — End-game closure rules
|
||||||
|
|||||||
@ -56,3 +56,8 @@
|
|||||||
- Short rest (few hours): 1d6 HP
|
- Short rest (few hours): 1d6 HP
|
||||||
- Long rest (safe haven): full HP
|
- Long rest (safe haven): full HP
|
||||||
- Healing salve: +1 HP | Antitoxin: cures poison
|
- Healing salve: +1 HP | Antitoxin: cures poison
|
||||||
|
|
||||||
|
### End Game Conditions
|
||||||
|
The story ends when: the character dies, the main quest reaches a definitive conclusion, the player chooses to retire, or all party members have fallen.
|
||||||
|
When the story ends, the LLM outputs `### THE END` in the narrative, followed by a reason, what happened, and an epilogue describing how the world evolved.
|
||||||
|
Call `read_rules` with `category: "end_game"` for full details on ending the game.
|
||||||
|
|||||||
56
rules/end_game.md
Normal file
56
rules/end_game.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# End Game — Closure & Epilogue
|
||||||
|
|
||||||
|
## When the Story Ends
|
||||||
|
|
||||||
|
The game ends when one of these conditions is reached:
|
||||||
|
|
||||||
|
1. **Death** — The character dies and is not revived. The story ends with their final moments.
|
||||||
|
2. **Quest Complete** — The main story arc reaches a definitive conclusion (the Darkmal is defeated, the Realm is saved or lost, etc.).
|
||||||
|
3. **Player Retires** — The player decides their character's journey is over and invokes the end.
|
||||||
|
4. **Mutual TPK** — All party members (if any) have fallen. The Realm's fate is sealed.
|
||||||
|
|
||||||
|
## The End Marker
|
||||||
|
|
||||||
|
When the story ends, the LLM must include this exact string as a level-3 heading in the narrative:
|
||||||
|
|
||||||
|
```
|
||||||
|
### THE END
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything after this marker is the **epilogue**, which contains:
|
||||||
|
|
||||||
|
1. **Why** — A brief statement of why the story ended (death, quest completion, retirement, etc.).
|
||||||
|
2. **What Happened** — A summary of the final events.
|
||||||
|
3. **The World After** — At least 2-3 paragraphs describing how the world and its key characters evolved and moved on after the event. This is the closing of the book.
|
||||||
|
|
||||||
|
## Example Epilogue Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
### THE END
|
||||||
|
|
||||||
|
**Why:** Dillion fell in the final battle against the Darkmal.
|
||||||
|
|
||||||
|
**What Happened:** Dillion faced the Darkmal in the heart of the Spire. The battle was fierce — Dillion's blade found its mark, but the Darkmal's final curse was fatal. Both fell.
|
||||||
|
|
||||||
|
**The World After:**
|
||||||
|
|
||||||
|
The Realm mourned. In the years that followed, the scars of the Darkmal's reign slowly healed. The villages rebuilt, and the wilds grew quiet once more...
|
||||||
|
|
||||||
|
Rina took up Dillion's cause, traveling the Realm to protect those who could not protect themselves. She spoke often of the weaver who stood against the darkness...
|
||||||
|
|
||||||
|
The Reaper, denied his prize, vanished into the eastern wastes. Some say he still searches for a path to lichdom, but without the Darkmal's power, his reach is broken...
|
||||||
|
```
|
||||||
|
|
||||||
|
## After the End
|
||||||
|
|
||||||
|
Once the marker is detected, the TUI shows a "Close the Book and Start a New One" button. Clicking it:
|
||||||
|
|
||||||
|
1. Archives the current session folder into `archive/<hero-name>/` with a timestamp.
|
||||||
|
2. Clears all session state for a fresh start.
|
||||||
|
3. Presents the character creation flow again.
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- The LLM must output `### THE END` exactly as shown — case-sensitive, with a space before "THE".
|
||||||
|
- Only the narrative tool block should contain the marker. No other tool blocks are needed for ending.
|
||||||
|
- The LLM should **not** call any state-changing tools after the marker — the epilogue is narrative-only.
|
||||||
@ -8,7 +8,7 @@ from datetime import datetime
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from engine_lib.models import TurnResult
|
from engine_lib.models import TurnResult, END_MARKER
|
||||||
from engine_lib import config
|
from engine_lib import config
|
||||||
from engine_lib.context import build_system_prompt
|
from engine_lib.context import build_system_prompt
|
||||||
from engine_lib.validation import validate_turn
|
from engine_lib.validation import validate_turn
|
||||||
@ -282,6 +282,8 @@ class GameEngine:
|
|||||||
|
|
||||||
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
|
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
|
||||||
|
|
||||||
|
game_over = END_MARKER in book_log
|
||||||
|
|
||||||
if on_action:
|
if on_action:
|
||||||
on_action("Turn complete")
|
on_action("Turn complete")
|
||||||
|
|
||||||
@ -306,6 +308,7 @@ class GameEngine:
|
|||||||
debug_info="; ".join(errors) if errors else "",
|
debug_info="; ".join(errors) if errors else "",
|
||||||
changes=changes,
|
changes=changes,
|
||||||
is_meta=is_meta,
|
is_meta=is_meta,
|
||||||
|
game_over=game_over,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
END_MARKER = "### THE END"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TurnResult:
|
class TurnResult:
|
||||||
"""Output of a complete turn."""
|
"""Output of a complete turn."""
|
||||||
@ -15,3 +17,4 @@ class TurnResult:
|
|||||||
debug_info: str = ""
|
debug_info: str = ""
|
||||||
changes: list[str] = field(default_factory=list)
|
changes: list[str] = field(default_factory=list)
|
||||||
is_meta: bool = False
|
is_meta: bool = False
|
||||||
|
game_over: bool = False
|
||||||
|
|||||||
@ -26,3 +26,6 @@ AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
|
|||||||
CHANGES_PATH = SESSION_DIR / "changes.md"
|
CHANGES_PATH = SESSION_DIR / "changes.md"
|
||||||
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
|
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
|
||||||
AUDIO_DIR = SESSION_DIR / "audio"
|
AUDIO_DIR = SESSION_DIR / "audio"
|
||||||
|
|
||||||
|
END_GAME_PATH = RULES_DIR / 'end_game.md'
|
||||||
|
ARCHIVE_DIR = BASE_DIR / 'archive'
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from string import Template
|
from string import Template
|
||||||
|
|
||||||
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
|
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
|
||||||
|
|
||||||
## Core Rules
|
## Core Rules
|
||||||
$core_rules
|
$core_rules
|
||||||
@ -45,12 +45,17 @@ Wrap each action in its own ```tool block:
|
|||||||
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}}
|
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}}
|
||||||
```
|
```
|
||||||
```tool
|
```tool
|
||||||
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Dillion explored the dungeon, found a hidden passage, and was ambushed by goblins."}}
|
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins."}}
|
||||||
```
|
```
|
||||||
|
|
||||||
```tool
|
```tool
|
||||||
{"tool": "read_rules", "args": {}}
|
{"tool": "read_rules", "args": {}}
|
||||||
```
|
```
|
||||||
|
or with a category:
|
||||||
|
```tool
|
||||||
|
{"tool": "read_rules", "args": {"category": "end_game"}}
|
||||||
|
```
|
||||||
|
(Categories: mechanics, core, character_creation, end_game)
|
||||||
|
|
||||||
**log_entry**: Provide a short, dense summary (1-2 sentences) of the turn's main events. This becomes the session log — be specific, factual, and concise.
|
**log_entry**: Provide a short, dense summary (1-2 sentences) of the turn's main events. This becomes the session log — be specific, factual, and concise.
|
||||||
|
|
||||||
@ -58,6 +63,15 @@ You are the sole authority over the game state. The player's action is a **propo
|
|||||||
|
|
||||||
**Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `remove_from_inventory` for that item AND apply the effects (e.g. `modify_vitals` for HP potions). If it's not on the sheet, reject the action — do not let them use items they don't have.
|
**Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `remove_from_inventory` for that item AND apply the effects (e.g. `modify_vitals` for HP potions). If it's not on the sheet, reject the action — do not let them use items they don't have.
|
||||||
|
|
||||||
|
## Ending the Game
|
||||||
|
|
||||||
|
When the story reaches a definitive end (character death, quest completion, or the player chooses to retire), output the exact marker `### THE END` as a heading in the narrative, then provide:
|
||||||
|
1. **Why** — why the story ended
|
||||||
|
2. **What Happened** — summary of final events
|
||||||
|
3. **The World After** — 2-3 paragraphs describing how the world and characters evolved
|
||||||
|
|
||||||
|
After the `### THE END` marker, do NOT call any state-changing tools. The epilogue is narrative-only. Call `read_rules` with `category: "end_game"` for full details.
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
### Character
|
### Character
|
||||||
|
|||||||
@ -9,13 +9,15 @@ GameEngine or other modules besides paths.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import (
|
from .paths import (
|
||||||
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
|
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
|
||||||
LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
|
LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
|
||||||
AUDIO_DIR,
|
AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
|
||||||
)
|
)
|
||||||
from .models import TurnResult
|
from .models import TurnResult
|
||||||
|
|
||||||
@ -227,3 +229,36 @@ def update_journal(add: list[str] | None = None, done: list[str] | None = None)
|
|||||||
cleaned.append(line)
|
cleaned.append(line)
|
||||||
prev_blank = is_blank
|
prev_blank = is_blank
|
||||||
JOURNAL_PATH.write_text("\n".join(cleaned) + "\n")
|
JOURNAL_PATH.write_text("\n".join(cleaned) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_hero_name() -> str:
|
||||||
|
"""Extract the hero's name from character.md."""
|
||||||
|
text = read_file(CHAR_PATH)
|
||||||
|
if not text:
|
||||||
|
return "unknown-hero"
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip().startswith("**Name:**"):
|
||||||
|
name = line.split(":", 1)[1].strip().strip("*_ \t")
|
||||||
|
return name.lower().replace(" ", "-") or "unknown-hero"
|
||||||
|
return "unknown-hero"
|
||||||
|
|
||||||
|
|
||||||
|
def archive_session() -> str:
|
||||||
|
"""Archive the current session folder to archive/<hero-name>-<timestamp>/ and clear session state for a fresh start.
|
||||||
|
Returns the archive path as a string."""
|
||||||
|
hero = extract_hero_name()
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
archive_dir = ARCHIVE_DIR / f"{hero}-{ts}"
|
||||||
|
archive_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if SESSION_DIR.exists():
|
||||||
|
shutil.copytree(SESSION_DIR, archive_dir, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
# Clear session state
|
||||||
|
for child in SESSION_DIR.iterdir():
|
||||||
|
if child.is_file():
|
||||||
|
child.unlink()
|
||||||
|
elif child.is_dir() and child.name != "__pycache__":
|
||||||
|
shutil.rmtree(child)
|
||||||
|
|
||||||
|
return str(archive_dir)
|
||||||
|
|||||||
@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH
|
from .paths import (
|
||||||
|
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH,
|
||||||
|
CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH,
|
||||||
|
)
|
||||||
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
|
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
|
||||||
|
|
||||||
|
|
||||||
@ -18,7 +21,7 @@ TOOL_REGISTRY: dict[str, dict] = {
|
|||||||
"world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
|
"world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
|
||||||
"journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}},
|
"journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}},
|
||||||
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened"}},
|
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened"}},
|
||||||
"read_rules": {"description": "Read the full mechanics reference (exploration, deck tables, grit, healing, etc.). Call when you need details beyond the Core Rules in the prompt.", "args": {}},
|
"read_rules": {"description": "Read a rules file by category. Categories: mechanics (full mechanics reference), core (core mechanics), character_creation, end_game (end-game closure rules). Call when you need details beyond the Core Rules in the prompt.", "args": {"category": "optional — one of: mechanics, core, character_creation, end_game (default: mechanics)"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -159,11 +162,23 @@ def tool_finalize_turn(args: dict) -> str:
|
|||||||
return f"Ambience set to {raw}."
|
return f"Ambience set to {raw}."
|
||||||
|
|
||||||
|
|
||||||
|
RULES_CATEGORIES = {
|
||||||
|
"mechanics": MECHANICS_PATH,
|
||||||
|
"core": CORE_RULES_PATH,
|
||||||
|
"character_creation": CHARACTER_CREATION_PATH,
|
||||||
|
"end_game": END_GAME_PATH,
|
||||||
|
}
|
||||||
|
|
||||||
def tool_read_rules(args: dict) -> str:
|
def tool_read_rules(args: dict) -> str:
|
||||||
"""Read the full mechanics.md and return its content."""
|
"""Read a rules file by category and return its content."""
|
||||||
content = read_file(MECHANICS_PATH)
|
category = (args or {}).get("category", "mechanics")
|
||||||
|
path = RULES_CATEGORIES.get(category)
|
||||||
|
if not path:
|
||||||
|
allowed = ", ".join(RULES_CATEGORIES)
|
||||||
|
return f"**Error:** unknown category '{category}'. Allowed: {allowed}."
|
||||||
|
content = read_file(path)
|
||||||
if not content:
|
if not content:
|
||||||
return "**Error:** rules/mechanics.md not found."
|
return f"**Error:** {path.name} not found."
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,8 @@ VALIDATION_PROMPT = """You are a strict RPG game master validating whether a pla
|
|||||||
- Is the player trying to use an item they don't have? -> invalid
|
- Is the player trying to use an item they don't have? -> invalid
|
||||||
- Are they asserting something that contradicts the state? -> invalid
|
- Are they asserting something that contradicts the state? -> invalid
|
||||||
- Is the action nonsensical given the situation? -> invalid
|
- Is the action nonsensical given the situation? -> invalid
|
||||||
|
- Is the player's action or intention unclear or ambiguous? -> invalid (explain what is unclear and why)
|
||||||
|
- If you are uncertain whether the action is valid, reject it and describe exactly why you are unsure.
|
||||||
- Does the action make sense given the character's abilities and resources? -> valid
|
- Does the action make sense given the character's abilities and resources? -> valid
|
||||||
- Pay close attention to the Recent Story section — entities like monsters, NPCs, and hazards currently present in the scene ARE valid targets for action.
|
- Pay close attention to the Recent Story section — entities like monsters, NPCs, and hazards currently present in the scene ARE valid targets for action.
|
||||||
- If valid, also check: if they're using a consumable item, note that it must be removed from inventory.
|
- If valid, also check: if they're using a consumable item, note that it must be removed from inventory.
|
||||||
|
|||||||
33
tools/run.py
33
tools/run.py
@ -18,7 +18,7 @@ from rich.markdown import Markdown as RichMarkdown
|
|||||||
from rich.theme import Theme
|
from rich.theme import Theme
|
||||||
|
|
||||||
from engine import GameEngine
|
from engine import GameEngine
|
||||||
from engine_lib.models import TurnResult
|
from engine_lib.models import TurnResult, END_MARKER
|
||||||
from engine_lib import state
|
from engine_lib import state
|
||||||
from run_utils import (
|
from run_utils import (
|
||||||
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
|
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
|
||||||
@ -87,6 +87,9 @@ class ChaosTUI(App):
|
|||||||
#mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; }
|
#mute-btn { dock: bottom; width: 6; height: 1; background: #2a2a2a; color: #888; border: none; padding: 0 1; min-width: 6; margin: 0; }
|
||||||
#mute-btn:hover { background: #3a3a3a; color: #ccc; }
|
#mute-btn:hover { background: #3a3a3a; color: #ccc; }
|
||||||
#mute-btn.muted { color: #ff6b6b; text-style: bold; }
|
#mute-btn.muted { color: #ff6b6b; text-style: bold; }
|
||||||
|
#end-game-btn { display: none; margin: 1 2; height: 3; background: #5a3a00; color: #ffd93d; border: solid #8a5a00; }
|
||||||
|
#end-game-btn.visible { display: block; }
|
||||||
|
#end-game-btn:hover { background: #7a4a00; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@ -112,6 +115,7 @@ class ChaosTUI(App):
|
|||||||
self._book_pages: list[str] = []
|
self._book_pages: list[str] = []
|
||||||
self._prev_page_count = 0
|
self._prev_page_count = 0
|
||||||
self._settings_loaded = False
|
self._settings_loaded = False
|
||||||
|
self._game_over = False
|
||||||
|
|
||||||
def _load_settings(self) -> dict:
|
def _load_settings(self) -> dict:
|
||||||
defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0}
|
defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0}
|
||||||
@ -149,6 +153,7 @@ class ChaosTUI(App):
|
|||||||
yield Static("*Awaiting the fates...*", id="play-narrative")
|
yield Static("*Awaiting the fates...*", id="play-narrative")
|
||||||
yield Static("", id="play-status")
|
yield Static("", id="play-status")
|
||||||
yield Input(placeholder="Type your action and press Enter...", id="play-input")
|
yield Input(placeholder="Type your action and press Enter...", id="play-input")
|
||||||
|
yield Button("Close the Book and Start a New One", id="end-game-btn", variant="warning")
|
||||||
with TabPane("CHARACTER", id="char-tab"):
|
with TabPane("CHARACTER", id="char-tab"):
|
||||||
with VerticalScroll():
|
with VerticalScroll():
|
||||||
yield CharPane(id="char-content")
|
yield CharPane(id="char-content")
|
||||||
@ -195,6 +200,7 @@ class ChaosTUI(App):
|
|||||||
self._settings_loaded = True
|
self._settings_loaded = True
|
||||||
|
|
||||||
def _begin_game(self):
|
def _begin_game(self):
|
||||||
|
self._game_over = False
|
||||||
self._last_narrative: str = ""
|
self._last_narrative: str = ""
|
||||||
pages = load_book_pages()
|
pages = load_book_pages()
|
||||||
if pages and pages != ["*The story has not begun.*"]:
|
if pages and pages != ["*The story has not begun.*"]:
|
||||||
@ -209,6 +215,18 @@ class ChaosTUI(App):
|
|||||||
return
|
return
|
||||||
self._call_llm()
|
self._call_llm()
|
||||||
|
|
||||||
|
def _archive_and_reset(self) -> None:
|
||||||
|
"""Archive the session and reset for a new game."""
|
||||||
|
self._game_over = False
|
||||||
|
btn = self.query_one("#end-game-btn", Button)
|
||||||
|
btn.remove_class("visible")
|
||||||
|
archive_path = state.archive_session()
|
||||||
|
self._book_pages = ["*The story has not begun.*"]
|
||||||
|
self._book_page = 0
|
||||||
|
self._render_book_page()
|
||||||
|
self._begin_game()
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
def _check_ambience(self):
|
def _check_ambience(self):
|
||||||
if app_ambience_player:
|
if app_ambience_player:
|
||||||
app_ambience_player.poll()
|
app_ambience_player.poll()
|
||||||
@ -219,6 +237,8 @@ class ChaosTUI(App):
|
|||||||
app_ambience_player.toggle_mute()
|
app_ambience_player.toggle_mute()
|
||||||
self._update_mute_button()
|
self._update_mute_button()
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
elif event.button.id == "end-game-btn":
|
||||||
|
self._archive_and_reset()
|
||||||
|
|
||||||
def _update_mute_button(self) -> None:
|
def _update_mute_button(self) -> None:
|
||||||
btn = self.query_one("#mute-btn", Button)
|
btn = self.query_one("#mute-btn", Button)
|
||||||
@ -316,6 +336,17 @@ class ChaosTUI(App):
|
|||||||
elif result.log_entry and not result.is_meta:
|
elif result.log_entry and not result.is_meta:
|
||||||
state.append_log(f"- {result.log_entry}")
|
state.append_log(f"- {result.log_entry}")
|
||||||
state.apply_state(result)
|
state.apply_state(result)
|
||||||
|
|
||||||
|
if result.game_over:
|
||||||
|
self._game_over = True
|
||||||
|
self._set_narrative(result.book_log if result.book_log else "(The story has ended.)")
|
||||||
|
inp = self.query_one("#play-input", Input)
|
||||||
|
inp.disabled = True
|
||||||
|
inp.placeholder = "The story has ended."
|
||||||
|
btn = self.query_one("#end-game-btn", Button)
|
||||||
|
btn.add_class("visible")
|
||||||
|
return
|
||||||
|
|
||||||
if result.book_log or not result.user_prompt:
|
if result.book_log or not result.user_prompt:
|
||||||
self._display_scene(result)
|
self._display_scene(result)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -161,8 +161,8 @@ def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
|
|||||||
|
|
||||||
valid, reason, action = validate_turn(
|
valid, reason, action = validate_turn(
|
||||||
"I use my healing salve",
|
"I use my healing salve",
|
||||||
narrative="Dillion applies the salve to his wound.",
|
narrative="Kael applies the salve to his wound.",
|
||||||
log_entry="Dillion used his healing salve to restore 2 HP.",
|
log_entry="Kael used his healing salve to restore 2 HP.",
|
||||||
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}},
|
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}},
|
||||||
{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
||||||
story="At the tavern",
|
story="At the tavern",
|
||||||
@ -187,8 +187,8 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
|
|||||||
|
|
||||||
valid, reason, action = validate_turn(
|
valid, reason, action = validate_turn(
|
||||||
"I buy a round for the house",
|
"I buy a round for the house",
|
||||||
narrative="Dillion orders drinks for everyone.",
|
narrative="Kael orders drinks for everyone.",
|
||||||
log_entry="Dillion bought a round at the tavern.",
|
log_entry="Kael bought a round at the tavern.",
|
||||||
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}],
|
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}],
|
||||||
story="At the tavern",
|
story="At the tavern",
|
||||||
log="- Entered the tavern",
|
log="- Entered the tavern",
|
||||||
@ -212,8 +212,8 @@ def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
|
|||||||
|
|
||||||
valid, reason, action = validate_turn(
|
valid, reason, action = validate_turn(
|
||||||
"I use my healing salve",
|
"I use my healing salve",
|
||||||
narrative="Dillion applies the salve to his wound.",
|
narrative="Kael applies the salve to his wound.",
|
||||||
log_entry="Dillion used his healing salve.",
|
log_entry="Kael used his healing salve.",
|
||||||
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
||||||
story="At the tavern",
|
story="At the tavern",
|
||||||
log="- Entered the tavern",
|
log="- Entered the tavern",
|
||||||
@ -237,8 +237,8 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
|
|||||||
|
|
||||||
valid, reason, action = validate_turn(
|
valid, reason, action = validate_turn(
|
||||||
"I attack the dragon",
|
"I attack the dragon",
|
||||||
narrative="Dillion swings his sword.",
|
narrative="Kael swings his sword.",
|
||||||
log_entry="Dillion attacked the dragon.",
|
log_entry="Kael attacked the dragon.",
|
||||||
changes=[{"tool": "modify_vitals", "args": {"current_hp": 10}}],
|
changes=[{"tool": "modify_vitals", "args": {"current_hp": 10}}],
|
||||||
story="A dragon appears!",
|
story="A dragon appears!",
|
||||||
log="- Dragon spotted",
|
log="- Dragon spotted",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user