diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68cbefc..4aacd7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,4 +11,5 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - run: cargo test --locked + - run: cargo run --locked --example console -- --walkthrough - run: cargo clippy --locked --all-targets --all-features -- -D warnings diff --git a/.gitignore b/.gitignore index ea8c4bf..1a489bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +*.save.json diff --git a/Cargo.lock b/Cargo.lock index d881d0c..ed6525b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,7 +1623,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -1869,6 +1878,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1923,6 +1941,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -1963,6 +1991,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2303,6 +2341,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -3445,6 +3493,8 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "sha2", + "tempfile", ] [[package]] @@ -4023,6 +4073,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4210,6 +4271,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4426,6 +4500,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typewit" version = "1.15.2" diff --git a/Cargo.toml b/Cargo.toml index 020d94b..1085e9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_path_to_error = "0.1" +sha2 = "0.10" +tempfile = "3" [features] default = [] diff --git a/README.md b/README.md index 56f359d..659655a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A minimal data-driven point-and-click adventure runtime. **The Last Light** is an original, hand-authored three-room fixture with pickups, inventory-gated actions, -branching dialogue, room transitions and a reachable ending. +branching dialogue, room transitions, versioned save/load and a reachable ending. ## Play @@ -13,17 +13,31 @@ API and platform setup. The old Bevy 0.6 scaffold has been updated. ```sh cargo run --locked --features desktop --example last_light # Optional path to another v1 scene: -cargo run --locked --features desktop --example last_light -- path/to/game.json +cargo run --locked --features desktop --example last_light -- path/to/game.json path/to/slot.json ``` Click the labeled rectangles, or press their numbers (1–9). Items are used automatically by matching hotspots. Dialogue is modal. **N** starts a new game. +**F5** saves; **F9** loads (also available as buttons). The desktop player uses native Bevy UI; on Linux it needs X11 and a Vulkan-capable driver (Mesa software rendering also works). Audio, gamepads and 3D are disabled. +For a terminal player or a complete self-checking demonstration (no GPU): + +```sh +cargo run --locked --example console +cargo run --locked --example console -- --walkthrough +``` + +Follow the [playable walkthrough](docs/walkthrough.md). The automated run saves +and restores an active conversation, a repaired lantern and the ending, using an +isolated temporary slot. Normal play saves to `last_light.save.json` in the +current directory. Failed loads preserve the running game. + ## Editor integration - [Versioned scene contract](docs/scene-format.md) +- [Save format and compatibility](docs/save-format.md) - [Generated JSON Schema](docs/scene-v1.schema.json) - [Fixture source](assets/last-light/game.json) and [asset license](assets/last-light/LICENSE.md) diff --git a/assets/last-light/walkthrough.txt b/assets/last-light/walkthrough.txt new file mode 100644 index 0000000..f2fa1b3 --- /dev/null +++ b/assets/last-light/walkthrough.txt @@ -0,0 +1,22 @@ +# These are also playable commands for the interactive console. +click crate +click workshop +click keeper +choose help +# Save mid-conversation while carrying the fuse, then replace the running game. +save +new +load +choose accept +click stairs +click panel +# Save after consuming an item; used hotspots and dialogue history must persist. +save +new +load +click beacon +# Endings must also survive a fresh instance. +save +new +load +quit diff --git a/docs/images/dialogue-restored.png b/docs/images/dialogue-restored.png new file mode 100644 index 0000000..5de9f52 Binary files /dev/null and b/docs/images/dialogue-restored.png differ diff --git a/docs/images/ending-restored.png b/docs/images/ending-restored.png new file mode 100644 index 0000000..4555db8 Binary files /dev/null and b/docs/images/ending-restored.png differ diff --git a/docs/save-format.md b/docs/save-format.md new file mode 100644 index 0000000..781e222 --- /dev/null +++ b/docs/save-format.md @@ -0,0 +1,52 @@ +# Save format v1 + +`Game::save_json` / `load_json` handle UTF-8 JSON; `save_file` / `load_file` add +filesystem persistence. The desktop and terminal players share these APIs. + +The envelope has required fields `save_version` (1), `game_id`, +`scene_fingerprint` and `state`. The fingerprint is lowercase SHA-256 of the typed +scene serialized by `serde_json::to_vec`: fixed struct field order, expanded +optional defaults, array order preserved. Source whitespace and object key order +do not matter. Any content change, including text, intentionally invalidates old +saves. No migrations are implemented. A future serializer/format change that +alters canonicalization must also change the save version. + +`state` contains: + +| Field | Representation | +| --- | --- | +| `room` | Current room ID | +| `inventory` | Sorted set of item IDs | +| `flags` | Sorted set of true flag IDs; absent means false | +| `used_hotspots` | Sorted set of `{room, hotspot}` for successful one-shot actions | +| `dialogue` | `{dialogue, node}` for the active conversation, or null | +| `visited_nodes` | Sorted set of `{dialogue, node}` | +| `chosen_choices` | Sorted set of `{dialogue, node, choice}` | +| `ending` | Ending ID, or null | + +Loading checks version, game identity, fingerprint, all state references, +one-shot hotspot markers, dialogue/choice history consistency, and terminal +state consistency. The active dialogue/ending must be exposed by the current +room. Unknown fields are rejected. Loading is transactional: the running state +is replaced only after every check succeeds. Error messages include the save +path (file API) and offending JSON field. Saves are local state snapshots, not +signed proofs of play; validation does not prove that arbitrary edited states +are reachable or enforce every game-specific puzzle invariant. + +Saving writes and syncs a temporary file beside the destination, then atomically +replaces the slot using `tempfile::persist`. Failed serialization, write, or +replacement leaves an existing slot intact. Parent directories must already +exist. Temp files are cleaned up on error. Concurrent writers use independent +temporary files; the last successful replacement wins. Directory metadata is not +synced, so power-loss durability depends on the platform/filesystem. + +The desktop player uses `.save.json` in its current directory by default; +its optional second positional argument chooses a different slot. F5/button +saves, F9/button loads, N/button starts a new game without deleting the slot. +Saving/loading is allowed during dialogue and after the ending. Missing, +corrupt, mismatched and inaccessible saves are shown as errors without resetting +the current game. The terminal fixture uses `last_light.save.json`. + +The status line, hover state, window configuration and UI entities are transient; +loading rebuilds the interface from the restored state. Item selection is not +needed: inventory-gated hotspots use matching items automatically. diff --git a/docs/walkthrough-transcript.txt b/docs/walkthrough-transcript.txt new file mode 100644 index 0000000..6a2e45c --- /dev/null +++ b/docs/walkthrough-transcript.txt @@ -0,0 +1,171 @@ +THE LAST LIGHT / PACE +Commands: number, click ID, choose ID, look, save, load, new, quit + +01 / The Quay +Night is falling. A boat waits beyond the reef, but the lighthouse is dark. Search the supply crate, then find the keeper. +Inventory: (empty) + 1. Search supply crate [click crate] + 2. Enter workshop [click workshop] + +> click crate +Under a folded oilskin, you find a spare fuse. + +01 / The Quay +Night is falling. A boat waits beyond the reef, but the lighthouse is dark. Search the supply crate, then find the keeper. +Inventory: Spare fuse + 1. Enter workshop [click workshop] + +> click workshop +Warm lamplight spills from the workshop. + +02 / The Workshop +The keeper studies a chart of the reef. Above the workbench, a locked stairway leads to the lantern. +Inventory: Spare fuse + 1. Return to quay [click quay] + 2. Talk to the keeper [click keeper] + 3. Unlock lantern stairs [click stairs] + +> click keeper +The keeper looks up from the chart. + +02 / The Workshop +The keeper studies a chart of the reef. Above the workbench, a locked stairway leads to the lantern. +Inventory: Spare fuse +The keeper: A boat is waiting offshore. Will you help me bring her home? + 1. Tell me how to fix the light. [choose help] + 2. I'll be back. [choose leave] + +> choose help +Tell me how to fix the light. + +02 / The Workshop +The keeper studies a chart of the reef. Above the workbench, a locked stairway leads to the lantern. +Inventory: Spare fuse +The keeper: Take my key. Find the spare fuse in the quay's supply crate and fit it in the lantern panel. Then light the beacon. + 1. Take the key. I'll get the light working. [choose accept] + 2. I need a moment. [choose leave] + +> save +Saved checkpoint. + +02 / The Workshop +The keeper studies a chart of the reef. Above the workbench, a locked stairway leads to the lantern. +Inventory: Spare fuse +The keeper: Take my key. Find the spare fuse in the quay's supply crate and fit it in the lantern panel. Then light the beacon. + 1. Take the key. I'll get the light working. [choose accept] + 2. I need a moment. [choose leave] + +> new +Started a new game. + +01 / The Quay +Night is falling. A boat waits beyond the reef, but the lighthouse is dark. Search the supply crate, then find the keeper. +Inventory: (empty) + 1. Search supply crate [click crate] + 2. Enter workshop [click workshop] + +> load +Loaded checkpoint. +Verified: room, inventory, flags, dialogue, history and ending match checkpoint. + +02 / The Workshop +The keeper studies a chart of the reef. Above the workbench, a locked stairway leads to the lantern. +Inventory: Spare fuse +The keeper: Take my key. Find the spare fuse in the quay's supply crate and fit it in the lantern panel. Then light the beacon. + 1. Take the key. I'll get the light working. [choose accept] + 2. I need a moment. [choose leave] + +> choose accept +Take the key. I'll get the light working. + +02 / The Workshop +The keeper studies a chart of the reef. Above the workbench, a locked stairway leads to the lantern. +Inventory: Spare fuse, Brass key + 1. Return to quay [click quay] + 2. Talk to the keeper [click keeper] + 3. Unlock lantern stairs [click stairs] + +> click stairs +The brass key turns. You climb to the lantern. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Spare fuse, Brass key + 1. Down to workshop [click workshop] + 2. Fit spare fuse [click panel] + 3. Light the beacon [click beacon] + +> click panel +The fuse clicks into place. The lens begins to turn. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Brass key + 1. Down to workshop [click workshop] + 2. Light the beacon [click beacon] + +> save +Saved checkpoint. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Brass key + 1. Down to workshop [click workshop] + 2. Light the beacon [click beacon] + +> new +Started a new game. + +01 / The Quay +Night is falling. A boat waits beyond the reef, but the lighthouse is dark. Search the supply crate, then find the keeper. +Inventory: (empty) + 1. Search supply crate [click crate] + 2. Enter workshop [click workshop] + +> load +Loaded checkpoint. +Verified: room, inventory, flags, dialogue, history and ending match checkpoint. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Brass key + 1. Down to workshop [click workshop] + 2. Light the beacon [click beacon] + +> click beacon +A ribbon of light sweeps the reef. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Brass key +The boat answers with two soft bells. Guided by your light, she slips safely into harbor. The keeper waves from the quay. You brought them home. / THE END + +> save +Saved checkpoint. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Brass key +The boat answers with two soft bells. Guided by your light, she slips safely into harbor. The keeper waves from the quay. You brought them home. / THE END + +> new +Started a new game. + +01 / The Quay +Night is falling. A boat waits beyond the reef, but the lighthouse is dark. Search the supply crate, then find the keeper. +Inventory: (empty) + 1. Search supply crate [click crate] + 2. Enter workshop [click workshop] + +> load +Loaded checkpoint. +Verified: room, inventory, flags, dialogue, history and ending match checkpoint. + +03 / The Lantern +The great lens is still. Fit the spare fuse into the dark control panel, then send a signal across the water. +Inventory: Brass key +The boat answers with two soft bells. Guided by your light, she slips safely into harbor. The keeper waves from the quay. You brought them home. / THE END + +> quit + +WALKTHROUGH PASS: safe_harbor reached and restored from disk. diff --git a/docs/walkthrough.md b/docs/walkthrough.md new file mode 100644 index 0000000..4db0501 --- /dev/null +++ b/docs/walkthrough.md @@ -0,0 +1,60 @@ +# Play The Last Light + +Run `cargo run --locked --features desktop --example last_light` for the native +Bevy game, or `cargo run --locked --example console` for the terminal version. +The same state machine powers both. Assets and dialogue are original; see the +[license](../assets/last-light/LICENSE.md). + +Click the labeled hotspots/choices, or use their displayed numbers. Numbering +changes when a one-shot hotspot disappears. All inventory use is automatic. + +| Step | Action | Expected result | +| --- | --- | --- | +| 1 | On the quay, Search supply crate | Spare fuse in inventory; crate disappears | +| 2 | Enter workshop | Room changes to workshop | +| 3 | Try Unlock lantern stairs (optional) | Keeper's key is required; no state change | +| 4 | Talk to the keeper | Greeting dialogue opens | +| 5 | Tell me how to fix the light | Instructions node opens | +| 6 | Save (F5), New (N), Load (F9) | Workshop, fuse and active instructions restored | +| 7 | Take the key. I'll get the light working | Key gained; dialogue closes; instructions remembered | +| 8 | Unlock lantern stairs | Room changes to lantern | +| 9 | Try Light the beacon (optional) | Repair is required; no state change | +| 10 | Fit spare fuse | Fuse consumed; repair flag set; panel disappears | +| 11 | Save, New, Load | Lantern, key, repair and dialogue history restored | +| 12 | Light the beacon | Boat reaches safe harbor; THE END | +| 13 | Save, New, Load | Ending restored; further gameplay remains disabled | + +You can visit the keeper before collecting the fuse, leave conversations and +return, and travel back to the quay. There is no timer or irreversible failure +in this fixture. On later conversations, the key-taking choice stays exhausted. + +## Reproducible demonstration + +```sh +cargo run --locked --example console -- --walkthrough +``` + +This replays [the playable commands](../assets/last-light/walkthrough.txt), writes +real temporary save files, starts fresh games at three checkpoints, compares the +entire restored state, and asserts the ending. It exits unsuccessfully on any +failed action or state mismatch. The temporary slot never replaces a player's +save. [The captured transcript](walkthrough-transcript.txt) shows a complete run. + +## Verification + +`cargo test --locked` covers invalid scenes, state transitions, blocked and +one-shot actions, dialogue modality/history, pointer hit bounds, Bevy message +ordering, each walkthrough step through a fresh save/load, corrupt/incompatible +saves, file replacement and I/O failure. CI also runs the scripted demonstration +and Clippy on all targets/features, including the desktop player. + +The native player was also exercised under Xvfb and Mesa software Vulkan on Linux: +mouse pickup and dialogue choice; keyboard navigation; locked stairs and an +unpowered beacon; F5/F9 and mouse Save/New/Load controls; restored dialogue, +consumed fuse and terminal ending. The 960×760 minimum window was checked for +readable controls. This is a manual native smoke check, separate from CI's +headless tests. Rust 1.99.1 and the locked Bevy 0.20.0 dependency were used. + +![Active dialogue restored with the fuse still in inventory](images/dialogue-restored.png) + +![Ending restored at the minimum supported window size](images/ending-restored.png) diff --git a/examples/console.rs b/examples/console.rs new file mode 100644 index 0000000..5ad801f --- /dev/null +++ b/examples/console.rs @@ -0,0 +1,179 @@ +//! Terminal player and reproducible, self-checking walkthrough of the fixture. +use pace_core::{runtime::*, scene::Scene}; +use std::{ + io::{self, BufRead, Write}, + path::Path, +}; + +type Result = std::result::Result>; + +fn new_game() -> Game { + Game::new( + Scene::from_json(include_str!("../assets/last-light/game.json")) + .expect("validated fixture"), + ) +} + +fn show(game: &Game) { + println!("\n{}\n{}", game.room().name, game.room().description); + let inventory: Vec<_> = game + .scene() + .items + .iter() + .filter(|i| game.state().inventory.contains(&i.id)) + .map(|i| i.name.as_str()) + .collect(); + println!( + "Inventory: {}", + if inventory.is_empty() { + "(empty)".into() + } else { + inventory.join(", ") + } + ); + if let Some(ending) = &game.state().ending { + println!( + "{}", + game.scene() + .endings + .iter() + .find(|e| &e.id == ending) + .unwrap() + .text + ); + } else if let Some(node) = game.active_node() { + println!("{}: {}", node.speaker, node.text); + for (i, c) in game.available_choices().iter().enumerate() { + println!(" {}. {} [choose {}]", i + 1, c.text, c.id); + } + } else { + for (i, h) in game.visible_hotspots().iter().enumerate() { + println!(" {}. {} [click {}]", i + 1, h.label, h.id); + } + } +} + +fn execute(game: &mut Game, line: &str, slot: &Path) -> Result { + let words: Vec<_> = line.split_whitespace().collect(); + match words.as_slice() { + ["click", id] => Ok(game.apply(AdventureCommand::Interact { + hotspot: (*id).into(), + })?), + ["choose", id] => Ok(game.apply(AdventureCommand::Choose { + choice: (*id).into(), + })?), + ["save"] => { + game.save_file(slot)?; + Ok("Saved checkpoint.".into()) + } + ["load"] => { + game.load_file(slot)?; + Ok("Loaded checkpoint.".into()) + } + ["new"] => { + *game = new_game(); + Ok("Started a new game.".into()) + } + ["look"] => Ok(String::new()), + [number] if number.parse::().is_ok() => { + let i = number + .parse::()? + .checked_sub(1) + .ok_or("Numbers start at 1")?; + let command = if game.state().dialogue.is_some() { + AdventureCommand::Choose { + choice: game + .available_choices() + .get(i) + .ok_or("No choice at that number")? + .id + .clone(), + } + } else { + AdventureCommand::Interact { + hotspot: game + .visible_hotspots() + .get(i) + .ok_or("No hotspot at that number")? + .id + .clone(), + } + }; + Ok(game.apply(command)?) + } + _ => Err("Use a number, click ID, choose ID, look, save, load, new, or quit".into()), + } +} + +fn play(reader: impl BufRead, game: &mut Game, slot: &Path, walkthrough: bool) -> Result<()> { + let mut checkpoint = None; + show(game); + for line in reader.lines() { + let line = line?; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + println!("\n> {line}"); + if line == "quit" { + break; + } + match execute(game, line, slot) { + Ok(text) => println!("{text}"), + Err(e) if walkthrough => { + return Err(format!("walkthrough command '{line}' failed: {e}").into()) + } + Err(e) => eprintln!("{e}"), + } + if walkthrough { + if line == "save" { + checkpoint = Some(game.state().clone()); + } + if line == "load" { + assert_eq!( + Some(game.state()), + checkpoint.as_ref(), + "complete saved state must survive a fresh game" + ); + println!("Verified: room, inventory, flags, dialogue, history and ending match checkpoint."); + } + } + show(game); + io::stdout().flush()?; + } + if walkthrough { + assert_eq!(game.state().ending.as_deref(), Some("safe_harbor")); + assert_eq!(game.state().room, "lantern"); + assert!(game.state().inventory.contains("key")); + assert!(!game.state().inventory.contains("fuse")); + println!("\nWALKTHROUGH PASS: safe_harbor reached and restored from disk."); + } + Ok(()) +} + +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + println!( + "THE LAST LIGHT / PACE\nCommands: number, click ID, choose ID, look, save, load, new, quit" + ); + let mut game = new_game(); + if args.as_slice() == ["--walkthrough"] { + // Never overwrite a player's slot during the automated demonstration. + let dir = tempfile::tempdir()?; + play( + io::Cursor::new(include_str!("../assets/last-light/walkthrough.txt")), + &mut game, + &dir.path().join("demo.json"), + true, + ) + } else if args.is_empty() { + play( + io::stdin().lock(), + &mut game, + Path::new("last_light.save.json"), + false, + ) + } else { + Err("Usage: cargo run --example console -- [--walkthrough]".into()) + } +} diff --git a/examples/last_light.rs b/examples/last_light.rs index 240cc94..3aa7e9b 100644 --- a/examples/last_light.rs +++ b/examples/last_light.rs @@ -15,10 +15,14 @@ const PANEL: Color = Color::srgb(0.1, 0.15, 0.18); struct Screen; #[derive(Resource)] struct Status(String); +#[derive(Resource)] +struct SavePath(std::path::PathBuf); #[derive(Message, Clone)] enum PlayerInput { Act(AdventureCommand), Restart, + Save, + Load, } fn main() -> Result<(), Box> { @@ -28,6 +32,9 @@ fn main() -> Result<(), Box> { }; let game = Game::new(Scene::from_json(&json)?); let title = game.scene().title.clone(); + let save_path = std::env::args() + .nth(2) + .unwrap_or_else(|| format!("{}.save.json", game.scene().game_id)); App::new() .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { @@ -44,6 +51,7 @@ fn main() -> Result<(), Box> { })) .insert_resource(ClearColor(INK)) .insert_resource(game) + .insert_resource(SavePath(save_path.into())) .insert_resource(Status( "Click a labeled hotspot, or press its number. Bring the boat home.".into(), )) @@ -67,6 +75,14 @@ fn keyboard( game: Res, mut input: MessageWriter, ) { + if keys.just_pressed(KeyCode::F5) { + input.write(PlayerInput::Save); + return; + } + if keys.just_pressed(KeyCode::F9) { + input.write(PlayerInput::Load); + return; + } if keys.just_pressed(KeyCode::KeyN) { input.write(PlayerInput::Restart); return; @@ -110,6 +126,7 @@ fn player_input( mut actions: MessageWriter, mut game: ResMut, mut status: ResMut, + path: Res, ) { // At most one UI action per frame, avoiding stale double-click/keyboard actions. if let Some(input) = input.read().next() { @@ -121,6 +138,18 @@ fn player_input( *game = Game::new(game.scene().clone().validate().expect("already validated")); status.0 = "A new evening on the quay. Find a way to light the beacon.".into(); } + PlayerInput::Save => { + status.0 = match game.save_file(&path.0) { + Ok(()) => format!("Saved to {}", path.0.display()), + Err(e) => format!("Save failed: {e}"), + }; + } + PlayerInput::Load => { + status.0 = match game.load_file(&path.0) { + Ok(()) => format!("Loaded {}", path.0.display()), + Err(e) => format!("Load failed: {e}"), + }; + } } } input.clear(); @@ -205,7 +234,11 @@ fn redraw( p.spawn(label("PACE / A THREE-ROOM ADVENTURE", 13.0, GOLD)); p.spawn(label(&game.scene().title, 32.0, PAPER)); }); - button(header, "New game [N]", PlayerInput::Restart, button_node()); + header.spawn(Node { column_gap: px(8), ..default() }).with_children(|p| { + button(p, "Save [F5]", PlayerInput::Save, button_node()); + button(p, "Load [F9]", PlayerInput::Load, button_node()); + button(p, "New [N]", PlayerInput::Restart, button_node()); + }); }); root.spawn(label(&room.name, 24.0, GOLD)); root.spawn((label(&room.description, 17.0, PAPER), Node { min_height: px(48), ..default() })); diff --git a/src/lib.rs b/src/lib.rs index 92772a0..40e4e48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod build; pub mod debug; pub mod runtime; +pub mod save; pub mod scene; diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..3e35341 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,217 @@ +//! Versioned saves, validated before replacing live state; atomic file replacement. +use crate::{runtime::*, scene::*}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fmt, io::Write, path::Path}; + +pub const SAVE_VERSION: u32 = 1; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Save { + save_version: u32, + game_id: String, + scene_fingerprint: String, + state: GameState, +} + +#[derive(Debug)] +pub struct SaveError(pub String); +impl fmt::Display for SaveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for SaveError {} + +fn fingerprint(scene: &Scene) -> String { + // Typed serialization fixes field order and expands defaults. Whitespace and + // object-key order in the source file do not invalidate an otherwise identical scene. + let bytes = serde_json::to_vec(scene).expect("validated scene is serializable"); + format!("{:x}", Sha256::digest(bytes)) +} + +impl Game { + pub fn save_json(&self) -> Result { + serde_json::to_string_pretty(&Save { + save_version: SAVE_VERSION, + game_id: self.scene().game_id.clone(), + scene_fingerprint: fingerprint(self.scene()), + state: self.state.clone(), + }) + .map_err(|e| SaveError(format!("could not serialize save: {e}"))) + } + + /// Failure leaves the live game untouched, including an active conversation. + pub fn load_json(&mut self, json: &str) -> Result<(), SaveError> { + let header: serde_json::Value = serde_json::from_str(json) + .map_err(|e| SaveError(format!("$: invalid save JSON: {e}")))?; + if let Some(version) = header.get("save_version").and_then(|v| v.as_u64()) { + if version != u64::from(SAVE_VERSION) { + return Err(SaveError(format!( + "$.save_version: unsupported save version {version}; expected {SAVE_VERSION}" + ))); + } + } + let mut de = serde_json::Deserializer::from_str(json); + let save: Save = serde_path_to_error::deserialize(&mut de) + .map_err(|e| SaveError(format!("$.{}: {}", e.path(), e.inner())))?; + if save.game_id != self.scene().game_id { + return Err(SaveError(format!( + "$.game_id: save belongs to '{}', expected '{}'", + save.game_id, + self.scene().game_id + ))); + } + if save.scene_fingerprint != fingerprint(self.scene()) { + return Err(SaveError("$.scene_fingerprint: scene content has changed; this save is incompatible (no v1 migration)".into())); + } + validate_state(self.scene(), &save.state)?; + self.state = save.state; + Ok(()) + } + + /// Writes/syncs a temporary file in the destination directory, then atomically + /// replaces the slot. Serialization/write/rename failures preserve the old file. + pub fn save_file(&self, path: impl AsRef) -> Result<(), SaveError> { + let path = path.as_ref(); + let json = self.save_json()?; + let io_error = |e: std::io::Error| SaveError(format!("{}: {e}", path.display())); + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(io_error)?; + temp.write_all(json.as_bytes()).map_err(io_error)?; + temp.as_file().sync_all().map_err(io_error)?; + temp.persist(path).map_err(|e| io_error(e.error))?; + Ok(()) + } + + pub fn load_file(&mut self, path: impl AsRef) -> Result<(), SaveError> { + let path = path.as_ref(); + let json = std::fs::read_to_string(path) + .map_err(|e| SaveError(format!("{}: {e}", path.display())))?; + self.load_json(&json) + .map_err(|e| SaveError(format!("{}: {e}", path.display()))) + } +} + +fn validate_state(scene: &Scene, state: &GameState) -> Result<(), SaveError> { + let mut errors = Vec::new(); + let mut check = |valid: bool, path: &str, message: String| { + if !valid { + errors.push(Diagnostic { + path: format!("$.state.{path}"), + message, + }); + } + }; + check( + scene.rooms.iter().any(|r| r.id == state.room), + "room", + format!("unknown room '{}'", state.room), + ); + for (i, item) in state.inventory.iter().enumerate() { + check( + scene.items.iter().any(|x| &x.id == item), + &format!("inventory[{i}]"), + format!("unknown item '{item}'"), + ); + } + for (i, flag) in state.flags.iter().enumerate() { + check( + scene.flags.contains(flag), + &format!("flags[{i}]"), + format!("unknown flag '{flag}'"), + ); + } + for (i, h) in state.used_hotspots.iter().enumerate() { + let hotspot = scene + .rooms + .iter() + .find(|r| r.id == h.room) + .and_then(|r| r.hotspots.iter().find(|x| x.id == h.hotspot)); + check( + hotspot.is_some_and(|h| h.once), + &format!("used_hotspots[{i}]"), + format!( + "expected existing one-shot hotspot '{}/{}'", + h.room, h.hotspot + ), + ); + } + let node = |p: &DialoguePosition| { + scene + .dialogues + .iter() + .find(|d| d.id == p.dialogue) + .and_then(|d| d.nodes.iter().find(|n| n.id == p.node)) + }; + for (i, p) in state.visited_nodes.iter().enumerate() { + check( + node(p).is_some(), + &format!("visited_nodes[{i}]"), + format!("unknown dialogue/node '{}/{}'", p.dialogue, p.node), + ); + } + for (i, c) in state.chosen_choices.iter().enumerate() { + let p = DialoguePosition { + dialogue: c.dialogue.clone(), + node: c.node.clone(), + }; + check( + node(&p).is_some_and(|n| n.choices.iter().any(|x| x.id == c.choice)), + &format!("chosen_choices[{i}]"), + format!("unknown choice '{}/{}/{}'", c.dialogue, c.node, c.choice), + ); + check( + state.visited_nodes.contains(&p), + &format!("chosen_choices[{i}]"), + "chosen choice's node must have been visited".into(), + ); + } + if let Some(p) = &state.dialogue { + check( + node(p).is_some(), + "dialogue", + format!("unknown dialogue/node '{}/{}'", p.dialogue, p.node), + ); + check( + state.visited_nodes.contains(p), + "dialogue", + "active node must have been visited".into(), + ); + check( + state.ending.is_none(), + "dialogue", + "an ended game cannot have active dialogue".into(), + ); + check(scene.rooms.iter().find(|r| r.id == state.room).is_some_and(|r| r.hotspots.iter().any(|h| matches!(&h.outcome, Outcome::Dialogue { dialogue } if dialogue == &p.dialogue))), "dialogue", "active dialogue must belong to a hotspot in the current room".into()); + } + if let Some(ending) = &state.ending { + check( + scene.endings.iter().any(|e| &e.id == ending), + "ending", + format!("unknown ending '{ending}'"), + ); + check( + scene + .rooms + .iter() + .find(|r| r.id == state.room) + .is_some_and(|r| { + r.hotspots + .iter() + .any(|h| matches!(&h.outcome, Outcome::End { ending: e } if e == ending)) + }), + "ending", + "ending must belong to a hotspot in the current room".into(), + ); + } + if errors.is_empty() { + Ok(()) + } else { + Err(SaveError(Diagnostics(errors).to_string())) + } +} diff --git a/tests/save_load.rs b/tests/save_load.rs new file mode 100644 index 0000000..24274cf --- /dev/null +++ b/tests/save_load.rs @@ -0,0 +1,199 @@ +use pace_core::{runtime::*, scene::Scene}; +use serde_json::{json, Value}; + +fn game() -> Game { + Game::new(Scene::from_json(include_str!("../assets/last-light/game.json")).unwrap()) +} +fn hit(g: &mut Game, id: &str) { + g.apply(AdventureCommand::Interact { hotspot: id.into() }) + .unwrap(); +} +fn choose(g: &mut Game, id: &str) { + g.apply(AdventureCommand::Choose { choice: id.into() }) + .unwrap(); +} +fn talking() -> Game { + let mut g = game(); + hit(&mut g, "crate"); + hit(&mut g, "workshop"); + hit(&mut g, "keeper"); + choose(&mut g, "help"); + g +} + +#[test] +fn fresh_instance_resumes_every_step_to_ending() { + let mut g = game(); + let steps = [ + (false, "crate"), + (false, "workshop"), + (false, "keeper"), + (true, "help"), + (true, "accept"), + (false, "stairs"), + (false, "panel"), + (false, "beacon"), + ]; + for (is_choice, id) in steps { + if is_choice { + choose(&mut g, id); + } else { + hit(&mut g, id); + } + let json = g.save_json().unwrap(); + let mut loaded = game(); + loaded.load_json(&json).unwrap(); + assert_eq!(loaded.state(), g.state(), "after {id}"); + assert_eq!( + loaded.save_json().unwrap(), + json, + "deterministic serialization" + ); + g = loaded; + } + assert_eq!(g.state().ending.as_deref(), Some("safe_harbor")); + assert!(!g.state().inventory.contains("fuse")); + assert!(g.state().inventory.contains("key")); + assert_eq!(g.state().room, "lantern"); + assert_eq!(g.state().visited_nodes.len(), 2); + assert_eq!(g.state().chosen_choices.len(), 2); + assert_eq!(g.state().used_hotspots.len(), 2); + assert!(g + .apply(AdventureCommand::Interact { + hotspot: "workshop".into() + }) + .is_err()); +} + +#[test] +fn file_save_resumes_room_inventory_and_active_dialogue_and_overwrites_slot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("slot.json"); + let original = talking(); + original.save_file(&path).unwrap(); + let mut g = game(); + g.load_file(&path).unwrap(); + assert_eq!(g.state(), original.state()); + assert_eq!(g.state().room, "workshop"); + assert!(g.state().inventory.contains("fuse")); + assert_eq!(g.active_node().unwrap().id, "instructions"); + choose(&mut g, "accept"); + g.save_file(&path).unwrap(); + let mut reloaded = game(); + reloaded.load_file(&path).unwrap(); + assert_eq!(reloaded.state(), g.state()); + // A consumed dialogue choice and pickup stay consumed after loading. + hit(&mut reloaded, "keeper"); + choose(&mut reloaded, "help"); + assert_eq!(reloaded.available_choices().len(), 1); + choose(&mut reloaded, "leave"); + hit(&mut reloaded, "quay"); + assert!(reloaded.visible_hotspots().iter().all(|h| h.id != "crate")); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + 1, + "temporary file cleaned up" + ); +} + +#[test] +fn incompatible_or_corrupt_loads_leave_live_state_unchanged() { + let mut g = talking(); + let before = g.state().clone(); + let good: Value = serde_json::from_str(&g.save_json().unwrap()).unwrap(); + for (pointer, value, expected) in [ + ("/save_version", json!(99), "unsupported save version"), + ("/game_id", json!("another_game"), "save belongs to"), + ( + "/scene_fingerprint", + json!("bad"), + "scene content has changed", + ), + ("/state/room", json!("missing"), "$.state.room"), + ("/state/inventory", json!(["missing"]), "$.state.inventory"), + ("/state/flags", json!(["missing"]), "$.state.flags"), + ( + "/state/used_hotspots", + json!([{"room":"quay","hotspot":"workshop"}]), + "one-shot hotspot", + ), + ("/state/dialogue/node", json!("missing"), "$.state.dialogue"), + ( + "/state/dialogue/dialogue", + json!("missing"), + "$.state.dialogue", + ), + ("/state/visited_nodes", json!([]), "must have been visited"), + ( + "/state/chosen_choices", + json!([{"dialogue":"keeper","node":"greeting","choice":"missing"}]), + "unknown choice", + ), + ( + "/state/ending", + json!("safe_harbor"), + "ended game cannot have active dialogue", + ), + ("/state/ending", json!("missing"), "unknown ending"), + ] { + let mut bad = good.clone(); + *bad.pointer_mut(pointer).unwrap() = value; + let error = g.load_json(&bad.to_string()).unwrap_err().to_string(); + assert!(error.contains(expected), "{pointer}: {error}"); + assert_eq!(g.state(), &before, "{pointer}"); + } + for invalid in [ + "{", + "{}", + "null", + "{\"save_version\":1,\"unexpected\":true}", + ] { + assert!(g.load_json(invalid).is_err()); + assert_eq!(g.state(), &before); + } +} + +#[test] +fn changed_scene_rejected_but_json_formatting_and_key_order_do_not_matter() { + let g = talking(); + let json = g.save_json().unwrap(); + let reformatted = serde_json::to_string(&serde_json::to_value(g.scene()).unwrap()).unwrap(); + let mut same = Game::new(Scene::from_json(&reformatted).unwrap()); + same.load_json(&json).unwrap(); + assert_eq!(same.state(), g.state()); + let mut scene = g.scene().clone(); + scene.rooms[0].name = "Different content".into(); + let mut changed = Game::new(scene.validate().unwrap()); + assert!(changed + .load_json(&json) + .unwrap_err() + .to_string() + .contains("fingerprint")); +} + +#[test] +fn io_errors_are_reported_without_mutating_game_or_other_slots() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("slot.json"); + let mut g = talking(); + g.save_file(&path).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let before = g.state().clone(); + let missing = dir.path().join("missing/slot.json"); + assert!(g + .save_file(&missing) + .unwrap_err() + .to_string() + .contains("missing")); + assert!(g.save_file(dir.path()).is_err()); // rename must not replace a directory + assert!(g.load_file(&missing).is_err()); + assert_eq!(g.state(), &before); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + std::fs::write(&path, "truncated{").unwrap(); + assert!(g + .load_file(&path) + .unwrap_err() + .to_string() + .contains("slot.json")); + assert_eq!(g.state(), &before); +}