use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::info;
use super::types::GameState;
use crate::scene::types::StateEffect;
pub const SAVE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveEnvelope {
pub version: u32,
pub label: String,
pub timestamp: u64,
pub state: GameState,
}
impl SaveEnvelope {
pub fn new(state: GameState, label: impl Into<String>) -> Self {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|_| {
eprintln!("warning: system clock unavailable, save timestamp set to 0");
std::time::Duration::ZERO
})
.as_secs();
Self {
version: SAVE_VERSION,
label: label.into(),
timestamp,
state,
}
}
}
#[derive(Debug)]
pub struct StateStore {
state: GameState,
save_dir: PathBuf,
}
impl StateStore {
pub fn new_game(save_dir: impl Into<PathBuf>) -> Self {
let state = GameState::new_game();
info!(chapter = %state.chapter, beat = %state.beat, "new game started");
Self {
state,
save_dir: save_dir.into(),
}
}
pub fn from_state(state: GameState, save_dir: impl Into<PathBuf>) -> Self {
Self {
state,
save_dir: save_dir.into(),
}
}
pub fn load(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read save file: {}", path.display()))?;
let envelope: SaveEnvelope = ron::from_str(&contents)
.with_context(|| "save file is corrupt or uses an unknown format")?;
if envelope.version != SAVE_VERSION {
anyhow::bail!(
"save version mismatch: file is v{}, game expects v{}. \
Save migration is not yet supported.",
envelope.version,
SAVE_VERSION,
);
}
let save_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
info!(
version = envelope.version,
label = %envelope.label,
chapter = %envelope.state.chapter,
beat = %envelope.state.beat,
"save loaded"
);
Ok(Self {
state: envelope.state,
save_dir,
})
}
pub fn save(&self, slot_name: &str) -> Result<PathBuf> {
std::fs::create_dir_all(&self.save_dir)
.with_context(|| format!("failed to create save directory: {}", self.save_dir.display()))?;
let label = format!(
"{} — {} ({})",
self.state.chapter, self.state.beat, self.state.age_phase_label()
);
let envelope = SaveEnvelope::new(self.state.clone(), label);
let path = self.save_dir.join(format!("{}.ron", slot_name));
let tmp_path = self.save_dir.join(format!("{}.ron.tmp", slot_name));
let bak_path = self.save_dir.join(format!("{}.ron.bak", slot_name));
let serialized = ron::ser::to_string_pretty(&envelope, ron::ser::PrettyConfig::default())
.context("failed to serialize game state")?;
std::fs::write(&tmp_path, &serialized)
.with_context(|| format!("failed to write temp save file: {}", tmp_path.display()))?;
if path.exists() {
let _ = std::fs::rename(&path, &bak_path);
}
std::fs::rename(&tmp_path, &path)
.with_context(|| format!("failed to rename temp save to final: {}", path.display()))?;
info!(slot = slot_name, path = %path.display(), "game saved");
Ok(path)
}
pub fn delete_save(slot: &str, save_dir: &Path) -> Result<(), String> {
let path = save_dir.join(format!("{}.ron", slot));
if !path.exists() {
return Err(format!("Save slot '{}' is already empty.", slot));
}
if let (Ok(canonical_dir), Ok(canonical_path)) = (
std::fs::canonicalize(save_dir),
std::fs::canonicalize(&path),
) {
if !canonical_path.starts_with(&canonical_dir) {
return Err("Invalid save path.".to_string());
}
}
std::fs::remove_file(&path)
.map_err(|e| format!("Failed to delete save: {}", e))?;
info!(slot = slot, "save deleted");
Ok(())
}
pub fn save_dir(&self) -> &Path {
&self.save_dir
}
pub fn state(&self) -> &GameState {
&self.state
}
pub fn state_mut(&mut self) -> &mut GameState {
&mut self.state
}
pub fn apply_effect(&mut self, effect: &StateEffect) {
tracing::debug!(?effect, "applying state effect");
self.state.apply_effect(effect);
}
pub fn apply_effects(&mut self, effects: &[StateEffect]) {
for effect in effects {
self.apply_effect(effect);
}
}
pub fn check(&self, condition: &crate::scene::types::Condition) -> bool {
self.state.check_condition(condition)
}
pub fn check_all(&self, conditions: &[crate::scene::types::Condition]) -> bool {
self.state.check_all(conditions)
}
}
pub fn auto_save(state: &GameState, save_dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(save_dir)
.with_context(|| format!("failed to create save directory: {}", save_dir.display()))?;
let label = format!(
"Auto — {} — {} ({})",
state.chapter, state.beat, state.age_phase_label()
);
let envelope = SaveEnvelope::new(state.clone(), label);
let path = save_dir.join("autosave.ron");
let serialized = ron::ser::to_string_pretty(&envelope, ron::ser::PrettyConfig::default())
.context("failed to serialize game state for autosave")?;
std::fs::write(&path, &serialized)
.with_context(|| format!("failed to write autosave: {}", path.display()))?;
info!(path = %path.display(), "autosave written");
Ok(path)
}
impl GameState {
pub fn age_phase_label(&self) -> &'static str {
match self.age_phase {
crate::types::AgePhase::Youth => "Age 19",
crate::types::AgePhase::YoungMan => "Age 24",
crate::types::AgePhase::Adult => "Age 34",
crate::types::AgePhase::Older => "Age 50+",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
use crate::scene::types::*;
use crate::state::types::*;
use tempfile::TempDir;
fn find_member<'a>(state: &'a GameState, id: &str) -> Option<&'a PartyMemberState> {
state.party.members.iter().find(|m| m.id.0 == id)
}
#[test]
fn round_trip_new_game() {
let dir = TempDir::new().unwrap();
let store = StateStore::new_game(dir.path());
let path = store.save("test_slot").unwrap();
assert!(path.exists());
let loaded = StateStore::load(&path).unwrap();
assert_eq!(loaded.state().chapter.0, "prologue");
assert_eq!(loaded.state().beat.0, "p1");
assert_eq!(loaded.state().age_phase, AgePhase::Adult);
assert_eq!(loaded.state().party.members.len(), 2);
assert!(loaded.state().party.has_member(&CharacterId::new("galen")));
assert!(loaded.state().party.has_member(&CharacterId::new("eli")));
assert_eq!(loaded.state().prologue_choice, None);
assert_eq!(loaded.state().relay_branch, None);
}
#[test]
fn version_mismatch_fails_cleanly() {
let dir = TempDir::new().unwrap();
let store = StateStore::new_game(dir.path());
let path = store.save("test_slot").unwrap();
let mut contents = std::fs::read_to_string(&path).unwrap();
contents = contents.replace(
&format!("version: {}", SAVE_VERSION),
"version: 999",
);
std::fs::write(&path, &contents).unwrap();
let result = StateStore::load(&path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("version mismatch"), "error was: {}", err);
}
#[test]
fn delete_save_removes_file() {
let dir = TempDir::new().unwrap();
let store = StateStore::new_game(dir.path());
let path = store.save("to_delete").unwrap();
assert!(path.exists());
StateStore::delete_save("to_delete", dir.path()).unwrap();
assert!(!path.exists());
}
#[test]
fn delete_empty_slot_fails() {
let dir = TempDir::new().unwrap();
let result = StateStore::delete_save("nonexistent", dir.path());
assert!(result.is_err());
}
#[test]
fn auto_save_creates_file() {
let dir = TempDir::new().unwrap();
let state = GameState::new_game();
let path = super::auto_save(&state, dir.path()).unwrap();
assert!(path.exists());
assert!(path.file_name().unwrap().to_str().unwrap().contains("autosave"));
let loaded = StateStore::load(&path).unwrap();
assert_eq!(loaded.state().chapter.0, "prologue");
}
#[test]
fn save_creates_backup() {
let dir = TempDir::new().unwrap();
let store = StateStore::new_game(dir.path());
let path = store.save("backup_test").unwrap();
assert!(path.exists());
let bak_path = dir.path().join("backup_test.ron.bak");
assert!(!bak_path.exists(), "no backup on first save");
let path2 = store.save("backup_test").unwrap();
assert!(path2.exists());
assert!(bak_path.exists(), "backup created on second save");
let loaded = StateStore::load(&path2).unwrap();
assert_eq!(loaded.state().chapter.0, "prologue");
let loaded_bak = StateStore::load(&bak_path).unwrap();
assert_eq!(loaded_bak.state().chapter.0, "prologue");
}
#[test]
fn save_no_leftover_tmp() {
let dir = TempDir::new().unwrap();
let store = StateStore::new_game(dir.path());
store.save("tmp_test").unwrap();
let tmp_path = dir.path().join("tmp_test.ron.tmp");
assert!(!tmp_path.exists(), "temp file should be renamed away");
}
#[test]
fn eli_loyalty_line_grayed_out() {
let dir = TempDir::new().unwrap();
let store = StateStore::new_game(dir.path());
let eli = find_member(store.state(), "eli").unwrap();
assert!(eli.unlocked_skills.is_empty());
let _loyalty = crate::combat::types::SkillLine::Loyalty;
}
#[test]
fn dead_drop_persists() {
let dir = TempDir::new().unwrap();
let mut store = StateStore::new_game(dir.path());
let effect = StateEffect::UnlockSkill {
character: CharacterId::new("galen"),
skill: SkillId::new("dead_drop"),
};
store.apply_effect(&effect);
assert!(store.state().party.has_skill(
&CharacterId::new("galen"),
&SkillId::new("dead_drop"),
));
let path = store.save("dead_drop_test").unwrap();
let loaded = StateStore::load(&path).unwrap();
assert!(loaded.state().party.has_skill(
&CharacterId::new("galen"),
&SkillId::new("dead_drop"),
));
}
#[test]
fn evidence_integrity_survives() {
let dir = TempDir::new().unwrap();
let mut store = StateStore::new_game(dir.path());
store.state_mut().evidence.push(EvidenceItem {
id: EvidenceId::new("relay_manifest"),
evidence_type: EvidenceType::Documentary,
source_chapter: ChapterId::new("ch2"),
integrity: 45, verified_against: vec![EvidenceId::new("archive_original")],
});
let path = store.save("evidence_test").unwrap();
let loaded = StateStore::load(&path).unwrap();
let evidence = loaded.state().evidence.iter()
.find(|e| e.id.0 == "relay_manifest").unwrap();
assert_eq!(evidence.integrity, 45);
assert_eq!(evidence.verified_against.len(), 1);
assert_eq!(evidence.verified_against[0].0, "archive_original");
}
#[test]
fn witness_state_branching() {
let dir = TempDir::new().unwrap();
let mut store = StateStore::new_game(dir.path());
store.apply_effect(&StateEffect::SetWitnessState {
id: WitnessId::new("tom_reed"),
alive: true,
integrity: 80,
});
store.apply_effect(&StateEffect::SetWitnessState {
id: WitnessId::new("nella_creed"),
alive: false,
integrity: 0,
});
let path = store.save("witness_test").unwrap();
let loaded = StateStore::load(&path).unwrap();
let tom = loaded.state().witness_states.get("tom_reed").unwrap();
assert!(tom.alive);
assert_eq!(tom.integrity, 80);
let nella = loaded.state().witness_states.get("nella_creed").unwrap();
assert!(!nella.alive);
assert_eq!(nella.integrity, 0);
}
#[test]
fn hand_injury_persists() {
let dir = TempDir::new().unwrap();
let mut store = StateStore::new_game(dir.path());
if let Some(galen) = store.state_mut().party.members.iter_mut()
.find(|m| m.id.0 == "galen") {
galen.hand_state = HandState::Damaged;
}
let path = store.save("hand_test").unwrap();
let loaded = StateStore::load(&path).unwrap();
let galen = find_member(loaded.state(), "galen").unwrap();
assert_eq!(galen.hand_state, HandState::Damaged);
}
#[test]
fn memory_objects_persist() {
let dir = TempDir::new().unwrap();
let mut store = StateStore::new_game(dir.path());
store.apply_effect(&StateEffect::AddMemoryObject(
MemoryObjectId::new("wanted_poster"),
));
store.apply_effect(&StateEffect::AddMemoryObject(
MemoryObjectId::new("biscuit_cloth"),
));
store.apply_effect(&StateEffect::TransformMemoryObject {
id: MemoryObjectId::new("biscuit_cloth"),
new_state: "bloodstained".to_string(),
});
let path = store.save("memory_test").unwrap();
let loaded = StateStore::load(&path).unwrap();
assert_eq!(loaded.state().memory_objects.len(), 2);
let poster = loaded.state().memory_objects.iter()
.find(|o| o.id.0 == "wanted_poster").unwrap();
assert_eq!(poster.state, "active");
let cloth = loaded.state().memory_objects.iter()
.find(|o| o.id.0 == "biscuit_cloth").unwrap();
assert_eq!(cloth.state, "bloodstained");
}
#[test]
fn condition_checking() {
let mut state = GameState::new_game();
state.prologue_choice = Some(PrologueChoice::HomesteadFirst);
assert!(state.check_condition(&Condition::PrologueChoice(PrologueChoice::HomesteadFirst)));
assert!(!state.check_condition(&Condition::PrologueChoice(PrologueChoice::TownDirect)));
state.apply_effect(&StateEffect::SetFlag {
id: FlagId::new("bitter_cut_pulled_punches"),
value: FlagValue::Bool(true),
});
assert!(state.check_condition(&Condition::Flag {
id: FlagId::new("bitter_cut_pulled_punches"),
value: FlagValue::Bool(true),
}));
state.apply_effect(&StateEffect::AdjustReputation {
axis: ReputationAxis::Rancher,
delta: 15,
});
assert!(state.check_condition(&Condition::Reputation {
axis: ReputationAxis::Rancher,
op: CompareOp::Gte,
threshold: 10,
}));
assert!(!state.check_condition(&Condition::Reputation {
axis: ReputationAxis::Rancher,
op: CompareOp::Gte,
threshold: 20,
}));
}
#[test]
fn relay_branch_first_class() {
let dir = TempDir::new().unwrap();
let mut store = StateStore::new_game(dir.path());
store.state_mut().relay_branch = Some(RelayBranch::Nella);
store.state_mut().nella_alive = Some(true);
store.state_mut().tom_alive = Some(false);
assert!(store.check(&Condition::RelayBranch(RelayBranch::Nella)));
assert!(!store.check(&Condition::RelayBranch(RelayBranch::Tom)));
let path = store.save("relay_test").unwrap();
let loaded = StateStore::load(&path).unwrap();
assert_eq!(loaded.state().relay_branch, Some(RelayBranch::Nella));
assert_eq!(loaded.state().nella_alive, Some(true));
assert_eq!(loaded.state().tom_alive, Some(false));
}
#[test]
fn complex_effect_sequence() {
let mut state = GameState::new_game();
let effects = vec![
StateEffect::SetFlag {
id: FlagId::new("beat5_choice"),
value: FlagValue::Text("homestead_first".to_string()),
},
StateEffect::AdjustReputation { axis: ReputationAxis::Rancher, delta: 10 },
StateEffect::AdjustReputation { axis: ReputationAxis::TownLaw, delta: -5 },
StateEffect::AddMemoryObject(MemoryObjectId::new("wanted_poster")),
StateEffect::AdjustResource { resource: ResourceKind::Water, delta: -30 },
StateEffect::AdjustResource { resource: ResourceKind::HorseStamina, delta: -40 },
StateEffect::SetRelationship {
a: CharacterId::new("galen"),
b: CharacterId::new("eli"),
value: 15,
},
];
state.apply_all(&effects);
assert_eq!(state.reputation.get(ReputationAxis::Rancher), 10);
assert_eq!(state.reputation.get(ReputationAxis::TownLaw), -5);
assert_eq!(state.memory_objects.len(), 1);
assert_eq!(state.resources.water, 70);
assert_eq!(state.resources.horse_stamina, 60);
assert_eq!(*state.party.relationships.get("galen:eli").unwrap(), 15);
}
}