//! Characters, and saving them. //! //! A save is a universe seed plus everything you've changed in it: who you //! are, what's in your hold, where your ship is, which wrecks you've //! stripped, what you've learned about what lives aboard them. The universe //! itself isn't saved; it's regenerated from the seed, which is one more //! reason seeds must stay stable (see `seed`). //! //! Each character is one JSON file in the saves folder, //! `$XDG_DATA_HOME/orrery/saves` or `~/.local/share/orrery/saves`. The game //! writes it whenever the scene changes and when you quit. use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::app::{Ctx, Hold}; use crate::dungeon::Pos; use crate::dungeon::arms::{self, Dmg, Weapon}; use crate::dungeon::crawl::{Crawl, MONSTERS, SavedCrawl}; use crate::items::{self, Armor, Gear, Item, Stack}; use crate::names::{Artifact, Culture}; use crate::scenes::inventory::Sort; use crate::space::locale::ORES; use crate::trade::{MAX_FUEL, MEDPATCH_LIMIT, Rack}; /// The save format. Bump it when a change means older Orreries can't read /// what this one writes; this one reads any save at or below it. 2: the /// inventory, which an older Orrery would read as having nothing on you. pub const VERSION: u32 = 2; /// Who you are. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Character { /// "Khaseem ul-Tirun". pub name: String, /// Where you're from. For now it only flavours the names you're offered. pub origin: Culture, /// Your ship, without "the": "Ithane". pub ship: String, } impl Character { /// "the Ithane". pub fn ship_name(&self) -> String { format!("the {}", self.ship) } /// "a Qaari salvager". pub fn calling(&self) -> String { format!("{} salvager", crate::names::a(self.origin.adjective())) } } /// What you've done, for the character sheet. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Record { /// Wrecks you've gone aboard. pub boarded: u32, /// Of those, how many you got back from with what you found. pub escaped: u32, /// Times you were brought down and dragged back by your crew. pub downed: u32, /// Times you left by the emergency hatch. pub abandoned: u32, /// What you've killed, by kind. pub slain: BTreeMap, } impl Record { pub fn slain_total(&self) -> u32 { self.slain.values().sum() } } /// A save file's contents. Everything is in plain owned types, so a file /// says what it means without the code that wrote it. #[derive(Serialize, Deserialize)] struct Save { version: u32, /// When it was written, in seconds since 1970. saved: u64, character: Character, seed: u64, day: f64, /// The day you set out for where you are, so the wrecks you came for /// are still there. #[serde(default)] departed: Option, /// Which system you're in. Saves from before there were others are in /// the system their universe started in. #[serde(default)] star: Option, /// The systems you've been to. #[serde(default)] visited: Vec, location: usize, /// The name of where you are, for the list of saves. #[serde(default)] place: String, /// Whether your ship is set down there, rather than in flight. #[serde(default)] landed: bool, #[serde(default)] boarded: u64, #[serde(default)] lenses: u32, #[serde(default)] stripped: Vec, hold: SavedHold, #[serde(default)] bestiary: Vec<(String, Dmg)>, #[serde(default)] record: Record, /// The wreck you were aboard, if you were in one. #[serde(default)] crawl: Option, /// Armourers' racks as you left them this fortnight. #[serde(default)] racks: Vec, /// How you had your inventory sorted. #[serde(default, deserialize_with = "lenient")] inventory_sort: Sort, } /// A sort from a save, or the usual one if it names a sort there's no /// longer. fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let value = serde_json::Value::deserialize(d)?; Ok(Sort::deserialize(value).unwrap_or_default()) } #[derive(Serialize, Deserialize)] struct SavedHold { #[serde(default)] scrip: u32, /// Tons of each ore, by name. #[serde(default)] ore: BTreeMap, ore_capacity: u32, /// What's on you. Saves from before the inventory have `artifacts`, /// `medpatches` and `arms` instead. #[serde(default)] gear: Option, #[serde(default)] lockers: Vec, #[serde(default)] deck: Vec<(Pos, Stack)>, #[serde(default = "full_tank")] fuel: u32, #[serde(default = "full_tank")] fuel_capacity: u32, #[serde(default)] debt: u32, // From before the inventory: read, never written. #[serde(default, skip_serializing)] artifacts: Vec, #[serde(default, skip_serializing)] medpatches: u32, #[serde(default, skip_serializing)] arms: Option, } /// How weapons were saved before the inventory: all of them in one list, /// with which were ready. #[derive(Deserialize)] struct OldArms { weapons: Vec, melee: usize, ranged: usize, } /// What a save from before the inventory had on you and aboard: the /// weapons and medpatches you took into every wreck go on you, and the /// artifacts from the shelf go in the lockers. fn from_before(h: &mut SavedHold) -> (Gear, Vec) { let mut gear = Gear::default(); if let Some(OldArms { weapons, melee, ranged }) = h.arms.take() { let (mut blade, mut gun) = (None, None); for (i, w) in weapons.into_iter().enumerate() { if i == melee && !w.is_gun() && blade.is_none() { blade = Some(w); } else if i == ranged && w.is_gun() && gun.is_none() { gun = Some(w); } else { gear.add(Stack::one(Item::Weapon(w))); } } gear.melee = blade.unwrap_or_else(arms::cutting_torch); gear.gun = gun.unwrap_or_else(arms::rivet_gun); } gear.add(Stack { item: Item::Medpatch, count: h.medpatches.min(MEDPATCH_LIMIT) }); let lockers = h.artifacts.drain(..).map(|a| Stack::one(Item::Artifact(a))).collect(); (gear, lockers) } /// Puts right a pile a save file got wrong: nothing empty, no copies of /// what can't be copied, none of your own kit (which is always on you), /// every weapon's numbers sensible. fn mend_pile(pile: &mut Vec) { pile.retain(|s| s.count > 0); pile.iter_mut().for_each(mend_stack); } fn mend_stack(s: &mut Stack) { if !s.item.stacks() { s.count = 1; } match &mut s.item { Item::Weapon(w) => w.mend(), Item::Armor(a) => a.mend(), _ => {} } } /// If you're carrying more than you can, the heaviest things in your pack /// go to the lockers until you aren't. fn unburden(gear: &mut Gear, lockers: &mut Vec) { while gear.carried() > gear.capacity { let heaviest = (0..gear.pack.len()).max_by_key(|&i| gear.pack[i].mass()); let Some(i) = heaviest else { break }; let count = gear.pack[i].count; items::stow(lockers, items::take(&mut gear.pack, i, count)); } } fn full_tank() -> u32 { Hold::BASE_FUEL } /// What the list of saves shows about one, without loading it. pub struct Summary { pub character: Character, pub day: f64, pub place: String, pub scrip: u32, pub record: Record, /// Seconds since 1970. pub saved: u64, } /// A file in the saves folder: what's in it, or why it can't be read. pub struct Slot { pub path: PathBuf, pub summary: Result, } /// Where saves go: `$XDG_DATA_HOME/orrery/saves`, or /// `~/.local/share/orrery/saves`. pub fn default_dir() -> Option { let data = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?; Some(data.join("orrery").join("saves")) } /// A new file for `character` in universe `seed`: their name, made safe for /// a file name, and the universe, so two characters of the same name don't /// share one. pub fn new_slot(dir: &Path, character: &Character, seed: u64) -> PathBuf { let mut slug = String::new(); for c in character.name.chars().flat_map(char::to_lowercase) { if c.is_ascii_alphanumeric() { slug.push(c); } else if !slug.is_empty() && !slug.ends_with('-') { slug.push('-'); } } let slug: String = slug.trim_end_matches('-').chars().take(24).collect(); let slug = if slug.is_empty() { "character".to_string() } else { slug }; dir.join(format!("{slug}-{seed:016x}.json")) } /// Every save in `dir`, most recently played first; the ones that can't be /// read come last, with why. pub fn list(dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new() }; let mut slots: Vec = entries .filter_map(Result::ok) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|x| x == "json")) .map(|path| { let summary = read(&path).map(|s| Summary { day: s.day, place: s.place, scrip: s.hold.scrip, record: s.record, saved: s.saved, character: s.character, }); Slot { path, summary } }) .collect(); slots.sort_by_key(|s| (s.summary.as_ref().map_or(1, |_| 0), std::cmp::Reverse(saved(s)))); slots } fn saved(slot: &Slot) -> u64 { slot.summary.as_ref().map_or(0, |s| s.saved) } /// Writes the game in `ctx` to its slot. Writes a new file and moves it /// over the old, so a crash halfway through leaves the old save whole. pub fn write(ctx: &Ctx) -> io::Result<()> { let Some(path) = &ctx.slot else { return Ok(()) }; write_save(path, &capture(ctx)) } fn write_save(path: &Path, save: &Save) -> io::Result<()> { if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } let json = serde_json::to_string_pretty(save).map_err(io::Error::other)?; let fresh = path.with_extension("json.new"); fs::write(&fresh, json + "\n")?; fs::rename(&fresh, path) } /// Loads the save at `path` into `ctx`, which becomes that character's game /// from then on. If the save can't be read, says why and leaves `ctx` as it /// was. pub fn load(path: &Path, ctx: &mut Ctx) -> Result<(), String> { let save = read(path)?; restore(save, ctx); ctx.slot = Some(path.to_path_buf()); Ok(()) } pub fn delete(path: &Path) -> io::Result<()> { fs::remove_file(path) } fn read(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|e| e.to_string())?; let save: Save = serde_json::from_str(&text).map_err(|e| e.to_string())?; if save.version > VERSION { return Err(format!("saved by a newer Orrery (save format {})", save.version)); } Ok(save) } /// Seconds since 1970. pub fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } fn capture(ctx: &Ctx) -> Save { let h = &ctx.hold; let mut stripped: Vec = ctx.stripped.iter().copied().collect(); stripped.sort_unstable(); Save { version: VERSION, saved: now(), character: ctx.character.clone(), seed: ctx.seed, day: ctx.day, departed: ctx.departed, star: Some(ctx.star), visited: ctx.visited.iter().copied().collect(), location: ctx.location, place: match &ctx.crawl { Some(c) => format!("aboard {} in {}", c.name, ctx.system.name), None => format!("{} in {}", ctx.system.places[ctx.location].name, ctx.system.name), }, landed: ctx.landed, boarded: ctx.boarded, lenses: ctx.lenses, stripped, hold: SavedHold { scrip: h.scrip, ore: h.ore.iter().map(|(&k, &v)| (k.to_string(), v)).collect(), ore_capacity: h.ore_capacity, gear: Some(h.gear.clone()), lockers: h.lockers.clone(), deck: h.deck.clone(), fuel: h.fuel, fuel_capacity: h.fuel_capacity, debt: h.debt, artifacts: Vec::new(), medpatches: 0, arms: None, }, bestiary: ctx.bestiary.iter().map(|&(m, d)| (m.to_string(), d)).collect(), record: ctx.record.clone(), crawl: ctx.crawl.clone(), racks: ctx.racks.clone(), inventory_sort: ctx.inventory_sort, } } /// Starts `ctx` afresh in the save's universe, then puts back everything /// the save changed. Anything that doesn't fit this version's universe (an /// ore or a monster that no longer exists, a place past the last one) is /// dropped or put somewhere sensible rather than refused. fn restore(save: Save, ctx: &mut Ctx) { ctx.begin(save.seed, save.character); let star = save.star.filter(|&s| s < ctx.galaxy.sites.len()).unwrap_or(ctx.galaxy.home); if star != ctx.star { ctx.enter_system(star); ctx.location = ctx.system.home_place(); } ctx.visited = save.visited.into_iter().filter(|&s| s < ctx.galaxy.sites.len()).collect(); ctx.visited.insert(star); let placed = save.location < ctx.system.places.len(); if placed { ctx.location = save.location; ctx.landed = save.landed; } if save.day.is_finite() && save.day >= 0.0 { ctx.day = save.day; // Only if you're still where you set out for. ctx.departed = save.departed.filter(|d| placed && (0.0..=ctx.day).contains(d)); } ctx.boarded = save.boarded; ctx.lenses = save.lenses; ctx.stripped = save.stripped.into_iter().collect(); let mut h = save.hold; let (mut gear, mut lockers) = match h.gear.take() { Some(gear) => (gear, std::mem::take(&mut h.lockers)), None => from_before(&mut h), }; gear.mend(); mend_pile(&mut lockers); unburden(&mut gear, &mut lockers); let mut deck = h.deck; deck.retain(|(_, s)| s.count > 0); deck.iter_mut().for_each(|(_, s)| mend_stack(s)); ctx.hold = Hold { scrip: h.scrip, ore: h .ore .into_iter() .filter_map(|(name, tons)| ORES.iter().find(|o| o.name == name).map(|o| (o.name, tons))) .collect(), ore_capacity: h.ore_capacity.max(Hold::BASE_ORE_CAPACITY), gear, lockers, deck, fuel_capacity: h.fuel_capacity.clamp(Hold::BASE_FUEL, MAX_FUEL), fuel: 0, debt: h.debt, }; ctx.hold.fuel = h.fuel.min(ctx.hold.fuel_capacity); ctx.bestiary = save .bestiary .into_iter() .filter_map(|(name, dmg)| MONSTERS.iter().find(|k| k.name == name).map(|k| (k.name, dmg))) .collect(); ctx.record = save.record; // A wreck that no longer fits the ship its seed makes is lost, as if you // got out before it came apart: you're back as you were before boarding. ctx.crawl = save.crawl.filter(|c| Crawl::resume(c).is_some()); // Only this fortnight's racks, as `trade::rack` keeps: one saved when // racks were restocked every week has a week's number, not a // fortnight's, and could pass for another fortnight's later on. ctx.inventory_sort = save.inventory_sort; ctx.racks = save.racks; ctx.racks.retain(|r| r.fortnight == crate::trade::fortnight(ctx.day)); for r in &mut ctx.racks { r.weapons.iter_mut().for_each(Weapon::mend); r.armour.iter_mut().for_each(Armor::mend); } } /// For tests: a folder of their own under the system's temp folder, empty. #[cfg(test)] pub fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("orrery-{}-{name}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); dir } #[cfg(test)] mod tests { use super::*; use crate::dungeon::arms::{self, Source}; use crate::names; use crate::seed; use crate::term::KeyboardCaps; fn ctx(seed: u64) -> Ctx { Ctx::new(seed, KeyboardCaps { key_release: false }) } fn character(name: &str) -> Character { Character { name: name.into(), origin: Culture::Drift, ship: "Long Patience".into() } } /// A game well under way. fn played(dir: &Path) -> Ctx { let mut ctx = ctx(1312); ctx.begin(1312, character("Ysolde Marr")); ctx.slot = Some(new_slot(dir, &ctx.character, ctx.seed)); ctx.day = 41.5; let next = ctx.galaxy.sites[ctx.star].lanes[0]; ctx.enter_system(next); ctx.travel(ctx.system.places.len() - 1, 2.5); ctx.landed = false; ctx.boarded = 3; ctx.lenses = 1; ctx.stripped.extend([7, 99]); let mut rng = seed::rng(3); let mut artifact = names::artifact(&mut rng); artifact.identified = true; ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); ctx.hold.scrip = 1234; ctx.hold.ore.insert("nickel", 12); ctx.hold.ore_capacity = 60; ctx.hold.gear.add(Stack { item: Item::Medpatch, count: 4 }); ctx.hold.fuel_capacity = 6; ctx.hold.fuel = 3; ctx.hold.debt = 75; let blade = (0..).map(|_| arms::find(&mut rng, Source::Armoury, true)).find(|w| !w.is_gun()); ctx.hold.gear.add(Stack::one(Item::Weapon(blade.unwrap()))); ctx.hold.gear.ready(1).unwrap(); ctx.hold.gear.capacity += 10_000; let salvage = items::Salvage { title: "working servo".into(), value: 35, mass: 4000 }; ctx.hold.deck.push((Pos::new(20, 9), Stack { item: Item::Salvage(salvage), count: 2 })); ctx.bestiary.insert(("choir-wraith", Dmg::Pierce)); ctx.record.boarded = 5; ctx.record.slain.insert("rust-mite".into(), 9); ctx } #[test] fn an_armourers_rack_is_as_you_left_it() { let dir = scratch("rack"); let mut ctx = played(&dir); let place = ctx.system.home_place(); let culture = ctx.system.culture; let rack = crate::trade::rack(&mut ctx.racks, &ctx.system, place, culture, ctx.day); let sold = rack.weapons.remove(0); rack.weapons.push(sold); let mut worn = rack.armour.remove(0); worn.durability /= 2; rack.armour.push(worn); let titles = |c: &Ctx| c.racks[0].weapons.iter().map(|w| w.title.clone()).collect::>(); write(&ctx).unwrap(); let mut back = self::ctx(1); load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); assert_eq!(titles(&back), titles(&ctx)); assert_eq!(back.racks[0].armour, ctx.racks[0].armour); assert_eq!(back.racks[0].fortnight, ctx.racks[0].fortnight); } #[test] fn your_inventory_sort_is_kept_and_an_unknown_one_forgotten() { use crate::scenes::inventory::SortBy; let dir = scratch("sort"); let mut ctx = played(&dir); ctx.inventory_sort = Sort { by: SortBy::Density, reversed: true }; write(&ctx).unwrap(); let mut back = self::ctx(1); load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); assert_eq!(back.inventory_sort, ctx.inventory_sort); let mut json: serde_json::Value = serde_json::from_str(&fs::read_to_string(ctx.slot.as_ref().unwrap()).unwrap()).unwrap(); json["inventory_sort"] = serde_json::json!({ "by": "vibes", "reversed": true }); fs::write(ctx.slot.as_ref().unwrap(), json.to_string()).unwrap(); load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); assert_eq!(back.inventory_sort, Sort::default()); } #[test] fn a_wreck_you_quit_in_is_there_when_you_come_back() { let dir = scratch("wreck"); let mut ctx = played(&dir); let mut wreck = crate::dungeon::crawl::Crawl::new(5); wreck.gear = ctx.hold.gear.clone(); ctx.crawl = Some(wreck.saved()); write(&ctx).unwrap(); let summary = &list(&dir)[0].summary; assert!(summary.as_ref().unwrap().place.starts_with("aboard "), "the list says so"); let mut back = self::ctx(1); load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); // The same wreck, as you left it (floats aside, which JSON rounds). let resumed = |c: &Ctx| Crawl::resume(c.crawl.as_ref().unwrap()).unwrap(); let (was, is) = (resumed(&ctx), resumed(&back)); assert_eq!( (is.seed, is.player, is.hp, is.turns), (was.seed, was.player, was.hp, was.turns) ); assert_eq!((is.loot.len(), is.monsters.len()), (was.loot.len(), was.monsters.len())); assert_eq!(is.gear.pack, was.gear.pack); let scenes = crate::scenes::flight::resume(&back); assert_eq!(scenes.len(), 2, "flying, with the wreck on top"); // One that no longer fits its ship is lost, and you're as you were // before you boarded. let mut json: serde_json::Value = serde_json::from_str(&fs::read_to_string(ctx.slot.as_ref().unwrap()).unwrap()).unwrap(); json["crawl"]["player"] = serde_json::json!({ "x": -5, "y": -5 }); fs::write(ctx.slot.as_ref().unwrap(), json.to_string()).unwrap(); load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); assert_eq!(back.crawl, None); assert_eq!(back.hold.gear, ctx.hold.gear); let _ = fs::remove_dir_all(dir); } #[test] fn a_save_brings_back_everything() { let dir = scratch("round-trip"); let ctx = played(&dir); write(&ctx).unwrap(); let mut back = self::ctx(1); load(ctx.slot.as_ref().unwrap(), &mut back).unwrap(); assert_eq!(back.character, ctx.character); assert_eq!((back.seed, back.day, back.location), (ctx.seed, ctx.day, ctx.location)); assert_eq!(back.departed, Some(41.5), "the day you set out for here"); assert_eq!(back.system.name, ctx.system.name, "the same universe, and system"); assert_eq!(back.star, ctx.star); assert_eq!(back.visited, ctx.visited); assert_eq!(back.visited.len(), 2); assert_eq!((back.hold.fuel, back.hold.fuel_capacity, back.hold.debt), (3, 6, 75)); assert_eq!((back.landed, back.boarded, back.lenses), (false, 3, 1)); assert_eq!(back.stripped, ctx.stripped); assert_eq!( (back.hold.scrip, back.hold.ore_capacity, back.hold.medpatches()), (1234, 60, 4) ); assert_eq!(back.hold.ore, ctx.hold.ore); assert_eq!(back.hold.gear, ctx.hold.gear); assert_eq!(back.hold.lockers, ctx.hold.lockers); assert_eq!(back.hold.deck, ctx.hold.deck); assert_eq!(back.bestiary, ctx.bestiary); assert_eq!(back.record, ctx.record); assert_eq!(back.slot, ctx.slot); let _ = fs::remove_dir_all(dir); } #[test] fn what_no_longer_exists_is_dropped() { let dir = scratch("stale"); let ctx = played(&dir); let mut save = capture(&ctx); save.hold.ore.insert("unobtainium".into(), 5); save.bestiary.push(("snark".into(), Dmg::Cut)); save.location = 10_000; let gear = save.hold.gear.as_mut().unwrap(); std::mem::swap(&mut gear.melee, &mut gear.gun); gear.pack.retain(|s| !s.item.is_kit()); gear.pack.push(Stack { item: Item::Medpatch, count: 0 }); save.hold.lockers.push(Stack::one(Item::Weapon(arms::rivet_gun()))); let path = dir.join("stale.json"); write_save(&path, &save).unwrap(); let mut back = self::ctx(1); load(&path, &mut back).unwrap(); assert_eq!(back.hold.ore.len(), 1); assert_eq!(back.bestiary.len(), 1); assert_eq!(back.location, back.system.home_place()); let gear = &back.hold.gear; assert!(!gear.melee.is_gun() && gear.gun.is_gun()); assert!(gear.pack.iter().all(|s| s.count > 0)); let stored = Stack::one(Item::Weapon(arms::rivet_gun())); assert!(back.hold.lockers.contains(&stored), "your own can be stowed"); let _ = fs::remove_dir_all(dir); } #[test] fn saves_from_before_the_galaxy_stay_where_they_were() { let dir = scratch("before-galaxy"); let mut ctx = played(&dir); // Back in the system the universe began in, wherever the // character's people have their seat. ctx.visited.clear(); ctx.enter_system(ctx.galaxy.home); ctx.location = 2; let mut save = capture(&ctx); save.star = None; save.visited.clear(); let json = serde_json::to_string(&save).unwrap(); let mut json: serde_json::Value = serde_json::from_str(&json).unwrap(); for field in ["star", "visited"] { json.as_object_mut().unwrap().remove(field); } for field in ["fuel", "fuel_capacity", "debt"] { json["hold"].as_object_mut().unwrap().remove(field); } let path = dir.join("old.json"); fs::write(&path, json.to_string()).unwrap(); let mut back = self::ctx(1); load(&path, &mut back).unwrap(); assert_eq!(back.star, back.galaxy.home); assert_eq!(back.location, 2); assert_eq!(back.system.name, crate::space::home_system(1312).name); assert_eq!(back.visited.len(), 1); assert_eq!((back.hold.fuel, back.hold.fuel_capacity, back.hold.debt), (4, 4, 0)); let _ = fs::remove_dir_all(dir); } #[test] fn saves_from_before_the_inventory_keep_everything() { let dir = scratch("before-inventory"); let ctx = played(&dir); let mut json = serde_json::to_value(capture(&ctx)).unwrap(); let hold = json["hold"].as_object_mut().unwrap(); for field in ["gear", "lockers", "deck"] { hold.remove(field); } let mut rng = seed::rng(8); let artifact = names::artifact(&mut rng); let cleaver = (0..).map(|_| arms::find(&mut rng, Source::Crew, false)).find(|w| !w.is_gun()).unwrap(); let mut old = serde_json::to_value(&cleaver).unwrap(); old.as_object_mut().unwrap().remove("mass"); let torch = serde_json::to_value(arms::cutting_torch()).unwrap(); let rivets = serde_json::to_value(arms::rivet_gun()).unwrap(); hold.insert("artifacts".into(), serde_json::json!([artifact])); hold.insert("medpatches".into(), 3.into()); hold.insert( "arms".into(), serde_json::json!({ "weapons": [torch, rivets, old], "melee": 2, "ranged": 1 }), ); json["version"] = 1.into(); let path = dir.join("v1.json"); fs::write(&path, json.to_string()).unwrap(); let mut back = self::ctx(1); load(&path, &mut back).unwrap(); let gear = &back.hold.gear; assert_eq!(gear.melee, cleaver, "ready as it was, and weighed"); assert!(gear.gun.kit && gear.gun.is_gun()); assert_eq!(gear.weapons().count(), 3, "the torch is in the pack"); assert_eq!(gear.medpatches(), 3); assert!(back.hold.lockers.contains(&Stack::one(Item::Artifact(artifact)))); let _ = fs::remove_dir_all(dir); } #[test] fn unreadable_saves_are_refused_and_change_nothing() { let dir = scratch("unreadable"); let garbled = dir.join("garbled.json"); fs::write(&garbled, "{ not json").unwrap(); let newer = dir.join("newer.json"); let mut save = capture(&played(&dir)); save.version = VERSION + 1; write_save(&newer, &save).unwrap(); let mut ctx = self::ctx(5); ctx.hold.scrip = 77; assert!(load(&garbled, &mut ctx).is_err()); assert!(load(&newer, &mut ctx).unwrap_err().contains("newer")); assert_eq!((ctx.seed, ctx.hold.scrip, ctx.slot.is_none()), (5, 77, true)); let _ = fs::remove_dir_all(dir); } #[test] fn saves_list_newest_first_and_the_broken_last() { let dir = scratch("list"); for (name, saved) in [("Old Hand", 100), ("New Face", 300), ("Middling", 200)] { let mut ctx = played(&dir); ctx.character.name = name.into(); let mut save = capture(&ctx); save.saved = saved; write_save(&new_slot(&dir, &ctx.character, ctx.seed), &save).unwrap(); } fs::write(dir.join("broken.json"), "[]").unwrap(); fs::write(dir.join("notes.txt"), "not a save").unwrap(); let slots = list(&dir); let names: Vec = slots .iter() .map(|s| s.summary.as_ref().map_or("?".into(), |s| s.character.name.clone())) .collect(); assert_eq!(names, ["New Face", "Middling", "Old Hand", "?"]); assert_eq!(slots[0].summary.as_ref().unwrap().scrip, 1234); let _ = fs::remove_dir_all(dir); } #[test] fn slot_names_are_safe() { let dir = Path::new("/saves"); let slot = |name: &str| new_slot(dir, &character(name), 255); assert_eq!(slot("Khaseem ul-Tirun"), dir.join("khaseem-ul-tirun-00000000000000ff.json")); assert_eq!(slot("../../etc"), dir.join("etc-00000000000000ff.json")); assert_eq!(slot("☼☼"), dir.join("character-00000000000000ff.json")); } #[test] fn saving_without_a_slot_does_nothing() { assert!(write(&ctx(1)).is_ok()); } }