Hogwarts_SBox_AI-RPG / data /save_system.py
cereeenn120's picture
Refactor ui design removing gradio
ee5b304
Raw History Blame Contribute Delete
10.3 kB
"""
save_system.py β€” Local JSON save/load system.
v2: Browser download / upload ile local file-based save.
HF Dataset bağımlılığı kaldırıldı.
Multi-save doğal olarak destekleniyor β€” oyuncu istediği kadar
.json dosyası saklayabilir, istediğini yükleyebilir.
Save dosyasΔ± adΔ±: {karakter}_{house}_lv{level}_{zaman}.json
Save formatΔ±:
{
"version": 2,
"saved_at": "ISO timestamp",
"character_name": "...",
"house": "...",
"year": N,
"level": N,
"state": { ... full player state ... },
"history": [ ... chatbot history ... ]
}
"""
import copy
import json
import logging
import os
import re
import tempfile
import uuid
from datetime import datetime
from game.logging_config import event, get_logger, state_fields
from game.player import safe_player_name
from game.presentation import narrative_choices_allowed
from systems.festival_system import init_festivals
from systems.interaction_system import init_interaction
from systems.journal_system import init_journal
from systems.location_system import init_location
from systems.quest_system import init_quests
from systems.time_system import init_time
SAVE_VERSION = 3
logger = get_logger("save")
def export_save(state, history):
"""Return a portable JSON document without creating a UI or a file."""
return {
"version": SAVE_VERSION, "saved_at": datetime.now().isoformat(),
"character_name": safe_player_name(state.get("name")),
"house": state.get("house"), "year": state.get("year"),
"level": state.get("level", 1),
"state": copy.deepcopy(state), "history": copy.deepcopy(history),
}
def restore_save(document):
"""Validate and migrate a save on a private copy; never mutate a live game."""
if not isinstance(document, dict):
raise ValueError("Choose a valid Hogwarts JSON save.")
if "version" in document and document["version"] not in (1, 2, SAVE_VERSION):
raise ValueError("This save version is not supported.")
state = copy.deepcopy(document.get("state", document))
history = copy.deepcopy(document.get("history", []))
if not isinstance(state, dict) or not isinstance(state.get("name"), str):
raise ValueError("The save is missing a character.")
if state.get("house", "Gryffindor") not in {"Gryffindor", "Slytherin", "Ravenclaw", "Hufflepuff"}:
raise ValueError("Invalid house in save.")
if type(state.get("year", 3)) is not int or not 1 <= state.get("year", 3) <= 7:
raise ValueError("Invalid school year in save.")
for key in ("strength", "agility", "intelligence", "charisma", "willpower", "xp", "level",
"credits", "unspent_stat_points", "light_dark", "order_chaos", "trouble_level",
"reputation", "house_points", "_state_revision"):
if key in state and type(state[key]) is not int:
raise ValueError("Invalid numeric field: " + key)
for key in ("traits", "aspirations", "discovered_locations"):
if key in state and (not isinstance(state[key], list) or not all(isinstance(v, str) for v in state[key])):
raise ValueError("Invalid list: " + key)
for key in ("memory", "detention", "quests", "relationships", "dynamic_npcs", "known_spells",
"journal", "interaction", "_ui_cache", "action_clock", "social_daily", "schedule_results"):
if key in state and not isinstance(state[key], dict):
raise ValueError("Invalid object: " + key)
for key in ("inventory", "world_events", "_enrichment"):
if key in state and (not isinstance(state[key], list) or not all(isinstance(v, dict) for v in state[key])):
raise ValueError("Invalid entries: " + key)
if not isinstance(history, list) or not all(
isinstance(row, (list, tuple)) and len(row) == 2
and all(isinstance(v, str) or v is None for v in row) for row in history
):
raise ValueError("Invalid story history.")
# A loaded game must not attach to another game's outstanding background jobs.
state["id"] = str(uuid.uuid4())
state.pop("_enrichment", None)
try:
_backfill_state(state)
from game.action_engine import sync_mode
sync_mode(state)
except (TypeError, KeyError, AttributeError, ValueError, IndexError) as exc:
raise ValueError("The save contains invalid game data.") from exc
state["_ui_notice"] = "Game restored. Your turn β€” choose an action below."
return state, history
# ──────────────────────────────────────────────────────────────
# SAVE
# ──────────────────────────────────────────────────────────────
def prepare_save_download(state_json: str, history_json) -> str:
"""
Oyun state'ini + chatbot geçmişini bir JSON dosyasına yazar.
Gradio gr.File output iΓ§in dosya yolunu dΓΆndΓΌrΓΌr.
app.py'deki save_wrapper tarafından çağrılır.
state_json : json.dumps(state) β€” string
history_json : json.dumps(history) veya doğrudan liste
DΓΆndΓΌrΓΌr : YazΔ±lan dosyanΔ±n absolute path'i
"""
state = json.loads(state_json)
if isinstance(history_json, str):
history = json.loads(history_json) if history_json else []
else:
history = history_json or []
event(logger, logging.DEBUG, "save.started", history_turns=len(history), **state_fields(state))
# Dosya adΔ±: gΓΌvenli, okunabilir
state["name"] = safe_player_name(state.get("name"))
char_slug = re.sub(r"[^a-z0-9]+", "_", state["name"].lower()).strip("_") or "player"
house_slug = re.sub(r"[^a-z0-9]+", "_", state.get("house", "unknown").strip().lower()).strip("_")
level = state.get("level", 1)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{char_slug}_{house_slug}_lv{level}_{ts}.json"
save_data = export_save(state, history)
save_dir = os.path.join(tempfile.gettempdir(), "hogwarts_saves")
os.makedirs(save_dir, exist_ok=True)
file_path = os.path.join(save_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(save_data, f, ensure_ascii=False, indent=2)
size_kb = os.path.getsize(file_path) / 1024
event(logger, logging.INFO, "save.completed", filename=filename, size_kb=round(size_kb, 1), save_version=SAVE_VERSION, **state_fields(state))
return file_path
# ──────────────────────────────────────────────────────────────
# LOAD
# ──────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────
# HELPERS β€” BACKFILL & FORMAT
# ──────────────────────────────────────────────────────────────
def _backfill_state(state: dict) -> None:
"""
Eski save dosyalarΔ±ndaki eksik alanlarΔ± gΓΌvenli biΓ§imde doldurur.
Δ°dempotent β€” zaten var olan alanlarΔ± ezmez.
"""
state["name"] = safe_player_name(state.get("name"))
# Repair missing canonical avatars in saves made before bundled art existed.
if state.get("is_canonical_character") and not (
state.get("avatar") and os.path.isfile(state["avatar"])
):
from game.vlm_engine import get_static_npc_image
state["avatar"] = get_static_npc_image(state.get("canonical_npc_id"))
state.setdefault("id", str(uuid.uuid4()))
original_keys = set(state)
defaults = {
"strength": 5, "agility": 5, "intelligence": 5,
"charisma": 5, "willpower": 5,
"light_dark": 0, "order_chaos": 0, "trouble_level": 0,
"reputation": 0, "house_points": 0,
"house": "Gryffindor", "year": 3,
"traits": [], "aspirations": [],
"credits": 150, "inventory": [],
"relationships": {}, "dynamic_npcs": {}, "known_spells": {},
"xp": 0, "level": 1, "unspent_stat_points": 0,
"quests": {},
"detention": {
"active": False, "assigned_by": None, "reason": None,
"turns_remaining": 0, "total_count": 0,
},
"memory": {
"long_term": [], "summary": "",
"turns_since_summary": 0, "summary_interval": 5,
},
}
for key, default in defaults.items():
if key not in state:
state[key] = default
# Alt-alan backfill
mem = state.setdefault("memory", {})
mem.setdefault("long_term", [])
mem.setdefault("summary", "")
mem.setdefault("turns_since_summary", 0)
mem.setdefault("summary_interval", 5)
det = state.setdefault("detention", {})
det.setdefault("active", False)
det.setdefault("assigned_by", None)
det.setdefault("reason", None)
det.setdefault("turns_remaining", 0)
det.setdefault("total_count", 0)
# Eksik sistemleri başlat
if not state.get("time") or not state.get("schedule"):
init_time(state, state.get("year", 3))
init_journal(state)
init_quests(state)
init_festivals(state)
init_location(state) # idempotent β€” won't overwrite existing location
init_interaction(state)
state.pop("_interaction_instruction", None)
# UI cache backfill (eski save'lerde yoksa)
state.setdefault("_ui_cache", {
"choices": [""] * 5,
"scene_path": None,
"npc_path": None,
"item_path": None,
})
if not narrative_choices_allowed(state):
state["_ui_cache"]["choices"] = [""] * 5
added = sorted(set(state) - original_keys)
event(logger, logging.INFO if added else logging.DEBUG, "load.state_backfilled", added_fields=added, **state_fields(state))