summaryrefslogtreecommitdiff
path: root/src/save.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/save.rs')
-rw-r--r--src/save.rs790
1 files changed, 790 insertions, 0 deletions
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<String, u32>,
+}
+
+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<f64>,
+ /// Which system you're in. Saves from before there were others are in
+ /// the system their universe started in.
+ #[serde(default)]
+ star: Option<usize>,
+ /// The systems you've been to.
+ #[serde(default)]
+ visited: Vec<usize>,
+ 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<u64>,
+ 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<SavedCrawl>,
+ /// Armourers' racks as you left them this fortnight.
+ #[serde(default)]
+ racks: Vec<Rack>,
+ /// 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<Sort, D::Error> {
+ 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<String, u32>,
+ ore_capacity: u32,
+ /// What's on you. Saves from before the inventory have `artifacts`,
+ /// `medpatches` and `arms` instead.
+ #[serde(default)]
+ gear: Option<Gear>,
+ #[serde(default)]
+ lockers: Vec<Stack>,
+ #[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<Artifact>,
+ #[serde(default, skip_serializing)]
+ medpatches: u32,
+ #[serde(default, skip_serializing)]
+ arms: Option<OldArms>,
+}
+
+/// How weapons were saved before the inventory: all of them in one list,
+/// with which were ready.
+#[derive(Deserialize)]
+struct OldArms {
+ weapons: Vec<Weapon>,
+ 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<Stack>) {
+ 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<Stack>) {
+ 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<Stack>) {
+ 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<Summary, String>,
+}
+
+/// Where saves go: `$XDG_DATA_HOME/orrery/saves`, or
+/// `~/.local/share/orrery/saves`.
+pub fn default_dir() -> Option<PathBuf> {
+ 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<Slot> {
+ let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
+ let mut slots: Vec<Slot> = 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<Save, String> {
+ 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<u64> = 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::<Vec<_>>();
+ 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<String> = 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());
+ }
+}