Skip to main content

proof_engine/save/
format.rs

1//! Save file format, slot management, and the `SaveManager`.
2//!
3//! ## File layout
4//!
5//! ```text
6//! [magic: 4 bytes "SAVE"]
7//! [header length: u32 little-endian]
8//! [header JSON bytes]
9//! [snapshot length: u32 little-endian]
10//! [snapshot JSON bytes]
11//! [checksum: u32 little-endian] — CRC-32-style over header+snapshot bytes
12//! ```
13//!
14//! The format is intentionally simple and human-inspectable (the JSON sections
15//! are UTF-8 text). No compression or encryption is applied by default.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use crate::save::serializer::{DeserializeError, SerializedValue};
21use crate::save::snapshot::{SnapshotSerializer, WorldSnapshot};
22
23// ─────────────────────────────────────────────
24//  SaveError
25// ─────────────────────────────────────────────
26
27/// Errors that can occur during save file operations.
28#[derive(Debug, Clone)]
29pub enum SaveError {
30    /// A filesystem error.
31    Io(String),
32    /// The file is not a valid save file (bad magic, truncated, etc.).
33    Corrupt,
34    /// The save file was written by an incompatible engine version.
35    VersionMismatch { expected: u32, found: u32 },
36    /// The stored checksum does not match the computed checksum.
37    ChecksumMismatch,
38    /// No save file exists at the requested slot.
39    SlotEmpty(u8),
40    /// A serialization/deserialization problem.
41    Serialize(String),
42}
43
44impl std::fmt::Display for SaveError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            SaveError::Io(s) => write!(f, "I/O error: {s}"),
48            SaveError::Corrupt => write!(f, "save file is corrupt"),
49            SaveError::VersionMismatch { expected, found } => {
50                write!(f, "version mismatch: expected {expected}, found {found}")
51            }
52            SaveError::ChecksumMismatch => write!(f, "checksum mismatch — file may be corrupt"),
53            SaveError::SlotEmpty(n) => write!(f, "save slot {n} is empty"),
54            SaveError::Serialize(s) => write!(f, "serialization error: {s}"),
55        }
56    }
57}
58
59impl std::error::Error for SaveError {}
60
61impl From<DeserializeError> for SaveError {
62    fn from(e: DeserializeError) -> Self {
63        SaveError::Serialize(e.to_string())
64    }
65}
66
67// ─────────────────────────────────────────────
68//  Magic / version constants
69// ─────────────────────────────────────────────
70
71/// The four magic bytes at the start of every save file.
72pub const SAVE_MAGIC: [u8; 4] = *b"SAVE";
73
74/// The current save file format version.
75pub const CURRENT_FORMAT_VERSION: u32 = 1;
76
77// ─────────────────────────────────────────────
78//  Simple CRC-32 (no external crate)
79// ─────────────────────────────────────────────
80
81/// Compute a simple 32-bit checksum over `data`.
82///
83/// This is a polynomial-based CRC-32 (IEEE) computed without lookup tables so
84/// no crate dependency is required.
85pub fn compute_checksum(data: &[u8]) -> u32 {
86    let mut crc: u32 = 0xFFFF_FFFF;
87    for &byte in data {
88        crc ^= byte as u32;
89        for _ in 0..8 {
90            if crc & 1 != 0 {
91                crc = (crc >> 1) ^ 0xEDB8_8320;
92            } else {
93                crc >>= 1;
94            }
95        }
96    }
97    !crc
98}
99
100// ─────────────────────────────────────────────
101//  SaveHeader
102// ─────────────────────────────────────────────
103
104/// Metadata stored at the top of a save file.
105///
106/// The header is always present and can be read without loading the full snapshot,
107/// which is useful for displaying slot previews in a save/load menu.
108#[derive(Debug, Clone)]
109pub struct SaveHeader {
110    /// Magic bytes — always `SAVE_MAGIC`.
111    pub magic: [u8; 4],
112    /// Format version (not the game version).
113    pub version: u32,
114    /// Human-readable game version string, e.g. `"1.2.3"`.
115    pub game_version: String,
116    /// Unix timestamp (seconds) when this save was written.
117    pub timestamp: u64,
118    /// Player name at save time.
119    pub player_name: String,
120    /// Cumulative play time in seconds.
121    pub play_time_seconds: f64,
122    /// Arbitrary key-value metadata (level name, difficulty, etc.).
123    pub metadata: HashMap<String, String>,
124}
125
126impl SaveHeader {
127    pub fn new() -> Self {
128        Self {
129            magic: SAVE_MAGIC,
130            version: CURRENT_FORMAT_VERSION,
131            game_version: "0.1.0".to_string(),
132            timestamp: current_unix_ts(),
133            player_name: "Player".to_string(),
134            play_time_seconds: 0.0,
135            metadata: HashMap::new(),
136        }
137    }
138
139    pub fn with_player(mut self, name: impl Into<String>) -> Self {
140        self.player_name = name.into();
141        self
142    }
143
144    pub fn with_play_time(mut self, secs: f64) -> Self {
145        self.play_time_seconds = secs;
146        self
147    }
148
149    pub fn with_game_version(mut self, ver: impl Into<String>) -> Self {
150        self.game_version = ver.into();
151        self
152    }
153
154    pub fn set_meta(&mut self, key: impl Into<String>, value: impl Into<String>) {
155        self.metadata.insert(key.into(), value.into());
156    }
157
158    pub fn get_meta(&self, key: &str) -> Option<&str> {
159        self.metadata.get(key).map(String::as_str)
160    }
161
162    fn to_serialized(&self) -> SerializedValue {
163        let mut map = HashMap::new();
164        map.insert("magic".into(), SerializedValue::Str(
165            std::str::from_utf8(&self.magic).unwrap_or("SAVE").to_string()
166        ));
167        map.insert("version".into(), SerializedValue::Int(self.version as i64));
168        map.insert("game_version".into(), SerializedValue::Str(self.game_version.clone()));
169        map.insert("timestamp".into(), SerializedValue::Int(self.timestamp as i64));
170        map.insert("player_name".into(), SerializedValue::Str(self.player_name.clone()));
171        map.insert("play_time_seconds".into(), SerializedValue::Float(self.play_time_seconds));
172        let meta: HashMap<String, SerializedValue> = self.metadata.iter()
173            .map(|(k, v)| (k.clone(), SerializedValue::Str(v.clone())))
174            .collect();
175        map.insert("metadata".into(), SerializedValue::Map(meta));
176        SerializedValue::Map(map)
177    }
178
179    fn from_serialized(sv: &SerializedValue) -> Result<Self, SaveError> {
180        let version = sv.get("version")
181            .and_then(|v| v.as_int())
182            .unwrap_or(CURRENT_FORMAT_VERSION as i64) as u32;
183        let game_version = sv.get("game_version")
184            .and_then(|v| v.as_str())
185            .unwrap_or("unknown")
186            .to_string();
187        let timestamp = sv.get("timestamp")
188            .and_then(|v| v.as_int())
189            .unwrap_or(0) as u64;
190        let player_name = sv.get("player_name")
191            .and_then(|v| v.as_str())
192            .unwrap_or("Player")
193            .to_string();
194        let play_time_seconds = sv.get("play_time_seconds")
195            .and_then(|v| v.as_float())
196            .unwrap_or(0.0);
197        let metadata: HashMap<String, String> = sv.get("metadata")
198            .and_then(|v| v.as_map())
199            .map(|m| {
200                m.iter()
201                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
202                    .collect()
203            })
204            .unwrap_or_default();
205
206        Ok(SaveHeader {
207            magic: SAVE_MAGIC,
208            version,
209            game_version,
210            timestamp,
211            player_name,
212            play_time_seconds,
213            metadata,
214        })
215    }
216}
217
218impl Default for SaveHeader {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224// ─────────────────────────────────────────────
225//  SaveFile
226// ─────────────────────────────────────────────
227
228/// A complete in-memory representation of a save file.
229///
230/// Call `write_to_bytes()` / `write_to_file()` to persist it.
231pub struct SaveFile {
232    pub header: SaveHeader,
233    pub snapshot: WorldSnapshot,
234    /// CRC-32 checksum of header + snapshot bytes. Computed lazily on write.
235    pub checksum: u32,
236}
237
238impl SaveFile {
239    // ── Construction ───────────────────────────────────────────────────────
240
241    pub fn new(snapshot: WorldSnapshot) -> Self {
242        Self {
243            header: SaveHeader::new(),
244            snapshot,
245            checksum: 0,
246        }
247    }
248
249    pub fn with_header(mut self, header: SaveHeader) -> Self {
250        self.header = header;
251        self
252    }
253
254    // ── Serialization ──────────────────────────────────────────────────────
255
256    /// Serialize to a byte vector using the binary format described in the module doc.
257    pub fn write_to_bytes(&mut self) -> Vec<u8> {
258        let header_bytes = self.header.to_serialized().to_json_string().into_bytes();
259        let snapshot_bytes = SnapshotSerializer::to_bytes(&self.snapshot);
260
261        // Compute checksum over both sections
262        let mut checksum_input = Vec::with_capacity(header_bytes.len() + snapshot_bytes.len());
263        checksum_input.extend_from_slice(&header_bytes);
264        checksum_input.extend_from_slice(&snapshot_bytes);
265        self.checksum = compute_checksum(&checksum_input);
266
267        let mut out = Vec::with_capacity(
268            4 + 4 + header_bytes.len() + 4 + snapshot_bytes.len() + 4,
269        );
270
271        // Magic
272        out.extend_from_slice(&SAVE_MAGIC);
273        // Header length + header
274        out.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
275        out.extend_from_slice(&header_bytes);
276        // Snapshot length + snapshot
277        out.extend_from_slice(&(snapshot_bytes.len() as u32).to_le_bytes());
278        out.extend_from_slice(&snapshot_bytes);
279        // Checksum
280        out.extend_from_slice(&self.checksum.to_le_bytes());
281
282        out
283    }
284
285    /// Write the save file to disk at `path`.
286    pub fn write_to_file(&mut self, path: &str) -> Result<(), SaveError> {
287        let bytes = self.write_to_bytes();
288        // Create parent directories
289        if let Some(parent) = Path::new(path).parent() {
290            if !parent.as_os_str().is_empty() {
291                std::fs::create_dir_all(parent)
292                    .map_err(|e| SaveError::Io(format!("create_dir_all: {e}")))?;
293            }
294        }
295        std::fs::write(path, &bytes)
296            .map_err(|e| SaveError::Io(format!("write '{path}': {e}")))?;
297        Ok(())
298    }
299
300    // ── Deserialization ────────────────────────────────────────────────────
301
302    /// Parse a `SaveFile` from raw bytes.
303    pub fn read_from_bytes(bytes: &[u8]) -> Result<SaveFile, SaveError> {
304        if bytes.len() < 4 {
305            return Err(SaveError::Corrupt);
306        }
307
308        // Magic
309        if &bytes[0..4] != b"SAVE" {
310            return Err(SaveError::Corrupt);
311        }
312        let mut cursor = 4usize;
313
314        // Header
315        let header_len = read_u32(bytes, cursor)? as usize;
316        cursor += 4;
317        if cursor + header_len > bytes.len() {
318            return Err(SaveError::Corrupt);
319        }
320        let header_bytes = &bytes[cursor..cursor + header_len];
321        cursor += header_len;
322
323        // Snapshot
324        let snap_len = read_u32(bytes, cursor)? as usize;
325        cursor += 4;
326        if cursor + snap_len > bytes.len() {
327            return Err(SaveError::Corrupt);
328        }
329        let snap_bytes = &bytes[cursor..cursor + snap_len];
330        cursor += snap_len;
331
332        // Checksum
333        if cursor + 4 > bytes.len() {
334            return Err(SaveError::Corrupt);
335        }
336        let stored_checksum = read_u32(bytes, cursor)?;
337
338        // Verify checksum
339        let mut checksum_input = Vec::with_capacity(header_len + snap_len);
340        checksum_input.extend_from_slice(header_bytes);
341        checksum_input.extend_from_slice(snap_bytes);
342        let computed = compute_checksum(&checksum_input);
343        if computed != stored_checksum {
344            return Err(SaveError::ChecksumMismatch);
345        }
346
347        // Parse header
348        let header_str = std::str::from_utf8(header_bytes).map_err(|_| SaveError::Corrupt)?;
349        let header_sv = SerializedValue::from_json_str(header_str)
350            .map_err(|e| SaveError::Serialize(e.to_string()))?;
351        let header = SaveHeader::from_serialized(&header_sv)?;
352
353        // Version check
354        if header.version != CURRENT_FORMAT_VERSION {
355            return Err(SaveError::VersionMismatch {
356                expected: CURRENT_FORMAT_VERSION,
357                found: header.version,
358            });
359        }
360
361        // Parse snapshot
362        let snapshot = SnapshotSerializer::from_bytes(snap_bytes)
363            .map_err(|e| SaveError::Serialize(e.to_string()))?;
364
365        Ok(SaveFile { header, snapshot, checksum: stored_checksum })
366    }
367
368    /// Read a `SaveFile` from a file on disk.
369    pub fn read_from_file(path: &str) -> Result<SaveFile, SaveError> {
370        let bytes = std::fs::read(path)
371            .map_err(|e| SaveError::Io(format!("read '{path}': {e}")))?;
372        SaveFile::read_from_bytes(&bytes)
373    }
374
375    // ── Validation ─────────────────────────────────────────────────────────
376
377    /// Re-compute the checksum and compare it with the stored value.
378    pub fn verify_checksum(&self) -> bool {
379        let header_bytes = self.header.to_serialized().to_json_string().into_bytes();
380        let snapshot_bytes = SnapshotSerializer::to_bytes(&self.snapshot);
381        let mut input = Vec::with_capacity(header_bytes.len() + snapshot_bytes.len());
382        input.extend_from_slice(&header_bytes);
383        input.extend_from_slice(&snapshot_bytes);
384        compute_checksum(&input) == self.checksum
385    }
386}
387
388fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, SaveError> {
389    if offset + 4 > bytes.len() {
390        return Err(SaveError::Corrupt);
391    }
392    Ok(u32::from_le_bytes([
393        bytes[offset],
394        bytes[offset + 1],
395        bytes[offset + 2],
396        bytes[offset + 3],
397    ]))
398}
399
400// ─────────────────────────────────────────────
401//  SaveSlot
402// ─────────────────────────────────────────────
403
404/// A single numbered save slot. `header` is `None` if the slot is empty.
405#[derive(Debug, Clone)]
406pub struct SaveSlot {
407    /// The slot number (0-based).
408    pub slot_number: u8,
409    /// Absolute path to the save file on disk (may not exist if slot is empty).
410    pub path: String,
411    /// The header parsed from the file, or `None` if the slot is empty.
412    pub header: Option<SaveHeader>,
413}
414
415impl SaveSlot {
416    pub fn new(slot_number: u8, path: String) -> Self {
417        Self { slot_number, path, header: None }
418    }
419
420    pub fn is_empty(&self) -> bool {
421        self.header.is_none()
422    }
423
424    pub fn player_name(&self) -> Option<&str> {
425        self.header.as_ref().map(|h| h.player_name.as_str())
426    }
427
428    pub fn play_time(&self) -> Option<f64> {
429        self.header.as_ref().map(|h| h.play_time_seconds)
430    }
431
432    pub fn timestamp(&self) -> Option<u64> {
433        self.header.as_ref().map(|h| h.timestamp)
434    }
435}
436
437// ─────────────────────────────────────────────
438//  SaveManager
439// ─────────────────────────────────────────────
440
441/// Manages multiple save slots under a single directory.
442///
443/// Slots are numbered from 0. Slot 255 is reserved for the auto-save.
444pub struct SaveManager {
445    /// Directory where save files are stored.
446    pub save_dir: String,
447    /// Cached slot metadata.
448    slots: Vec<SaveSlot>,
449}
450
451const AUTO_SAVE_SLOT: u8 = 255;
452const AUTO_SAVE_FILENAME: &str = "autosave.sav";
453
454impl SaveManager {
455    /// Create a new `SaveManager` pointing at `save_dir`.
456    ///
457    /// The directory is created lazily when a save is written.
458    pub fn new(save_dir: impl Into<String>) -> Self {
459        Self {
460            save_dir: save_dir.into(),
461            slots: Vec::new(),
462        }
463    }
464
465    // ── Slot path helpers ──────────────────────────────────────────────────
466
467    fn slot_path(&self, slot: u8) -> String {
468        if slot == AUTO_SAVE_SLOT {
469            format!("{}/{}", self.save_dir, AUTO_SAVE_FILENAME)
470        } else {
471            format!("{}/save_{:02}.sav", self.save_dir, slot)
472        }
473    }
474
475    // ── List ───────────────────────────────────────────────────────────────
476
477    /// Return up to `max` slot descriptors (0 through `max-1`), reading headers
478    /// from disk where save files exist.
479    pub fn list_slots(&self, max: usize) -> Vec<SaveSlot> {
480        let mut result = Vec::with_capacity(max);
481        for i in 0..max.min(255) {
482            let slot_num = i as u8;
483            let path = self.slot_path(slot_num);
484            let header = Self::try_read_header(&path);
485            result.push(SaveSlot { slot_number: slot_num, path, header });
486        }
487        result
488    }
489
490    fn try_read_header(path: &str) -> Option<SaveHeader> {
491        let bytes = std::fs::read(path).ok()?;
492        let file = SaveFile::read_from_bytes(&bytes).ok()?;
493        Some(file.header)
494    }
495
496    // ── Save ───────────────────────────────────────────────────────────────
497
498    /// Write a save to slot `slot`.
499    pub fn save_to_slot(
500        &self,
501        slot: u8,
502        snapshot: WorldSnapshot,
503        header: SaveHeader,
504    ) -> Result<(), SaveError> {
505        let path = self.slot_path(slot);
506        let mut file = SaveFile::new(snapshot).with_header(header);
507        file.write_to_file(&path)
508    }
509
510    // ── Load ───────────────────────────────────────────────────────────────
511
512    /// Load the full save file from slot `slot`.
513    pub fn load_slot(&self, slot: u8) -> Result<SaveFile, SaveError> {
514        let path = self.slot_path(slot);
515        if !Path::new(&path).exists() {
516            return Err(SaveError::SlotEmpty(slot));
517        }
518        SaveFile::read_from_file(&path)
519    }
520
521    // ── Delete ─────────────────────────────────────────────────────────────
522
523    /// Delete the save file in slot `slot`.
524    pub fn delete_slot(&self, slot: u8) -> Result<(), SaveError> {
525        let path = self.slot_path(slot);
526        if !Path::new(&path).exists() {
527            return Ok(()); // idempotent
528        }
529        std::fs::remove_file(&path)
530            .map_err(|e| SaveError::Io(format!("delete slot {slot}: {e}")))
531    }
532
533    // ── Auto-save ──────────────────────────────────────────────────────────
534
535    /// Write the auto-save slot.
536    pub fn auto_save(&self, snapshot: WorldSnapshot) -> Result<(), SaveError> {
537        let mut header = SaveHeader::new();
538        header.set_meta("slot_type", "autosave");
539        self.save_to_slot(AUTO_SAVE_SLOT, snapshot, header)
540    }
541
542    /// Returns `true` if an auto-save file exists.
543    pub fn has_auto_save(&self) -> bool {
544        Path::new(&self.slot_path(AUTO_SAVE_SLOT)).exists()
545    }
546
547    /// Load the auto-save.
548    pub fn load_auto_save(&self) -> Result<SaveFile, SaveError> {
549        self.load_slot(AUTO_SAVE_SLOT)
550    }
551
552    // ── Most recent ────────────────────────────────────────────────────────
553
554    /// Return the slot number of the most recently written save (by Unix timestamp).
555    ///
556    /// Scans up to the first 32 slots plus the auto-save slot.
557    pub fn most_recent_slot(&self) -> Option<u8> {
558        let mut best: Option<(u8, u64)> = None;
559
560        for slot_num in 0u8..32 {
561            let path = self.slot_path(slot_num);
562            if let Some(header) = Self::try_read_header(&path) {
563                if best.map_or(true, |(_, ts)| header.timestamp > ts) {
564                    best = Some((slot_num, header.timestamp));
565                }
566            }
567        }
568
569        // Also check auto-save
570        let auto_path = self.slot_path(AUTO_SAVE_SLOT);
571        if let Some(header) = Self::try_read_header(&auto_path) {
572            if best.map_or(true, |(_, ts)| header.timestamp > ts) {
573                best = Some((AUTO_SAVE_SLOT, header.timestamp));
574            }
575        }
576
577        best.map(|(slot, _)| slot)
578    }
579
580    // ── Utilities ──────────────────────────────────────────────────────────
581
582    /// Ensure the save directory exists.
583    pub fn ensure_dir(&self) -> Result<(), SaveError> {
584        std::fs::create_dir_all(&self.save_dir)
585            .map_err(|e| SaveError::Io(format!("create save dir '{}': {e}", self.save_dir)))
586    }
587
588    /// Count non-empty slots (0..32).
589    pub fn used_slot_count(&self) -> usize {
590        (0u8..32)
591            .filter(|&slot| Path::new(&self.slot_path(slot)).exists())
592            .count()
593    }
594}
595
596// ─────────────────────────────────────────────
597//  Time helper
598// ─────────────────────────────────────────────
599
600fn current_unix_ts() -> u64 {
601    use std::time::{SystemTime, UNIX_EPOCH};
602    SystemTime::now()
603        .duration_since(UNIX_EPOCH)
604        .map(|d| d.as_secs())
605        .unwrap_or(0)
606}
607
608// ─────────────────────────────────────────────
609//  Tests
610// ─────────────────────────────────────────────
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::save::serializer::SerializedValue;
616
617    fn tmp_dir() -> String {
618        use std::time::{SystemTime, UNIX_EPOCH};
619        let t = SystemTime::now()
620            .duration_since(UNIX_EPOCH)
621            .unwrap()
622            .subsec_nanos();
623        let p = std::env::temp_dir().join(format!("proof_save_{t}"));
624        std::fs::create_dir_all(&p).unwrap();
625        p.to_string_lossy().to_string()
626    }
627
628    fn cleanup(dir: &str) {
629        let _ = std::fs::remove_dir_all(dir);
630    }
631
632    #[test]
633    fn checksum_deterministic() {
634        let data = b"hello world";
635        assert_eq!(compute_checksum(data), compute_checksum(data));
636        assert_ne!(compute_checksum(data), compute_checksum(b"hello worlD"));
637    }
638
639    #[test]
640    fn save_header_defaults() {
641        let h = SaveHeader::new();
642        assert_eq!(h.magic, SAVE_MAGIC);
643        assert_eq!(h.version, CURRENT_FORMAT_VERSION);
644    }
645
646    #[test]
647    fn save_file_roundtrip_bytes() {
648        let mut snap = WorldSnapshot::new();
649        snap.timestamp = 77.0;
650        snap.add_entity(1, {
651            let mut m = std::collections::HashMap::new();
652            m.insert("hp".into(), SerializedValue::Int(100));
653            m
654        });
655
656        let mut file = SaveFile::new(snap);
657        file.header.player_name = "Tester".into();
658
659        let bytes = file.write_to_bytes();
660        let restored = SaveFile::read_from_bytes(&bytes).unwrap();
661        assert_eq!(restored.header.player_name, "Tester");
662        assert_eq!(restored.snapshot.timestamp, 77.0);
663        assert_eq!(restored.snapshot.entity_count(), 1);
664    }
665
666    #[test]
667    fn save_file_verify_checksum() {
668        let mut file = SaveFile::new(WorldSnapshot::new());
669        file.write_to_bytes();
670        assert!(file.verify_checksum());
671    }
672
673    #[test]
674    fn save_file_checksum_mismatch() {
675        let mut file = SaveFile::new(WorldSnapshot::new());
676        let mut bytes = file.write_to_bytes();
677        // Flip the last byte of the snapshot section (before checksum)
678        let len = bytes.len();
679        bytes[len - 5] ^= 0xFF;
680        let result = SaveFile::read_from_bytes(&bytes);
681        assert!(matches!(result, Err(SaveError::ChecksumMismatch)));
682    }
683
684    #[test]
685    fn save_file_bad_magic() {
686        let bytes = b"NOPE...";
687        let result = SaveFile::read_from_bytes(bytes);
688        assert!(matches!(result, Err(SaveError::Corrupt)));
689    }
690
691    #[test]
692    fn save_manager_write_and_load() {
693        let dir = tmp_dir();
694        let mgr = SaveManager::new(&dir);
695
696        let mut snap = WorldSnapshot::new();
697        snap.set_meta("level", "forest");
698        let header = SaveHeader::new().with_player("Alice");
699        mgr.save_to_slot(0, snap, header).unwrap();
700
701        let file = mgr.load_slot(0).unwrap();
702        assert_eq!(file.header.player_name, "Alice");
703        assert_eq!(file.snapshot.get_meta("level"), Some("forest"));
704
705        cleanup(&dir);
706    }
707
708    #[test]
709    fn save_manager_empty_slot_error() {
710        let dir = tmp_dir();
711        let mgr = SaveManager::new(&dir);
712        let result = mgr.load_slot(7);
713        assert!(matches!(result, Err(SaveError::SlotEmpty(7))));
714        cleanup(&dir);
715    }
716
717    #[test]
718    fn save_manager_auto_save() {
719        let dir = tmp_dir();
720        let mgr = SaveManager::new(&dir);
721        assert!(!mgr.has_auto_save());
722        mgr.auto_save(WorldSnapshot::new()).unwrap();
723        assert!(mgr.has_auto_save());
724        cleanup(&dir);
725    }
726
727    #[test]
728    fn save_manager_delete_slot() {
729        let dir = tmp_dir();
730        let mgr = SaveManager::new(&dir);
731        mgr.save_to_slot(1, WorldSnapshot::new(), SaveHeader::new()).unwrap();
732        assert!(mgr.load_slot(1).is_ok());
733        mgr.delete_slot(1).unwrap();
734        assert!(matches!(mgr.load_slot(1), Err(SaveError::SlotEmpty(1))));
735        cleanup(&dir);
736    }
737
738    #[test]
739    fn save_manager_most_recent() {
740        let dir = tmp_dir();
741        let mgr = SaveManager::new(&dir);
742        mgr.save_to_slot(0, WorldSnapshot::new(), SaveHeader::new()).unwrap();
743        // Sleep a tiny bit to get a different timestamp
744        std::thread::sleep(std::time::Duration::from_millis(1100));
745        mgr.save_to_slot(1, WorldSnapshot::new(), SaveHeader::new()).unwrap();
746        let recent = mgr.most_recent_slot();
747        // Slot 1 was written last — should be the most recent
748        assert!(recent.is_some());
749        cleanup(&dir);
750    }
751
752    #[test]
753    fn save_slot_is_empty() {
754        let slot = SaveSlot::new(0, "path/save_00.sav".into());
755        assert!(slot.is_empty());
756    }
757
758    #[test]
759    fn save_header_metadata() {
760        let mut h = SaveHeader::new();
761        h.set_meta("difficulty", "hard");
762        assert_eq!(h.get_meta("difficulty"), Some("hard"));
763    }
764}