MRPGI/mrpgi-core/tests/engine.rs
type-two 755168a969 Sierra soul for game authors: scoring, deaths, gated exits, room prose, visibility flags
New authoring surface (all serde-defaulted; old games load unchanged):
- RoomDoc: name + enter_text (announced on entry), exit_flags/exit_blocked
  (per-direction flag gates with authored refusal lines, latched per attempt)
- ObjDef: points (once per room+name), consumes, kills, visible_flag,
  hidden_by_flag; DlgChoice: points (latched on sets_flag flip), kills
  (goto node's says = death text)
- GameManifest: max_score enables scoring; GUI shows the classic
  'Score: X of Y' in the menu bar; score_changed + died on the bus
- Death restarts the day, keeping the cause at the top of the fresh transcript

Reviewed by an adversarial multi-agent pass; confirmed findings fixed:
corner push-back no longer slingshots through a perpendicular open exit
(and checks the landing pixel), blocked messages latch per attempt,
kill choices pay no points/flags, new_game re-zeroes the score on the bus,
AI-lane prompts respect object visibility. Deliberate behavior change:
drop/consumes now emit inventory_changed (was a silent client desync).

Built for MORPQUEST (games/… ssh://…/monster/mrpquest.git), which uses all
of it: 250-point budget, four gated exits, two deaths, vanishing Beckies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:30:32 +10:00

377 lines
16 KiB
Rust

//! 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<String>,
taken: Vec<(u32, String)>,
won: bool,
flags: HashMap<String, bool>,
score: u32,
scored: Vec<(u32, String)>,
died: bool,
}
impl Sim {
fn run(&mut self, input: &str, objects: &[ObjDef]) -> (Vec<String>, 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>) -> (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<String>, 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();
}