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
+11 -5
View File
@@ -766,14 +766,20 @@ pub(crate) mod tests {
id id
} }
/// Stamps a solid object at `(x, y)` with **no script attached**, returning its id. /// Stamps an object at `(x, y)` with **no script attached**, returning its id.
/// ///
/// For exercising the "an object without a script is inert" path; everything /// For the "an object without a script is inert" path, and for plain physical
/// else should use [`object_at`]. /// props (a pushable block with no behavior of its own). Everything scripted
pub(crate) fn plain_object_at(board: &mut Board, x: usize, y: usize) -> ObjectId { /// should use [`object_at`].
pub(crate) fn plain_object_at(
board: &mut Board,
x: usize,
y: usize,
enter: EnterResponse,
) -> ObjectId {
let tile = TileSpec::Object { let tile = TileSpec::Object {
script: None, script: None,
enter: EnterResponse::Block, enter,
glyph: ObjectDef::default_glyph(), glyph: ObjectDef::default_glyph(),
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
name: None, name: None,
+1 -1
View File
@@ -26,7 +26,7 @@ pub struct Portal {
/// Name of the arrival portal on the target board. /// Name of the arrival portal on the target board.
pub target_name: String, pub target_name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
glyph: Option<Glyph> pub glyph: Option<Glyph>
} }
impl Portal { impl Portal {
+117 -96
View File
@@ -1,187 +1,208 @@
use super::{log_texts, scripted_object, scripts_from}; use super::{log_texts, scripts_from};
use crate::builtin::Archetype; use crate::board::tests::{crate_at, is_builtin, object_at, open_board, wall_at};
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph;
use crate::tile::EnterResponse;
use crate::utils::ObjectId;
use std::time::Duration; 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] #[test]
fn move_command_relocates_the_source_object() { fn move_command_relocates_the_source_object() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { move(East); }");
let mut game =
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")]));
game.run_init();
let b = game.board();
// East increments x by one; the object started at (2, 1). // 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] #[test]
fn move_into_a_wall_or_edge_is_a_noop() { fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored. // 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 = 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(); game.run_init();
assert_eq!(game.board().objects[&1].x, 0); assert_eq!(loc(&game, id), (1, 1));
} }
#[test] #[test]
fn set_tile_command_changes_the_source_glyph() { fn set_tile_command_changes_the_source_glyph() {
let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { set_tile(7); }");
let mut game = assert_eq!(glyph(&game, id).tile, 7);
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);
} }
#[test] #[test]
fn object_pushes_crate_on_init() { fn object_pushes_crate_on_init() {
// A scripted object moving east into a crate shoves it (step_object path). // A scripted object moving east into a crate shoves it (the step_object path).
let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); 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); crate_at(&mut board, 2, 0);
let mut game = 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(); game.run_init();
assert_eq!(loc(&game, id), (2, 0));
let b = game.board(); let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); assert!(is_builtin(&b, 3, 0, "crate")); // crate shoved one east
assert_eq!(b.get(2, 0).1, Archetype::Empty); assert!(b.get(1, 0).is_none()); // the object's old cell is vacated
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
} }
#[test] #[test]
fn object_push_into_player() { fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room. // 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 (game, id) = game_with_mover(5, 1, (2, 0), (1, 0), "fn init(me) { move(East); }");
let mut game = assert_eq!(loc(&game, id), (2, 0));
GameState::with_scripts(board, scripts_from(&[("m", "fn init(m) { move(East); }")])); assert_eq!(game.board().player_pos(), (3, 0));
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));
}
// With a wall behind the player, the object is blocked and nothing moves. // 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); wall_at(&mut board, 3, 0);
let mut game = 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(); game.run_init();
let b = game.board();
assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); assert_eq!(loc(&game, id), (1, 0));
assert_eq!((b.player.x, b.player.y), (2, 0)); assert_eq!(game.board().player_pos(), (2, 0));
} }
#[test] #[test]
fn move_cost_rate_limits_repeated_moves() { fn move_cost_rate_limits_repeated_moves() {
// init queues two moves; only the first resolves immediately, the second must // init queues two moves; only the first resolves immediately, the second must
// wait the full 250 ms cooldown. // wait the full 250 ms cooldown that move() appends behind it.
let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); let (mut game, id) = game_with_mover(
let mut game = GameState::with_scripts( 5,
board, 1,
scripts_from(&[("m", "fn init(m) { move(East); move(East); }")]), (0, 0),
(1, 0),
"fn init(me) { move(East); move(East); }",
); );
game.run_init(); assert_eq!(loc(&game, id).0, 2); // first move applied (1 -> 2)
assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2)
// 200 ms of ticks: still inside the cooldown, no further movement. // 200 ms of ticks: still inside the cooldown, no further movement.
game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100));
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. // Crossing the 250 ms mark releases the queued second move.
game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[&1].x, 3); assert_eq!(loc(&game, id).0, 3);
} }
#[test] #[test]
fn inline_delay_paces_subsequent_moves() { fn inline_delay_paces_subsequent_moves() {
// move() always appends a Delay(250 ms) to the queue, so a second queued move // 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. // 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); wall_at(&mut board, 2, 1);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("m", "fn init(m) { move(East); move(South); }")]), scripts_from(&[("m", "fn init(me) { move(East); move(South); }")]),
); );
game.run_init(); game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved. // First (eastward) move is blocked by the wall: object hasn't moved.
assert_eq!( assert_eq!(loc(&game, id), (1, 1));
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 1)
);
// The Delay(250 ms) appended by move(East) is still live, so South is pending. // 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));
game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100));
assert_eq!( assert_eq!(loc(&game, id), (1, 1));
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 1)
);
// Past 250 ms the queued South move resolves. // Past 250 ms the queued South move resolves.
game.tick(Duration::from_millis(100)); game.tick(Duration::from_millis(100));
assert_eq!( assert_eq!(loc(&game, id), (1, 2));
(game.board().objects[&1].x, game.board().objects[&1].y),
(1, 2)
);
} }
#[test] #[test]
fn queue_length_reports_pending_actions() { fn queue_length_reports_pending_actions() {
// Two zero-cost actions are queued before the length is read, then all three // Two zero-cost actions are queued before the length is read; log() bypasses the
// (incl. the log) drain in one pump. // queue so it reports the length as of that moment, then both set_tiles drain.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]); let (game, id) = game_with_mover(
let mut game = GameState::with_scripts( 3,
board, 1,
scripts_from(&[( (0, 0),
"q", (1, 0),
"fn init(m) { set_tile(5); set_tile(6); log(`len=${m.queue.length}`); }", "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!(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] #[test]
fn queue_clear_drops_pending_actions() { fn queue_clear_drops_pending_actions() {
// clear() empties the output queue mid-script, so the queued moves never run. // 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, id) = game_with_mover(
let mut game = GameState::with_scripts( 5,
board, 1,
scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")]), (0, 0),
(1, 0),
"fn init(me) { move(East); move(East); me.queue.clear(); }",
); );
game.run_init();
game.tick(Duration::from_millis(300)); 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] #[test]
fn blocked_reports_solid_and_clear() { 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. // 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); wall_at(&mut board, 2, 0);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
board,
scripts_from(&[(
"b",
"fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]),
);
game.run_init(); 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. // Open ahead, nothing pending: blocked() is false.
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); let mut board = open_board(3, 1, (0, 0));
let mut game = GameState::with_scripts( let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
board, let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
scripts_from(&[(
"b",
"fn init(m) { if m.blocked(East) { set_tile(9); } else { set_tile(7); } }",
)]),
);
game.run_init(); 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::board::Board;
use crate::floor::Floor; use crate::board::tests::open_board;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use crate::portal::Portal;
use crate::utils::{Direction, PlayerPos}; use crate::utils::Direction;
use crate::world::World; use crate::world::World;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap}; use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use crate::portal::Portal;
/// Builds a 3×3 board with the player at `(px, py)` and the given portals. /// Builds a 3×3 board with the player at `player` and the given portals.
fn make_board(px: i64, py: i64, portals: Vec<Portal>) -> Board { fn make_board(player: (usize, usize), portals: Vec<Portal>) -> Board {
Board { let mut board = open_board(3, 3, player);
name: "test".into(), board.portals = portals;
width: 3, board
height: 3, }
grid: vec![(Glyph::transparent(), Archetype::Empty); 9],
floor: Floor::Blank, /// A glyphless portal at `(x, y)` pointing at `target_name` on `target_board`.
decorations: Vec::new(), fn portal(
sensors: Vec::new(), x: usize,
player: PlayerPos { x: px, y: py }, y: usize,
objects: BTreeMap::new(), name: &str,
next_object_id: 1, target_board: &str,
portals, target_name: &str,
board_script_name: None, ) -> Portal {
dark: false, Portal {
load_errors: Vec::new(), x,
registry: HashMap::new(), 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"` /// - `"b2"`: player at `(0, 0)`, portal `"from_b1"` at `(1, 1)` → `"b1"` / `"to_b2"`
fn two_board_world() -> World { fn two_board_world_with_player(b1_player: (usize, usize)) -> World {
let b1 = make_board( let b1 = make_board(b1_player, vec![portal(2, 0, "to_b2", "b2", "from_b1")]);
0, let b2 = make_board((0, 0), vec![portal(1, 1, "from_b1", "b1", "to_b2")]);
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(),
}],
);
World { World {
name: "test".into(), name: "test".into(),
start: "b1".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] #[test]
fn enter_board_switches_board() { fn enter_board_switches_board() {
let mut game = GameState::from_world(two_board_world()); 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() { fn enter_board_places_player_at_arrival_portal() {
let mut game = GameState::from_world(two_board_world()); let mut game = GameState::from_world(two_board_world());
game.enter_board("b2", "from_b1"); game.enter_board("b2", "from_b1");
let board = game.board();
// Arrival portal "from_b1" is at (1, 1) on b2. // Arrival portal "from_b1" is at (1, 1) on b2.
assert_eq!(board.player.x, 1); assert_eq!(game.board().player_pos(), (1, 1));
assert_eq!(board.player.y, 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] #[test]
@@ -112,10 +120,8 @@ fn enter_board_unknown_entry_logs_error() {
#[test] #[test]
fn try_move_onto_portal_switches_board() { fn try_move_onto_portal_switches_board() {
let world = two_board_world(); // Start the player one cell west of b1's portal at (2, 0).
// Start the player one cell west of the portal at (2, 0). let mut game = GameState::from_world(two_board_world_with_player((1, 0)));
world.boards["b1"].borrow_mut().player = PlayerPos { x: 1, y: 0 };
let mut game = GameState::from_world(world);
game.try_move(Direction::East); game.try_move(Direction::East);
assert_eq!(game.current_board_name(), "b2"); assert_eq!(game.current_board_name(), "b2");
} }
+4 -55
View File
@@ -1,49 +1,13 @@
mod actions; 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 game_portals;
mod map_file; // mod map_file;
mod movement; mod movement;
mod scripting; mod scripting;
use crate::builtin::Archetype;
use crate::board::Board;
use crate::floor::Floor;
use crate::game::GameState; use crate::game::GameState;
use crate::glyph::Glyph; use std::collections::HashMap;
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))
}
/// Converts a `(name, source)` slice into a `HashMap` suitable for /// Converts a `(name, source)` slice into a `HashMap` suitable for
/// [`GameState::with_scripts`]. /// [`GameState::with_scripts`].
@@ -54,21 +18,6 @@ fn scripts_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
.collect() .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. /// Flattens each log line into a single string for easy assertions.
fn log_texts(game: &GameState) -> Vec<String> { fn log_texts(game: &GameState) -> Vec<String> {
game.log game.log
+82 -72
View File
@@ -1,163 +1,173 @@
use crate::builtin::Archetype; //! Player push mechanics, driven through [`GameState::try_move`].
use crate::board::tests::{add_floor, crate_at, open_board, stamp, wall_at}; //!
//! 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::game::GameState;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::object_def::ObjectDef; use crate::tile::EnterResponse;
use crate::utils::{Direction, Pushable}; use crate::utils::{Direction, Pushable};
use color::Rgba8; use color::Rgba8;
use crate::Builtin;
#[test] #[test]
fn pushing_a_crate_reveals_the_floor_underneath() { fn pushing_a_crate_reveals_the_floor_underneath() {
// The cell a crate is pushed off of becomes transparent and glyph_at shows the // The cell the player vacates becomes empty, and glyph_at falls through to the
// floor on the layer beneath. After the push the player is at (1,0); check the // board floor. After the push the player is at (1,0); check (0,0) — it must
// cell the player vacated (0,0) — it must reveal the floor glyph, not black. // reveal the floor glyph, not black.
let mut board = open_board(4, 1, (0, 0), vec![]); let mut board = open_board(4, 1, (0, 0));
let floor_glyph = Glyph { let floor_glyph = Glyph {
tile: ',' as u32, tile: ',' as u32,
fg: Rgba8 { fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
r: 40, bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
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); crate_at(&mut board, 1, 0);
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert_eq!(b.player_pos(), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty); assert!(is_builtin(&b, 2, 0, "crate"));
assert!(b.get(0, 0).is_none()); // vacated
assert_eq!(b.glyph_at(0, 0), floor_glyph); assert_eq!(b.glyph_at(0, 0), floor_glyph);
} }
#[test] #[test]
fn player_pushes_single_crate() { 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); crate_at(&mut board, 1, 0);
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!(b.player_pos(), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty); assert!(is_builtin(&b, 2, 0, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert!(b.get(0, 0).is_none());
} }
#[test] #[test]
fn push_blocked_by_wall_moves_nothing() { 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); crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0); wall_at(&mut board, 2, 0);
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); assert_eq!(b.player_pos(), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert!(is_builtin(&b, 1, 0, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Wall, "wall")); assert!(is_builtin(&b, 2, 0, "wall"));
} }
#[test] #[test]
fn push_blocked_by_edge_moves_nothing() { fn push_blocked_by_edge_moves_nothing() {
// Crate on the last column; player pushes it toward the board edge. // 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); crate_at(&mut board, 1, 0);
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); assert_eq!(b.player_pos(), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert!(is_builtin(&b, 1, 0, "crate"));
} }
#[test] #[test]
fn cascade_pushes_two_crates() { 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, 1, 0);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!(b.player_pos(), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty); assert!(is_builtin(&b, 2, 0, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert!(is_builtin(&b, 3, 0, "crate"));
assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate"));
} }
#[test] #[test]
fn cascade_blocked_by_wall_moves_nothing() { 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, 1, 0);
crate_at(&mut board, 2, 0); crate_at(&mut board, 2, 0);
wall_at(&mut board, 3, 0); wall_at(&mut board, 3, 0);
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); assert_eq!(b.player_pos(), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert!(is_builtin(&b, 1, 0, "crate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); assert!(is_builtin(&b, 2, 0, "crate"));
} }
#[test] #[test]
fn player_pushes_pushable_solid_object() { fn player_pushes_pushable_solid_object() {
// A solid, pushable object is shoved exactly like a crate. // A plain object whose EnterResponse is Push(Any) is shoved exactly like a
let mut obj = ObjectDef::new(1, 0); // crate — the crate builtin has no privileged status, it just carries that
obj.behavior.pushable = Pushable::Any; // same enter response.
let board = open_board(4, 1, (0, 0), vec![obj]); 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); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!(b.player_pos(), (1, 0));
assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); assert_eq!(
b.get_hookable(id).expect("object still on the board").location(),
(2, 0)
);
} }
#[test] #[test]
fn hcrate_pushes_east_but_not_north() { fn hcrate_pushes_east_but_not_north() {
// Pushing east: the HCrate slides. // Pushing east: Push(Horizontal) allows it, so the HCrate slides.
let mut board = open_board(4, 1, (0, 0), vec![]); let mut board = open_board(4, 1, (0, 0));
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::HCrate, "hcrate")); builtin_at(&mut board, 1, 0, "hcrate");
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
{
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!(b.player_pos(), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty); assert!(is_builtin(&b, 2, 0, "hcrate"));
assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::HCrate, "hcrate")); }
drop(b);
// Pushing north into an HCrate: blocked, nothing moves. // Pushing north into an HCrate: the direction isn't allowed, so can_push fails
let mut board = open_board(1, 4, (0, 3), vec![]); // and the move degrades to a bump — nothing moves.
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::HCrate, "hcrate")); let mut board = open_board(1, 4, (0, 3));
builtin_at(&mut board, 0, 2, "hcrate");
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::North); game.try_move(Direction::North);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3)); assert_eq!(b.player_pos(), (0, 3));
assert_eq!(b.get(0, 2).1, Archetype::Builtin(Builtin::HCrate, "hcrate")); assert!(is_builtin(&b, 0, 2, "hcrate"));
} }
#[test] #[test]
fn vcrate_pushes_north_but_not_east() { fn vcrate_pushes_north_but_not_east() {
// Pushing north: the VCrate slides. // Pushing north: Push(Vertical) allows it, so the VCrate slides.
let mut board = open_board(1, 4, (0, 3), vec![]); let mut board = open_board(1, 4, (0, 3));
stamp(&mut board, 0, 2, Archetype::Builtin(Builtin::VCrate, "vcrate")); builtin_at(&mut board, 0, 2, "vcrate");
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::North); game.try_move(Direction::North);
{
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2)); assert_eq!(b.player_pos(), (0, 2));
assert_eq!(b.get(0, 2).1, Archetype::Empty); assert!(is_builtin(&b, 0, 1, "vcrate"));
assert_eq!(b.get(0, 1).1, Archetype::Builtin(Builtin::VCrate, "vcrate")); }
drop(b);
// Pushing east into a VCrate: blocked, nothing moves. // Pushing east into a VCrate: blocked, nothing moves.
let mut board = open_board(4, 1, (0, 0), vec![]); let mut board = open_board(4, 1, (0, 0));
stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::VCrate, "vcrate")); builtin_at(&mut board, 1, 0, "vcrate");
let mut game = GameState::new(board); let mut game = GameState::new(board);
game.try_move(Direction::East); game.try_move(Direction::East);
let b = game.board(); let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); assert_eq!(b.player_pos(), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::VCrate, "vcrate")); assert!(is_builtin(&b, 1, 0, "vcrate"));
} }
+225 -232
View File
@@ -1,14 +1,52 @@
use super::{board_with_object, log_texts, nonsolid_object, scripted_object, scripts_from}; use super::{log_texts, scripts_from};
use crate::board::tests::{crate_at, open_board}; use crate::board::Board;
use crate::board::tests::{object_at, open_board, plain_object_at, sensor_at};
use crate::game::{GameState, ScrollLine}; 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; 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] #[test]
fn init_runs_only_on_run_init_not_at_construction() { fn init_runs_only_on_run_init_not_at_construction() {
let (board, scripts) = board_with_object( let (board, scripts, _) = board_with_object(
Some("greet"), Some("greet"),
&[("greet", r#"fn init(m) { log("hello"); }"#)], &[("greet", r#"fn init(me) { log("hello"); }"#)],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
// init must not fire during construction / deserialization. // init must not fire during construction / deserialization.
@@ -20,9 +58,9 @@ fn init_runs_only_on_run_init_not_at_construction() {
#[test] #[test]
fn tick_calls_script_tick_with_elapsed_seconds() { fn tick_calls_script_tick_with_elapsed_seconds() {
let (board, scripts) = board_with_object( let (board, scripts, _) = board_with_object(
Some("t"), 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); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
@@ -36,15 +74,16 @@ fn tick_calls_script_tick_with_elapsed_seconds() {
#[test] #[test]
fn missing_hooks_and_no_script_are_noops() { fn missing_hooks_and_no_script_are_noops() {
// Object with no script: nothing happens. // Object with no script at all: nothing happens.
let (board, scripts) = board_with_object(None, &[]); let (board, scripts, _) = board_with_object(None, &[]);
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
game.tick(Duration::from_millis(33)); game.tick(Duration::from_millis(33));
assert!(game.log.is_empty()); assert!(game.log.is_empty());
// Script defines neither init nor tick: also a no-op. // 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); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
game.tick(Duration::from_millis(33)); game.tick(Duration::from_millis(33));
@@ -53,23 +92,30 @@ fn missing_hooks_and_no_script_are_noops() {
#[test] #[test]
fn compile_and_unknown_script_errors_are_logged() { fn compile_and_unknown_script_errors_are_logged() {
// A reference to a script name that isn't in the table. // A reference to a script name that isn't in the pool. See todo.md #5: this is
let (board, scripts) = board_with_object(Some("ghost"), &[]); // 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); 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. // 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); let game = GameState::with_scripts(board, scripts);
assert!(log_texts(&game)[0].contains("failed to compile")); assert!(log_texts(&game)[0].contains("failed to compile"));
} }
#[test] #[test]
fn script_reads_board_through_view() { 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( let mut game = GameState::with_scripts(
board, 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(); game.run_init();
assert_eq!(log_texts(&game), vec!["3"]); assert_eq!(log_texts(&game), vec!["3"]);
@@ -77,34 +123,34 @@ fn script_reads_board_through_view() {
#[test] #[test]
fn commands_are_routed_to_their_own_source_object() { fn commands_are_routed_to_their_own_source_object() {
// Two objects with different scripts move in opposite directions; each // Two objects with different scripts move in opposite directions; each must
// must affect only itself (the per-call tag routes the command source). // affect only itself (the per-call tag routes the command to its source queue).
let board = open_board( let mut board = open_board(5, 3, (0, 0));
5, let east = object_at(&mut board, 0, 1, "e", EnterResponse::Block);
3, let west = object_at(&mut board, 4, 1, "w", EnterResponse::Block);
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
("e", "fn init(m) { move(East); }"), ("e", "fn init(me) { move(East); }"),
("w", "fn init(m) { move(West); }"), ("w", "fn init(me) { move(West); }"),
]), ]),
); );
game.run_init(); game.run_init();
let b = game.board(); assert_eq!(loc(&game, east), (1, 1)); // moved east from 0
assert_eq!(b.objects[&1].x, 1); // moved east from 0 assert_eq!(loc(&game, west), (3, 1)); // moved west from 4
assert_eq!(b.objects[&2].x, 3); // moved west from 4
} }
#[test] #[test]
fn start_map_greeter_runs_init() { fn start_map_greeter_runs_init() {
// End-to-end against the shipped example map: load it, run init, and // End-to-end against the shipped example map: load it, run init, and confirm the
// confirm the greeter read the board (interpolated message) and wrote to // greeter read the board (interpolated message) and wrote to itself (set_tile).
// itself (set_tile). Also guards the example from drifting out of sync. // Also guards the example from drifting out of sync with the engine.
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml"); 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. // Pin to the "start" board regardless of the world's current default entry point.
world.start = "start".to_string(); world.start = "start".to_string();
let mut game = GameState::from_world(world); let mut game = GameState::from_world(world);
@@ -115,52 +161,53 @@ fn start_map_greeter_runs_init() {
.any(|t| t.contains("hello from object")), .any(|t| t.contains("hello from object")),
"greeter init should log a greeting" "greeter init should log a greeting"
); );
assert!( let has_smiley = game
game.board().objects.values().any(|o| o.glyph.tile == 2), .board()
"greeter set_tile(2) should change its glyph" .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] #[test]
fn set_tag_adds_and_removes_via_my_id() { fn set_tag_adds_and_removes_via_my_id() {
// A script calls set_tag(Me.id, "active", true) in init; the tag must be // A script calls set_tag(me.id, "active", true) in init; the tag must be present
// present on the object afterward. // on the object afterward.
let (board, scripts) = board_with_object( let (board, scripts, id) = board_with_object(
Some("t"), 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); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); 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. // A script removes a pre-existing tag.
let (mut board2, scripts2) = board_with_object( let (mut board2, scripts2, id2) = board_with_object(
Some("t2"), 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. // Seed the tag before construction.
board2 board2
.objects .scripting_mut(id2)
.get_mut(&1)
.unwrap() .unwrap()
.scripting
.tags .tags
.insert("active".to_string()); .insert("active".to_string());
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
assert!(!game2.board().objects[&1].scripting.tags.contains("active")); assert!(!has_tag(&game2, id2, "active"));
} }
#[test] #[test]
fn has_tag_reads_own_tags() { fn has_tag_reads_own_tags() {
// set_tag queues an action; has_tag reads the board state. So set_tag in // set_tag queues an action; has_tag reads board state. So set_tag in init() takes
// init() takes effect after init returns; a subsequent tick sees it. // effect after init returns, and a subsequent tick sees it.
let (board, scripts) = board_with_object( let (board, scripts, _) = board_with_object(
Some("t"), Some("t"),
&[( &[(
"t", "t",
r#"fn init(m) { set_tag(m.id, "active", true); } r#"fn init(me) { set_tag(me.id, "active", true); }
fn tick(m,dt) { log(m.has_tag("active").to_string()); }"#, fn tick(me, dt) { log(me.has_tag("active").to_string()); }"#,
)], )],
); );
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
@@ -172,18 +219,21 @@ fn has_tag_reads_own_tags() {
#[test] #[test]
fn objects_with_tag_returns_matching_ids() { fn objects_with_tag_returns_matching_ids() {
// A 5×1 board with two objects; one has tag "enemy". The script on the first // 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. // queries Board.tagged("enemy") and logs the count and the id it found.
let obj1 = scripted_object(0, 0, "q"); let mut board = open_board(5, 1, (4, 0));
let mut obj2 = scripted_object(1, 0, "none"); object_at(&mut board, 0, 0, "q", EnterResponse::Block);
// obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not. let enemy = object_at(&mut board, 1, 0, "none", EnterResponse::Block);
obj2.scripting.tags.insert("enemy".to_string()); board
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); .scripting_mut(enemy)
.unwrap()
.tags
.insert("enemy".to_string());
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init(m) { r#"fn init(me) {
let infos = Board.tagged("enemy"); let infos = Board.tagged("enemy");
log(infos.len().to_string()); log(infos.len().to_string());
log(infos[0].id.to_string()); log(infos[0].id.to_string());
@@ -195,22 +245,24 @@ fn objects_with_tag_returns_matching_ids() {
game.run_init(); game.run_init();
let texts = log_texts(&game); let texts = log_texts(&game);
assert_eq!(texts[0], "1"); // exactly one match 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] #[test]
fn my_name_returns_name_or_empty_string() { fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name(). // An object with a name set should see it via me.name.
let (mut board, scripts) = let (mut board, scripts, id) =
board_with_object(Some("n"), &[("n", r#"fn init(m) { log(m.name); }"#)]); board_with_object(Some("n"), &[("n", r#"fn init(me) { log(me.name); }"#)]);
board.objects.get_mut(&1).unwrap().scripting.name = Some("beacon".to_string()); board.scripting_mut(id).unwrap().name = Some("beacon".to_string());
let mut game = GameState::with_scripts(board, scripts); let mut game = GameState::with_scripts(board, scripts);
game.run_init(); game.run_init();
assert_eq!(log_texts(&game), vec!["beacon"]); assert_eq!(log_texts(&game), vec!["beacon"]);
// An unnamed object should get (). // An unnamed object should get ().
let (board2, scripts2) = let (board2, scripts2, _) = board_with_object(
board_with_object(Some("n"), &[("n", r#"fn init(m) { if m.name == () { log("null"); }}"#)]); Some("n"),
&[("n", r#"fn init(me) { if me.name == () { log("null"); } }"#)],
);
let mut game2 = GameState::with_scripts(board2, scripts2); let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init(); game2.run_init();
assert_eq!(log_texts(&game2), vec!["null"]); assert_eq!(log_texts(&game2), vec!["null"]);
@@ -218,18 +270,18 @@ fn my_name_returns_name_or_empty_string() {
#[test] #[test]
fn object_id_for_name_finds_by_name() { fn object_id_for_name_finds_by_name() {
// A board with two objects; one is named. The querying object uses // A board with two objects; one is named. The querying object uses Board.named
// object_id_for_name to find the named one and logs its id. // to find it and logs its id, then confirms a miss returns ().
let obj1 = scripted_object(0, 0, "q"); let mut board = open_board(5, 1, (4, 0));
let mut obj2 = scripted_object(1, 0, "none"); object_at(&mut board, 0, 0, "q", EnterResponse::Block);
obj2.scripting.name = Some("target".to_string()); let target = object_at(&mut board, 1, 0, "none", EnterResponse::Block);
let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); board.scripting_mut(target).unwrap().name = Some("target".to_string());
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
( (
"q", "q",
r#"fn init(m) { r#"fn init(me) {
log(Board.named("target").id.to_string()); log(Board.named("target").id.to_string());
let miss = Board.named("missing"); let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() }); log(if miss == () { "not_found" } else { miss.id.to_string() });
@@ -240,63 +292,66 @@ fn object_id_for_name_finds_by_name() {
); );
game.run_init(); game.run_init();
let texts = log_texts(&game); 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 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] #[test]
fn player_bump_reports_the_direction_it_came_from() { fn player_bump_reports_the_direction_it_came_from() {
// The player walks East into a solid scripted object; the object is bumped from // The player walks East into a Block object; the object is bumped from the West
// the West side (opposite the player's travel) and the player does not move onto it. // 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")]); let mut board = open_board(3, 1, (0, 0));
object_at(&mut board, 1, 0, "b", EnterResponse::Block);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, 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.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked assert_eq!(game.board().player_pos(), (0, 0)); // blocked
// bump's log is emitted into the object's queue; a tick flushes it. // log() bypasses the queue, so no tick is needed to flush it.
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumped from West")); 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] #[test]
fn bump_direction_supports_comparison_and_offset() { 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( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[( scripts_from(&[(
"b", "b",
// Player bumps from the West, so `dir == West` and the bumper sits at // Player bumps from the West, so `dir == West` and the bumper sits at
// (me.x + dir.dx) = 1 + (-1) = 0. // (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.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumper at 0")); assert!(log_texts(&game).iter().any(|t| t == "bumper at 0"));
} }
// ── scroll ──────────────────────────────────────────────────────────────────
#[test] #[test]
fn scroll_opens_on_player_bump() { fn scroll_opens_on_player_bump() {
// A solid object's bump() calls scroll([text, [choice, display]]). After the // A Block object's bump() calls scroll([text, [choice, display]]). The bump
// player bumps it and a tick resolves the queued action, active_scroll is set // resolves inside try_move, so active_scroll is set with the right variants.
// with the correct ScrollLine variants. let mut board = open_board(3, 1, (0, 0));
let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); object_at(&mut board, 1, 0, "s", EnterResponse::Block);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[( scripts_from(&[(
"s", "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(); game.run_init();
// The bump resolves within try_move now, so the scroll is open immediately.
game.try_move(Direction::East); game.try_move(Direction::East);
let scroll = game let scroll = game
@@ -313,13 +368,13 @@ fn scroll_opens_on_player_bump() {
#[test] #[test]
fn handle_scroll_without_choice_clears_it() { 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( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]), scripts_from(&[("s", r#"fn bump(me, dir) { scroll(["Hello"]); }"#)]),
); );
game.run_init(); game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East); game.try_move(Direction::East);
assert!(game.active_scroll.is_some()); 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() { fn handle_scroll_with_choice_dispatches_send_to_source() {
// Setting choice on the scroll before a tick fires send() on the source object; // Setting choice on the scroll before a tick fires send() on the source object;
// fn eat() logging "eaten" confirms the dispatch arrived. // 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( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[( scripts_from(&[(
"s", "s",
r#" r#"
fn bump(m,dir) { scroll(["Muffin?", ["eat", "Eat it"]]); } fn bump(me, dir) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); } fn eat() { log("eaten"); }
"#, "#,
)]), )]),
); );
game.run_init(); game.run_init();
// The bump resolves within try_move, so the scroll is open right away.
game.try_move(Direction::East); game.try_move(Direction::East);
assert!(game.active_scroll.is_some()); 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.active_scroll.as_mut().unwrap().choice = Some("eat".to_string());
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_none()); assert!(game.active_scroll.is_none());
@@ -358,21 +413,26 @@ fn handle_scroll_with_choice_dispatches_send_to_source() {
); );
} }
// ── ordering ────────────────────────────────────────────────────────────────
#[test] #[test]
fn a_later_object_sees_an_earlier_objects_move_this_tick() { fn a_later_object_sees_an_earlier_objects_move_this_tick() {
// The core of the epic: each object's queued actions apply immediately, before // Each object's queued actions apply immediately, before the next (higher-id)
// the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East // object runs its hook. Object A at (0,0) moves East onto (1,0); object B at
// onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0). // (1,1) then checks the cell to its North (1,0). Because A already moved there
// Because A already moved there this tick, B observes it as blocked. Under the // this tick, B observes it as blocked — under a collect-all-then-apply model B
// old collect-all-then-apply model B would have seen (1,0) still empty. // would have seen (1,0) still empty.
let a = scripted_object(0, 0, "a"); let mut board = open_board(3, 2, (2, 1));
let b = scripted_object(1, 1, "b"); let a = object_at(&mut board, 0, 0, "a", EnterResponse::Block);
let board = open_board(3, 2, (2, 1), vec![a, b]); object_at(&mut board, 1, 1, "b", EnterResponse::Block);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ scripts_from(&[
("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"), ("a", "fn tick(me, dt) { move(East); }"),
("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#), (
"b",
r#"fn tick(me, dt) { log(if me.blocked(North) { "blocked" } else { "clear" }); }"#,
),
]), ]),
); );
game.run_init(); game.run_init();
@@ -380,7 +440,7 @@ fn a_later_object_sees_an_earlier_objects_move_this_tick() {
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
// A moved onto (1,0), and B saw it there the same tick. // 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"]); 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 // 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 // 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 // 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. // once (x=1) then waits ~0.25s before the next call fires.
// Under the old every-frame model an unguarded tick would enqueue a fresh move each let mut board = open_board(6, 1, (5, 0));
// frame, racing the object east far faster. let id = object_at(&mut board, 0, 0, "m", EnterResponse::Block);
let obj = scripted_object(0, 0, "m");
let board = open_board(6, 1, (5, 0), vec![obj]);
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, board,
// Note: no `if m.queue.length == 0` guard — the engine provides it. // Note: no `if me.queue.length == 0` guard — the engine provides it.
scripts_from(&[("m", "fn tick(m, dt) { move(East); }")]), scripts_from(&[("m", "fn tick(me, dt) { move(East); }")]),
); );
game.run_init(); game.run_init();
@@ -407,143 +465,78 @@ fn tick_is_gated_on_an_empty_queue() {
for _ in 0..3 { for _ in 0..3 {
game.tick(Duration::from_millis(100)); 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` // Once the Delay fully drains the queue empties, so the next frame calls `tick`
// again and the object takes its second step. // again and the object takes its second step.
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);
} }
#[test] #[test]
fn a_send_cycle_terminates_via_the_called_guard() { fn a_send_cycle_defers_instead_of_recursing() {
// Two objects send "go" to each other in a cycle. Without the per-invocation // Two objects send "poke" to each other in a cycle. `run_send` invokes the target
// "already-called" guard this would recurse forever; with it, each (object, fn, // function directly but deliberately does *not* drain its queue, so the re-send
// args) fires at most once, so the cascade settles after one round-trip. That the // lands in the target's queue and fires on the next drain instead of recursing
// call returns at all — and logs exactly one "B" then one "A" — proves it. // inside this dispatch. The cycle therefore never settles — but it advances a
let a = scripted_object(0, 0, "a"); // bounded amount per tick, so the game keeps running rather than hanging.
let b = scripted_object(1, 0, "b"); //
let board = open_board(3, 1, (2, 0), vec![a, b]); // 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( let mut game = GameState::with_scripts(
board, board,
scripts_from(&[ 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(); game.run_init();
// One round-trip: A.init's send runs B.poke, whose re-send is queued on B and
// B.poke fires once (from A.init's send), then A.poke once (from B.poke's send); // drained later in the same run_init pass, running A.poke. A's re-send waits.
// A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops.
assert_eq!(log_texts(&game), vec!["B", "A"]); 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 hook ──────────────────────────────────────────────────────────────
// `enter(me, dir)` fires on a non-solid object when a solid relocates onto its // `enter(me, dir)` fires on a **sensor** when the player steps onto its cell, with
// cell. `dir` is the side the entrant came from (opposite the travel direction // `dir` the side the player came from. This is the only remaining enter trigger:
// for a cardinal move/push; best-effort for teleport/shift). // object movement, pushes, teleports and shifts dispatch no hooks (see todo.md).
#[test] #[test]
fn player_walking_onto_a_nonsolid_fires_enter_from_the_travel_side() { fn player_walking_onto_a_sensor_fires_enter_from_the_travel_side() {
// The player walks East onto a non-solid trigger; it entered from the West. // The player walks East onto a sensor; it entered from the West.
let board = open_board(3, 1, (0, 0), vec![nonsolid_object(1, 0, "e")]); let mut board = open_board(3, 1, (0, 0));
sensor_at(&mut board, 1, 0, "e");
let mut game = GameState::with_scripts( let mut game = GameState::with_scripts(
board, 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.run_init();
game.try_move(Direction::East); game.try_move(Direction::East);
// The player is not blocked by a non-solid — it moves onto the cell. // A sensor is off-grid, so it never blocks: the player moves onto the cell.
assert_eq!((game.board().player.x, game.board().player.y), (1, 0)); assert_eq!(game.board().player_pos(), (1, 0));
assert!(log_texts(&game).iter().any(|t| t == "entered from West")); 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")));
}