End game rules and player name extraction

This commit is contained in:
Dejvino 2026-07-04 21:50:51 +02:00
parent 1c64ac79f4
commit f36387f1bf
12 changed files with 217 additions and 19 deletions

View File

@ -22,7 +22,10 @@ in that process.
the-chaos/
├── rules/ # LOCKED — game rules, do not modify
│ ├── deck/ # Card tables
│ └── mechanics.md # Core mechanics reference
│ ├── mechanics.md # Core mechanics reference
│ ├── core_mechanics.md # Condensed core rules (injected into system prompt)
│ ├── character_creation.md # Character creation rules
│ └── end_game.md # End-game closure rules
├── tools/ # Game system code
│ ├── __init__.py
│ ├── engine.py # Game engine (prompt builder, LLM client, parser, state)
@ -352,6 +355,7 @@ This allows compatibility with OpenAI-compatible servers that return content in
- `session/ambience.md` — Current ambience (written by engine)
- `session/log/<date>.md` — Session logs (written by engine)
- `session/tweaks.md` — House rules (manual edit)
- `archive/<hero>-<timestamp>/` — Archived completed games
## LLM Strategies Explained
@ -380,3 +384,30 @@ Uses single LLM call with all tools available:
4. Check config.json for LLM settings
5. Look for missing imports in the engine.py file
6. Verify that the LLM provider is correctly configured in config.json
## End Game Mechanics
The game ends when the LLM outputs the marker `### THE END` in the narrative. Detection is done by checking `END_MARKER in book_log` in `engine.py`.
### End Game Flow
1. LLM outputs narrative containing `### THE END`
2. Engine detects the marker and sets `TurnResult.game_over = True`
3. TUI shows the epilogue and disables input
4. A "Close the Book and Start a New One" button appears
5. On click: `state.archive_session()` copies `session/` to `archive/<hero-name>-<timestamp>/`, clears session state, and restarts the game loop
### Rules for the LLM
- The marker must be exactly `### THE END` (level-3 heading)
- After the marker: provide **Why**, **What Happened**, and **The World After** (2-3 paragraphs)
- Do NOT call state-changing tools after the marker
- Use `read_rules` with `category: "end_game"` for full details
### read_rules Categories
The `read_rules` tool accepts a `category` parameter:
- `mechanics` — Full mechanics reference (default)
- `core` — Condensed core rules
- `character_creation` — Character creation rules
- `end_game` — End-game closure rules

View File

@ -56,3 +56,8 @@
- Short rest (few hours): 1d6 HP
- Long rest (safe haven): full HP
- Healing salve: +1 HP | Antitoxin: cures poison
### End Game Conditions
The story ends when: the character dies, the main quest reaches a definitive conclusion, the player chooses to retire, or all party members have fallen.
When the story ends, the LLM outputs `### THE END` in the narrative, followed by a reason, what happened, and an epilogue describing how the world evolved.
Call `read_rules` with `category: "end_game"` for full details on ending the game.

56
rules/end_game.md Normal file
View 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.

View File

@ -8,7 +8,7 @@ from datetime import datetime
from difflib import SequenceMatcher
from pathlib import Path
from engine_lib.models import TurnResult
from engine_lib.models import TurnResult, END_MARKER
from engine_lib import config
from engine_lib.context import build_system_prompt
from engine_lib.validation import validate_turn
@ -282,6 +282,8 @@ class GameEngine:
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
game_over = END_MARKER in book_log
if on_action:
on_action("Turn complete")
@ -306,6 +308,7 @@ class GameEngine:
debug_info="; ".join(errors) if errors else "",
changes=changes,
is_meta=is_meta,
game_over=game_over,
)

View File

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

View File

@ -26,3 +26,6 @@ AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
CHANGES_PATH = SESSION_DIR / "changes.md"
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
AUDIO_DIR = SESSION_DIR / "audio"
END_GAME_PATH = RULES_DIR / 'end_game.md'
ARCHIVE_DIR = BASE_DIR / 'archive'

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from string import Template
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
## 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
{"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": "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.
@ -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.
## 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
### Character

View File

@ -9,13 +9,15 @@ GameEngine or other modules besides paths.
from __future__ import annotations
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from .paths import (
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
AUDIO_DIR,
AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
)
from .models import TurnResult
@ -227,3 +229,36 @@ def update_journal(add: list[str] | None = None, done: list[str] | None = None)
cleaned.append(line)
prev_blank = is_blank
JOURNAL_PATH.write_text("\n".join(cleaned) + "\n")
def extract_hero_name() -> str:
"""Extract the hero's name from character.md."""
text = read_file(CHAR_PATH)
if not text:
return "unknown-hero"
for line in text.splitlines():
if line.strip().startswith("**Name:**"):
name = line.split(":", 1)[1].strip().strip("*_ \t")
return name.lower().replace(" ", "-") or "unknown-hero"
return "unknown-hero"
def archive_session() -> str:
"""Archive the current session folder to archive/<hero-name>-<timestamp>/ and clear session state for a fresh start.
Returns the archive path as a string."""
hero = extract_hero_name()
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
archive_dir = ARCHIVE_DIR / f"{hero}-{ts}"
archive_dir.parent.mkdir(parents=True, exist_ok=True)
if SESSION_DIR.exists():
shutil.copytree(SESSION_DIR, archive_dir, dirs_exist_ok=True)
# Clear session state
for child in SESSION_DIR.iterdir():
if child.is_file():
child.unlink()
elif child.is_dir() and child.name != "__pycache__":
shutil.rmtree(child)
return str(archive_dir)

View File

@ -3,7 +3,10 @@ from __future__ import annotations
import json
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
@ -18,7 +21,7 @@ TOOL_REGISTRY: dict[str, dict] = {
"world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
"journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}},
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened"}},
"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}."
RULES_CATEGORIES = {
"mechanics": MECHANICS_PATH,
"core": CORE_RULES_PATH,
"character_creation": CHARACTER_CREATION_PATH,
"end_game": END_GAME_PATH,
}
def tool_read_rules(args: dict) -> str:
"""Read the full mechanics.md and return its content."""
content = read_file(MECHANICS_PATH)
"""Read a rules file by category and return its content."""
category = (args or {}).get("category", "mechanics")
path = RULES_CATEGORIES.get(category)
if not path:
allowed = ", ".join(RULES_CATEGORIES)
return f"**Error:** unknown category '{category}'. Allowed: {allowed}."
content = read_file(path)
if not content:
return "**Error:** rules/mechanics.md not found."
return f"**Error:** {path.name} not found."
return content

View File

@ -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
- Are they asserting something that contradicts the state? -> 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
- 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.

View File

@ -18,7 +18,7 @@ from rich.markdown import Markdown as RichMarkdown
from rich.theme import Theme
from engine import GameEngine
from engine_lib.models import TurnResult
from engine_lib.models import TurnResult, END_MARKER
from engine_lib import state
from run_utils import (
BOOK_PATH, CHAR_PATH, CHANGES_PATH, SETTINGS_PATH,
@ -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:hover { background: #3a3a3a; color: #ccc; }
#mute-btn.muted { color: #ff6b6b; text-style: bold; }
#end-game-btn { display: none; margin: 1 2; height: 3; background: #5a3a00; color: #ffd93d; border: solid #8a5a00; }
#end-game-btn.visible { display: block; }
#end-game-btn:hover { background: #7a4a00; }
"""
BINDINGS = [
@ -112,6 +115,7 @@ class ChaosTUI(App):
self._book_pages: list[str] = []
self._prev_page_count = 0
self._settings_loaded = False
self._game_over = False
def _load_settings(self) -> dict:
defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0}
@ -149,6 +153,7 @@ class ChaosTUI(App):
yield Static("*Awaiting the fates...*", id="play-narrative")
yield Static("", id="play-status")
yield Input(placeholder="Type your action and press Enter...", id="play-input")
yield Button("Close the Book and Start a New One", id="end-game-btn", variant="warning")
with TabPane("CHARACTER", id="char-tab"):
with VerticalScroll():
yield CharPane(id="char-content")
@ -195,6 +200,7 @@ class ChaosTUI(App):
self._settings_loaded = True
def _begin_game(self):
self._game_over = False
self._last_narrative: str = ""
pages = load_book_pages()
if pages and pages != ["*The story has not begun.*"]:
@ -209,6 +215,18 @@ class ChaosTUI(App):
return
self._call_llm()
def _archive_and_reset(self) -> None:
"""Archive the session and reset for a new game."""
self._game_over = False
btn = self.query_one("#end-game-btn", Button)
btn.remove_class("visible")
archive_path = state.archive_session()
self._book_pages = ["*The story has not begun.*"]
self._book_page = 0
self._render_book_page()
self._begin_game()
self._save_settings()
def _check_ambience(self):
if app_ambience_player:
app_ambience_player.poll()
@ -219,6 +237,8 @@ class ChaosTUI(App):
app_ambience_player.toggle_mute()
self._update_mute_button()
self._save_settings()
elif event.button.id == "end-game-btn":
self._archive_and_reset()
def _update_mute_button(self) -> None:
btn = self.query_one("#mute-btn", Button)
@ -316,6 +336,17 @@ class ChaosTUI(App):
elif result.log_entry and not result.is_meta:
state.append_log(f"- {result.log_entry}")
state.apply_state(result)
if result.game_over:
self._game_over = True
self._set_narrative(result.book_log if result.book_log else "(The story has ended.)")
inp = self.query_one("#play-input", Input)
inp.disabled = True
inp.placeholder = "The story has ended."
btn = self.query_one("#end-game-btn", Button)
btn.add_class("visible")
return
if result.book_log or not result.user_prompt:
self._display_scene(result)
else:

View File

@ -161,8 +161,8 @@ def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn(
"I use my healing salve",
narrative="Dillion applies the salve to his wound.",
log_entry="Dillion used his healing salve to restore 2 HP.",
narrative="Kael applies the salve to his wound.",
log_entry="Kael used his healing salve to restore 2 HP.",
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}},
{"tool": "modify_vitals", "args": {"current_hp": 8}}],
story="At the tavern",
@ -187,8 +187,8 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
valid, reason, action = validate_turn(
"I buy a round for the house",
narrative="Dillion orders drinks for everyone.",
log_entry="Dillion bought a round at the tavern.",
narrative="Kael orders drinks for everyone.",
log_entry="Kael bought a round at the tavern.",
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}],
story="At 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(
"I use my healing salve",
narrative="Dillion applies the salve to his wound.",
log_entry="Dillion used his healing salve.",
narrative="Kael applies the salve to his wound.",
log_entry="Kael used his healing salve.",
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}],
story="At 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(
"I attack the dragon",
narrative="Dillion swings his sword.",
log_entry="Dillion attacked the dragon.",
narrative="Kael swings his sword.",
log_entry="Kael attacked the dragon.",
changes=[{"tool": "modify_vitals", "args": {"current_hp": 10}}],
story="A dragon appears!",
log="- Dragon spotted",