Themes + Saves system

This commit is contained in:
Dejvino 2026-07-05 12:50:22 +02:00
parent bd0582c78b
commit a32fa9ef5c
29 changed files with 413 additions and 43 deletions

2
.gitignore vendored
View File

@ -6,3 +6,5 @@ __pycache__/
session/audio/ session/audio/
llm.log llm.log
config.json config.json
saves/
themes/*/audio/*.mp3

View File

@ -20,37 +20,49 @@ in that process.
``` ```
the-chaos/ the-chaos/
├── rules/ # LOCKED — game rules, do not modify ├── themes/ # SHARED theme packs (versioned)
│ ├── deck/ # Card tables │ └── default/
│ ├── mechanics.md # Core mechanics reference │ ├── theme.json # Theme metadata
│ ├── core_mechanics.md # Condensed core rules (injected into system prompt) │ ├── rules/ # LOCKED — game rules
│ ├── character_creation.md # Character creation rules │ │ ├── deck/ # Card tables
│ └── end_game.md # End-game closure rules │ │ ├── mechanics.md
│ │ ├── core_mechanics.md
│ │ ├── character_creation.md
│ │ └── end_game.md
│ ├── audio/ # Baseline ambience tracks
│ ├── ambience_options.md
│ └── character_template.md
├── 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
│ ├── run.py # TUI (Textual app, game loop, narrative, input) │ ├── run.py # TUI (Textual app)
│ ├── ambience.py # CLI shortcut for ambience switching │ ├── ambience.py # CLI shortcut for ambience switching
│ ├── draw_card.py # Card drawing tool │ ├── draw_card.py # Card drawing tool
│ ├── music-fetch.py # YouTube audio downloader │ ├── music-fetch.py # YouTube audio downloader
│ ├── roll_dice.py # Dice rolling tool │ ├── roll_dice.py # Dice rolling tool
│ ├── store_turn.py # DEPRECATED — use engine.py archive_turn instead
│ ├── test_imports.py # Import validation test │ ├── test_imports.py # Import validation test
│ └── test_runtime.py # Runtime import test │ └── test_runtime.py # Runtime import test
├── scripts/ # UNLOCKED — helper scripts ├── scripts/ # UNLOCKED — helper scripts
├── run.sh # Entry point (just calls tools/run.py) ├── run.sh # Entry point (just calls tools/run.py)
└── session/ # Game state (read/write by engine) ├── session/ # Game state (read/write by engine)
├── config.json # LLM provider config │ ├── config.json # LLM provider config
├── character.md # Player character sheet │ ├── current_theme # Active theme id (e.g. "default")
├── world.md # Keep & Realm state │ ├── character.md # Player character sheet (evolved)
├── book.md # Story book (append-only turn archive) │ ├── world.md # Keep & Realm state (evolved)
├── journal.md # TODO / DONE tracking │ ├── book.md # Story book (append-only turn archive)
├── ambience.md # Current ambience name │ ├── journal.md # TODO / DONE tracking
├── ambience_options.md # Ambience → file mapping │ ├── ambience.md # Current ambience name
├── ambience_sources.md # Track source URLs │ ├── ambience_options.md # Working copy (seeded from theme)
├── tweaks.md # House rules log │ ├── ambience_sources.md # Track download URLs
├── audio/ # Music files │ ├── tweaks.md # House rules log
└── log/ # Session logs by date │ ├── audio/ # Working copy (seeded from theme)
│ └── log/ # Session logs by date
└── saves/ # Save slots (gitignored, per session)
├── _autosave/
│ ├── metadata.json # {hero, theme, timestamp, preview}
│ └── ... # session state snapshot (no audio)
└── my-campaign/
└── ...
``` ```
## How It Works ## How It Works
@ -348,15 +360,35 @@ This allows compatibility with OpenAI-compatible servers that return content in
## Session Files ## Session Files
- `session/config.json` — LLM config (edit directly) - `session/config.json` — LLM config (edit directly)
- `session/current_theme` — Active theme id (written by engine on init)
- `session/character.md` — PC state (written by engine) - `session/character.md` — PC state (written by engine)
- `session/world.md` — Realm state (written by engine) - `session/world.md` — Realm state (written by engine)
- `session/book.md` — Story archive (written by engine) - `session/book.md` — Story archive (written by engine)
- `session/journal.md` — TODO/DONE list (written by engine) - `session/journal.md` — TODO/DONE list (written by engine)
- `session/ambience.md` — Current ambience (written by engine) - `session/ambience.md` — Current ambience (written by engine)
- `session/ambience_options.md` — Working copy of ambience config (seeded from theme)
- `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 - `archive/<hero>-<timestamp>/` — Archived completed games
## Save Slots
- `saves/<slot>/` — Full session snapshots (no audio, includes theme ref)
- `saves/_autosave/` — Auto-save before loading another slot
- API in `state.py`: `save_game()`, `load_game()`, `list_saves()`, `delete_save()`
## Theme Management
Themes live in `themes/<id>/` and contain:
- `theme.json``{id, name, version, description}`
- `rules/` — Game mechanics (read by engine at runtime)
- `audio/` — Baseline ambience tracks (seeded to session/ on new game)
- `ambience_options.md` — Baseline ambience → file mapping
- `character_template.md` — Starting character sheet template
Engine reads rules from `themes/<current_theme>/rules/` dynamically.
`session/ambience_options.md` and `session/audio/` are working copies seeded from the theme on init.
## LLM Strategies Explained ## LLM Strategies Explained
### "conversational" Strategy ### "conversational" Strategy

View File

@ -0,0 +1,82 @@
# Ambience Options
Set the current ambience by writing its name into `session/ambience.md`.
The TUI polls this file and crossfades to the matching track.
Music files go in `session/audio/`. Supported formats: `.mp3`, `.ogg`, `.wav`.
When multiple files are listed, one is chosen at random each time the ambience activates.
## Requirements
```bash
pip install miniaudio yt-dlp
```
`ffmpeg` must also be installed on your system.
## Fetching New Tracks
Use the music-fetch tool to search YouTube and download tracks:
```bash
# Auto-search for a tavern track
python3 tools/music-fetch.py tavern
# Custom query
python3 tools/music-fetch.py "deep fen" "swamp ambience D&D"
# Specific video
python3 tools/music-fetch.py tavern --url "https://youtu.be/..."
# Replace all tracks for an ambience
python3 tools/music-fetch.py tavern --replace
# Preview without downloading
python3 tools/music-fetch.py tavern --dry-run
# Allow tracks longer than 10 minutes
python3 tools/music-fetch.py tavern --allow-long-songs
```
Sources are recorded in `session/ambience_sources.md` so tracks can be
re-downloaded without keeping audio files in git.
## Available Ambiences
| Ambience | Files |
|----------|-------|
| silence | (stops all music) |
| calm | calm_01.ogg |
| combat | combat_01.ogg |
| dungeon | dungeon_01.ogg, dungeon_02.ogg |
| forest | forest_01.ogg |
| tavern | tavern_01.ogg |
| tension | tension_01.ogg |
| town | town_01.ogg |
| wilds | wilds_01.ogg |
## Usage (DM)
```bash
# Switch to forest ambience
echo "forest" > session/ambience.md
# Stop music
echo "silence" > session/ambience.md
```
Or use the companion CLI shortcut:
```bash
python3 tools/ambience.py forest
python3 tools/ambience.py silence
```
## Status Display
The TUI status bar shows the current ambience:
```
Dillion ❤ 10 │ 42 entries │ 3 todo │ 2026-06-24 │ ♫ tavern
```
If miniaudio is not installed, the status bar shows a hint and no audio plays.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,28 @@
# Character
**Name:**
**Thing:**
**Failed Career:**
**What's Gone Wrong:**
## Traits
- **STR:** 10
- **DEX:** 10
- **WIL:** 10
## Vitals
- **Max Health:** 10
- **Current Health:** 10
- **Armour:** None
- **Weapon:** Unarmed (1d4)
- **Cash:** 0
## Chains
## Discords
## Gear
## Notes & Scribbles

View File

@ -0,0 +1,6 @@
{
"id": "default",
"name": "The Chaos — Default",
"version": "1.0.0",
"description": "The base game: a dark fantasy realm of wild magic, ancient ruins, and fragile hope."
}

View File

@ -20,7 +20,7 @@ import yaml
import random import random
import os import os
DECK_DIR = os.path.join(os.path.dirname(__file__), '..', 'rules', 'deck') DECK_DIR = os.path.join(os.path.dirname(__file__), '..', 'themes', 'default', 'rules', 'deck')
DECKS = { DECKS = {
'souls': 'souls.yaml', 'souls': 'souls.yaml',

View File

@ -16,7 +16,7 @@ from engine_lib.tools_handler import execute_tool, describe_change, extract_tool
from engine_lib.parsing import log_turn_details from engine_lib.parsing import log_turn_details
from engine_lib import state from engine_lib import state
from engine_lib.llm import call_llm from engine_lib.llm import call_llm
from engine_lib.paths import CHARACTER_CREATION_PATH, RULES_INJECTION_PATH from engine_lib.paths import get_character_creation_path, RULES_INJECTION_PATH
class GameEngine: class GameEngine:
@ -74,7 +74,7 @@ class GameEngine:
is_new_game = not player_action and not recent_narrative is_new_game = not player_action and not recent_narrative
system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log) system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log)
if is_new_game: if is_new_game:
cc = state.read_file(CHARACTER_CREATION_PATH) cc = state.read_file(get_character_creation_path())
if cc: if cc:
system += f"\n\n## Character Creation Reference\n{cc}" system += f"\n\n## Character Creation Reference\n{cc}"
state.append_llm_log(f"\n[NEW GAME] injected character_creation.md ({len(cc)} chars)") state.append_llm_log(f"\n[NEW GAME] injected character_creation.md ({len(cc)} chars)")

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH, CORE_RULES_PATH, RULES_INJECTION_PATH from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH, get_core_rules_path, RULES_INJECTION_PATH
from .prompts import SYSTEM_PROMPT from .prompts import SYSTEM_PROMPT
from . import state from . import state
@ -12,7 +12,7 @@ def build_system_prompt(recent_narrative: str | None = None, recent_log: str | N
log = recent_log if recent_log is not None else state.read_recent_log() log = recent_log if recent_log is not None else state.read_recent_log()
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*" journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
story = recent_narrative if recent_narrative is not None else state.read_recent_book(2) story = recent_narrative if recent_narrative is not None else state.read_recent_book(2)
core_rules = state.read_file(CORE_RULES_PATH) or "*No core rules file.*" core_rules = state.read_file(get_core_rules_path()) or "*No core rules file.*"
extra = state.read_file(RULES_INJECTION_PATH) extra = state.read_file(RULES_INJECTION_PATH)
extra_section = f"\n\n## Full Mechanics Reference\n{extra}" if extra else "" extra_section = f"\n\n## Full Mechanics Reference\n{extra}" if extra else ""
return SYSTEM_PROMPT.substitute( return SYSTEM_PROMPT.substitute(

View File

@ -1,18 +1,11 @@
#!/usr/bin/env python3
"""
paths.py Path constants for The Chaos game engine.
"""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
RULES_DIR = BASE_DIR / 'rules' THEMES_DIR = BASE_DIR / 'themes'
CORE_RULES_PATH = RULES_DIR / 'core_mechanics.md'
MECHANICS_PATH = RULES_DIR / 'mechanics.md'
CHARACTER_CREATION_PATH = RULES_DIR / 'character_creation.md'
SESSION_DIR = BASE_DIR / 'session' SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json' CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md' CHAR_PATH = SESSION_DIR / 'character.md'
@ -27,6 +20,48 @@ CHANGES_PATH = SESSION_DIR / "changes.md"
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md" RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
META_LOG_PATH = SESSION_DIR / "meta_log.md" META_LOG_PATH = SESSION_DIR / "meta_log.md"
AUDIO_DIR = SESSION_DIR / "audio" AUDIO_DIR = SESSION_DIR / "audio"
ACTIVE_THEME_PATH = SESSION_DIR / "current_theme"
END_GAME_PATH = RULES_DIR / 'end_game.md'
ARCHIVE_DIR = BASE_DIR / 'archive' ARCHIVE_DIR = BASE_DIR / 'archive'
def get_active_theme_id() -> str:
if ACTIVE_THEME_PATH.exists():
return ACTIVE_THEME_PATH.read_text().strip() or "default"
return "default"
def get_theme_dir(theme_id: str | None = None) -> Path:
return THEMES_DIR / (theme_id or get_active_theme_id())
def get_rules_dir(theme_id: str | None = None) -> Path:
return get_theme_dir(theme_id) / "rules"
def get_core_rules_path(theme_id: str | None = None) -> Path:
return get_rules_dir(theme_id) / "core_mechanics.md"
def get_mechanics_path(theme_id: str | None = None) -> Path:
return get_rules_dir(theme_id) / "mechanics.md"
def get_character_creation_path(theme_id: str | None = None) -> Path:
return get_rules_dir(theme_id) / "character_creation.md"
def get_end_game_path(theme_id: str | None = None) -> Path:
return get_rules_dir(theme_id) / "end_game.md"
def get_character_template_path(theme_id: str | None = None) -> Path:
return get_theme_dir(theme_id) / "character_template.md"
def get_theme_audio_dir(theme_id: str | None = None) -> Path:
return get_theme_dir(theme_id) / "audio"
def get_theme_ambience_options_path(theme_id: str | None = None) -> Path:
return get_theme_dir(theme_id) / "ambience_options.md"

View File

@ -17,7 +17,8 @@ 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,
META_LOG_PATH, AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR, META_LOG_PATH, AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR, THEMES_DIR,
ACTIVE_THEME_PATH,
) )
from .models import TurnResult from .models import TurnResult
@ -277,3 +278,185 @@ def archive_session() -> str:
shutil.rmtree(child) shutil.rmtree(child)
return str(archive_dir) return str(archive_dir)
# ── Theme management ─────────────────────────────────────────
def list_themes() -> list[dict]:
"""Scan themes/ and return metadata for each available theme."""
if not THEMES_DIR.exists():
return [{"id": "default", "name": "Default", "description": "Built-in theme"}]
themes = []
for d in sorted(THEMES_DIR.iterdir()):
if not d.is_dir():
continue
meta_path = d / "theme.json"
if meta_path.exists():
try:
import json
meta = json.loads(meta_path.read_text())
except Exception:
meta = {}
else:
meta = {}
themes.append({
"id": meta.get("id", d.name),
"name": meta.get("name", d.name),
"version": meta.get("version", "0.0.0"),
"description": meta.get("description", ""),
})
return themes
def get_active_theme() -> str:
"""Read the active theme id from session/current_theme."""
if ACTIVE_THEME_PATH.exists():
return ACTIVE_THEME_PATH.read_text().strip() or "default"
return "default"
def set_active_theme(theme_id: str) -> None:
"""Write the active theme id to session/current_theme."""
ACTIVE_THEME_PATH.parent.mkdir(parents=True, exist_ok=True)
ACTIVE_THEME_PATH.write_text(theme_id.strip() + "\n")
def init_session_from_theme(theme_id: str | None = None) -> None:
"""Seed session files from a theme. Only copies files that don't exist yet."""
from .paths import (
get_theme_dir, get_character_template_path, get_theme_audio_dir,
get_theme_ambience_options_path,
)
tid = theme_id or get_active_theme()
theme_dir = get_theme_dir(tid)
set_active_theme(tid)
# Character template
tmpl = get_character_template_path(tid)
if tmpl.exists() and not CHAR_PATH.exists():
shutil.copy2(tmpl, CHAR_PATH)
# World template (optional)
world_tmpl = theme_dir / "world_template.md"
if world_tmpl.exists() and not WORLD_PATH.exists():
shutil.copy2(world_tmpl, WORLD_PATH)
# Ambience options baseline
ao = get_theme_ambience_options_path(tid)
if ao.exists() and not AMBIENCE_OPTIONS_PATH.exists():
shutil.copy2(ao, AMBIENCE_OPTIONS_PATH)
# Audio files (seed if session/audio/ is empty)
theme_audio = get_theme_audio_dir(tid)
if theme_audio.exists():
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
existing = set(f.name for f in AUDIO_DIR.iterdir()) if AUDIO_DIR.exists() else set()
for f in theme_audio.iterdir():
if f.is_file() and f.name not in existing:
shutil.copy2(f, AUDIO_DIR)
# ── Save / Load ─────────────────────────────────────────────
SAVES_DIR = Path(__file__).resolve().parent.parent.parent / 'saves'
def save_game(slot_name: str) -> str:
"""Save current session to saves/<slot_name>/.
Excludes audio/ (seeded from theme). Returns the save path."""
slot_dir = SAVES_DIR / slot_name
slot_dir.mkdir(parents=True, exist_ok=True)
# Copy session files (excluding audio/)
for child in SESSION_DIR.iterdir():
if child.name == "audio":
continue
if child.is_file():
shutil.copy2(child, slot_dir / child.name)
elif child.is_dir():
shutil.copytree(child, slot_dir / child.name, dirs_exist_ok=True)
# Write metadata
hero = extract_hero_name()
theme = get_active_theme()
narrative = read_recent_book(1)
preview = narrative.strip()[:200] if narrative else ""
meta = {
"hero": hero,
"theme": theme,
"timestamp": datetime.now().isoformat(),
"preview": preview,
}
import json
(slot_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n")
return str(slot_dir)
def load_game(slot_name: str) -> str:
"""Load session from saves/<slot_name>/ back into session/.
Returns the hero name for UI reload."""
slot_dir = SAVES_DIR / slot_name
if not slot_dir.exists():
raise FileNotFoundError(f"Save slot not found: {slot_name}")
# Clear current session (same as archive_session)
for child in list(SESSION_DIR.iterdir()):
if child.is_file():
child.unlink()
elif child.is_dir() and child.name != "__pycache__":
shutil.rmtree(child)
# Restore save files
for child in slot_dir.iterdir():
if child.name == "metadata.json":
continue
if child.is_file():
shutil.copy2(child, SESSION_DIR / child.name)
elif child.is_dir():
shutil.copytree(child, SESSION_DIR / child.name, dirs_exist_ok=True)
# Re-seed audio from theme (audio is never in saves)
theme = get_active_theme()
from .paths import get_theme_audio_dir
theme_audio = get_theme_audio_dir(theme)
if theme_audio.exists():
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
for f in theme_audio.iterdir():
if f.is_file() and not (AUDIO_DIR / f.name).exists():
shutil.copy2(f, AUDIO_DIR)
return extract_hero_name()
def list_saves() -> list[dict]:
"""Return list of save slots with metadata."""
if not SAVES_DIR.exists():
return []
slots = []
import json
for d in sorted(SAVES_DIR.iterdir()):
if not d.is_dir():
continue
meta_path = d / "metadata.json"
meta = {}
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text())
except Exception:
pass
slots.append({
"slot": d.name,
"hero": meta.get("hero", "?"),
"theme": meta.get("theme", "?"),
"timestamp": meta.get("timestamp", ""),
"preview": meta.get("preview", ""),
})
return slots
def delete_save(slot_name: str) -> None:
"""Remove a save slot."""
slot_dir = SAVES_DIR / slot_name
if slot_dir.exists():
shutil.rmtree(slot_dir)

View File

@ -4,8 +4,9 @@ import json
import re import re
from .paths import ( from .paths import (
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH, AMBIENCE_PATH, CHAR_PATH, WORLD_PATH,
CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH, get_mechanics_path, get_core_rules_path,
get_character_creation_path, get_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
@ -301,10 +302,10 @@ def tool_finalize_turn(args: dict) -> str:
RULES_CATEGORIES = { RULES_CATEGORIES = {
"mechanics": MECHANICS_PATH, "mechanics": get_mechanics_path(),
"core": CORE_RULES_PATH, "core": get_core_rules_path(),
"character_creation": CHARACTER_CREATION_PATH, "character_creation": get_character_creation_path(),
"end_game": END_GAME_PATH, "end_game": get_end_game_path(),
} }
def tool_read_rules(args: dict) -> str: def tool_read_rules(args: dict) -> str:

View File

@ -182,6 +182,7 @@ class ChaosTUI(App):
def on_mount(self): def on_mount(self):
clear_llm_log() clear_llm_log()
ensure_log() ensure_log()
state.init_session_from_theme()
self.console._theme = MARKDOWN_THEME self.console._theme = MARKDOWN_THEME
self._init_book() self._init_book()
self.set_interval(REFRESH_SECS, self._check_ambience) self.set_interval(REFRESH_SECS, self._check_ambience)