From c3be2329c0d4972d90226e307aa8534c8d168641 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 25 Jul 2026 12:48:11 -0500 Subject: [PATCH] fixing tests --- kiln-core/src/board.rs | 16 +- kiln-core/src/portal.rs | 4 +- kiln-core/src/tests/actions.rs | 213 +++++++------ kiln-core/src/tests/collision.rs | 42 --- kiln-core/src/tests/game_portals.rs | 118 +++---- kiln-core/src/tests/mod.rs | 59 +--- kiln-core/src/tests/movement.rs | 158 ++++----- kiln-core/src/tests/scripting.rs | 475 ++++++++++++++-------------- 8 files changed, 514 insertions(+), 571 deletions(-) delete mode 100644 kiln-core/src/tests/collision.rs diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index f3d0341..c9c0702 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -766,14 +766,20 @@ pub(crate) mod tests { 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 - /// else should use [`object_at`]. - pub(crate) fn plain_object_at(board: &mut Board, x: usize, y: usize) -> ObjectId { + /// For the "an object without a script is inert" path, and for plain physical + /// props (a pushable block with no behavior of its own). Everything scripted + /// should use [`object_at`]. + pub(crate) fn plain_object_at( + board: &mut Board, + x: usize, + y: usize, + enter: EnterResponse, + ) -> ObjectId { let tile = TileSpec::Object { script: None, - enter: EnterResponse::Block, + enter, glyph: ObjectDef::default_glyph(), optics: Optics { opaque: true, glow: 0 }, name: None, diff --git a/kiln-core/src/portal.rs b/kiln-core/src/portal.rs index d8df624..060fb42 100644 --- a/kiln-core/src/portal.rs +++ b/kiln-core/src/portal.rs @@ -26,11 +26,11 @@ pub struct Portal { /// Name of the arrival portal on the target board. pub target_name: String, #[serde(skip_serializing_if = "Option::is_none")] - glyph: Option + pub glyph: Option } impl Portal { pub fn location(&self) -> (usize, usize) { (self.x, self.y) } -} \ No newline at end of file +} diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index f2cef61..9aa07ff 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -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); } + + + diff --git a/kiln-core/src/tests/collision.rs b/kiln-core/src/tests/collision.rs deleted file mode 100644 index 00f8eeb..0000000 --- a/kiln-core/src/tests/collision.rs +++ /dev/null @@ -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 -} diff --git a/kiln-core/src/tests/game_portals.rs b/kiln-core/src/tests/game_portals.rs index 3764fd4..121dbf3 100644 --- a/kiln-core/src/tests/game_portals.rs +++ b/kiln-core/src/tests/game_portals.rs @@ -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) -> 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) -> 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"); } diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index ba2d16e..f824cb0 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -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) { - 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 { .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 { game.log diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index 8f9a629..d1856b0 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -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")); } diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index f18f893..b043e04 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -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, 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"))); -}