MRPCI/mrpci-core/tests/playthrough.rs
type-two ce42d69a3a v0.5 — playtest-driven fixes + engine/UX upgrades
Bugs found by playing the game over the bus:
- hotspots now have positions: 'walk to precinct door' pathfinds there,
  and touch verbs walk over to hotspots like they do to props/NPCs
- GameState::settle(): the MCP surface resolves walk-then-act in one call
  instead of returning zero events on a distant verb
- typed 'use X on Y' walks over like a clicked use (was unlimited range)
- taking/deleting a solid prop rebakes — no more lingering invisible walls
- sergeant dialogue no longer loops back to its greeting
- serde-facing maps are BTreeMap: room JSON and --sample are byte-stable

Engine:
- sprite-sheet actor views: <name>-sheet<C>x<R>.png slices into C frames x
  R direction loops (vend-bot now rolls on animated treads)
- pixel-accurate click targeting with 1px slop; typed commands click
  mid-sprite (a feet click lands between an actor's legs)
- parser: compass walking (go east / n), 'bye' ends conversations,
  'look' lists exits

GUI:
- hover hot-text: cursor reads the sentence a click would say
- death banner + '+N' score toast

Tests: full-playthrough regression suite (win path, death rewind,
walk-over dialogue, solid-prop footprint) + sheet slicing unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:38:33 +10:00

113 lines
3.9 KiB
Rust

//! The whole demo game, played to the win over the bus — the regression net
//! for verbs, walk-then-act, parser targeting, death-rewind and scoring.
//! (Run `mrpci-headless --sample` first; CI does.)
use mrpci_core::{Command, Event, GameState, World};
fn game_dir() -> Option<String> {
for p in ["games/neon-precinct", "../games/neon-precinct"] {
if std::path::Path::new(p).join("game.json").exists() {
return Some(p.to_string());
}
}
None
}
fn boot() -> Option<GameState> {
let dir = game_dir()?;
let mut gs = GameState::new(World::load_game(dir));
gs.apply(Command::NewGame { room: None });
Some(gs)
}
/// Apply a command, then settle any walk-then-act it queued.
fn act(gs: &mut GameState, cmd: Command) -> Vec<Event> {
let mut ev = gs.apply(cmd);
ev.extend(gs.settle(2000));
ev
}
fn say(gs: &mut GameState, text: &str) -> Vec<Event> {
act(gs, Command::Parse { text: text.into() })
}
#[test]
fn full_case_closes_at_25_points() {
let Some(mut gs) = boot() else { return };
// Street → alley → keycard.
say(&mut gs, "go east");
assert_eq!(gs.world.current, 2, "east edge exit reaches Rain Alley");
say(&mut gs, "take keycard");
assert!(gs.inventory.contains(&"keycard".to_string()), "keycard picked up (walk-then-act)");
assert_eq!(gs.score, 5);
// Back to the street; typed hotspot walking must reach the precinct.
say(&mut gs, "go west");
assert_eq!(gs.world.current, 0);
say(&mut gs, "walk to precinct door");
assert_eq!(gs.world.current, 1, "'walk to precinct door' pathfinds onto the door hotspot");
// Locker: keycard opens it, script hands over the evidence.
say(&mut gs, "use keycard on locker");
assert!(gs.flags.get("locker_open").copied().unwrap_or(false));
assert_eq!(gs.score, 15);
say(&mut gs, "look at locker");
assert!(gs.inventory.contains(&"evidence".to_string()), "room script bags the data-slate");
// Terminal closes the case.
say(&mut gs, "use evidence on terminal");
assert!(gs.won, "terminal win fires");
assert_eq!(gs.score, 25, "all points on the table");
assert!(!gs.inventory.contains(&"evidence".to_string()), "terminal consumes the slate");
}
#[test]
fn death_rewinds_to_room_entry_not_the_start() {
let Some(mut gs) = boot() else { return };
act(&mut gs, Command::GotoRoom { n: 2 });
say(&mut gs, "take keycard");
assert_eq!(gs.score, 5);
let ev = say(&mut gs, "touch fusebox");
assert!(ev.iter().any(|e| matches!(e, Event::Died { .. })), "the fusebox kills");
assert_eq!(gs.world.current, 2, "rewind lands at this room's entry, not a restart");
assert_eq!(gs.score, 0, "points rewind with the checkpoint");
assert!(gs.inventory.is_empty(), "so does the pocket");
}
#[test]
fn taking_a_solid_prop_clears_its_footprint() {
let Some(mut gs) = boot() else { return };
let prop = mrpci_core::PropDef {
name: "loot crate".into(),
sprite: "crate".into(),
x: 160,
y: 160,
takeable: true,
solid: true,
..Default::default()
};
act(&mut gs, Command::UpsertProp { room: None, prop });
assert!(!gs.world.screens.walkable(160, 158), "solid prop blocks while present");
say(&mut gs, "take loot crate");
assert!(gs.inventory.contains(&"loot crate".to_string()));
assert!(gs.world.screens.walkable(160, 158), "footprint cleared once taken — no invisible wall");
}
#[test]
fn dialogue_ends_on_bye_and_talk_walks_over() {
let Some(mut gs) = boot() else { return };
say(&mut gs, "walk to precinct door");
assert_eq!(gs.world.current, 1);
let ev = say(&mut gs, "talk to sergeant");
assert!(
ev.iter().any(|e| matches!(e, Event::DialogueOpen { .. })),
"a distant talk walks over and still opens the conversation"
);
let ev = say(&mut gs, "bye");
assert!(ev.iter().any(|e| matches!(e, Event::DialogueClosed)), "'bye' ends it");
assert!(gs.dlg.is_none());
}