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>
113 lines
3.9 KiB
Rust
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());
|
|
}
|