From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/save.rs | 790 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 790 insertions(+) create mode 100644 src/save.rs (limited to 'src/save.rs') diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..23c7b62 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,790 @@ +//! 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()); + } +} -- cgit v1.2.3