diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 389df33..e6df337 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -633,10 +633,10 @@ pub(crate) mod tests { use crate::builtin::Builtin; use crate::floor::Floor; use crate::glyph::Glyph; - use crate::utils::Direction; + use crate::utils::{Direction, ObjectId}; use color::Rgba8; use std::collections::HashMap; - use crate::tile::{DrawLayer, IntoTile, Optics, ScriptAttributes, Sensor, Tile, TileSpec}; + use crate::tile::{DrawLayer, IntoTile, Optics, ScriptAttributes, Sensor, SensorSpec, Tile, TileSpec}; /// Builds an all-empty `w×h` board. /// @@ -685,6 +685,48 @@ pub(crate) mod tests { *board.get_mut(x, y) = Some(TileSpec::gem().into_tile(&mut board.next_object_id).unwrap()); } + /// Stamps the builtin named `kind` (any alias accepted by [`Builtin::from_name`], + /// e.g. `"spinner_cw"`, `"pusher_east"`) onto the grid, returning its id. + /// + /// The generic counterpart to [`crate_at`]/[`wall_at`]/[`gem_at`]: `into_tile` + /// attaches the family's [`ScriptKey`](crate::tile::ScriptKey) and the + /// `BUILTIN_` tag, so the object is fully live with no world script pool. + /// Panics on an unknown `kind`. + pub(crate) fn builtin_at(board: &mut Board, x: usize, y: usize, kind: &str) -> ObjectId { + let tile = TileSpec::Builtin { kind: kind.to_string(), glyph: None } + .into_tile(&mut board.next_object_id) + .unwrap_or_else(|e| panic!("{e}")); + let id = match &tile { + Tile::Object(obj) => obj.scripting.id, + Tile::Player => unreachable!("a builtin never resolves to the player"), + }; + *board.get_mut(x, y) = Some(tile); + id + } + + /// Adds an invisible, script-only [`Sensor`] at `(x, y)` running the world script + /// named `script`, returning its id. + /// + /// This is how a test gets "a scripted thing that doesn't get in the way": every + /// object on the grid is solid now, so a script host that must not block movement + /// (or must share a cell) has to live off-grid in [`Board::sensors`]. + pub(crate) fn sensor_at(board: &mut Board, x: usize, y: usize, script: &str) -> ObjectId { + let sensor = SensorSpec { + x, + y, + script: Some(script.to_string()), + glyph: Glyph::transparent(), + optics: Optics::default(), + name: None, + tags: Vec::new(), + draw_layer: DrawLayer::Below, + } + .into_sensor(&mut board.next_object_id); + let id = sensor.scripting.id; + board.sensors.push(sensor); + id + } + /// Stamps a player cell onto the grid. pub(crate) fn player_at(board: &mut Board, x: usize, y: usize) { *board.get_mut(x, y) = Some(TileSpec::player().into_tile(&mut board.next_object_id).unwrap()); diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index ef8b6ae..31ea1e3 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -564,16 +564,17 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) { mod tests { use super::GameState; use crate::Direction; - use crate::builtin::Builtin; - use crate::board::tests::{crate_at, gem_at, open_board, wall_at}; - use crate::object_def::ObjectDef; + use crate::board::tests::{ + builtin_at, crate_at, gem_at, is_builtin, open_board, sensor_at, wall_at, + }; use std::collections::HashMap; use std::time::Duration; #[test] fn walking_onto_a_gem_grabs_it() { - // A gem terrain cell at (1,0); expanding turns it into the builtin gem - // object running scripts/gem.rhai. The player starts at (0,0). + // A gem builtin object at (1,0), running scripts/gem.rhai. Its + // EnterResponse::Grab means the player's step fires grab() rather than being + // blocked. The player starts at (0,0). let mut board = open_board(3, 1, (0, 0)); gem_at(&mut board, 1, 0); let mut game = GameState::new(board); @@ -583,43 +584,20 @@ mod tests { // The gem was grabbed: gem count up, gem object gone, player on its cell. assert_eq!(game.player.borrow().gems, 1); + assert!(game.board().all_ids().is_empty()); assert_eq!(game.board().player_pos(), (1, 0)); } - #[test] - fn pushing_a_gem_into_the_player_does_not_grab() { - // A non-solid script object at (0,0) pushes the gem at (1,0) east toward the - // player at (2,0). Grab now fires only on player movement, so the gem is an - // ordinary solid here: the chain can't move (the player is backed against - // the board edge), so nothing happens and the gem is not collected. - let mut sobj = ObjectDef::new(0, 0); - sobj.behavior.solid = false; - sobj.scripting.script_name = Some("s".to_string()); - let mut board = open_board(3, 1, (2, 0), vec![sobj]); - stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); - board.expand_builtin_archetypes(); - let scripts = HashMap::from([( - "s".to_string(), - "fn tick(dt) { if Queue.length() == 0 { push(1, 0, East); } }".to_string(), - )]); - let mut game = GameState::with_scripts(board, scripts); - game.run_init(); - game.tick(Duration::from_millis(16)); - game.tick(Duration::from_millis(16)); - - // No grab: the gem is untouched and the player never moved. - assert_eq!(game.player.borrow().gems, 0); - assert!(game.board().objects.values().any(|o| o.behavior.grab)); - assert_eq!((game.board().player.x, game.board().player.y), (2, 0)); - } - - /// Builds a `GameState` with one non-solid object running `src` as its script. + /// Builds a `GameState` with a script-only sensor at (0,0) running `src`, a crate + /// at (2,0), and the player parked against the right-hand edge. + /// + /// The script host is a [`Sensor`](crate::tile::Sensor) rather than an object: + /// every grid object is solid now, so a scripted thing that must not interfere + /// with movement has to live off-grid. fn game_with_object_script(board_w: usize, src: &str) -> GameState { - let mut obj = ObjectDef::new(0, 0); - obj.behavior.solid = false; - obj.scripting.script_name = Some("s".to_string()); - let mut board = open_board(board_w, 1, (board_w as i64 - 1, 0), vec![obj]); + let mut board = open_board(board_w, 1, (board_w - 1, 0)); + sensor_at(&mut board, 0, 0, "s"); crate_at(&mut board, 2, 0); let scripts = HashMap::from([("s".to_string(), src.to_string())]); let mut game = GameState::with_scripts(board, scripts); @@ -630,28 +608,25 @@ mod tests { #[test] fn script_shift_rotates_crates() { // Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle: - // crate moves to (3,0), empty moves back to (2,0). - let mut game = game_with_object_script( - 5, - "fn tick(m,dt) { if m.queue.length == 0 { shift([[2, 0], [3, 0]]); } }", - ); + // crate moves to (3,0), empty moves back to (2,0). No queue guard is needed — + // the host only calls `tick` when the object's queue has drained. + let mut game = game_with_object_script(5, "fn tick(me, dt) { shift([[2, 0], [3, 0]]); }"); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(2, 0).1, Archetype::Empty); - assert_eq!(b.get(3, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); + assert!(b.get(2, 0).is_none()); + assert!(is_builtin(&b, 3, 0, "crate")); } #[test] fn script_passable_distinguishes_empty_solid_and_offboard() { - // (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid - // object sits at (0,0); the player at (3,0). passable should report only the - // genuinely empty in-bounds cell. - let mut obj = ObjectDef::new(0, 0); - obj.behavior.solid = false; - obj.scripting.script_name = Some("s".to_string()); - let mut board = open_board(4, 1, (3, 0), vec![obj]); + // (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The script + // sensor sits at (0,0) — sensors never occupy a cell, so it does not make its + // own cell impassable; the player is at (3,0). `passable` should report only + // the genuinely empty in-bounds cell. + let mut board = open_board(4, 1, (3, 0)); + sensor_at(&mut board, 0, 0, "s"); crate_at(&mut board, 2, 0); - let src = "fn init(m) { \ + let src = "fn init(me) { \ log(if Board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \ log(if Board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \ log(if Board.passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }"; @@ -669,15 +644,13 @@ mod tests { #[test] fn log_is_immediate_and_not_paced_by_the_queue() { - // `delay(5.0)` parks the object's action queue for 5 seconds, then `log()` + // `delay(5.0)` parks the sensor's action queue for 5 seconds, then `log()` // fires. Because logging bypasses the queue entirely, the line must appear // right after run_init (dt = 0) — under the old queued-Action behavior it // would have been stuck behind the delay and absent here. - let mut obj = ObjectDef::new(0, 0); - obj.behavior.solid = false; - obj.scripting.script_name = Some("s".to_string()); - let board = open_board(4, 1, (3, 0), vec![obj]); - let src = "fn init(m) { delay(5.0); log(\"immediate\"); }"; + let mut board = open_board(4, 1, (3, 0)); + sensor_at(&mut board, 0, 0, "s"); + let src = "fn init(me) { delay(5.0); log(\"immediate\"); }"; let scripts = HashMap::from([("s".to_string(), src.to_string())]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); @@ -690,52 +663,53 @@ mod tests { ); } - /// Builds a 3×3 board with a non-solid clockwise spinner object (running the - /// real `scripts/spinner.rhai`) at the centre and the player parked on it. + /// Builds a board with a clockwise `spinner_cw` builtin at (1,1), plus crates and + /// walls at the given ring cells. fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState { spinner_board_dir(crates, walls, false) } - /// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the - /// script spins counter-clockwise (clockwise is the default when no tag present). + /// Like [`spinner_board`] but `ccw` stamps the `spinner_ccw` alias instead, so the + /// shared `scripts/spinner.rhai` reads its `BUILTIN_spinner_ccw` tag and spins the + /// other way (clockwise is the default when the tag is absent). + /// + /// The board is 4×3, not 3×3: the spinner is a solid object now, so the player + /// needs a cell of its own at (3,1) instead of being parked on the spinner, and + /// the 8-cell ring around (1,1) must stay fully in bounds or `apply_shift` + /// rejects the whole rotation. Ring coordinates are unchanged from the 3×3 + /// layout — N(1,0) NE(2,0) E(2,1) SE(2,2) S(1,2) SW(0,2) W(0,1) NW(0,0). fn spinner_board_dir( crates: &[(usize, usize)], walls: &[(usize, usize)], ccw: bool, ) -> GameState { - let mut obj = ObjectDef::new(1, 1); - obj.behavior.solid = false; - obj.scripting.script_name = Some("spinner".to_string()); - if ccw { - obj.scripting.tags.insert("BUILTIN_spinner_ccw".to_string()); - } - let mut board = open_board(3, 3, (1, 1), vec![obj]); + let mut board = open_board(4, 3, (3, 1)); + // Stamped first, so the spinner holds the lowest id and ticks before anything else. + builtin_at(&mut board, 1, 1, if ccw { "spinner_ccw" } else { "spinner_cw" }); for &(x, y) in crates { crate_at(&mut board, x, y); } for &(x, y) in walls { wall_at(&mut board, x, y); } - let scripts = HashMap::from([( - "spinner".to_string(), - include_str!("scripts/spinner.rhai").to_string(), - )]); - let mut game = GameState::with_scripts(board, scripts); + // No world script pool: a builtin carries its own ScriptKey and source. + let mut game = GameState::new(board); game.run_init(); game } #[test] fn spinner_does_not_destroy_a_blocked_neighbour() { - // Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the - // wall, so N must not rotate onto NE — the old (can_shift-only) script - // overwrote and destroyed NE's crate. The cascade leaves everything put. + // Crates at N(1,0) and NE(2,0), a wall at E(2,1). The wall is immobile, and + // `apply_shift` cascades that backward over the contiguous run of solids + // behind it, so N must not rotate onto NE — an earlier one-cell-lookahead + // version overwrote and destroyed NE's crate. Everything stays put. let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // N kept - assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NE kept (not destroyed) - assert_eq!(b.get(2, 1).1, Archetype::Builtin(Builtin::Wall, "wall")); // wall kept + assert!(is_builtin(&b, 1, 0, "crate")); // N kept + assert!(is_builtin(&b, 2, 0, "crate")); // NE kept (not destroyed) + assert!(is_builtin(&b, 2, 1, "wall")); // wall kept } #[test] @@ -745,10 +719,10 @@ mod tests { let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(2, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NE: filled by N - assert_eq!(b.get(1, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // N: filled by NW - assert_eq!(b.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NW: filled by W - assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here) + assert!(is_builtin(&b, 2, 0, "crate")); // NE: filled by N + assert!(is_builtin(&b, 1, 0, "crate")); // N: filled by NW + assert!(is_builtin(&b, 0, 0, "crate")); // NW: filled by W + assert!(b.get(0, 1).is_none()); // W: vacated (hole moved here) } #[test] @@ -758,9 +732,9 @@ mod tests { let mut game = spinner_board_dir(&[(1, 0)], &[], true); game.tick(Duration::from_millis(16)); let b = game.board(); - assert_eq!(b.get(0, 0).1, Archetype::Builtin(Builtin::Crate, "crate")); // NW: crate rotated counter-clockwise - assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched - assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated + assert!(is_builtin(&b, 0, 0, "crate")); // NW: crate rotated counter-clockwise + assert!(b.get(2, 0).is_none()); // NE: untouched + assert!(b.get(1, 0).is_none()); // N: vacated } #[test] @@ -768,31 +742,34 @@ mod tests { // The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame // per 0.5s rotation, while the colours stay put. let mut game = spinner_board(&[], &[]); - let id = *game.board().objects.keys().next().unwrap(); - let (fg0, bg0) = { - let b = game.board(); - (b.objects[&id].glyph.fg, b.objects[&id].glyph.bg) + // With no crates or walls the spinner is the board's only object; the player + // is a `Tile::Player`, which carries no id. + let id = game.board().all_ids()[0]; + let glyph_of = |game: &GameState| { + game.board() + .get_hookable(id) + .expect("the spinner stays on the board") + .glyph() }; + let base = glyph_of(&game); let mut seq = Vec::new(); for _ in 0..5 { game.tick(Duration::from_secs_f64(0.5)); - let b = game.board(); - seq.push(b.objects[&id].glyph.tile); - assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged"); - assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged"); + let g = glyph_of(&game); + seq.push(g.tile); + assert_eq!(g.fg, base.fg, "fg unchanged"); + assert_eq!(g.bg, base.bg, "bg unchanged"); } assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]); } #[test] fn set_key_gives_and_takes_keys() { - let mut sobj = ObjectDef::new(0, 0); - sobj.behavior.solid = false; - sobj.scripting.script_name = Some("s".to_string()); - let board = open_board(2, 1, (1, 0), vec![sobj]); + let mut board = open_board(2, 1, (1, 0)); + sensor_at(&mut board, 0, 0, "s"); let scripts = HashMap::from([( "s".to_string(), - "fn init(m) { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(), + r#"fn init(me) { set_key("blue", true); set_key("red", true); }"#.to_string(), )]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); @@ -803,13 +780,11 @@ mod tests { assert!(!keys.cyan); // cyan was not set by the script // A second script can take a key. - let mut sobj2 = ObjectDef::new(0, 0); - sobj2.behavior.solid = false; - sobj2.scripting.script_name = Some("t".to_string()); - let board2 = open_board(2, 1, (1, 0), vec![sobj2]); + let mut board2 = open_board(2, 1, (1, 0)); + sensor_at(&mut board2, 0, 0, "t"); let scripts2 = HashMap::from([( "t".to_string(), - "fn init(m) { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(), + r#"fn init(me) { set_key("blue", true); set_key("blue", false); }"#.to_string(), )]); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); @@ -819,13 +794,11 @@ mod tests { #[test] fn set_key_unknown_color_logs_error() { - let mut sobj = ObjectDef::new(0, 0); - sobj.behavior.solid = false; - sobj.scripting.script_name = Some("s".to_string()); - let board = open_board(2, 1, (1, 0), vec![sobj]); + let mut board = open_board(2, 1, (1, 0)); + sensor_at(&mut board, 0, 0, "s"); let scripts = HashMap::from([( "s".to_string(), - r#"fn init(m) { set_key("chartreuse", true); }"#.to_string(), + r#"fn init(me) { set_key("chartreuse", true); }"#.to_string(), )]); let mut game = GameState::with_scripts(board, scripts); game.run_init();