Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development.
Something went wrong. Try again.
21 kB · 632 lines
Rust
at commit 4e7bfe58
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633//! 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 nameconst 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<Minion>, pub henchmen: Vec<Henchman>, pub map_width: i32, pub map_height: i32, pub map_tiles: Vec<TileType>, 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<String> = 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<String> = 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<String> = (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<String> = 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<SaveState, String> { 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<String> = Vec::new(); let mut active_schemes: Vec<crate::entities::player::ActiveScheme> = Vec::new(); let mut powers: Vec<crate::superpowers::Superpower> = 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<TileType> = Vec::new(); let mut map_powered: HashSet<(i32, i32)> = HashSet::new(); let mut minions: Vec<Minion> = Vec::new(); let mut henchmen: Vec<Henchman> = 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<String> = 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<i32> = 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<i32> = 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<i32> = 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<i32> = 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<i32> = 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); }}