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
+1 -1
View File
@@ -20,7 +20,7 @@ import yaml
import random
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 = {
'souls': 'souls.yaml',
+2 -2
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 import state
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:
@@ -74,7 +74,7 @@ class GameEngine:
is_new_game = not player_action and not recent_narrative
system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log)
if is_new_game:
cc = state.read_file(CHARACTER_CREATION_PATH)
cc = state.read_file(get_character_creation_path())
if 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)")
+2 -2
View File
@@ -1,6 +1,6 @@
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 . 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()
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)
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_section = f"\n\n## Full Mechanics Reference\n{extra}" if extra else ""
return SYSTEM_PROMPT.substitute(
+45 -10
View File
@@ -1,18 +1,11 @@
#!/usr/bin/env python3
"""
paths.py — Path constants for The Chaos game engine.
"""
from __future__ import annotations
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
RULES_DIR = BASE_DIR / 'rules'
CORE_RULES_PATH = RULES_DIR / 'core_mechanics.md'
MECHANICS_PATH = RULES_DIR / 'mechanics.md'
CHARACTER_CREATION_PATH = RULES_DIR / 'character_creation.md'
THEMES_DIR = BASE_DIR / 'themes'
SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md'
@@ -27,6 +20,48 @@ CHANGES_PATH = SESSION_DIR / "changes.md"
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
META_LOG_PATH = SESSION_DIR / "meta_log.md"
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'
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"
+184 -1
View File
@@ -17,7 +17,8 @@ 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,
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
@@ -277,3 +278,185 @@ def archive_session() -> str:
shutil.rmtree(child)
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)
+7 -6
View File
@@ -4,8 +4,9 @@ import json
import re
from .paths import (
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH,
CORE_RULES_PATH, CHARACTER_CREATION_PATH, END_GAME_PATH,
AMBIENCE_PATH, CHAR_PATH, WORLD_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
@@ -301,10 +302,10 @@ def tool_finalize_turn(args: dict) -> str:
RULES_CATEGORIES = {
"mechanics": MECHANICS_PATH,
"core": CORE_RULES_PATH,
"character_creation": CHARACTER_CREATION_PATH,
"end_game": END_GAME_PATH,
"mechanics": get_mechanics_path(),
"core": get_core_rules_path(),
"character_creation": get_character_creation_path(),
"end_game": get_end_game_path(),
}
def tool_read_rules(args: dict) -> str:
+1
View File
@@ -182,6 +182,7 @@ class ChaosTUI(App):
def on_mount(self):
clear_llm_log()
ensure_log()
state.init_session_from_theme()
self.console._theme = MARKDOWN_THEME
self._init_book()
self.set_interval(REFRESH_SECS, self._check_ambience)