Themes + Saves system
This commit is contained in:
parent
bd0582c78b
commit
a32fa9ef5c
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,3 +6,5 @@ __pycache__/
|
|||||||
session/audio/
|
session/audio/
|
||||||
llm.log
|
llm.log
|
||||||
config.json
|
config.json
|
||||||
|
saves/
|
||||||
|
themes/*/audio/*.mp3
|
||||||
|
|||||||
74
AGENTS.md
74
AGENTS.md
@ -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
|
||||||
|
|||||||
82
themes/default/ambience_options.md
Normal file
82
themes/default/ambience_options.md
Normal 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.
|
||||||
BIN
themes/default/audio/calm_01.ogg
Normal file
BIN
themes/default/audio/calm_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/combat_01.ogg
Normal file
BIN
themes/default/audio/combat_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/dungeon_01.ogg
Normal file
BIN
themes/default/audio/dungeon_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/dungeon_02.ogg
Normal file
BIN
themes/default/audio/dungeon_02.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/forest_01.ogg
Normal file
BIN
themes/default/audio/forest_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/tavern_01.ogg
Normal file
BIN
themes/default/audio/tavern_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/tension_01.ogg
Normal file
BIN
themes/default/audio/tension_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/town_01.ogg
Normal file
BIN
themes/default/audio/town_01.ogg
Normal file
Binary file not shown.
BIN
themes/default/audio/wilds_01.ogg
Normal file
BIN
themes/default/audio/wilds_01.ogg
Normal file
Binary file not shown.
28
themes/default/character_template.md
Normal file
28
themes/default/character_template.md
Normal 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
|
||||||
6
themes/default/theme.json
Normal file
6
themes/default/theme.json
Normal 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."
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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)")
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user