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>
196 lines
8.1 KiB
Rust
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();
|
|
}
|