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>
377 lines
16 KiB
Rust
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();
|
|
}
|