From f36387f1bffa9912e726dc02991cf9fc740bf65b Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sat, 4 Jul 2026 21:50:51 +0200 Subject: [PATCH] End game rules and player name extraction --- AGENTS.md | 33 +++++++++++++++++- rules/core_mechanics.md | 5 +++ rules/end_game.md | 56 +++++++++++++++++++++++++++++++ tools/engine.py | 5 ++- tools/engine_lib/models.py | 3 ++ tools/engine_lib/paths.py | 3 ++ tools/engine_lib/prompts.py | 18 ++++++++-- tools/engine_lib/state.py | 37 +++++++++++++++++++- tools/engine_lib/tools_handler.py | 25 +++++++++++--- tools/engine_lib/validation.py | 2 ++ tools/run.py | 33 +++++++++++++++++- tools/test_validation.py | 16 ++++----- 12 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 rules/end_game.md diff --git a/AGENTS.md b/AGENTS.md index 9b69270..29c3399 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,10 @@ in that process. the-chaos/ ├── rules/ # LOCKED — game rules, do not modify │ ├── deck/ # Card tables -│ └── mechanics.md # Core mechanics reference +│ ├── mechanics.md # Core mechanics reference +│ ├── core_mechanics.md # Condensed core rules (injected into system prompt) +│ ├── character_creation.md # Character creation rules +│ └── end_game.md # End-game closure rules ├── tools/ # Game system code │ ├── __init__.py │ ├── engine.py # Game engine (prompt builder, LLM client, parser, state) @@ -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/.md` — Session logs (written by engine) - `session/tweaks.md` — House rules (manual edit) +- `archive/-/` — 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/-/`, 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 diff --git a/rules/core_mechanics.md b/rules/core_mechanics.md index aef7cf8..13d68f9 100644 --- a/rules/core_mechanics.md +++ b/rules/core_mechanics.md @@ -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. diff --git a/rules/end_game.md b/rules/end_game.md new file mode 100644 index 0000000..aeebd7b --- /dev/null +++ b/rules/end_game.md @@ -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//` 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. diff --git a/tools/engine.py b/tools/engine.py index cca773e..be1c705 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -8,7 +8,7 @@ from datetime import datetime from difflib import SequenceMatcher from pathlib import Path -from engine_lib.models import TurnResult +from engine_lib.models import TurnResult, END_MARKER from engine_lib import config from engine_lib.context import build_system_prompt from engine_lib.validation import validate_turn @@ -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, ) diff --git a/tools/engine_lib/models.py b/tools/engine_lib/models.py index f5cc387..61be5e4 100644 --- a/tools/engine_lib/models.py +++ b/tools/engine_lib/models.py @@ -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 diff --git a/tools/engine_lib/paths.py b/tools/engine_lib/paths.py index 6a916e3..8721148 100644 --- a/tools/engine_lib/paths.py +++ b/tools/engine_lib/paths.py @@ -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' diff --git a/tools/engine_lib/prompts.py b/tools/engine_lib/prompts.py index 1343db9..d1a7990 100644 --- a/tools/engine_lib/prompts.py +++ b/tools/engine_lib/prompts.py @@ -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 diff --git a/tools/engine_lib/state.py b/tools/engine_lib/state.py index 2ab8be9..a4aa61f 100644 --- a/tools/engine_lib/state.py +++ b/tools/engine_lib/state.py @@ -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/-/ 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) diff --git a/tools/engine_lib/tools_handler.py b/tools/engine_lib/tools_handler.py index 2afe0df..915e82b 100644 --- a/tools/engine_lib/tools_handler.py +++ b/tools/engine_lib/tools_handler.py @@ -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 diff --git a/tools/engine_lib/validation.py b/tools/engine_lib/validation.py index dacb69e..f1601c3 100644 --- a/tools/engine_lib/validation.py +++ b/tools/engine_lib/validation.py @@ -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. diff --git a/tools/run.py b/tools/run.py index e332c56..d0d4c91 100755 --- a/tools/run.py +++ b/tools/run.py @@ -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: diff --git a/tools/test_validation.py b/tools/test_validation.py index 316d8d5..2930db2 100644 --- a/tools/test_validation.py +++ b/tools/test_validation.py @@ -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",