Themes + Saves system
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user