//! The world data model — rooms, objects, dialogue, strokes — and [`World`], //! the single source of truth for "what game is loaded and which room are we //! in". Everything here is serde: a game is a folder of JSON + PNGs, and an //! LLM expansion is just JSON that deserializes. use crate::assets; use crate::framebuffer::{FrameBuffer, CTRL_BLOCK, PIC_H, PIC_W}; use crate::parser::VerbDef; use crate::picture::{self, PriFill}; use crate::view::{self, Cel}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum Meaning { None, Floor, Wall, Water, Trigger, } impl Meaning { pub fn pri(self) -> PriFill { match self { Meaning::None => PriFill::Keep, Meaning::Floor => PriFill::Band, Meaning::Wall => PriFill::Set(CTRL_BLOCK), Meaning::Water => PriFill::Set(1), Meaning::Trigger => PriFill::Set(2), } } pub fn label(self) -> &'static str { match self { Meaning::None => "none", Meaning::Floor => "floor", Meaning::Wall => "wall", Meaning::Water => "water", Meaning::Trigger => "trigger", } } } /// One editable paint operation. A room's look + walkability is the replayable /// list of these — the modern echo of AGI's PICTURE opcode streams. #[derive(Clone, Serialize, Deserialize)] pub enum Stroke { Brush { pts: Vec<(i32, i32)>, color: u8, meaning: Meaning, size: i32 }, Line { pts: Vec<(i32, i32)>, color: u8 }, Rect { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning }, Ellipse { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning }, Fill { x: i32, y: i32, color: u8, meaning: Meaning }, Image { name: String, x: i32, y: i32, meaning: Meaning }, } #[derive(Clone, Default, Serialize, Deserialize)] pub struct DlgChoice { pub text: String, pub goto: i32, // next node index, or -1 to end the conversation /// Only offered while this flag is true (empty = always offered). #[serde(default)] pub requires_flag: String, /// Picking this choice sets this flag true (empty = none). #[serde(default)] pub sets_flag: String, /// Points awarded when this choice flips its `sets_flag` false → true /// (the flag latch is what makes the award once-only). #[serde(default)] pub points: u32, /// Picking this choice kills the player, Sierra style. The `goto` node's /// `says` is printed as the death text, then the day restarts. #[serde(default)] pub kills: bool, } #[derive(Clone, Serialize, Deserialize)] pub struct DlgNode { pub says: String, #[serde(default)] pub choices: Vec, } /// A live object: a sprite the player can look at / take / use / talk to. Its /// base point (x, y) is its feet, like the ego, so it depth-sorts the same way. #[derive(Clone, Default, Serialize, Deserialize)] pub struct ObjDef { pub name: String, pub sprite: String, pub x: i32, pub y: i32, #[serde(default)] pub look: String, #[serde(default)] pub takeable: bool, #[serde(default)] pub synonyms: String, // space-separated alternate names #[serde(default)] pub use_text: String, // shown on "use"/"open" #[serde(default)] pub needs: String, // item required to use it (empty = no lock) #[serde(default)] pub wins: bool, // using it (successfully) wins the game /// Using it also requires this flag to be true (empty = no gate). #[serde(default)] pub requires_flag: String, /// Using it (successfully) sets this flag true (empty = none). #[serde(default)] pub sets_flag: String, #[serde(default)] pub dialogue: Vec, // node 0 = entry; empty = no conversation /// Points for taking it (takeable) or first successful use (otherwise). /// Awarded once per room+name. #[serde(default)] pub points: u32, /// A successful use removes the `needs` item from inventory. #[serde(default)] pub consumes: bool, /// A successful use kills the player (use_text is the death text). #[serde(default)] pub kills: bool, /// Object exists only while this flag is true (empty = always). #[serde(default)] pub visible_flag: String, /// Object disappears once this flag is true (empty = never). #[serde(default)] pub hidden_by_flag: String, } /// Is this object currently part of the room, given taken-list and flags? pub fn obj_visible( o: &ObjDef, room: u32, taken: &[(u32, String)], flags: &std::collections::HashMap, ) -> bool { let on = |name: &str| flags.get(name).copied().unwrap_or(false); !taken.iter().any(|(r, n)| *r == room && *n == o.name) && (o.visible_flag.is_empty() || on(&o.visible_flag)) && (o.hidden_by_flag.is_empty() || !on(&o.hidden_by_flag)) } #[derive(Clone, Serialize, Deserialize)] pub struct RoomDoc { pub strokes: Vec, pub spawn: (i32, i32), #[serde(default)] pub exits: [Option; 4], // N, E, S, W → target room index #[serde(default)] pub objects: Vec, #[serde(default)] pub music: u8, // 0 = silence, 1.. = ambient mood /// Display name, shown on entry and in `look` ("" = just the number). #[serde(default)] pub name: String, /// Prose printed when the ego enters the room ("" = the old stock line). #[serde(default)] pub enter_text: String, /// Per-direction flag required before that exit works ("" = open). N,E,S,W. #[serde(default = "empty4")] pub exit_flags: [String; 4], /// What to say when the matching exit refuses ("" = a stock line). N,E,S,W. #[serde(default = "empty4")] pub exit_blocked: [String; 4], } fn empty4() -> [String; 4] { [String::new(), String::new(), String::new(), String::new()] } impl Default for RoomDoc { fn default() -> Self { RoomDoc { strokes: Vec::new(), spawn: (80, 150), exits: [None; 4], objects: Vec::new(), music: 0, name: String::new(), enter_text: String::new(), exit_flags: empty4(), exit_blocked: empty4(), } } } /// `game.json` — what makes a folder a game. Every field defaults, so a bare /// folder of rooms is already a valid game (that's how legacy projects load). #[derive(Clone, Serialize, Deserialize)] pub struct GameManifest { #[serde(default = "default_name")] pub name: String, #[serde(default)] pub start_room: u32, #[serde(default = "default_intro")] pub intro_text: String, /// Extra verb synonyms, merged over the built-in table by canonical name. #[serde(default)] pub verbs: Vec, /// Initial flag values for a new game. #[serde(default)] pub flags: HashMap, /// Total points on offer; 0 = scoring off (no HUD line, no score events). #[serde(default)] pub max_score: u32, /// The banner shown when the player dies and the day restarts. #[serde(default = "default_death")] pub death_text: String, } fn default_name() -> String { "untitled".into() } fn default_death() -> String { "\u{2020} You have died. The day restarts. \u{2020}".into() } fn default_intro() -> String { "You blink awake. Type 'help'.".into() } impl Default for GameManifest { fn default() -> Self { GameManifest { name: default_name(), start_room: 0, intro_text: default_intro(), verbs: Vec::new(), flags: HashMap::new(), max_score: 0, death_text: default_death(), } } } // --- stroke application ------------------------------------------------ /// Paint a filled disc (brush / eraser). pub fn disc(fb: &mut FrameBuffer, cx: i32, cy: i32, size: i32, color: u8, pri: PriFill) { for dy in -size..=size { for dx in -size..=size { if dx * dx + dy * dy <= size * size { picture::px(fb, cx + dx, cy + dy, Some(color), pri); } } } } /// Stamp a cel centered on (cx, cy); transparent pixels are skipped. `pri` /// writes walkability under the painted pixels. pub fn stamp(fb: &mut FrameBuffer, cel: &Cel, cx: i32, cy: i32, pri: PriFill) { let ox = cx - cel.w as i32 / 2; let oy = cy - cel.h as i32 / 2; for y in 0..cel.h { for x in 0..cel.w { let c = cel.at(x, y); if c == view::TRANSPARENT { continue; } picture::px(fb, ox + x as i32, oy + y as i32, Some(c), pri); } } } pub fn apply_stroke(fb: &mut FrameBuffer, s: &Stroke, sprites: &[(String, Cel)]) { match s { Stroke::Brush { pts, color, meaning, size } => { for &(x, y) in pts { disc(fb, x, y, *size, *color, meaning.pri()); } } Stroke::Line { pts, color } => { for w in pts.windows(2) { picture::line_into(fb, w[0], w[1], *color, PriFill::Keep); } } Stroke::Rect { x, y, w, h, color, meaning } => { picture::rect_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri()); } Stroke::Ellipse { x, y, w, h, color, meaning } => { picture::ellipse_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri()); } Stroke::Fill { x, y, color, meaning } => { picture::flood_fill(fb, *x, *y, Some(*color), meaning.pri()); } Stroke::Image { name, x, y, meaning } => { if let Some((_, cel)) = sprites.iter().find(|(n, _)| n == name) { stamp(fb, cel, *x, *y, meaning.pri()); } } } } /// Find a walkable spawn near `prefer`, scanning upward from the bottom. pub fn find_spawn(fb: &FrameBuffer, prefer: (i32, i32)) -> (i32, i32) { if fb.priority_at(prefer.0, prefer.1) != CTRL_BLOCK { return prefer; } for y in (20..164).rev() { for x in 8..152 { if fb.priority_at(x, y) != CTRL_BLOCK { return (x, y); } } } prefer } /// A whole world in one JSON document: the manifest + every room. What cloud /// saves store and what `to_bundle`/`apply_bundle` round-trip. #[derive(Clone, Serialize, Deserialize)] pub struct WorldBundle { #[serde(default)] pub manifest: GameManifest, #[serde(default)] pub rooms: HashMap, } /// The loaded game: manifest + sprites + the current room baked into a /// framebuffer, plus an in-memory overlay of rooms that differ from disk /// (unsaved editor work, or lore upserted live over the bus). pub struct World { pub base: PathBuf, pub manifest: GameManifest, pub current: u32, pub doc: RoomDoc, pub fb: FrameBuffer, pub sprites: Vec<(String, Cel)>, pub overlay: HashMap, } impl World { /// Load a game folder: `game.json` (all-defaults if absent) + `rooms/` + /// `sprites/`. A legacy project (bare `rooms/*.json` in the cwd) is just a /// game folder with no manifest — one code path serves both. pub fn load_game(dir: impl Into) -> Self { let base: PathBuf = dir.into(); let manifest: GameManifest = std::fs::read_to_string(base.join("game.json")) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let sprites = assets::load_sprite_dir(&base.join("sprites").to_string_lossy()); let mut w = World { base, current: manifest.start_room, manifest, doc: RoomDoc::default(), fb: FrameBuffer::new(), sprites, overlay: HashMap::new(), }; let start = w.current; // Legacy fallback: a single old-style `rooms/room.json` acts as room 0. if !w.room_path(start).exists() && start == 0 { let legacy = w.base.join("rooms/room.json"); if let Ok(s) = std::fs::read_to_string(legacy) { if let Ok(doc) = serde_json::from_str::(&s) { w.overlay.insert(0, doc); } } } w.goto_room(start); w } /// Build a World entirely from memory — no filesystem. This is how the /// browser build boots: the game folder is baked into the binary and /// handed over here as parsed rooms + quantized sprites. pub fn from_memory( manifest: GameManifest, rooms: impl IntoIterator, sprites: Vec<(String, Cel)>, ) -> Self { let mut w = World { base: PathBuf::from("."), current: manifest.start_room, manifest, doc: RoomDoc::default(), fb: FrameBuffer::new(), sprites, overlay: rooms.into_iter().collect(), }; let start = w.current; w.goto_room(start); w } pub fn room_path(&self, n: u32) -> PathBuf { self.base.join(format!("rooms/room{}.json", n)) } /// Snapshot the whole world as one JSON document — manifest + every room /// (disk, overlay, and the live current room). The single-file project /// format used by cloud saves and shareable world files. pub fn to_bundle(&self) -> WorldBundle { let mut rooms: HashMap = HashMap::new(); // rooms on disk if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().into_owned(); if let Some(n) = name.strip_prefix("room").and_then(|s| s.strip_suffix(".json")).and_then(|s| s.parse().ok()) { if let Some(doc) = self.peek_room(n) { rooms.insert(n, doc); } } } } // overlay wins over disk; the live doc wins over everything for (n, doc) in &self.overlay { rooms.insert(*n, doc.clone()); } rooms.insert(self.current, self.doc.clone()); WorldBundle { manifest: self.manifest.clone(), rooms } } /// Replace the loaded world with a bundle (sprites are kept — bundles /// carry world data, not art). The caller decides what to do next /// (usually `Command::NewGame`). pub fn apply_bundle(&mut self, b: WorldBundle) { self.manifest = b.manifest; self.overlay = b.rooms.into_iter().collect(); let start = self.manifest.start_room; self.goto_room(start); } /// Read a room doc without switching to it: overlay first, then disk. pub fn peek_room(&self, n: u32) -> Option { if let Some(doc) = self.overlay.get(&n) { return Some(doc.clone()); } std::fs::read_to_string(self.room_path(n)).ok().and_then(|s| serde_json::from_str(&s).ok()) } /// Switch the working room WITHOUT saving. Overlay wins over disk; a /// missing room is a fresh blank one. pub fn goto_room(&mut self, n: u32) { self.current = n; self.doc = self.peek_room(n).unwrap_or_default(); self.bake(); } /// Replay the current room's strokes into the framebuffer. pub fn bake(&mut self) { self.fb.clear(); for s in &self.doc.strokes { apply_stroke(&mut self.fb, s, &self.sprites); } } /// Stash the current room into the overlay (so leaving and returning /// keeps unsaved changes) — used before playtesting live edits. pub fn stash_current(&mut self) { self.overlay.insert(self.current, self.doc.clone()); } /// Insert/replace a room. If it's the current room the framebuffer rebakes. /// With `persist` it is also written to disk (and dropped from the overlay). pub fn upsert_room(&mut self, n: u32, doc: RoomDoc, persist: bool) -> std::io::Result<()> { if persist { self.write_room(n, &doc)?; self.overlay.remove(&n); } else { self.overlay.insert(n, doc.clone()); } if n == self.current { self.doc = doc; self.bake(); } Ok(()) } pub fn write_room(&self, n: u32, doc: &RoomDoc) -> std::io::Result<()> { let path = self.room_path(n); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let j = serde_json::to_string_pretty(doc).map_err(std::io::Error::other)?; std::fs::write(path, j) } /// Save the current room to disk. pub fn save_current(&mut self) -> std::io::Result<()> { self.write_room(self.current, &self.doc.clone())?; self.overlay.remove(&self.current); Ok(()) } pub fn sprite_cel(&self, name: &str) -> Option<&Cel> { self.sprites.iter().find(|(n, _)| n == name).map(|(_, c)| c) } pub fn reload_sprites(&mut self) { self.sprites = assets::load_sprite_dir(&self.base.join("sprites").to_string_lossy()); self.bake(); } /// The ego's view. A game folder overrides the default party robot by /// dropping `sprites/ego.png` (feet on the bottom edge, like any prop); /// `ego1.png` is an optional second cel for the walk bob. pub fn ego_view(&self) -> view::View { // Read the already-loaded sprite table rather than stat'ing the folder: // a wasm build is baked into the binary and has no `sprites/` to look at, // so the filesystem version silently fell back to the robot on the web. match self.sprite_cel("ego") { Some(c0) => { let mut cels = vec![c0.clone()]; if let Some(c1) = self.sprite_cel("ego1") { cels.push(c1.clone()); } view::View { loops: vec![view::ViewLoop { cels }] } } None => view::default_robot_view(), } } /// Sanity-check a room before committing it (the lore-ingestion gate): /// exits must point at rooms that exist (on disk, in the overlay, or being /// upserted alongside), and the room must have somewhere to stand. pub fn validate_room(&self, n: u32, doc: &RoomDoc) -> Result<(), String> { let mut fb = FrameBuffer::new(); for s in &doc.strokes { apply_stroke(&mut fb, s, &self.sprites); } let (sx, sy) = find_spawn(&fb, doc.spawn); if fb.priority_at(sx, sy) == CTRL_BLOCK { return Err(format!("room {} has no walkable spawn — it's all wall", n)); } for (d, t) in doc.exits.iter().enumerate() { if let Some(t) = t { let known = *t == n || self.overlay.contains_key(t) || self.room_path(*t).exists(); if !known { return Err(format!( "room {} exit {} points at room {}, which doesn't exist yet — upsert it first", n, ["N", "E", "S", "W"][d], t )); } } } Ok(()) } } // Keep constants nearby for consumers that reason about room geometry. pub const ROOM_W: usize = PIC_W; pub const ROOM_H: usize = PIC_H; // --- collections: a folder of game folders ------------------------------ /// Does this directory hold a playable game? (Manifest, a start room, or a /// legacy single-room project all count — the same tests `load_game` uses.) pub fn is_game_dir(dir: &std::path::Path) -> bool { dir.join("game.json").exists() || dir.join("rooms/room0.json").exists() || dir.join("rooms/room.json").exists() } /// Scan a directory for game folders: every immediate subdirectory that /// `is_game_dir` accepts, as `(path, display name)`, sorted by folder name. /// The display name comes from the game's manifest; a folder with no /// manifest shows under its own name. pub fn scan_collection(dir: &std::path::Path) -> Vec<(PathBuf, String)> { let mut out = Vec::new(); let Ok(rd) = std::fs::read_dir(dir) else { return out }; for entry in rd.flatten() { let p = entry.path(); if p.is_dir() && is_game_dir(&p) { let name = std::fs::read_to_string(p.join("game.json")) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .map(|m| m.name) .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()); out.push((p, name)); } } out.sort_by(|a, b| a.0.file_name().cmp(&b.0.file_name())); out } /// The collection's own title, for the picker screen: an optional /// `collection.json` `{"name": "..."}` in the directory, else the folder /// name with separators spaced out, uppercased. pub fn collection_name(dir: &std::path::Path) -> String { #[derive(Deserialize)] struct C { name: String, } if let Ok(s) = std::fs::read_to_string(dir.join("collection.json")) { if let Ok(c) = serde_json::from_str::(&s) { return c.name; } } dir.file_name() .map(|n| n.to_string_lossy().replace(['-', '_'], " ").to_uppercase()) .unwrap_or_else(|| "MRPGI".to_string()) } #[cfg(test)] mod collection_tests { use super::*; #[test] fn scans_names_and_sorts_a_collection() { let base = std::env::temp_dir().join(format!("mrpgi-coll-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("b-two/rooms")).unwrap(); std::fs::write(base.join("b-two/rooms/room0.json"), "{}").unwrap(); std::fs::create_dir_all(base.join("a-one")).unwrap(); std::fs::write(base.join("a-one/game.json"), r#"{"name":"Alpha"}"#).unwrap(); std::fs::create_dir_all(base.join("not-a-game")).unwrap(); std::fs::write(base.join("collection.json"), r#"{"name":"My Shelf"}"#).unwrap(); assert!(!is_game_dir(&base)); let got = scan_collection(&base); assert_eq!(got.len(), 2); assert_eq!(got[0].1, "Alpha"); assert_eq!(got[1].1, "b-two"); assert_eq!(collection_name(&base), "My Shelf"); let _ = std::fs::remove_dir_all(&base); } }