""" 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))