fixing tests

This commit is contained in:
2026-07-25 12:48:11 -05:00
parent ed219d74e5
commit c3be2329c0
8 changed files with 514 additions and 571 deletions
+117 -96
View File
@@ -1,187 +1,208 @@
use super::{log_texts, scripted_object, scripts_from};
use crate::builtin::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use super::{log_texts, scripts_from};
use crate::board::tests::{crate_at, is_builtin, object_at, open_board, wall_at};
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::tile::EnterResponse;
use crate::utils::ObjectId;
use std::time::Duration;
use crate::Builtin;
/// The cell object `id` currently occupies.
fn loc(game: &GameState, id: ObjectId) -> (usize, usize) {
game.board()
.get_hookable(id)
.expect("object still on the board")
.location()
}
/// Object `id`'s current glyph.
fn glyph(game: &GameState, id: ObjectId) -> Glyph {
game.board()
.get_hookable(id)
.expect("object still on the board")
.glyph()
}
/// Builds a game with one `Block` object at `(x, y)` running `src`, plus the player
/// parked at `player`, and runs `init()`.
///
/// The mover is `EnterResponse::Block` — an ordinary solid that blocks others and
/// cannot be shoved, which is what a self-propelled scripted object should be. Its
/// `enter_response` describes how it answers *others* entering its cell and should
/// not constrain its own `move()`.
fn game_with_mover(
w: usize,
h: usize,
player: (usize, usize),
at: (usize, usize),
src: &str,
) -> (GameState, ObjectId) {
let mut board = open_board(w, h, player);
let id = object_at(&mut board, at.0, at.1, "m", EnterResponse::Block);
let mut game = GameState::with_scripts(board, scripts_from(&[("m", src)]));
game.run_init();
(game, id)
}
#[test]
fn move_command_relocates_the_source_object() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]);
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init();
let b = game.board();
let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { move(East); }");
// East increments x by one; the object started at (2, 1).
assert_eq!((b.objects[&1].x, b.objects[&1].y), (3, 1));
assert_eq!(loc(&game, id), (3, 1));
}
#[test]
fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored.
let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]);
let (game, id) = game_with_mover(5, 3, (0, 0), (0, 1), "fn init(me) { move(West); }");
assert_eq!(loc(&game, id), (0, 1));
// Object facing a wall: blocked, also ignored.
let mut board = open_board(5, 3, (0, 0));
let id = object_at(&mut board, 1, 1, "m", EnterResponse::Block);
wall_at(&mut board, 2, 1);
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(West); }")]));
GameState::with_scripts(board, scripts_from(&[("m", "fn init(me) { move(East); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].x, 0);
assert_eq!(loc(&game, id), (1, 1));
}
#[test]
fn set_tile_command_changes_the_source_glyph() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]);
let mut game =
GameState::with_scripts(board, scripts_from(&[("s", "fn init(m) { set_tile(7); }")]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7);
let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { set_tile(7); }");
assert_eq!(glyph(&game, id).tile, 7);
}
#[test]
fn object_pushes_crate_on_init() {
// A scripted object moving east into a crate shoves it (step_object path).
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
// A scripted object moving east into a crate shoves it (the step_object path).
let mut board = open_board(4, 1, (0, 0));
let id = object_at(&mut board, 1, 0, "m", EnterResponse::Block);
crate_at(&mut board, 2, 0);
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
GameState::with_scripts(board, scripts_from(&[("m", "fn init(me) { move(East); }")]));
game.run_init();
assert_eq!(loc(&game, id), (2, 0));
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert!(is_builtin(&b, 3, 0, "crate")); // crate shoved one east
assert!(b.get(1, 0).is_none()); // the object's old cell is vacated
}
#[test]
fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room.
let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init();
{
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
assert_eq!((b.player.x, b.player.y), (3, 0));
}
let (game, id) = game_with_mover(5, 1, (2, 0), (1, 0), "fn init(me) { move(East); }");
assert_eq!(loc(&game, id), (2, 0));
assert_eq!(game.board().player_pos(), (3, 0));
// With a wall behind the player, the object is blocked and nothing moves.
let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]);
let mut board = open_board(4, 1, (2, 0));
let id = object_at(&mut board, 1, 0, "m", EnterResponse::Block);
wall_at(&mut board, 3, 0);
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
GameState::with_scripts(board, scripts_from(&[("m", "fn init(me) { move(East); }")]));
game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0));
assert_eq!((b.player.x, b.player.y), (2, 0));
assert_eq!(loc(&game, id), (1, 0));
assert_eq!(game.board().player_pos(), (2, 0));
}
#[test]
fn move_cost_rate_limits_repeated_moves() {
// init queues two moves; only the first resolves immediately, the second must
// wait the full 250 ms cooldown.
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init(m) { move(East); move(East); }")]),
// wait the full 250 ms cooldown that move() appends behind it.
let (mut game, id) = game_with_mover(
5,
1,
(0, 0),
(1, 0),
"fn init(me) { move(East); move(East); }",
);
game.run_init();
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
assert_eq!(loc(&game, id).0, 2); // first move applied (1 -> 2)
// 200 ms of ticks: still inside the cooldown, no further movement.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[&1].x, 2);
assert_eq!(loc(&game, id).0, 2);
// Crossing the 250 ms mark releases the queued second move.
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[&1].x, 3);
assert_eq!(loc(&game, id).0, 3);
}
#[test]
fn inline_delay_paces_subsequent_moves() {
// move() always appends a Delay(250 ms) to the queue, so a second queued move
// is held back by that delay even if the first move was blocked by a wall.
let mut board = open_board(3, 3, (0, 0), vec![scripted_object(1, 1, "m")]);
let mut board = open_board(3, 3, (0, 0));
let id = object_at(&mut board, 1, 1, "m", EnterResponse::Block);
wall_at(&mut board, 2, 1);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("m", "fn init(m) { move(East); move(South); }")]),
scripts_from(&[("m", "fn init(me) { move(East); move(South); }")]),
);
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
assert_eq!(
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 1)
);
assert_eq!(loc(&game, id), (1, 1));
// The Delay(250 ms) appended by move(East) is still live, so South is pending.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
assert_eq!(
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 1)
);
assert_eq!(loc(&game, id), (1, 1));
// Past 250 ms the queued South move resolves.
game.tick(Duration::from_millis(100));
assert_eq!(
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 2)
);
assert_eq!(loc(&game, id), (1, 2));
}
#[test]
fn queue_length_reports_pending_actions() {
// Two zero-cost actions are queued before the length is read, then all three
// (incl. the log) drain in one pump.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"q",
"fn init(m) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }",
)]),
// Two zero-cost actions are queued before the length is read; log() bypasses the
// queue so it reports the length as of that moment, then both set_tiles drain.
let (game, id) = game_with_mover(
3,
1,
(0, 0),
(1, 0),
"fn init(me) { set_tile(5); set_tile(6); log(`len=${me.queue.length}`); }",
);
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
assert_eq!(game.board().objects[&1].glyph.tile, 6); // last set_tile won
assert_eq!(glyph(&game, id).tile, 6); // last set_tile won
}
#[test]
fn queue_clear_drops_pending_actions() {
// clear() empties the output queue mid-script, so the queued moves never run.
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "c")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]),
let (mut game, id) = game_with_mover(
5,
1,
(0, 0),
(1, 0),
"fn init(me) { move(East); move(East); me.queue.clear(); }",
);
game.run_init();
game.tick(Duration::from_millis(300));
assert_eq!(game.board().objects[&1].x, 1); // never moved
assert_eq!(loc(&game, id), (1, 0)); // never moved
}
#[test]
fn blocked_reports_solid_and_clear() {
let src = "fn init(me) { if me.blocked(East) { set_tile(9); } else { set_tile(7); } }";
// Solid ahead (a wall): blocked() is true.
let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut board = open_board(3, 1, (0, 0));
let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
wall_at(&mut board, 2, 0);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
"fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]),
);
let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 9);
assert_eq!(glyph(&game, id).tile, 9);
// Open ahead, nothing pending: blocked() is false.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
"fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]),
);
let mut board = open_board(3, 1, (0, 0));
let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
game.run_init();
assert_eq!(game.board().objects[&1].glyph.tile, 7);
assert_eq!(glyph(&game, id).tile, 7);
}
-42
View File
@@ -1,42 +0,0 @@
use super::{log_texts, scripted_object, scripts_from};
use crate::board::tests::open_board;
use crate::game::GameState;
use std::time::Duration;
#[test]
fn collision_priority_resolves_in_array_order_and_bumps() {
// Two objects move into the same empty cell (1,0). obj0 (earlier) wins it;
// obj1 is blocked and bumps obj0. obj1 itself receives no bump.
let board = open_board(
3,
2,
(0, 1), // player off the contested row
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
(
"e",
"fn init(m) { move(East); } fn bump(m,dir) { log(`o0 from ${dir}`); }",
),
(
"w",
"fn init(m) { move(West); } fn bump(m,dir) { log(`o1 from ${dir}`); }",
),
]),
);
game.run_init();
// The bump fires during resolution but its log is emitted into obj0's queue;
// a later tick (past both objects' move cooldown) flushes it to the game log.
game.tick(Duration::from_millis(300));
{
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); // obj0 won the cell
assert_eq!((b.objects[&2].x, b.objects[&2].y), (2, 0)); // obj1 blocked
}
let logs = log_texts(&game);
// obj1 moved West into obj0, so the bump arrives from the East side of obj0.
assert!(logs.iter().any(|t| t == "o0 from East")); // obj0 bumped by obj1
assert!(!logs.iter().any(|t| t.starts_with("o1 from"))); // obj1 not bumped
}
+62 -56
View File
@@ -1,63 +1,51 @@
use crate::builtin::Archetype;
//! Board-to-board transitions via [`GameState::enter_board`] and portal stepping.
//!
//! Portals live in `Board::portals`, parallel to the grid rather than in it — the
//! player shares a cell with one — so `try_move` checks `portal_at` only after the
//! player has actually relocated.
use crate::board::Board;
use crate::floor::Floor;
use crate::board::tests::open_board;
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::utils::{Direction, PlayerPos};
use crate::portal::Portal;
use crate::utils::Direction;
use crate::world::World;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use std::rc::Rc;
use crate::portal::Portal;
/// Builds a 3×3 board with the player at `(px, py)` and the given portals.
fn make_board(px: i64, py: i64, portals: Vec<Portal>) -> Board {
Board {
name: "test".into(),
width: 3,
height: 3,
grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
floor: Floor::Blank,
decorations: Vec::new(),
sensors: Vec::new(),
player: PlayerPos { x: px, y: py },
objects: BTreeMap::new(),
next_object_id: 1,
portals,
board_script_name: None,
dark: false,
load_errors: Vec::new(),
registry: HashMap::new(),
/// Builds a 3×3 board with the player at `player` and the given portals.
fn make_board(player: (usize, usize), portals: Vec<Portal>) -> Board {
let mut board = open_board(3, 3, player);
board.portals = portals;
board
}
/// A glyphless portal at `(x, y)` pointing at `target_name` on `target_board`.
fn portal(
x: usize,
y: usize,
name: &str,
target_board: &str,
target_name: &str,
) -> Portal {
Portal {
x,
y,
name: name.to_string(),
target_board: target_board.to_string(),
target_name: target_name.to_string(),
glyph: None,
}
}
/// Two-board world used by all tests in this module.
/// Two-board world used by all tests in this module, with b1's player at `b1_player`.
///
/// - `"b1"`: player at `(0, 0)`, portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"`
/// - `"b1"`: portal `"to_b2"` at `(2, 0)` → `"b2"` / `"from_b1"`
/// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"`
fn two_board_world() -> World {
let b1 = make_board(
0,
0,
vec![Portal {
name: "to_b2".into(),
x: 2,
y: 0,
target_board: "b2".into(),
target_name: "from_b1".into(),
}],
);
let b2 = make_board(
0,
0,
vec![Portal {
name: "from_b1".into(),
x: 1,
y: 1,
target_board: "b1".into(),
target_name: "to_b2".into(),
}],
);
fn two_board_world_with_player(b1_player: (usize, usize)) -> World {
let b1 = make_board(b1_player, vec![portal(2, 0, "to_b2", "b2", "from_b1")]);
let b2 = make_board((0, 0), vec![portal(1, 1, "from_b1", "b1", "to_b2")]);
World {
name: "test".into(),
start: "b1".into(),
@@ -70,6 +58,11 @@ fn two_board_world() -> World {
}
}
/// The two-board world with b1's player at the origin.
fn two_board_world() -> World {
two_board_world_with_player((0, 0))
}
#[test]
fn enter_board_switches_board() {
let mut game = GameState::from_world(two_board_world());
@@ -81,10 +74,25 @@ fn enter_board_switches_board() {
fn enter_board_places_player_at_arrival_portal() {
let mut game = GameState::from_world(two_board_world());
game.enter_board("b2", "from_b1");
let board = game.board();
// Arrival portal "from_b1" is at (1, 1) on b2.
assert_eq!(board.player.x, 1);
assert_eq!(board.player.y, 1);
assert_eq!(game.board().player_pos(), (1, 1));
}
#[test]
fn entering_a_board_leaves_exactly_one_player_on_it() {
// b2 is authored with a player at (0,0); arriving through the portal must
// *relocate* that player rather than stamping a second one, or the board ends
// up with two Tile::Player cells and player_pos() reports whichever comes
// first in row-major order.
let mut game = GameState::from_world(two_board_world());
game.enter_board("b2", "from_b1");
let b = game.board();
let players = (0..b.height)
.flat_map(|y| (0..b.width).map(move |x| (x, y)))
.filter(|&(x, y)| b.get(x, y).as_ref().is_some_and(|t| t.player()))
.count();
assert_eq!(players, 1, "arriving on a board must not duplicate the player");
}
#[test]
@@ -112,10 +120,8 @@ fn enter_board_unknown_entry_logs_error() {
#[test]
fn try_move_onto_portal_switches_board() {
let world = two_board_world();
// Start the player one cell west of the portal at (2, 0).
world.boards["b1"].borrow_mut().player = PlayerPos { x: 1, y: 0 };
let mut game = GameState::from_world(world);
// Start the player one cell west of b1's portal at (2, 0).
let mut game = GameState::from_world(two_board_world_with_player((1, 0)));
game.try_move(Direction::East);
assert_eq!(game.current_board_name(), "b2");
}
+4 -55
View File
@@ -1,49 +1,13 @@
mod actions;
mod collision;
// TODO(migration): map_file is not yet ported — it is blocked on the map files
// themselves still being pre-BoardSpec (see todo.md #5).
mod game_portals;
mod map_file;
// mod map_file;
mod movement;
mod scripting;
use crate::builtin::Archetype;
use crate::board::Board;
use crate::floor::Floor;
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::utils::PlayerPos;
use std::collections::{BTreeMap, HashMap};
/// Builds a 1×1 board with a single object that optionally references a script,
/// and returns both the board and the `(name, source)` entries as a script map.
///
/// Pass the returned scripts to [`GameState::with_scripts`] so they are compiled.
fn board_with_object(
object_script: Option<&str>,
scripts: &[(&str, &str)],
) -> (Board, HashMap<String, String>) {
let mut object = ObjectDef::new(0, 0);
object.id = 1;
object.scripting.script_name = object_script.map(str::to_string);
let board = Board {
name: "test".into(),
width: 1,
height: 1,
grid: vec![(Glyph::transparent(), Archetype::Empty)],
floor: Floor::Blank,
decorations: Vec::new(),
sensors: Vec::new(),
player: PlayerPos { x: 0, y: 0 },
objects: BTreeMap::from([(1, object)]),
next_object_id: 2,
portals: Vec::new(),
board_script_name: None,
dark: false,
load_errors: Vec::new(),
registry: HashMap::new(),
};
(board, scripts_from(scripts))
}
use std::collections::HashMap;
/// Converts a `(name, source)` slice into a `HashMap` suitable for
/// [`GameState::with_scripts`].
@@ -54,21 +18,6 @@ fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
.collect()
}
/// Returns an `ObjectDef` at `(x, y)` bound to the named script.
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = ObjectDef::new(x, y);
o.scripting.script_name = Some(script.to_string());
o
}
/// Returns a **non-solid** `ObjectDef` at `(x, y)` bound to the named script — the
/// kind that receives an `enter` hook when a solid relocates onto its cell.
fn nonsolid_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = scripted_object(x, y, script);
o.behavior.solid = false;
o
}
/// Flattens each log line into a single string for easy assertions.
fn log_texts(game: &GameState) -> Vec<String> {
game.log
+84 -74
View File
@@ -1,163 +1,173 @@
use crate::builtin::Archetype;
use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at};
//! Player push mechanics, driven through [`GameState::try_move`].
//!
//! Pushability now lives on the target's [`EnterResponse`]: `Push(Pushable::Any)`
//! for a crate, `Push(Horizontal)`/`Push(Vertical)` for the axis-locked ones, and
//! `Block` for a wall. `try_move` consults `Board::can_push` and, when the chain
//! can move, pushes from the *player's* cell — the player is itself pushable, so it
//! rides along at the head of the chain.
use crate::board::tests::{add_floor, builtin_at, crate_at, is_builtin, open_board, plain_object_at, wall_at};
use crate::game::GameState;
use crate::glyph::Glyph;
use crate::object_def::ObjectDef;
use crate::tile::EnterResponse;
use crate::utils::{Direction, Pushable};
use color::Rgba8;
use crate::Builtin;
#[test]
fn pushing_a_crate_reveals_the_floor_underneath() {
// The cell a crate is pushed off of becomes transparent and glyph_at shows the
// floor on the layer beneath. After the push the player is at (1,0); check the
// cell the player vacated (0,0) — it must reveal the floor glyph, not black.
let mut board = open_board(4, 1, (0, 0), vec![]);
// The cell the player vacates becomes empty, and glyph_at falls through to the
// board floor. After the push the player is at (1,0); check (0,0) — it must
// reveal the floor glyph, not black.
let mut board = open_board(4, 1, (0, 0));
let floor_glyph = Glyph {
tile: ',' as u32,
fg: Rgba8 {
r: 40,
g: 60,
b: 40,
a: 255,
},
bg: Rgba8 {
r: 5,
g: 10,
b: 5,
a: 255,
},
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
};
add_floor(&mut board, floor_glyph); // floor below; terrain becomes layer 1
add_floor(&mut board, floor_glyph);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.player_pos(), (1, 0));
assert!(is_builtin(&b, 2, 0, "crate"));
assert!(b.get(0, 0).is_none()); // vacated
assert_eq!(b.glyph_at(0, 0), floor_glyph);
}
#[test]
fn player_pushes_single_crate() {
let mut board = open_board(4, 1, (0, 0), vec![]);
let mut board = open_board(4, 1, (0, 0));
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.player_pos(), (1, 0));
assert!(is_builtin(&b, 2, 0, "crate"));
assert!(b.get(0, 0).is_none());
}
#[test]
fn push_blocked_by_wall_moves_nothing() {
let mut board = open_board(4, 1, (0, 0), vec![]);
let mut board = open_board(4, 1, (0, 0));
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Wall, "wall"));
assert_eq!(b.player_pos(), (0, 0));
assert!(is_builtin(&b, 1, 0, "crate"));
assert!(is_builtin(&b, 2, 0, "wall"));
}
#[test]
fn push_blocked_by_edge_moves_nothing() {
// Crate on the last column; player pushes it toward the board edge.
let mut board = open_board(2, 1, (0, 0), vec![]);
let mut board = open_board(2, 1, (0, 0));
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.player_pos(), (0, 0));
assert!(is_builtin(&b, 1, 0, "crate"));
}
#[test]
fn cascade_pushes_two_crates() {
let mut board = open_board(5, 1, (0, 0), vec![]);
let mut board = open_board(5, 1, (0, 0));
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.player_pos(), (1, 0));
assert!(is_builtin(&b, 2, 0, "crate"));
assert!(is_builtin(&b, 3, 0, "crate"));
}
#[test]
fn cascade_blocked_by_wall_moves_nothing() {
let mut board = open_board(5, 1, (0, 0), vec![]);
let mut board = open_board(5, 1, (0, 0));
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
wall_at(&mut board, 3, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
assert_eq!(b.player_pos(), (0, 0));
assert!(is_builtin(&b, 1, 0, "crate"));
assert!(is_builtin(&b, 2, 0, "crate"));
}
#[test]
fn player_pushes_pushable_solid_object() {
// A solid, pushable object is shoved exactly like a crate.
let mut obj = ObjectDef::new(1, 0);
obj.behavior.pushable = Pushable::Any;
let board = open_board(4, 1, (0, 0), vec![obj]);
// A plain object whose EnterResponse is Push(Any) is shoved exactly like a
// crate — the crate builtin has no privileged status, it just carries that
// same enter response.
let mut board = open_board(4, 1, (0, 0));
let id = plain_object_at(&mut board, 1, 0, EnterResponse::Push(Pushable::Any));
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0));
assert_eq!(b.player_pos(), (1, 0));
assert_eq!(
b.get_hookable(id).expect("object still on the board").location(),
(2, 0)
);
}
#[test]
fn hcrate_pushes_east_but_not_north() {
// Pushing east: the HCrate slides.
let mut board = open_board(4, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::HCrate, "hcrate"));
// Pushing east: Push(Horizontal) allows it, so the HCrate slides.
let mut board = open_board(4, 1, (0, 0));
builtin_at(&mut board, 1, 0, "hcrate");
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate"));
drop(b);
{
let b = game.board();
assert_eq!(b.player_pos(), (1, 0));
assert!(is_builtin(&b, 2, 0, "hcrate"));
}
// Pushing north into an HCrate: blocked, nothing moves.
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::HCrate, "hcrate"));
// Pushing north into an HCrate: the direction isn't allowed, so can_push fails
// and the move degrades to a bump — nothing moves.
let mut board = open_board(1, 4, (0, 3));
builtin_at(&mut board, 0, 2, "hcrate");
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3));
assert_eq!(b.get(0, 2).1, Archetype::Builtin(Builtin::HCrate, "hcrate"));
assert_eq!(b.player_pos(), (0, 3));
assert!(is_builtin(&b, 0, 2, "hcrate"));
}
#[test]
fn vcrate_pushes_north_but_not_east() {
// Pushing north: the VCrate slides.
let mut board = open_board(1, 4, (0, 3), vec![]);
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::VCrate, "vcrate"));
// Pushing north: Push(Vertical) allows it, so the VCrate slides.
let mut board = open_board(1, 4, (0, 3));
builtin_at(&mut board, 0, 2, "vcrate");
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2));
assert_eq!(b.get(0, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 1).1, Archetype::Builtin(Builtin::VCrate, "vcrate"));
drop(b);
{
let b = game.board();
assert_eq!(b.player_pos(), (0, 2));
assert!(is_builtin(&b, 0, 1, "vcrate"));
}
// Pushing east into a VCrate: blocked, nothing moves.
let mut board = open_board(4, 1, (0, 0), vec![]);
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::VCrate, "vcrate"));
let mut board = open_board(4, 1, (0, 0));
builtin_at(&mut board, 1, 0, "vcrate");
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::VCrate, "vcrate"));
assert_eq!(b.player_pos(), (0, 0));
assert!(is_builtin(&b, 1, 0, "vcrate"));
}
+234 -241
View File
@@ -1,14 +1,52 @@
use super::{board_with_object, log_texts, nonsolid_object, scripted_object, scripts_from};
use crate::board::tests::{crate_at, open_board};
use super::{log_texts, scripts_from};
use crate::board::Board;
use crate::board::tests::{object_at, open_board, plain_object_at, sensor_at};
use crate::game::{GameState, ScrollLine};
use crate::utils::Direction;
use crate::tile::EnterResponse;
use crate::utils::{Direction, ObjectId};
use std::collections::HashMap;
use std::time::Duration;
/// Builds a 2×1 board with a single `Block` object at (0,0) that optionally
/// references a script, the player parked at (1,0), and the `(name, source)`
/// entries as a script map. Returns the object's id alongside them.
///
/// The player needs a cell of its own — one thing per cell — so this is 2×1 rather
/// than the 1×1 board the pre-`Tile` version used.
fn board_with_object(
object_script: Option<&str>,
scripts: &[(&str, &str)],
) -> (Board, HashMap<String, String>, ObjectId) {
let mut board = open_board(2, 1, (1, 0));
let id = match object_script {
Some(name) => object_at(&mut board, 0, 0, name, EnterResponse::Block),
None => plain_object_at(&mut board, 0, 0, EnterResponse::Block),
};
(board, scripts_from(scripts), id)
}
/// Whether object `id` currently carries `tag`.
fn has_tag(game: &GameState, id: ObjectId, tag: &str) -> bool {
game.board()
.get_hookable(id)
.expect("object still on the board")
.tags()
.contains(tag)
}
/// The cell object `id` currently occupies.
fn loc(game: &GameState, id: ObjectId) -> (usize, usize) {
game.board()
.get_hookable(id)
.expect("object still on the board")
.location()
}
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
let (board, scripts) = board_with_object(
let (board, scripts, _) = board_with_object(
Some("greet"),
&[("greet", r#"fn init(m) { log("hello"); }"#)],
&[("greet", r#"fn init(me) { log("hello"); }"#)],
);
let mut game = GameState::with_scripts(board, scripts);
// init must not fire during construction / deserialization.
@@ -20,9 +58,9 @@ fn init_runs_only_on_run_init_not_at_construction() {
#[test]
fn tick_calls_script_tick_with_elapsed_seconds() {
let (board, scripts) = board_with_object(
let (board, scripts, _) = board_with_object(
Some("t"),
&[("t", r#"fn tick(m,dt) { log(dt.to_string()); }"#)],
&[("t", r#"fn tick(me, dt) { log(dt.to_string()); }"#)],
);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
@@ -36,15 +74,16 @@ fn tick_calls_script_tick_with_elapsed_seconds() {
#[test]
fn missing_hooks_and_no_script_are_noops() {
// Object with no script: nothing happens.
let (board, scripts) = board_with_object(None, &[]);
// Object with no script at all: nothing happens.
let (board, scripts, _) = board_with_object(None, &[]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
// Script defines neither init nor tick: also a no-op.
let (board, scripts) = board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]);
let (board, scripts, _) =
board_with_object(Some("e"), &[("e", r#"fn other() { log("x"); }"#)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game.tick(Duration::from_millis(33));
@@ -53,23 +92,30 @@ fn missing_hooks_and_no_script_are_noops() {
#[test]
fn compile_and_unknown_script_errors_are_logged() {
// A reference to a script name that isn't in the table.
let (board, scripts) = board_with_object(Some("ghost"), &[]);
// A reference to a script name that isn't in the pool. See todo.md #5: this is
// currently a silent `continue` in ScriptHost::new, so nothing is logged.
let (board, scripts, _) = board_with_object(Some("ghost"), &[]);
let game = GameState::with_scripts(board, scripts);
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
assert!(
log_texts(&game)
.first()
.is_some_and(|t| t.contains("unknown script 'ghost'")),
"a script_name absent from the world pool should be reported"
);
// A script that fails to compile is reported at construction time.
let (board, scripts) = board_with_object(Some("bad"), &[("bad", "fn init( {")]);
let (board, scripts, _) = board_with_object(Some("bad"), &[("bad", "fn init( {")]);
let game = GameState::with_scripts(board, scripts);
assert!(log_texts(&game)[0].contains("failed to compile"));
}
#[test]
fn script_reads_board_through_view() {
let board = open_board(5, 3, (3, 1), vec![scripted_object(2, 1, "r")]);
let mut board = open_board(5, 3, (3, 1));
object_at(&mut board, 2, 1, "r", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("r", "fn init(m) { log(Player.x.to_string()); }")]),
scripts_from(&[("r", "fn init(me) { log(Player.x.to_string()); }")]),
);
game.run_init();
assert_eq!(log_texts(&game), vec!["3"]);
@@ -77,34 +123,34 @@ fn script_reads_board_through_view() {
#[test]
fn commands_are_routed_to_their_own_source_object() {
// Two objects with different scripts move in opposite directions; each
// must affect only itself (the per-call tag routes the command source).
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
);
// Two objects with different scripts move in opposite directions; each must
// affect only itself (the per-call tag routes the command to its source queue).
let mut board = open_board(5, 3, (0, 0));
let east = object_at(&mut board, 0, 1, "e", EnterResponse::Block);
let west = object_at(&mut board, 4, 1, "w", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("e", "fn init(m) { move(East); }"),
("w", "fn init(m) { move(West); }"),
("e", "fn init(me) { move(East); }"),
("w", "fn init(me) { move(West); }"),
]),
);
game.run_init();
let b = game.board();
assert_eq!(b.objects[&1].x, 1); // moved east from 0
assert_eq!(b.objects[&2].x, 3); // moved west from 4
assert_eq!(loc(&game, east), (1, 1)); // moved east from 0
assert_eq!(loc(&game, west), (3, 1)); // moved west from 4
}
#[test]
fn start_map_greeter_runs_init() {
// End-to-end against the shipped example map: load it, run init, and
// confirm the greeter read the board (interpolated message) and wrote to
// itself (set_tile). Also guards the example from drifting out of sync.
// End-to-end against the shipped example map: load it, run init, and confirm the
// greeter read the board (interpolated message) and wrote to itself (set_tile).
// Also guards the example from drifting out of sync with the engine.
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
let mut world = crate::world::load(path).expect("load start.toml");
// Keep the failure message short: a TomlError's Display embeds the whole file.
let mut world = crate::world::load(path).unwrap_or_else(|e| {
let first = e.to_string().lines().next().unwrap_or_default().to_string();
panic!("load start.toml failed (see todo.md #5, maps are still pre-BoardSpec): {first}")
});
// Pin to the "start" board regardless of the world's current default entry point.
world.start = "start".to_string();
let mut game = GameState::from_world(world);
@@ -115,52 +161,53 @@ fn start_map_greeter_runs_init() {
.any(|t| t.contains("hello from object")),
"greeter init should log a greeting"
);
assert!(
game.board().objects.values().any(|o| o.glyph.tile == 2),
"greeter set_tile(2) should change its glyph"
);
let has_smiley = game
.board()
.all_ids()
.into_iter()
.filter_map(|id| game.board().get_hookable(id).map(|o| o.glyph().tile))
.any(|tile| tile == 2);
assert!(has_smiley, "greeter set_tile(2) should change its glyph");
}
#[test]
fn set_tag_adds_and_removes_via_my_id() {
// A script calls set_tag(Me.id, "active", true) in init; the tag must be
// present on the object afterward.
let (board, scripts) = board_with_object(
// A script calls set_tag(me.id, "active", true) in init; the tag must be present
// on the object afterward.
let (board, scripts, id) = board_with_object(
Some("t"),
&[("t", r#"fn init(m) { set_tag(m.id, "active", true); }"#)],
&[("t", r#"fn init(me) { set_tag(me.id, "active", true); }"#)],
);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.board().objects[&1].scripting.tags.contains("active"));
assert!(has_tag(&game, id, "active"));
// A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object(
let (mut board2, scripts2, id2) = board_with_object(
Some("t2"),
&[("t2", r#"fn init(m) { set_tag(m.id, "active", false); }"#)],
&[("t2", r#"fn init(me) { set_tag(me.id, "active", false); }"#)],
);
// Seed the tag before construction.
board2
.objects
.get_mut(&1)
.scripting_mut(id2)
.unwrap()
.scripting
.tags
.insert("active".to_string());
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.board().objects[&1].scripting.tags.contains("active"));
assert!(!has_tag(&game2, id2, "active"));
}
#[test]
fn has_tag_reads_own_tags() {
// set_tag queues an action; has_tag reads the board state. So set_tag in
// init() takes effect after init returns; a subsequent tick sees it.
let (board, scripts) = board_with_object(
// set_tag queues an action; has_tag reads board state. So set_tag in init() takes
// effect after init returns, and a subsequent tick sees it.
let (board, scripts, _) = board_with_object(
Some("t"),
&[(
"t",
r#"fn init(m) { set_tag(m.id, "active", true); }
fn tick(m,dt) { log(m.has_tag("active").to_string()); }"#,
r#"fn init(me) { set_tag(me.id, "active", true); }
fn tick(me, dt) { log(me.has_tag("active").to_string()); }"#,
)],
);
let mut game = GameState::with_scripts(board, scripts);
@@ -172,22 +219,25 @@ fn has_tag_reads_own_tags() {
#[test]
fn objects_with_tag_returns_matching_ids() {
// A 5×1 board with two objects; one has tag "enemy". The script on the first
// object queries objects_with_tag("enemy") and logs the ids it finds.
let obj1 = scripted_object(0, 0, "q");
let mut obj2 = scripted_object(1, 0, "none");
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not.
obj2.scripting.tags.insert("enemy".to_string());
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
// queries Board.tagged("enemy") and logs the count and the id it found.
let mut board = open_board(5, 1, (4, 0));
object_at(&mut board, 0, 0, "q", EnterResponse::Block);
let enemy = object_at(&mut board, 1, 0, "none", EnterResponse::Block);
board
.scripting_mut(enemy)
.unwrap()
.tags
.insert("enemy".to_string());
let mut game = GameState::with_scripts(
board,
scripts_from(&[
(
"q",
r#"fn init(m) {
let infos = Board.tagged("enemy");
log(infos.len().to_string());
log(infos[0].id.to_string());
}"#,
r#"fn init(me) {
let infos = Board.tagged("enemy");
log(infos.len().to_string());
log(infos[0].id.to_string());
}"#,
),
("none", ""),
]),
@@ -195,22 +245,24 @@ fn objects_with_tag_returns_matching_ids() {
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "1"); // exactly one match
assert_eq!(texts[1], "2"); // obj2 is id 2
assert_eq!(texts[1], enemy.to_string());
}
#[test]
fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name().
let (mut board, scripts) =
board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]);
board.objects.get_mut(&1).unwrap().scripting.name = Some("beacon".to_string());
// An object with a name set should see it via me.name.
let (mut board, scripts, id) =
board_with_object(Some("n"), &[("n", r#"fn init(me) { log(me.name); }"#)]);
board.scripting_mut(id).unwrap().name = Some("beacon".to_string());
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get ().
let (board2, scripts2) =
board_with_object(Some("n"), &[("n", r#"fn init(m) { if m.name == () { log("null"); }}"#)]);
let (board2, scripts2, _) = board_with_object(
Some("n"),
&[("n", r#"fn init(me) { if me.name == () { log("null"); } }"#)],
);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert_eq!(log_texts(&game2), vec!["null"]);
@@ -218,85 +270,88 @@ fn my_name_returns_name_or_empty_string() {
#[test]
fn object_id_for_name_finds_by_name() {
// A board with two objects; one is named. The querying object uses
// object_id_for_name to find the named one and logs its id.
let obj1 = scripted_object(0, 0, "q");
let mut obj2 = scripted_object(1, 0, "none");
obj2.scripting.name = Some("target".to_string());
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]);
// A board with two objects; one is named. The querying object uses Board.named
// to find it and logs its id, then confirms a miss returns ().
let mut board = open_board(5, 1, (4, 0));
object_at(&mut board, 0, 0, "q", EnterResponse::Block);
let target = object_at(&mut board, 1, 0, "none", EnterResponse::Block);
board.scripting_mut(target).unwrap().name = Some("target".to_string());
let mut game = GameState::with_scripts(
board,
scripts_from(&[
(
"q",
r#"fn init(m) {
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#,
r#"fn init(me) {
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#,
),
("none", ""),
]),
);
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "2"); // obj2 is id 2
assert_eq!(texts[0], target.to_string());
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
}
// Ensure try_move from the player side also triggers scripted bump
// ── bump ────────────────────────────────────────────────────────────────────
// `bump(me, dir)` fires when the player presses into a solid object — the only
// remaining bump trigger, since object movement no longer dispatches hooks.
#[test]
fn player_bump_reports_the_direction_it_came_from() {
// The player walks East into a solid scripted object; the object is bumped from
// the West side (opposite the player's travel) and the player does not move onto it.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
// The player walks East into a Block object; the object is bumped from the West
// side (opposite the player's travel) and the player does not move onto it.
let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "b", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("b", "fn bump(m,dir) { log(`bumped from ${dir}`); }")]),
scripts_from(&[("b", "fn bump(me, dir) { log(`bumped from ${dir}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
// bump's log is emitted into the object's queue; a tick flushes it.
game.tick(Duration::from_millis(16));
assert_eq!(game.board().player_pos(), (0, 0)); // blocked
// log() bypasses the queue, so no tick is needed to flush it.
assert!(log_texts(&game).iter().any(|t| t == "bumped from West"));
}
// A bump handler can compare the direction and read its `dx`/`dy` offset to
// locate the bumper's cell.
#[test]
fn bump_direction_supports_comparison_and_offset() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]);
// A bump handler can compare the direction and read its dx/dy to locate the bumper.
let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "b", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"b",
// Player bumps from the West, so `dir == West` and the bumper sits at
// (me.x + dir.dx) = 1 + (-1) = 0.
"fn bump(m,dir) { if dir == West { log(`bumper at ${m.x + dir.dx}`); } }",
"fn bump(me, dir) { if dir == West { log(`bumper at ${me.x + dir.dx}`); } }",
)]),
);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumper at 0"));
}
// ── scroll ──────────────────────────────────────────────────────────────────
#[test]
fn scroll_opens_on_player_bump() {
// A solid object's bump() calls scroll([text, [choice, display]]). After the
// player bumps it and a tick resolves the queued action, active_scroll is set
// with the correct ScrollLine variants.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
// A Block object's bump() calls scroll([text, [choice, display]]). The bump
// resolves inside try_move, so active_scroll is set with the right variants.
let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "s", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"s",
r#"fn bump(m,dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
r#"fn bump(me, dir) { scroll(["Hello world", ["eat", "Eat it"]]); }"#,
)]),
);
game.run_init();
// The bump resolves within try_move now, so the scroll is open immediately.
game.try_move(Direction::East);
let scroll = game
@@ -313,13 +368,13 @@ fn scroll_opens_on_player_bump() {
#[test]
fn handle_scroll_without_choice_clears_it() {
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "s", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]),
scripts_from(&[("s", r#"fn bump(me, dir) { scroll(["Hello"]); }"#)]),
);
game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East);
assert!(game.active_scroll.is_some());
@@ -332,23 +387,23 @@ fn handle_scroll_without_choice_clears_it() {
fn handle_scroll_with_choice_dispatches_send_to_source() {
// Setting choice on the scroll before a tick fires send() on the source object;
// fn eat() logging "eaten" confirms the dispatch arrived.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]);
let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "s", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[(
"s",
r#"
fn bump(m,dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); }
fn bump(me, dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); }
"#,
)]),
);
game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East);
assert!(game.active_scroll.is_some());
// Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log.
// Set the choice, then tick — handle_scroll dispatches "eat".
game.active_scroll.as_mut().unwrap().choice = Some("eat".to_string());
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_none());
@@ -358,21 +413,26 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
);
}
// ── ordering ────────────────────────────────────────────────────────────────
#[test]
fn a_later_object_sees_an_earlier_objects_move_this_tick() {
// The core of the epic: each object's queued actions apply immediately, before
// the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East
// onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0).
// Because A already moved there this tick, B observes it as blocked. Under the
// old collect-all-then-apply model B would have seen (1,0) still empty.
let a = scripted_object(0, 0, "a");
let b = scripted_object(1, 1, "b");
let board = open_board(3, 2, (2, 1), vec![a, b]);
// Each object's queued actions apply immediately, before the next (higher-id)
// object runs its hook. Object A at (0,0) moves East onto (1,0); object B at
// (1,1) then checks the cell to its North (1,0). Because A already moved there
// this tick, B observes it as blocked — under a collect-all-then-apply model B
// would have seen (1,0) still empty.
let mut board = open_board(3, 2, (2, 1));
let a = object_at(&mut board, 0, 0, "a", EnterResponse::Block);
object_at(&mut board, 1, 1, "b", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"),
("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#),
("a", "fn tick(me, dt) { move(East); }"),
(
"b",
r#"fn tick(me, dt) { log(if me.blocked(North) { "blocked" } else { "clear" }); }"#,
),
]),
);
game.run_init();
@@ -380,7 +440,7 @@ fn a_later_object_sees_an_earlier_objects_move_this_tick() {
game.tick(Duration::from_millis(16));
// A moved onto (1,0), and B saw it there the same tick.
assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 0));
assert_eq!(loc(&game, a), (1, 0));
assert_eq!(log_texts(&game), vec!["blocked"]);
}
@@ -390,15 +450,13 @@ fn tick_is_gated_on_an_empty_queue() {
// tick that just `move`s east paces itself one step per drained move instead of
// piling up. `move` enqueues a Move plus a 0.25s Delay; while that Delay is still
// draining the engine skips re-running `tick`. With 100 ms frames the object steps
// once (x=1) then waits ~0.25s (the pending Delay) before the next call fires.
// Under the old every-frame model an unguarded tick would enqueue a fresh move each
// frame, racing the object east far faster.
let obj = scripted_object(0, 0, "m");
let board = open_board(6, 1, (5, 0), vec![obj]);
// once (x=1) then waits ~0.25s before the next call fires.
let mut board = open_board(6, 1, (5, 0));
let id = object_at(&mut board, 0, 0, "m", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
// Note: no `if m.queue.length == 0` guard — the engine provides it.
scripts_from(&[("m", "fn tick(m, dt) { move(East); }")]),
// Note: no `if me.queue.length == 0` guard — the engine provides it.
scripts_from(&[("m", "fn tick(me, dt) { move(East); }")]),
);
game.run_init();
@@ -407,143 +465,78 @@ fn tick_is_gated_on_an_empty_queue() {
for _ in 0..3 {
game.tick(Duration::from_millis(100));
}
assert_eq!(game.board().objects[&1].x, 1);
assert_eq!(loc(&game, id).0, 1);
// Once the Delay fully drains the queue empties, so the next frame calls `tick`
// again and the object takes its second step.
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[&1].x, 2);
assert_eq!(loc(&game, id).0, 2);
}
#[test]
fn a_send_cycle_terminates_via_the_called_guard() {
// Two objects send "go" to each other in a cycle. Without the per-invocation
// "already-called" guard this would recurse forever; with it, each (object, fn,
// args) fires at most once, so the cascade settles after one round-trip. That the
// call returns at all — and logs exactly one "B" then one "A" — proves it.
let a = scripted_object(0, 0, "a");
let b = scripted_object(1, 0, "b");
let board = open_board(3, 1, (2, 0), vec![a, b]);
fn a_send_cycle_defers_instead_of_recursing() {
// Two objects send "poke" to each other in a cycle. `run_send` invokes the target
// function directly but deliberately does *not* drain its queue, so the re-send
// lands in the target's queue and fires on the next drain instead of recursing
// inside this dispatch. The cycle therefore never settles — but it advances a
// bounded amount per tick, so the game keeps running rather than hanging.
//
// This is the regression guard for that design: draining inline (an
// easy-looking optimisation, warned against in run_send's doc comment) would
// recurse until the stack blows. That this test *returns at all* is half the
// assertion; the log growth is the other half.
let mut board = open_board(3, 1, (2, 0));
let a = object_at(&mut board, 0, 0, "a", EnterResponse::Block);
let b = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("a", r#"fn init(m) { send(2, "poke"); } fn poke(m) { log("A"); send(2, "poke"); }"#),
("b", r#"fn poke(m) { log("B"); send(1, "poke"); }"#),
(
"a",
&format!(
r#"fn init(me) {{ send({b}, "poke"); }} fn poke(me) {{ log("A"); send({b}, "poke"); }}"#
),
),
(
"b",
&format!(r#"fn poke(me) {{ log("B"); send({a}, "poke"); }}"#),
),
]),
);
game.run_init();
// B.poke fires once (from A.init's send), then A.poke once (from B.poke's send);
// A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops.
// One round-trip: A.init's send runs B.poke, whose re-send is queued on B and
// drained later in the same run_init pass, running A.poke. A's re-send waits.
assert_eq!(log_texts(&game), vec!["B", "A"]);
// Each subsequent tick advances the cycle by exactly one more round-trip.
for n in 1..=3 {
game.tick(Duration::from_millis(16));
assert_eq!(
log_texts(&game).len(),
2 * (n + 1),
"tick {n} should add exactly one B/A round-trip"
);
}
}
// ── enter hook ──────────────────────────────────────────────────────────────
// `enter(me, dir)` fires on a non-solid object when a solid relocates onto its
// cell. `dir` is the side the entrant came from (opposite the travel direction
// for a cardinal move/push; best-effort for teleport/shift).
// `enter(me, dir)` fires on a **sensor** when the player steps onto its cell, with
// `dir` the side the player came from. This is the only remaining enter trigger:
// object movement, pushes, teleports and shifts dispatch no hooks (see todo.md).
#[test]
fn player_walking_onto_a_nonsolid_fires_enter_from_the_travel_side() {
// The player walks East onto a non-solid trigger; it entered from the West.
let board = open_board(3, 1, (0, 0), vec![nonsolid_object(1, 0, "e")]);
fn player_walking_onto_a_sensor_fires_enter_from_the_travel_side() {
// The player walks East onto a sensor; it entered from the West.
let mut board = open_board(3, 1, (0, 0));
sensor_at(&mut board, 1, 0, "e");
let mut game = GameState::with_scripts(
board,
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
scripts_from(&[("e", "fn enter(me, dir) { log(`entered from ${dir}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
// The player is not blocked by a non-solid — it moves onto the cell.
assert_eq!((game.board().player.x, game.board().player.y), (1, 0));
// A sensor is off-grid, so it never blocks: the player moves onto the cell.
assert_eq!(game.board().player_pos(), (1, 0));
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn object_moving_onto_a_nonsolid_fires_enter() {
// A solid mover ticks East onto a non-solid trigger sharing the destination.
let mover = scripted_object(0, 0, "m");
let trigger = nonsolid_object(1, 0, "e");
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn pushing_a_crate_onto_a_nonsolid_fires_enter() {
// Player pushes a crate East onto a non-solid trigger's cell; the crate is the
// solid entrant, so `enter` fires (from West).
let mut board = open_board(4, 1, (0, 0), vec![nonsolid_object(2, 0, "e")]);
crate_at(&mut board, 1, 0);
let mut game = GameState::with_scripts(
board,
scripts_from(&[("e", "fn enter(m, dir) { log(`entered from ${dir}`); }")]),
);
game.run_init();
game.try_move(Direction::East);
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn teleporting_onto_a_nonsolid_fires_enter() {
// A solid object teleports itself from (0,0) onto the non-solid trigger at (1,0).
let mover = scripted_object(0, 0, "t");
let trigger = nonsolid_object(1, 0, "e");
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("t", "fn init(m) { teleport(m.id, 1, 0); }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
// Best-effort direction: the jump was one cell east, so it entered from West.
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn shifting_a_crate_onto_a_nonsolid_fires_enter() {
// An object shifts the ring [(1,0),(2,0)]: the crate at (1,0) rotates onto the
// non-solid trigger at (2,0), firing `enter` (best-effort West).
let shifter = nonsolid_object(0, 0, "s");
let trigger = nonsolid_object(2, 0, "e");
let mut board = open_board(4, 1, (3, 0), vec![shifter, trigger]);
crate_at(&mut board, 1, 0);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("s", "fn init(m) { shift([[1, 0], [2, 0]]); }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "entered from West"));
}
#[test]
fn a_nonsolid_mover_onto_a_nonsolid_does_not_fire_enter() {
// Only *solid* entrants trigger enter: a non-solid mover walking onto another
// non-solid must NOT fire it.
let mover = nonsolid_object(0, 0, "m");
let trigger = nonsolid_object(1, 0, "e");
let board = open_board(3, 1, (2, 0), vec![mover, trigger]);
let mut game = GameState::with_scripts(
board,
scripts_from(&[
("m", "fn tick(m, dt) { if m.queue.length == 0 { move(East); } }"),
("e", "fn enter(m, dir) { log(`entered from ${dir}`); }"),
]),
);
game.run_init();
game.tick(Duration::from_millis(16));
assert!(!log_texts(&game).iter().any(|t| t.starts_with("entered")));
}