# -*- coding: utf-8 -*- import os import pickle import glob from datetime import datetime from pathlib import Path class SaveManager: def __init__(self): """Initialize save manager with XDG-compliant save directory""" # Use XDG_CONFIG_HOME or default to ~/.config config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) self.save_dir = Path(config_home) / "storm-games" / "wicked-quest" self.save_dir.mkdir(parents=True, exist_ok=True) self.max_saves = 10 def create_save(self, player, current_level, game_start_time, current_game): """Create a save file with current game state""" if not player.can_save(): return False, "Not enough bone dust to save (need 200)" # Validate required parameters if current_game is None: return False, "No game selected to save" if current_level is None: return False, "No current level to save" # Spend the bone dust if not player.spend_save_bone_dust(200): return False, "Failed to spend bone dust" # Create save data save_data = { "player_state": { "xPos": player.xPos, "yPos": player.yPos, "health": player._health, "maxHealth": player._maxHealth, "lives": player._lives, "coins": player._coins, "saveBoneDust": player._saveBoneDust, "jackOLanternCount": player._jack_o_lantern_count, "shinBoneCount": player.shinBoneCount, "inventory": player.inventory, "collectedItems": player.collectedItems, "weapons": self._serialize_weapons(player.weapons), "currentWeaponName": player.currentWeapon.name if player.currentWeapon else None, "stats": self._serialize_stats(player.stats), "scoreboard": self._serialize_scoreboard(player.scoreboard), }, "game_state": { "currentLevel": current_level, "currentGame": current_game, "gameStartTime": game_start_time, "saveTime": datetime.now(), }, "version": "1.0", } # Generate filename with timestamp timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") filename = f"save_{timestamp}.pickle" filepath = self.save_dir / filename try: # Write to temporary file first, then rename for atomic operation temp_filepath = filepath.with_suffix(".tmp") with open(temp_filepath, "wb") as f: pickle.dump(save_data, f) f.flush() # Ensure data is written to disk os.fsync(f.fileno()) # Force write to disk # Atomic rename (replaces old file if it exists) temp_filepath.rename(filepath) # Clean up old saves if we exceed max_saves self._cleanup_old_saves() return True, f"Game saved to {filename}" except Exception as e: # Clean up temp file if it exists if temp_filepath.exists(): try: temp_filepath.unlink() except Exception: pass return False, f"Failed to save game: {str(e)}" def _serialize_weapons(self, weapons): """Serialize weapons for saving""" serialized = [] for weapon in weapons: serialized.append( { "name": weapon.name, "damage": weapon.damage, "range": weapon.range, "attackSound": weapon.attackSound, "hitSound": weapon.hitSound, "cooldown": weapon.cooldown, "attackDuration": weapon.attackDuration, "speedBonus": getattr(weapon, "speedBonus", 1.0), "jumpDurationBonus": getattr(weapon, "jumpDurationBonus", 1.0), } ) return serialized def _deserialize_weapons(self, weapon_data): """Deserialize weapons from save data""" from src.weapon import Weapon weapons = [] for data in weapon_data: # Handle backward compatibility for old saves speedBonus = data.get("speedBonus", 1.0) jumpDurationBonus = data.get("jumpDurationBonus", 1.0) cooldown = data.get("cooldown", 500) # Default cooldown for old saves # For old saves, restore proper bonuses and cooldowns for specific weapons if data["name"] == "witch_broom" and speedBonus == 1.0: speedBonus = 1.17 jumpDurationBonus = 1.25 # Restore proper cooldown for nunchucks in old saves if data["name"] == "nunchucks" and cooldown == 500: cooldown = 250 weapon = Weapon( name=data["name"], damage=data["damage"], range=data["range"], attackSound=data["attackSound"], hitSound=data["hitSound"], cooldown=cooldown, attackDuration=data["attackDuration"], speedBonus=speedBonus, jumpDurationBonus=jumpDurationBonus, ) weapons.append(weapon) return weapons def _serialize_stats(self, stats): """Serialize stats for saving""" return {"total": stats.total.copy(), "level": stats.level.copy()} def _deserialize_stats(self, stats_data): """Deserialize stats from save data""" from src.stat_tracker import StatTracker stats = StatTracker() if "total" in stats_data: stats.total.update(stats_data["total"]) if "level" in stats_data: stats.level.update(stats_data["level"]) return stats def _serialize_scoreboard(self, scoreboard): """Serialize scoreboard for saving""" return { "currentScore": getattr(scoreboard, "currentScore", 0), "highScores": getattr(scoreboard, "highScores", []), } def _deserialize_scoreboard(self, scoreboard_data): """Deserialize scoreboard from save data""" from libstormgames import Scoreboard scoreboard = Scoreboard() if "currentScore" in scoreboard_data: scoreboard.currentScore = scoreboard_data["currentScore"] if "highScores" in scoreboard_data: scoreboard.highScores = scoreboard_data["highScores"] return scoreboard def get_save_files(self): """Get list of save files with metadata""" save_files = [] pattern = str(self.save_dir / "save_*.pickle") for filepath in glob.glob(pattern): try: with open(filepath, "rb") as f: save_data = pickle.load(f) # Validate save data structure if not self._validate_save_data(save_data): print(f"Invalid save file structure: {filepath}") continue # Extract save info save_time = save_data["game_state"]["saveTime"] level = save_data["game_state"]["currentLevel"] game_name = save_data["game_state"]["currentGame"] # Format display name formatted_time = save_time.strftime("%B %d %I:%M%p") display_name = f"{formatted_time} {game_name} Level {level}" save_files.append( { "filepath": filepath, "display_name": display_name, "save_time": save_time, "level": level, "game_name": game_name, "save_data": save_data, } ) except (pickle.PickleError, EOFError, OSError) as e: print(f"Corrupted save file {filepath}: {e}") # Try to remove corrupted save file try: os.remove(filepath) print(f"Removed corrupted save file: {filepath}") except Exception: pass continue except Exception as e: print(f"Error reading save file {filepath}: {e}") continue # Sort by save time (newest first) save_files.sort(key=lambda x: x["save_time"], reverse=True) return save_files def load_save(self, filepath): """Load game state from save file""" try: with open(filepath, "rb") as f: save_data = pickle.load(f) return True, save_data except Exception as e: return False, f"Failed to load save: {str(e)}" def restore_player_state(self, player, save_data): """Restore player state from save data""" player_state = save_data["player_state"] # Restore basic attributes player.xPos = player_state["xPos"] player.yPos = player_state["yPos"] player._health = player_state["health"] player._maxHealth = player_state["maxHealth"] player._lives = player_state["lives"] player._coins = player_state["coins"] player._saveBoneDust = player_state["saveBoneDust"] player._jack_o_lantern_count = player_state["jackOLanternCount"] player.shinBoneCount = player_state["shinBoneCount"] player.inventory = player_state["inventory"] player.collectedItems = player_state["collectedItems"] # Restore weapons player.weapons = self._deserialize_weapons(player_state["weapons"]) # Restore current weapon current_weapon_name = player_state.get("currentWeaponName") if current_weapon_name: for weapon in player.weapons: if weapon.name == current_weapon_name: player.currentWeapon = weapon break # Restore stats if "stats" in player_state: player.stats = self._deserialize_stats(player_state["stats"]) else: from src.stat_tracker import StatTracker player.stats = StatTracker() # Restore scoreboard if "scoreboard" in player_state: player.scoreboard = self._deserialize_scoreboard(player_state["scoreboard"]) else: from libstormgames import Scoreboard player.scoreboard = Scoreboard() def _cleanup_old_saves(self): """Remove old save files if we exceed max_saves""" save_files = self.get_save_files() if len(save_files) > self.max_saves: # Remove oldest saves for save_file in save_files[self.max_saves:]: try: os.remove(save_file["filepath"]) except Exception as e: print(f"Error removing old save {save_file['filepath']}: {e}") def _validate_save_data(self, save_data): """Validate that save data has required structure""" try: # Check for required top-level keys required_keys = ["player_state", "game_state", "version"] if not all(key in save_data for key in required_keys): return False # Check player_state structure player_required = ["xPos", "yPos", "health", "maxHealth", "lives", "coins", "saveBoneDust"] if not all(key in save_data["player_state"] for key in player_required): return False # Check game_state structure game_required = ["currentLevel", "currentGame", "gameStartTime", "saveTime"] if not all(key in save_data["game_state"] for key in game_required): return False return True except Exception: return False def has_saves(self): """Check if any save files exist""" return len(self.get_save_files()) > 0