diff --git a/Cargo.lock b/Cargo.lock index f512f0f..af043db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2216,6 +2216,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2567,6 +2588,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -3781,6 +3813,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.54" @@ -4165,6 +4203,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -4581,6 +4630,7 @@ version = "0.2.0" dependencies = [ "bevy", "crossterm", + "dirs", "rand", ] diff --git a/Cargo.toml b/Cargo.toml index 5450340..c4f6dd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ bevy_ui = ["dep:bevy"] [dependencies] crossterm = { version = "0.29", optional = true } rand = "0.9" +dirs = "6" bevy = { version = "0.18", optional = true } [lib] diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 8642e19..df4e8ed 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -658,6 +658,29 @@ fn handle_input( if keyboard.just_pressed(KeyCode::Space) && game_state.phase == Phase::Build { start_wave(&mut game_state); } + + // Save/Load (Ctrl+S, Ctrl+L) + if keyboard.just_pressed(KeyCode::KeyS) && keyboard.pressed(KeyCode::ControlLeft) { + let state = create_save_state(&game_state); + match supervillain::save::save_game(&state) { + Ok(()) => game_state.log.push("Game saved!".to_string()), + Err(e) => game_state.log.push(format!("Save failed: {}", e)), + } + } + if keyboard.just_pressed(KeyCode::KeyL) && keyboard.pressed(KeyCode::ControlLeft) { + if supervillain::save::save_exists() { + match supervillain::save::load_game() { + Ok(state) => { + apply_save_state(&mut game_state, state); + game_state.log.push("Game loaded!".to_string()); + } + Err(e) => game_state.log.push(format!("Load failed: {}", e)), + } + } else { + game_state.log.push("No save file found.".to_string()); + } + } + if keyboard.just_pressed(KeyCode::KeyB) && game_state.phase == Phase::Build { game_state.build_mode = Some(build::BuildMode::new(&game_state.player)); } @@ -1310,3 +1333,55 @@ fn render_ui( } } } + +// ─── Save/Load Helpers ────────────────────────────────────────────────────── + +fn create_save_state(game_state: &GameState) -> supervillain::save::SaveState { + let gs = &*game_state; + let mut map_tiles = Vec::new(); + for y in 0..gs.game_map.height { + for x in 0..gs.game_map.width { + map_tiles.push(gs.game_map.get_tile(x, y)); + } + } + + supervillain::save::SaveState { + player: gs.player.clone(), + minions: gs.minions.clone(), + henchmen: gs.henchmen.clone(), + map_width: gs.game_map.width, + map_height: gs.game_map.height, + map_tiles, + map_powered: gs.game_map.powered.clone(), + map_throne: gs.game_map.throne_pos().unwrap_or((40, 20)), + map_entries: gs.game_map.entry_positions(), + wave: gs.player.wave, + phase: format!("{:?}", gs.phase), + } +} + +fn apply_save_state(game_state: &mut ResMut, state: supervillain::save::SaveState) { + let gs = &mut **game_state; + gs.player = state.player; + gs.minions = state.minions; + gs.henchmen = state.henchmen; + gs.phase = match state.phase.as_str() { + "Build" => Phase::Build, + "Defend" => Phase::Defend, + "WorldMap" => Phase::WorldMap, + "GameOver" => Phase::GameOver, + _ => Phase::Build, + }; + gs.game_map = map::GameMap::from_tiles( + state.map_width, + state.map_height, + state.map_tiles, + state.map_powered, + state.map_throne, + state.map_entries, + ); + gs.agents = Vec::new(); + gs.build_mode = None; + gs.research_menu = false; + gs.hire_menu = false; +} diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index bbf15c3..208b775 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -53,6 +53,9 @@ pub enum Command { ActivatePower7, ActivatePower8, ActivatePower9, + // Save/Load + SaveGame, + LoadGame, } pub fn handle_key( @@ -138,6 +141,15 @@ pub fn handle_key( } // Normal (build) mode keys + // Check for Ctrl modifiers first + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('s') => Some(Command::SaveGame), + KeyCode::Char('l') => Some(Command::LoadGame), + _ => None, + }; + } + match key.code { KeyCode::Up | KeyCode::Char('k') => Some(Command::MoveUp), KeyCode::Down | KeyCode::Char('j') => Some(Command::MoveDown), diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index e12f609..d5fe8f0 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -220,6 +220,26 @@ impl Game { fn handle_command(&mut self, cmd: Command) -> bool { match cmd { Command::Quit => return true, + Command::SaveGame => { + let state = self.create_save_state(); + match supervillain::save::save_game(&state) { + Ok(()) => self.ui.add_log("Game saved!"), + Err(e) => self.ui.add_log(&format!("Save failed: {}", e)), + } + } + Command::LoadGame => { + if supervillain::save::save_exists() { + match supervillain::save::load_game() { + Ok(state) => { + self.apply_save_state(state); + self.ui.add_log("Game loaded!"); + } + Err(e) => self.ui.add_log(&format!("Load failed: {}", e)), + } + } else { + self.ui.add_log("No save file found."); + } + } // Research menu Command::ResearchUp => { @@ -992,6 +1012,58 @@ impl Game { self.player.kills += 1; } } + + fn create_save_state(&self) -> supervillain::save::SaveState { + let mut map_tiles = Vec::new(); + for y in 0..self.game_map.height { + for x in 0..self.game_map.width { + map_tiles.push(self.game_map.get_tile(x, y)); + } + } + + supervillain::save::SaveState { + player: self.player.clone(), + minions: self.minions.clone(), + henchmen: self.henchmen.clone(), + map_width: self.game_map.width, + map_height: self.game_map.height, + map_tiles, + map_powered: self.game_map.powered.clone(), + map_throne: self.game_map.throne_pos().unwrap_or((40, 20)), + map_entries: self.game_map.entry_positions(), + wave: self.player.wave, + phase: format!("{:?}", self.phase), + } + } + + fn apply_save_state(&mut self, state: supervillain::save::SaveState) { + self.player = state.player; + self.minions = state.minions; + self.henchmen = state.henchmen; + self.phase = match state.phase.as_str() { + "Build" => Phase::Build, + "Defend" => Phase::Defend, + "WorldMap" => Phase::WorldMap, + "GameOver" => Phase::GameOver, + _ => Phase::Build, + }; + + // Reconstruct game map from saved tiles + self.game_map = map::GameMap::from_tiles( + state.map_width, + state.map_height, + state.map_tiles, + state.map_powered, + state.map_throne, + state.map_entries, + ); + + self.agents = Vec::new(); + self.build_mode = None; + self.research_menu = false; + self.hire_menu = false; + self.update_resources(); + } } fn main() -> io::Result<()> { diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 7e5213d..78ee8aa 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -324,6 +324,7 @@ impl UI { write(stdout, &mut row, "f: hire minions", Color::DarkGrey, false)?; write(stdout, &mut row, "m: world map", Color::DarkGrey, false)?; write(stdout, &mut row, "SPACE: next wave", Color::DarkGrey, false)?; + write(stdout, &mut row, "Ctrl+S: save Ctrl+L: load", Color::DarkGrey, false)?; write(stdout, &mut row, "q: quit", Color::DarkGrey, false)?; } diff --git a/src/combat.rs b/src/combat.rs index e2340bd..0887c84 100644 --- a/src/combat.rs +++ b/src/combat.rs @@ -160,7 +160,7 @@ impl CombatSystem { // Push agent back let dx = agents[i].entity.x - throne.0; let dy = agents[i].entity.y - throne.1; - let push = if dx != 0 || dy != 0 { 3 } else { 3 }; + let push = 3; let new_x = (agents[i].entity.x + dx.signum() * push).clamp(0, game_map.width - 1); let new_y = (agents[i].entity.y + dy.signum() * push).clamp(0, game_map.height - 1); if game_map.is_walkable(new_x, new_y) { @@ -527,7 +527,7 @@ impl CombatSystem { agent.entity.alive = false; self.kill_events.push(KillEvent { gold_value: 25, - agent_type: agent.agent_type.clone(), + agent_type: agent.agent_type, }); } } @@ -615,7 +615,7 @@ impl CombatSystem { agent.entity.alive = false; self.kill_events.push(KillEvent { gold_value: 25, - agent_type: agent.agent_type.clone(), + agent_type: agent.agent_type, }); } } @@ -664,7 +664,7 @@ impl CombatSystem { agent.entity.alive = false; self.kill_events.push(KillEvent { gold_value: 25, - agent_type: agent.agent_type.clone(), + agent_type: agent.agent_type, }); } } diff --git a/src/events.rs b/src/events.rs index 72a88f8..7fd5321 100644 --- a/src/events.rs +++ b/src/events.rs @@ -51,7 +51,7 @@ pub fn check_for_event(wave: i32, turn: i32) -> Option { let base_prob = 0.05 + (wave - 3) as f32 * 0.02; let prob = base_prob.min(0.25); // Cap at 25% - if rng.r#gen::() > prob { return None; } + if rng.random::() > prob { return None; } // Choose event type based on wave let events = available_events(wave); diff --git a/src/lib.rs b/src/lib.rs index 2dcf654..a29d38a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,4 @@ pub mod combat; pub mod build; pub mod superpowers; pub mod events; +pub mod save; diff --git a/src/map.rs b/src/map.rs index d72c591..7baef57 100644 --- a/src/map.rs +++ b/src/map.rs @@ -21,6 +21,27 @@ impl GameMap { map } + /// Reconstruct a GameMap from saved tile data. + pub fn from_tiles( + width: i32, + height: i32, + flat_tiles: Vec, + powered: HashSet<(i32, i32)>, + _throne: (i32, i32), + _entries: Vec<(i32, i32)>, + ) -> Self { + let mut tiles = vec![vec![TileType::Rock; width as usize]; height as usize]; + for y in 0..height { + for x in 0..width { + let idx = (y * width + x) as usize; + if idx < flat_tiles.len() { + tiles[y as usize][x as usize] = flat_tiles[idx]; + } + } + } + Self { width, height, tiles, powered } + } + fn generate_initial_lair(&mut self) { let cx = self.width / 2; let cy = self.height / 2; diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..ae82c14 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,576 @@ +//! 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, +} + +/// 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)); + + 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 = "Build".to_string(); + 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(); + } + _ => {} + } + } + + // 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, + }) +} + +/// 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: "Build".to_string(), + }; + assert_eq!(state.map_tiles.len(), 80 * 40); + } +}