//! Save/load system for game state persistence. //! //! Saves to a simple text format in the user's data directory. //! Uses a line-based format for easy debugging and corruption recovery. use std::collections::HashSet; use std::fs; use std::path::PathBuf; use crate::entities::{Henchman, Minion, Player}; use crate::tiles::TileType; /// Save file name const SAVE_FILE: &str = "supervillain_save.txt"; /// Get the save directory path. fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); path.push("supervillain"); path } /// Get the full save file path. fn save_path() -> PathBuf { let mut path = save_dir(); path.push(SAVE_FILE); path } /// Check if a save file exists. pub fn save_exists() -> bool { save_path().exists() } /// Serializable game state snapshot. #[derive(Debug, Clone)] pub struct SaveState { pub player: Player, pub minions: Vec, pub henchmen: Vec, pub map_width: i32, pub map_height: i32, pub map_tiles: Vec, pub map_powered: HashSet<(i32, i32)>, pub map_throne: (i32, i32), pub map_entries: Vec<(i32, i32)>, pub wave: i32, pub phase: String, /// Simulation tick counter (0 for legacy saves). pub sim_tick: u64, /// Ticks until the next raid (<= 0 means "reschedule on load"). pub next_raid_in: i32, } /// Save game state to disk. pub fn save_game(state: &SaveState) -> Result<(), String> { let dir = save_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {}", e))?; let mut lines: Vec = Vec::new(); // Header lines.push("SUPERVILLAIN_SAVE_v1".to_string()); // Player lines.push(format!( "PLAYER {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", state.player.entity.x, state.player.entity.y, state.player.entity.hp, state.player.entity.max_hp, state.player.entity.damage, state.player.gold, state.player.power, state.player.power_cap, state.player.research, state.player.minion_cap, state.player.minion_damage_bonus, state.player.minion_hp_bonus, state.player.trap_damage_bonus, state.player.research_bonus, state.player.heat_reduction_bonus, state.player.power_bonus, state.player.wave, state.player.score, state.player.kills, )); lines.push(format!( "PLAYER_EXTRA {} {} {} {}", state.player.heat, state.player.notoriety, state.player.gold_income, state.player.power_core_mk2 as i32, )); // Unlocks let unlocks: Vec<&str> = state.player.unlocked.iter().map(|s| s.as_str()).collect(); lines.push(format!("UNLOCKS {}", unlocks.join(","))); // Loot lines.push(format!("LOOT {}", state.player.loot.join(","))); // Active schemes for scheme in &state.player.active_schemes { lines.push(format!( "SCHEME {} {} {} {} {} {}", scheme.name, scheme.turns_remaining, scheme.gold_reward, scheme.heat_cost, scheme.notoriety_reward, scheme.minion_cost, )); } // Powers for power in &state.player.powers { lines.push(format!( "POWER {} {} {}", power.unlock_key, power.current_cooldown, power.cooldown, )); } lines.push(format!("INVULN {}", state.player.invulnerability_turns)); // Map lines.push(format!( "MAP {} {} {} {}", state.map_width, state.map_height, state.map_throne.0, state.map_throne.1, )); // Entries let entries: Vec = state .map_entries .iter() .map(|(x, y)| format!("{}:{}", x, y)) .collect(); lines.push(format!("ENTRIES {}", entries.join(","))); // Map tiles (row by row) for y in 0..state.map_height { let row: Vec = (0..state.map_width) .map(|x| { let tile = state.map_tiles[(y * state.map_width + x) as usize]; tile_to_char(tile).to_string() }) .collect(); lines.push(format!("TILES {}", row.join(""))); } // Powered tiles let powered: Vec = state .map_powered .iter() .map(|(x, y)| format!("{}:{}", x, y)) .collect(); lines.push(format!("POWERED {}", powered.join(","))); // Minions for m in &state.minions { lines.push(format!( "MINION {} {} {} {} {} {} {}", m.entity.x, m.entity.y, m.entity.hp, m.entity.max_hp, m.entity.damage, m.entity.alive as i32, m.class.name(), )); } // Henchmen for h in &state.henchmen { lines.push(format!( "HENCHMAN {} {} {} {} {} {}", h.entity.x, h.entity.y, h.entity.hp, h.entity.max_hp, h.entity.alive as i32, h.entity.name, )); } // Phase lines.push(format!("PHASE {}", state.phase)); // Sim clock and threat state lines.push(format!("SIM {} {}", state.sim_tick, state.next_raid_in)); let content = lines.join("\n"); fs::write(save_path(), content).map_err(|e| format!("Failed to write save: {}", e))?; Ok(()) } /// Load game state from disk. pub fn load_game() -> Result { let content = fs::read_to_string(save_path()).map_err(|e| format!("Failed to read save: {}", e))?; let lines: Vec<&str> = content.lines().collect(); if lines.is_empty() || lines[0] != "SUPERVILLAIN_SAVE_v1" { return Err("Invalid save file format".to_string()); } let mut player_x = 40; let mut player_y = 20; let mut player_hp = 100; let mut player_max_hp = 100; let mut player_damage = 15; let mut player_gold = 500; let mut player_power = 0; let mut player_power_cap = 0; let mut player_research = 0; let mut player_minion_cap = 5; let mut player_minion_dmg_bonus = 0; let mut player_minion_hp_bonus = 0; let mut player_trap_dmg_bonus = 0; let mut player_research_bonus = 0; let mut player_heat_red_bonus = 0; let mut player_power_bonus = 0; let mut player_wave = 0; let mut player_score = 0; let mut player_kills = 0; let mut player_heat = 0; let mut player_notoriety = 0; let mut player_gold_income = 0; let mut player_core_mk2 = false; let mut unlocks = HashSet::new(); let mut loot: Vec = Vec::new(); let mut active_schemes: Vec = Vec::new(); let mut powers: Vec = Vec::new(); let mut invulnerability_turns = 0; let mut map_width = 80; let mut map_height = 40; let mut map_throne = (40, 20); let mut map_entries: Vec<(i32, i32)> = Vec::new(); let mut map_tiles: Vec = Vec::new(); let mut map_powered: HashSet<(i32, i32)> = HashSet::new(); let mut minions: Vec = Vec::new(); let mut henchmen: Vec = Vec::new(); let mut phase = "Running".to_string(); let mut sim_tick: u64 = 0; let mut next_raid_in: i32 = -1; let mut tile_rows: Vec = Vec::new(); for line in &lines[1..] { let parts: Vec<&str> = line.splitn(2, ' ').collect(); if parts.is_empty() { continue; } let key = parts[0]; let value = if parts.len() > 1 { parts[1] } else { "" }; match key { "PLAYER" => { let v: Vec = value .split_whitespace() .filter_map(|s| s.parse().ok()) .collect(); if v.len() >= 19 { player_x = v[0]; player_y = v[1]; player_hp = v[2]; player_max_hp = v[3]; player_damage = v[4]; player_gold = v[5]; player_power = v[6]; player_power_cap = v[7]; player_research = v[8]; player_minion_cap = v[9]; player_minion_dmg_bonus = v[10]; player_minion_hp_bonus = v[11]; player_trap_dmg_bonus = v[12]; player_research_bonus = v[13]; player_heat_red_bonus = v[14]; player_power_bonus = v[15]; player_wave = v[16]; player_score = v[17]; player_kills = v[18]; } } "PLAYER_EXTRA" => { let v: Vec = value .split_whitespace() .filter_map(|s| s.parse().ok()) .collect(); if v.len() >= 4 { player_heat = v[0]; player_notoriety = v[1]; player_gold_income = v[2]; player_core_mk2 = v[3] != 0; } } "UNLOCKS" if !value.is_empty() => { for u in value.split(',') { if !u.is_empty() { unlocks.insert(u.to_string()); } } } "LOOT" if !value.is_empty() => { for l in value.split(',') { if !l.is_empty() { loot.push(l.to_string()); } } } "SCHEME" => { let v: Vec<&str> = value.split_whitespace().collect(); if v.len() >= 6 { active_schemes.push(crate::entities::player::ActiveScheme { name: v[0].to_string(), turns_remaining: v[1].parse().unwrap_or(0), gold_reward: v[2].parse().unwrap_or(0), heat_cost: v[3].parse().unwrap_or(0), notoriety_reward: v[4].parse().unwrap_or(0), minion_cost: v[5].parse().unwrap_or(0), loot: None, }); } } "POWER" => { let v: Vec<&str> = value.split_whitespace().collect(); if v.len() >= 3 { // Find the matching power from all_superpowers let all_powers = crate::superpowers::all_superpowers(); if let Some(mut power) = all_powers.into_iter().find(|p| p.unlock_key == v[0]) { power.current_cooldown = v[1].parse().unwrap_or(0); power.cooldown = v[2].parse().unwrap_or(power.cooldown); powers.push(power); } } } "INVULN" => { invulnerability_turns = value.parse().unwrap_or(0); } "MAP" => { let v: Vec = value .split_whitespace() .filter_map(|s| s.parse().ok()) .collect(); if v.len() >= 4 { map_width = v[0]; map_height = v[1]; map_throne = (v[2], v[3]); } } "ENTRIES" if !value.is_empty() => { for e in value.split(',') { let parts: Vec = e.split(':').filter_map(|s| s.parse().ok()).collect(); if parts.len() == 2 { map_entries.push((parts[0], parts[1])); } } } "TILES" => { tile_rows.push(value.to_string()); } "POWERED" if !value.is_empty() => { for p in value.split(',') { let parts: Vec = p.split(':').filter_map(|s| s.parse().ok()).collect(); if parts.len() == 2 { map_powered.insert((parts[0], parts[1])); } } } "MINION" => { let v: Vec<&str> = value.split_whitespace().collect(); if v.len() >= 7 { let class = match v[6] { "Worker" => crate::entities::MinionClass::Worker, "Guard" => crate::entities::MinionClass::Guard, "Technician" => crate::entities::MinionClass::Technician, "Scientist" => crate::entities::MinionClass::Scientist, _ => crate::entities::MinionClass::Worker, }; let mut m = Minion::new( v[0].parse().unwrap_or(0), v[1].parse().unwrap_or(0), class, player_minion_dmg_bonus, player_minion_hp_bonus, ); m.entity.hp = v[2].parse().unwrap_or(m.entity.hp); m.entity.max_hp = v[3].parse().unwrap_or(m.entity.max_hp); m.entity.damage = v[4].parse().unwrap_or(m.entity.damage); m.entity.alive = v[5].parse().unwrap_or(1) != 0; minions.push(m); } } "HENCHMAN" => { let v: Vec<&str> = value.split_whitespace().collect(); if v.len() >= 6 { let x: i32 = v[0].parse().unwrap_or(0); let y: i32 = v[1].parse().unwrap_or(0); let hp: i32 = v[2].parse().unwrap_or(100); let max_hp: i32 = v[3].parse().unwrap_or(100); let alive: bool = v[4].parse().unwrap_or(1) != 0; let name = v[5]; // Reconstruct henchman from starting roster, then override position/hp let roster = Henchman::starting_roster(x, y); if let Some(mut h) = roster.into_iter().find(|h| h.entity.name == name) { h.entity.x = x; h.entity.y = y; h.entity.hp = hp; h.entity.max_hp = max_hp; h.entity.alive = alive; henchmen.push(h); } } } "PHASE" => { phase = value.to_string(); } "SIM" => { let v: Vec<&str> = value.split_whitespace().collect(); if v.len() >= 2 { sim_tick = v[0].parse().unwrap_or(0); next_raid_in = v[1].parse().unwrap_or(-1); } } _ => {} } } // Reconstruct map tiles from char rows if !tile_rows.is_empty() { map_tiles = Vec::new(); for row in &tile_rows { for ch in row.chars() { map_tiles.push(char_to_tile(ch)); } } } // Build player let mut player = Player::new(player_x, player_y); player.entity.hp = player_hp; player.entity.max_hp = player_max_hp; player.entity.damage = player_damage; player.gold = player_gold; player.power = player_power; player.power_cap = player_power_cap; player.research = player_research; player.minion_cap = player_minion_cap; player.minion_damage_bonus = player_minion_dmg_bonus; player.minion_hp_bonus = player_minion_hp_bonus; player.trap_damage_bonus = player_trap_dmg_bonus; player.research_bonus = player_research_bonus; player.heat_reduction_bonus = player_heat_red_bonus; player.power_bonus = player_power_bonus; player.wave = player_wave; player.score = player_score; player.kills = player_kills; player.heat = player_heat; player.notoriety = player_notoriety; player.gold_income = player_gold_income; player.power_core_mk2 = player_core_mk2; player.unlocked = unlocks; player.loot = loot; player.active_schemes = active_schemes; player.powers = powers; player.invulnerability_turns = invulnerability_turns; Ok(SaveState { player, minions, henchmen, map_width, map_height, map_tiles, map_powered, map_throne, map_entries, wave: player_wave, phase, sim_tick, next_raid_in, }) } /// Delete the save file. pub fn delete_save() -> Result<(), String> { if save_path().exists() { fs::remove_file(save_path()).map_err(|e| format!("Failed to delete save: {}", e))?; } Ok(()) } /// Convert a tile type to a single character for save format. fn tile_to_char(tile: TileType) -> char { match tile { TileType::Floor => '.', TileType::Wall => '#', TileType::Rock => ' ', TileType::Throne => 'T', TileType::Entry => 'E', TileType::PowerCore => 'P', TileType::Lab => 'L', TileType::Vault => 'V', TileType::Barracks => 'B', TileType::GuardPost => 'G', TileType::Medbay => 'M', TileType::TrainingRoom => 'R', TileType::Hotel => 'H', TileType::Freezer => 'F', TileType::FakeEntry => '?', TileType::Door => 'd', TileType::SecurityDoor1 => '1', TileType::SecurityDoor2 => '2', TileType::SecurityDoor3 => '3', TileType::TrapSpike => 's', TileType::TrapFire => 'f', TileType::TrapElec => 'e', TileType::TrapGas => 'g', TileType::TrapLaser => 'l', TileType::TrapWind => 'w', TileType::Armory => 'A', TileType::ControlRoom => 'C', TileType::LootRoom => 'O', } } /// Convert a character back to a tile type. fn char_to_tile(ch: char) -> TileType { match ch { '.' => TileType::Floor, '#' => TileType::Wall, ' ' => TileType::Rock, 'T' => TileType::Throne, 'E' => TileType::Entry, 'P' => TileType::PowerCore, 'L' => TileType::Lab, 'V' => TileType::Vault, 'B' => TileType::Barracks, 'G' => TileType::GuardPost, 'M' => TileType::Medbay, 'R' => TileType::TrainingRoom, 'H' => TileType::Hotel, 'F' => TileType::Freezer, '?' => TileType::FakeEntry, 'd' => TileType::Door, '1' => TileType::SecurityDoor1, '2' => TileType::SecurityDoor2, '3' => TileType::SecurityDoor3, 's' => TileType::TrapSpike, 'f' => TileType::TrapFire, 'e' => TileType::TrapElec, 'g' => TileType::TrapGas, 'l' => TileType::TrapLaser, 'w' => TileType::TrapWind, 'A' => TileType::Armory, 'C' => TileType::ControlRoom, 'O' => TileType::LootRoom, _ => TileType::Rock, } } #[cfg(test)] mod tests { use super::*; #[test] fn tile_char_roundtrip() { let tiles = [ TileType::Floor, TileType::Wall, TileType::Rock, TileType::Throne, TileType::Entry, TileType::PowerCore, TileType::Lab, TileType::Vault, TileType::Barracks, TileType::GuardPost, TileType::Medbay, TileType::TrainingRoom, TileType::Hotel, TileType::Freezer, TileType::FakeEntry, TileType::Door, TileType::SecurityDoor1, TileType::SecurityDoor2, TileType::SecurityDoor3, TileType::TrapSpike, TileType::TrapFire, TileType::TrapElec, TileType::TrapGas, TileType::TrapLaser, TileType::TrapWind, TileType::Armory, TileType::ControlRoom, TileType::LootRoom, ]; for tile in &tiles { let ch = tile_to_char(*tile); let back = char_to_tile(ch); assert_eq!(*tile, back, "Roundtrip failed for {:?}", tile); } } #[test] fn save_dir_is_valid() { let dir = save_dir(); assert!(!dir.as_os_str().is_empty()); } #[test] fn save_state_creation() { let player = Player::new(40, 20); let state = SaveState { player, minions: Vec::new(), henchmen: Vec::new(), map_width: 80, map_height: 40, map_tiles: vec![TileType::Rock; 80 * 40], map_powered: HashSet::new(), map_throne: (40, 20), map_entries: vec![(0, 0)], wave: 1, phase: "Running".to_string(), sim_tick: 0, next_raid_in: -1, }; assert_eq!(state.map_tiles.len(), 80 * 40); } }