MRPGI/mrpgi-core/tests/engine.rs
type-two 96b45561d0 Carve the engine into a headless core behind a command/event bus
The handover plan (docs/HANDOVER.md), P0-P6, in one pass. One insight
powers it: CLI/HTTP/MCP/Python is one feature, not four — a headless
core driven by Commands in, Events out, with every surface a thin
adapter.

- mrpgi-core: the whole sim as a library with zero macroquad (the
  workspace boundary is the guardrail). GameState::apply/tick, fixed
  1/20s cycles, deterministic replays, headless RGBA/PNG rendering.
- mrpgi-headless: JSONL stdio REPL, --script replay (AI lane off =
  byte-identical), --render-room PNG, --serve HTTP (state/command/
  events cursor/frame.png), --mcp JSON-RPC stdio server whose
  render_frame returns a real PNG so a model sees its own moves.
- Games are folders: game.json manifest (all fields default — legacy
  dirs still load), data-driven verb table feeding both the parser and
  the AI whitelist, flags with requires_flag/sets_flag gating on
  objects and dialogue choices, live UpsertRoom lore ingestion with
  validation.
- mrpgi (GUI): now one consumer of the bus; editor commits through the
  same commands MCP uses; audio became core events with per-game
  sfx/ music/ file overrides beating the chiptune synth.
- Tests: parser goldens + a full win-path playthrough asserting
  byte-identical transcripts across runs.

Deleted: the old fused src/ (root is now a virtual workspace), the
dead demo Scene/Assets path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:18:33 +10:00

196 lines
8.1 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,
synonyms: String::new(),
use_text: use_text.into(),
needs: needs.into(),
wins,
requires_flag: String::new(),
sets_flag: String::new(),
dialogue: Vec::new(),
}
}
/// 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 verbs = default_verbs();
let mut taken = Vec::new();
let mut won = false;
let mut flags = HashMap::new();
let mut talk = None;
let (lines, understood) = run_command(input, &verbs, objects, 0, inventory, &mut taken, &mut won, &mut flags, &mut talk);
(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 verbs = default_verbs();
let mut inv = Vec::new();
let mut taken = Vec::new();
let mut won = false;
let mut flags = HashMap::new();
let mut talk = None;
let (lines, _) = run_command("open hatch", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
assert_eq!(lines[0], "It's locked. You need: battery.");
assert!(!won);
run_command("take battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
let (lines, _) = run_command("open hatch", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
assert_eq!(lines[0], "It opens!");
assert!(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 verbs = default_verbs();
let (mut inv, mut taken, mut won, mut flags, mut talk) = (Vec::new(), Vec::new(), false, HashMap::new(), None);
let (lines, _) = run_command("use door", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
assert_eq!(lines[0], "Nothing happens. Something else must come first.");
assert!(!won);
run_command("use lever", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
assert_eq!(flags.get("power_on"), Some(&true));
let (lines, _) = run_command("use door", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
assert_eq!(lines[0], "The door slides open.");
assert!(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 inv, mut taken, mut won, mut flags, mut talk) = (Vec::new(), Vec::new(), false, HashMap::new(), None);
let (lines, understood) = run_command("yoink battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk);
assert!(understood);
assert_eq!(lines[0], "You take the battery.");
}
/// 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();
}
/// 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();
}