diff --git a/mrpci-core/src/bin/mrpci-headless/main.rs b/mrpci-core/src/bin/mrpci-headless/main.rs index bf2852a..673cba1 100644 --- a/mrpci-core/src/bin/mrpci-headless/main.rs +++ b/mrpci-core/src/bin/mrpci-headless/main.rs @@ -30,7 +30,9 @@ fn main() { --sample write the demo game into games/neon-precinct\n\ --seed N seed the deterministic RNG\n\ --room N jump to room N at boot (with --render-room etc)\n\ - --ticks N advance N cycles at boot (palette cycling, NPCs)\n" + --ticks N advance N cycles at boot (palette cycling, NPCs)\n\ + --bundle FILE boot from a single-JSON world bundle (the wasm path)\n\ + --export-bundle F write the loaded game as one bundle JSON and exit\n" ); return; } @@ -48,8 +50,39 @@ fn main() { } let game_dir = get("--game").unwrap_or_else(|| ".".into()); - let world = World::load_game(&game_dir); + // `--bundle FILE` boots from a single-JSON world instead of a folder — + // the exact path a filesystem-less (wasm) build takes, testable here. + let world = match get("--bundle") { + Some(path) => { + let s = std::fs::read_to_string(&path).unwrap_or_else(|e| { + eprintln!("can't read bundle {}: {}", path, e); + std::process::exit(1); + }); + let bundle: mrpci_core::WorldBundle = serde_json::from_str(&s).unwrap_or_else(|e| { + eprintln!("bad bundle {}: {}", path, e); + std::process::exit(1); + }); + let mut sprites: Vec<_> = bundle.sprites.clone().into_iter().collect(); + sprites.sort_by(|a, b| a.0.cmp(&b.0)); + World::from_memory( + bundle.manifest, + bundle.rooms, + bundle.scripts, + sprites, + ) + } + None => World::load_game(&game_dir), + }; let mut gs = GameState::new(world); + + if let Some(path) = get("--export-bundle") { + let bundle = gs.world.to_bundle(); + let j = serde_json::to_string(&bundle).expect("bundle serializes"); + std::fs::write(&path, &j).expect("write bundle"); + println!("bundle exported: {} ({} rooms, {} scripts, {} sprites, {} bytes)", + path, bundle.rooms.len(), bundle.scripts.len(), bundle.sprites.len(), j.len()); + return; + } if let Some(seed) = get("--seed").and_then(|s| s.parse::().ok()) { gs.apply(Command::SetSeed { seed }); } diff --git a/mrpci-core/src/room.rs b/mrpci-core/src/room.rs index 53d6214..1c5bfe7 100644 --- a/mrpci-core/src/room.rs +++ b/mrpci-core/src/room.rs @@ -280,6 +280,11 @@ pub struct WorldBundle { /// Script sources by file name (bundles travel with their logic). #[serde(default)] pub scripts: HashMap, + /// Sprite art by name (raw RGBA; quantized per room at bake time). + /// Optional so old bundles still load — an empty map keeps whatever + /// sprites the receiving World already has. + #[serde(default)] + pub sprites: HashMap, } /// The loaded game: manifest + sprites + the current room baked into the @@ -485,15 +490,22 @@ impl World { } } } - WorldBundle { manifest: self.manifest.clone(), rooms, scripts } + let sprites = self.sprites.iter().map(|(n, s)| (n.clone(), s.clone())).collect(); + WorldBundle { manifest: self.manifest.clone(), rooms, scripts, sprites } } - /// Replace the loaded world with a bundle (sprites are kept — bundles - /// carry world data, not art). + /// Replace the loaded world with a bundle. A bundle that carries + /// sprites replaces the art wholesale (the wasm/bootstrap path); one + /// that doesn't keeps whatever this World already loaded. pub fn apply_bundle(&mut self, b: WorldBundle) { self.manifest = b.manifest; self.overlay = b.rooms.into_iter().collect(); self.script_overlay = b.scripts.into_iter().collect(); + if !b.sprites.is_empty() { + let mut sprites: Vec<(String, SpriteSrc)> = b.sprites.into_iter().collect(); + sprites.sort_by(|a, b| a.0.cmp(&b.0)); + self.sprites = sprites; + } let start = self.manifest.start_room; self.goto_room(start); } diff --git a/mrpci-core/src/view.rs b/mrpci-core/src/view.rs index 991086a..3e4cb25 100644 --- a/mrpci-core/src/view.rs +++ b/mrpci-core/src/view.rs @@ -19,8 +19,10 @@ use serde::{Deserialize, Serialize}; /// A raw RGBA sprite as loaded from disk — cooked into [`Cel`]s per room so -/// quantization always matches the room's actual palette. -#[derive(Clone)] +/// quantization always matches the room's actual palette. Serde so bundles +/// can carry art: a filesystem-less build (wasm, tests) gets sprites the +/// same way it gets rooms — inside one JSON document. +#[derive(Clone, Serialize, Deserialize)] pub struct SpriteSrc { pub w: usize, pub h: usize, diff --git a/mrpci-core/tests/bundle.rs b/mrpci-core/tests/bundle.rs new file mode 100644 index 0000000..df02302 --- /dev/null +++ b/mrpci-core/tests/bundle.rs @@ -0,0 +1,44 @@ +//! Bundle round-trip: a game folder → one JSON document → a fully working +//! World with NO filesystem. This is exactly how a wasm build boots, so it +//! gets a native test. + +use mrpci_core::{Command, GameState, World, WorldBundle}; + +fn game_dir() -> Option { + for p in ["games/neon-precinct", "../games/neon-precinct"] { + if std::path::Path::new(p).join("game.json").exists() { + return Some(p.to_string()); + } + } + None +} + +#[test] +fn bundle_round_trip_boots_without_fs() { + let Some(dir) = game_dir() else { + eprintln!("demo game not generated; skipping"); + return; + }; + let world = World::load_game(dir); + let json = serde_json::to_string(&world.to_bundle()).expect("bundle serializes"); + let bundle: WorldBundle = serde_json::from_str(&json).expect("bundle parses"); + assert_eq!(bundle.rooms.len(), 3, "all three demo rooms travel"); + assert!(bundle.scripts.len() >= 4, "main.rhai + room scripts travel"); + assert!(bundle.sprites.contains_key("keycard"), "art travels"); + + let mut sprites: Vec<_> = bundle.sprites.clone().into_iter().collect(); + sprites.sort_by(|a, b| a.0.cmp(&b.0)); + let world2 = World::from_memory(bundle.manifest, bundle.rooms, bundle.scripts, sprites); + let mut gs = GameState::new(world2); + gs.apply(Command::NewGame { room: None }); + + // The world is really alive: art cooked, scripts compiled, sim ticking. + assert!(gs.world.cel("keycard").is_some(), "sprites quantized from bundle art"); + let snap = gs.snapshot(); + assert_eq!(snap.room_name, "Neon Row"); + assert!(!snap.props.is_empty()); + gs.apply(Command::Tick { n: 60 }); + let frame = gs.render_indices(true); + assert_eq!(frame.len(), 320 * 190); + assert!(frame.iter().any(|&p| p != 0), "frame isn't blank"); +}