//! Golden tests for the parser and a full headless play-through of the //! sample game — the safety net under every future refactor. use mrpgi_core::ai::AiConfig; use mrpgi_core::parser::{build_verbs, default_verbs, run_command}; use mrpgi_core::state::{Command, Event}; use mrpgi_core::world::ObjDef; use mrpgi_core::{GameState, World}; use std::collections::HashMap; fn obj(name: &str, takeable: bool, needs: &str, use_text: &str, wins: bool) -> ObjDef { ObjDef { name: name.into(), sprite: "sign".into(), x: 80, y: 100, look: format!("It's a {}.", name), takeable, use_text: use_text.into(), needs: needs.into(), wins, ..Default::default() } } /// A bag of the parser's mutable state, so tests read like scripts. #[derive(Default)] struct Sim { inv: Vec, taken: Vec<(u32, String)>, won: bool, flags: HashMap, score: u32, scored: Vec<(u32, String)>, died: bool, } impl Sim { fn run(&mut self, input: &str, objects: &[ObjDef]) -> (Vec, bool) { let verbs = default_verbs(); let mut talk = None; run_command( input, &verbs, objects, 0, "", &mut self.inv, &mut self.taken, &mut self.won, &mut self.flags, &mut talk, &mut self.score, &mut self.scored, &mut self.died, ) } } /// Run one line against a tiny fixture room; return (first line, understood). fn run(input: &str, objects: &[ObjDef], inventory: &mut Vec) -> (String, bool) { let mut sim = Sim { inv: std::mem::take(inventory), ..Default::default() }; let (lines, understood) = sim.run(input, objects); *inventory = sim.inv; (lines.first().cloned().unwrap_or_default(), understood) } #[test] fn parser_goldens() { let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)]; let mut inv = Vec::new(); assert_eq!(run("look", &objects, &mut inv), ("Room 0. You see: battery, hatch.".into(), true)); assert_eq!(run("examine the battery", &objects, &mut inv), ("It's a battery.".into(), true)); assert_eq!(run("grab battery", &objects, &mut inv), ("You take the battery.".into(), true)); assert_eq!(inv, vec!["battery".to_string()]); assert_eq!(run("take hatch", &objects, &mut inv), ("You can't take the hatch.".into(), true)); assert_eq!(run("i", &objects, &mut inv), ("Carrying: battery.".into(), true)); assert_eq!(run("drop battery", &objects, &mut inv), ("You drop the battery.".into(), true)); assert_eq!(run("drop battery", &objects, &mut inv), ("You aren't carrying a 'battery'.".into(), true)); assert_eq!(run("dance wildly", &objects, &mut inv).1, false); assert_eq!(run("talk hatch", &objects, &mut inv), ("hatch has nothing to say.".into(), true)); } #[test] fn use_respects_needs_lock() { let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)]; let mut sim = Sim::default(); let (lines, _) = sim.run("open hatch", &objects); assert_eq!(lines[0], "It's locked. You need: battery."); assert!(!sim.won); sim.run("take battery", &objects); let (lines, _) = sim.run("open hatch", &objects); assert_eq!(lines[0], "It opens!"); assert!(sim.won); } #[test] fn use_respects_flag_gate_and_sets_flags() { let mut lever = obj("lever", false, "", "The lever clunks over.", false); lever.sets_flag = "power_on".into(); let mut door = obj("door", false, "", "The door slides open.", true); door.requires_flag = "power_on".into(); let objects = vec![lever, door]; let mut sim = Sim::default(); let (lines, _) = sim.run("use door", &objects); assert_eq!(lines[0], "Nothing happens. Something else must come first."); assert!(!sim.won); sim.run("use lever", &objects); assert_eq!(sim.flags.get("power_on"), Some(&true)); let (lines, _) = sim.run("use door", &objects); assert_eq!(lines[0], "The door slides open."); assert!(sim.won); } #[test] fn manifest_verbs_extend_the_table() { let extra = vec![mrpgi_core::parser::VerbDef { name: "take".into(), words: vec!["yoink".into()] }]; let verbs = build_verbs(&extra); let objects = vec![obj("battery", true, "", "", false)]; let mut sim = Sim::default(); let mut talk = None; let (lines, understood) = run_command( "yoink battery", &verbs, &objects, 0, "", &mut sim.inv, &mut sim.taken, &mut sim.won, &mut sim.flags, &mut talk, &mut sim.score, &mut sim.scored, &mut sim.died, ); assert!(understood); assert_eq!(lines[0], "You take the battery."); } #[test] fn points_award_once_and_consumes_eats_the_item() { let mut coin = obj("coin", true, "", "", false); coin.points = 5; let mut slot = obj("slot", false, "coin", "Clunk. The mechanism accepts your offering.", false); slot.points = 10; slot.consumes = true; let objects = vec![coin, slot]; let mut sim = Sim::default(); let (lines, _) = sim.run("take coin", &objects); assert_eq!(lines, vec!["You take the coin.".to_string(), "(+5 points.)".to_string()]); assert_eq!(sim.score, 5); let (lines, _) = sim.run("use slot", &objects); assert_eq!(lines[1], "(+10 points.)"); assert_eq!(sim.score, 15); assert!(sim.inv.is_empty(), "consumes must remove the coin"); // no re-award, and the coin is gone so the lock re-engages let (lines, _) = sim.run("use slot", &objects); assert_eq!(lines[0], "It's locked. You need: coin."); assert_eq!(sim.score, 15); } #[test] fn kills_sets_died_and_visibility_flags_hide_objects() { let mut kebab = obj("kebab", false, "", "You eat it. It eats back.", false); kebab.kills = true; let mut ghost = obj("ghost", false, "", "", false); ghost.visible_flag = "seance".into(); let mut queue = obj("queue", false, "", "", false); queue.hidden_by_flag = "dispersed".into(); let objects = vec![kebab, ghost, queue]; let mut sim = Sim::default(); let (lines, _) = sim.run("look ghost", &objects); assert!(lines[0].starts_with("You don't see any"), "invisible until its flag: {:?}", lines); let (lines, _) = sim.run("look queue", &objects); assert!(lines[0].starts_with("It's a queue")); sim.flags.insert("seance".into(), true); sim.flags.insert("dispersed".into(), true); let (lines, _) = sim.run("look ghost", &objects); assert!(lines[0].starts_with("It's a ghost")); let (lines, _) = sim.run("look queue", &objects); assert!(lines[0].starts_with("You don't see any"), "hidden once its flag set: {:?}", lines); let (lines, _) = sim.run("use kebab", &objects); assert_eq!(lines[0], "You eat it. It eats back."); assert!(sim.died); assert!(!sim.won); } /// Play the shipped sample game start → win, entirely over the bus, twice — /// and require the two transcripts to be byte-identical (replay determinism). #[test] fn sample_game_win_path_is_deterministic() { let dir = std::env::temp_dir().join(format!("mrpgi-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); mrpgi_core::kit::write_sample_world(&dir); let play = || -> (Vec, bool) { let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); let script = [ Command::NewGame { room: None }, Command::Parse { text: "take battery".into() }, Command::WalkTo { x: 156, y: 85 }, // through the east door of room 0 Command::Tick { n: 10 }, Command::WalkTo { x: 80, y: 18 }, // through the north door of room 1 Command::Tick { n: 10 }, Command::Parse { text: "use hatch".into() }, Command::Query, ]; let mut won = false; for cmd in script { for e in gs.apply(cmd) { if matches!(e, Event::Won) { won = true; } } } (gs.transcript.clone(), won) }; let (t1, won1) = play(); let (t2, won2) = play(); assert!(won1, "expected to win the sample game; transcript: {:?}", t1); assert!(won2); assert_eq!(t1, t2, "replay must be bit-identical"); std::fs::remove_dir_all(&dir).ok(); } /// Flag-gated exits refuse until the flag is set; death restarts the day; /// score events fire only when max_score is declared. #[test] fn gated_exits_death_and_score_over_the_bus() { let dir = std::env::temp_dir().join(format!("mrpgi-gate-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); mrpgi_core::kit::write_sample_world(&dir); // Gate room 0's east exit behind `belt-off`, name the room, add a killer // and a scoring object, and declare a max score. let mut doc: mrpgi_core::RoomDoc = serde_json::from_str(&std::fs::read_to_string(dir.join("rooms/room0.json")).unwrap()).unwrap(); doc.name = "The Boot-Up Closet".into(); doc.enter_text = "It smells like warm solder and second chances.".into(); doc.exit_flags[1] = "belt-off".into(); doc.exit_blocked[1] = "The seatbelt of destiny holds you fast.".into(); let mut belt = obj("belt", false, "", "Click. You are free.", false); belt.sets_flag = "belt-off".into(); belt.points = 7; doc.objects.push(belt); let mut pill = obj("pill", false, "", "You swallow the mystery pill.", false); pill.kills = true; doc.objects.push(pill); std::fs::write(dir.join("rooms/room0.json"), serde_json::to_string(&doc).unwrap()).unwrap(); let manifest = r#"{"name":"gate-test","max_score":7,"intro_text":"Go."}"#; std::fs::write(dir.join("game.json"), manifest).unwrap(); let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); let ev = gs.apply(Command::NewGame { room: None }); assert!(gs.transcript.iter().any(|l| l.contains("Boot-Up Closet")), "room name announced: {:?}", gs.transcript); drop(ev); // Blocked at the east edge until the belt flag is set. gs.apply(Command::WalkTo { x: 156, y: 85 }); assert_eq!(gs.world.current, 0, "gated exit must hold"); assert!(gs.transcript.iter().any(|l| l.contains("seatbelt of destiny")), "{:?}", gs.transcript); // Free the belt (+7 points, ScoreChanged), then the same walk passes. let ev = gs.apply(Command::Parse { text: "use belt".into() }); assert!(ev.iter().any(|e| matches!(e, Event::ScoreChanged { score: 7, max: 7 }))); gs.apply(Command::WalkTo { x: 156, y: 85 }); gs.apply(Command::Tick { n: 10 }); assert_eq!(gs.world.current, 1, "exit must open once flagged"); // Death: back to room 0, swallow the pill, expect Died + restart. gs.apply(Command::GotoRoom { n: 0 }); let ev = gs.apply(Command::Parse { text: "use pill".into() }); assert!(ev.iter().any(|e| matches!(e, Event::Died { .. }))); assert_eq!(gs.world.current, 0); assert_eq!(gs.score, 0, "death restarts the day"); assert!(gs.transcript.iter().any(|l| l.contains("You have died")), "{:?}", gs.transcript); std::fs::remove_dir_all(&dir).ok(); } /// Review regressions: a corner block must not slingshot the ego through the /// perpendicular open exit; held-key retries must not repeat the message; a /// killing choice must pay no points; death/new-game must re-zero the score /// on the event bus. #[test] fn blocked_corner_does_not_slingshot_and_kill_choices_pay_nothing() { let dir = std::env::temp_dir().join(format!("mrpgi-corner-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); mrpgi_core::kit::write_sample_world(&dir); // Room 0: WEST exit gated (flag never set), NORTH exit open, both to room 1. let mut doc: mrpgi_core::RoomDoc = serde_json::from_str(&std::fs::read_to_string(dir.join("rooms/room0.json")).unwrap()).unwrap(); doc.strokes.clear(); // fully walkable: the corner must be reachable doc.exits = [Some(1), None, None, Some(1)]; doc.exit_flags[3] = "never-set".into(); // A talker with a scoring choice and a killing choice. let mut npc = obj("cultist", false, "", "", false); npc.dialogue = vec![ mrpgi_core::world::DlgNode { says: "Join us?".into(), choices: vec![ mrpgi_core::world::DlgChoice { text: "Yes (die)".into(), goto: 1, kills: true, points: 50, sets_flag: "joined".into(), ..Default::default() }, ], }, mrpgi_core::world::DlgNode { says: "The robes were a mistake.".into(), choices: vec![] }, ]; doc.objects.push(npc); std::fs::write(dir.join("rooms/room0.json"), serde_json::to_string(&doc).unwrap()).unwrap(); std::fs::write(dir.join("game.json"), r#"{"name":"corner-test","max_score":50,"intro_text":"Go."}"#).unwrap(); let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); gs.apply(Command::NewGame { room: None }); // Walk into the NW corner: west gate blocks; the open north exit must NOT fire. gs.apply(Command::WalkTo { x: 6, y: 18 }); let before = gs.transcript.len(); gs.apply(Command::Tick { n: 30 }); assert_eq!(gs.world.current, 0, "must not slingshot through the north exit"); assert_eq!(gs.transcript.len(), before, "no repeated blocked messages while idle"); let blocks = gs.transcript.iter().filter(|l| l.contains("Something prevents")).count(); assert_eq!(blocks, 1, "the refusal prints once: {:?}", gs.transcript); // Kill choice: no points, no flag, score still zero after restart. gs.apply(Command::Parse { text: "talk cultist".into() }); let ev = gs.apply(Command::Choose { n: 1 }); assert!(ev.iter().any(|e| matches!(e, Event::Died { .. }))); assert!(ev.iter().any(|e| matches!(e, Event::ScoreChanged { score: 0, .. })), "restart re-zeroes the score on the bus"); assert_eq!(gs.score, 0); assert_eq!(gs.flags.get("joined"), None, "kills preempts sets_flag"); assert!(!gs.transcript.iter().any(|l| l.contains("points")), "kills pays nothing: {:?}", gs.transcript); assert!(gs.transcript.iter().any(|l| l.contains("robes were a mistake")), "death text from goto node"); std::fs::remove_dir_all(&dir).ok(); } /// Headless render smoke test: the frame is a real PNG with the right size. #[test] fn renders_a_png_with_no_window() { let dir = std::env::temp_dir().join(format!("mrpgi-render-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); mrpgi_core::kit::write_sample_world(&dir); let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); gs.apply(Command::NewGame { room: None }); let png = gs.render_png(true); assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]); let img = image::load_from_memory(&png).unwrap(); assert_eq!((img.width(), img.height()), (160, 168)); std::fs::remove_dir_all(&dir).ok(); } /// Lore ingestion: upsert a new room over the bus, walk into it. #[test] fn upsert_room_extends_a_running_game() { let dir = std::env::temp_dir().join(format!("mrpgi-lore-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); mrpgi_core::kit::write_sample_world(&dir); let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); gs.apply(Command::NewGame { room: None }); // A bad room (exit into the void) must be rejected... let mut bad = mrpgi_core::RoomDoc::default(); bad.exits[0] = Some(99); let ev = gs.apply(Command::UpsertRoom { n: 7, doc: bad, persist: false }); assert!(matches!(ev[0], Event::Error { .. })); // ...and a good one accepted and playable. let mut doc = mrpgi_core::RoomDoc::default(); doc.objects.push(obj("gift", true, "", "", false)); let ev = gs.apply(Command::UpsertRoom { n: 7, doc, persist: false }); assert!(matches!(ev[0], Event::RoomUpserted { room: 7, .. }), "unexpected: {}", serde_json::to_string(&ev).unwrap()); gs.apply(Command::GotoRoom { n: 7 }); let ev = gs.apply(Command::Parse { text: "take gift".into() }); assert!(ev.iter().any(|e| matches!(e, Event::InventoryChanged { .. }))); std::fs::remove_dir_all(&dir).ok(); }