From 9c2212520ce3658670e8e05f41070ef603f2edc6 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 7 Jun 2026 00:46:38 -0500 Subject: [PATCH] Moved tests --- kiln-core/src/game.rs | 631 ------------------ kiln-core/src/lib.rs | 5 +- kiln-core/src/map_file.rs | 399 ----------- kiln-core/src/tests/actions.rs | 253 +++++++ kiln-core/src/tests/collision.rs | 39 ++ kiln-core/src/tests/map_file/grid_errors.rs | 53 ++ kiln-core/src/tests/map_file/mod.rs | 89 +++ .../src/tests/map_file/object_placement.rs | 65 ++ .../src/tests/map_file/player_placement.rs | 70 ++ kiln-core/src/tests/map_file/round_trip.rs | 100 +++ kiln-core/src/tests/mod.rs | 51 ++ kiln-core/src/tests/movement.rs | 152 +++++ kiln-core/src/tests/scripting.rs | 139 ++++ 13 files changed, 1015 insertions(+), 1031 deletions(-) create mode 100644 kiln-core/src/tests/actions.rs create mode 100644 kiln-core/src/tests/collision.rs create mode 100644 kiln-core/src/tests/map_file/grid_errors.rs create mode 100644 kiln-core/src/tests/map_file/mod.rs create mode 100644 kiln-core/src/tests/map_file/object_placement.rs create mode 100644 kiln-core/src/tests/map_file/player_placement.rs create mode 100644 kiln-core/src/tests/map_file/round_trip.rs create mode 100644 kiln-core/src/tests/mod.rs create mode 100644 kiln-core/src/tests/movement.rs create mode 100644 kiln-core/src/tests/scripting.rs diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index ae59c86..258b889 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -187,634 +187,3 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option, scripts: &[(&str, &str)]) -> Board { - let mut object = ObjectDef::new(0, 0); - object.script_name = object_script.map(str::to_string); - Board { - name: "test".into(), - width: 1, - height: 1, - cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)], - floor: vec![Archetype::Empty.default_glyph()], - floor_spec: None, - player: Player { x: 0, y: 0 }, - objects: BTreeMap::from([(1, object)]), - next_object_id: 2, - portals: Vec::new(), - font: None, - zoom: 1, - scripts: scripts - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(), - board_script_name: None, - load_errors: Vec::new(), - } - } - - /// 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.script_name = Some(script.to_string()); - o - } - - /// Flattens each log line into a single string for easy assertions. - fn log_texts(game: &GameState) -> Vec { - game.log - .iter() - .map(|line| line.spans.iter().map(|s| s.text.as_str()).collect()) - .collect() - } - - #[test] - fn pushing_a_crate_reveals_the_floor_underneath() { - // The cell a crate is pushed off of becomes Empty and glyph_at shows floor. - // After the push the player is at (1,0); check the cell the player vacated - // (0,0) — it is Empty and must reveal the floor glyph, not black-on-black. - use color::Rgba8; - use crate::glyph::Glyph; - let mut board = open_board(4, 1, (0, 0), vec![], &[]); - 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 }, - }; - board.floor = vec![floor_glyph; 4]; - 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::Crate); - assert_eq!(b.get(1, 0).1, Archetype::Empty); - 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![], &[]); - 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)); // player advanced onto crate's old cell - assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate left this cell - assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east - } - - #[test] - fn push_blocked_by_wall_moves_nothing() { - let mut board = open_board(4, 1, (0, 0), vec![], &[]); - 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)); // blocked - assert_eq!(b.get(1, 0).1, Archetype::Crate); // crate didn't move - assert_eq!(b.get(2, 0).1, Archetype::Wall); // wall didn't move - } - - #[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![], &[]); - 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::Crate); - } - - #[test] - fn cascade_pushes_two_crates() { - let mut board = open_board(5, 1, (0, 0), vec![], &[]); - 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::Crate); - assert_eq!(b.get(3, 0).1, Archetype::Crate); - } - - #[test] - fn cascade_blocked_by_wall_moves_nothing() { - let mut board = open_board(5, 1, (0, 0), vec![], &[]); - 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::Crate); - assert_eq!(b.get(2, 0).1, Archetype::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); // solid by default - obj.pushable = true; - let board = open_board(4, 1, (0, 0), vec![obj], &[]); - 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)); // object shoved east - } - - #[test] - fn object_pushes_crate_on_init() { - // A scripted object moving east into a crate shoves it (move_object path). - let mut board = open_board( - 4, - 1, - (0, 0), // player behind the object, out of the push path - vec![scripted_object(1, 0, "m")], - &[("m", "fn init() { move(East); }")], - ); - crate_at(&mut board, 2, 0); - let mut game = GameState::new(board); - game.run_init(); - let b = game.board(); - assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); // object advanced - assert_eq!(b.get(2, 0).1, Archetype::Empty); // crate left (2,0) - assert_eq!(b.get(3, 0).1, Archetype::Crate); // crate pushed to (3,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::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::HCrate); - drop(b); - - // Pushing north into an HCrate: blocked, nothing moves. - let mut board = open_board(1, 4, (0, 3), vec![], &[]); - stamp(&mut board, 0, 2, Archetype::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)); // blocked - assert_eq!(b.get(0, 2).1, Archetype::HCrate); // didn't move - } - - #[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::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::VCrate); - drop(b); - - // Pushing east into a VCrate: blocked, nothing moves. - let mut board = open_board(4, 1, (0, 0), vec![], &[]); - stamp(&mut board, 1, 0, Archetype::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)); // blocked - assert_eq!(b.get(1, 0).1, Archetype::VCrate); // didn't move - } - - #[test] - fn init_runs_only_on_run_init_not_at_construction() { - let board = board_with_object( - Some("greet"), - &[("greet", r#"fn init() { log("hello"); }"#)], - ); - let mut game = GameState::new(board); - // init must not fire during construction / deserialization. - assert!(game.log.is_empty()); - - game.run_init(); - assert_eq!(log_texts(&game), vec!["hello"]); - } - - #[test] - fn tick_calls_script_tick_with_elapsed_seconds() { - let board = board_with_object( - Some("t"), - &[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], - ); - let mut game = GameState::new(board); - // No tick hook runs until tick() is called. - game.run_init(); - assert!(game.log.is_empty()); - - game.tick(Duration::from_millis(500)); - assert_eq!(game.log.len(), 1); - // The dt argument reached the script as ~0.5 seconds. - let logged: f64 = log_texts(&game)[0].parse().unwrap(); - assert!((logged - 0.5).abs() < 1e-9); - } - - #[test] - fn missing_hooks_and_no_script_are_noops() { - // Object with no script: nothing happens. - let mut game = GameState::new(board_with_object(None, &[])); - 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 mut game = GameState::new(board_with_object( - Some("e"), - &[("e", "fn other() { log(\"x\"); }")], - )); - game.run_init(); - game.tick(Duration::from_millis(33)); - assert!(game.log.is_empty()); - } - - #[test] - fn compile_and_unknown_script_errors_are_logged() { - // A reference to a script name that isn't in the table. - let game = GameState::new(board_with_object(Some("ghost"), &[])); - assert!(log_texts(&game)[0].contains("unknown script 'ghost'")); - - // A script that fails to compile is reported at construction time. - let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")])); - 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")], - &[("r", "fn init() { log(Board.player_x.to_string()); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - // The view reported the player's x (3). - assert_eq!(log_texts(&game), vec!["3"]); - } - - #[test] - fn move_command_relocates_the_source_object() { - let board = open_board( - 5, - 3, - (0, 0), - vec![scripted_object(2, 1, "m")], - &[("m", "fn init() { move(East); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - let b = game.board(); - // East increments x by one; the object started at (2, 1). - assert_eq!((b.objects[&1].x, b.objects[&1].y), (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")], - &[("m", "fn init() { move(West); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - assert_eq!(game.board().objects[&1].x, 0); - } - - #[test] - fn set_tile_command_changes_the_source_glyph() { - let board = open_board( - 5, - 3, - (0, 0), - vec![scripted_object(2, 1, "s")], - &[("s", "fn init() { set_tile(7); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - assert_eq!(game.board().objects[&1].glyph.tile, 7); - } - - #[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. - let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml"); - let board = crate::map_file::load(path).expect("load start.toml"); - let mut game = GameState::new(board); - game.run_init(); - assert!( - log_texts(&game) - .iter() - .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" - ); - } - - #[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). - // Targets are kept apart so the impassable objects don't block each other. - let board = open_board( - 5, - 3, - (0, 0), - vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")], - &[ - ("e", "fn init() { move(East); }"), - ("w", "fn init() { move(West); }"), - ], - ); - let mut game = GameState::new(board); - 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 - } - - #[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), // player to the west, out of the object's path - vec![scripted_object(1, 0, "m")], - &[("m", "fn init() { move(East); move(East); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - assert_eq!(game.board().objects[&1].x, 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); - - // Crossing the 250 ms mark releases the queued second move. - game.tick(Duration::from_millis(100)); - assert_eq!(game.board().objects[&1].x, 3); - } - - #[test] - fn blocked_move_still_costs_cooldown() { - // A move into a wall fails but still charges the 250 ms cooldown, so a second - // queued move (into open space) is delayed just as a successful move would be. - let mut board = open_board( - 3, - 3, - (0, 0), - vec![scripted_object(1, 1, "m")], - &[("m", "fn init() { move(East); move(South); }")], - ); - wall_at(&mut board, 2, 1); // blocks the eastward move - let mut game = GameState::new(board); - 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) - ); - - // The blocked move charged the cooldown, so South is still pending here. - 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) - ); - - // 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) - ); - } - - #[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")], - &[( - "q", - "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", - )], - ); - let mut game = GameState::new(board); - 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 - } - - #[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")], - &[("c", "fn init() { move(East); move(East); Queue.clear(); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - game.tick(Duration::from_millis(300)); - assert_eq!(game.board().objects[&1].x, 1); // never moved - } - - #[test] - fn blocked_reports_solid_and_clear() { - // Solid ahead (a wall): blocked() is true. - let mut board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "b")], - &[( - "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", - )], - ); - wall_at(&mut board, 2, 0); - let mut game = GameState::new(board); - game.run_init(); - assert_eq!(game.board().objects[&1].glyph.tile, 9); - - // Open ahead, nothing pending: blocked() is false. - let board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "b")], - &[( - "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", - )], - ); - let mut game = GameState::new(board); - game.run_init(); - assert_eq!(game.board().objects[&1].glyph.tile, 7); - } - - #[test] - fn blocked_sees_earlier_objects_pending_move() { - // obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked() - // toward that same cell and must see the pending move. - let board = open_board( - 5, - 3, - (0, 0), - vec![ - scripted_object(1, 1, "mover"), - scripted_object(3, 1, "checker"), - ], - &[ - ("mover", "fn tick(dt) { move(East); }"), - ( - "checker", - "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }", - ), - ], - ); - let mut game = GameState::new(board); - game.tick(Duration::from_millis(16)); - let b = game.board(); - assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1)); // mover advanced - assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move - } - - #[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")], - &[ - ( - "e", - "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", - ), - ( - "w", - "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", - ), - ], - ); - let mut game = GameState::new(board); - 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); - assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1 - assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped - } - - #[test] - fn player_bump_fires_with_negative_one() { - // The player walks into a solid scripted object; the object is bumped with -1 - // and the player does not move onto it. - let board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "b")], - &[("b", "fn bump(id) { log(`bumped by ${id}`); }")], - ); - let mut game = GameState::new(board); - 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!(log_texts(&game).iter().any(|t| t == "bumped by -1")); - } - - #[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")], - &[("m", "fn init() { move(East); }")], - ); - let mut game = GameState::new(board); - game.run_init(); - { - let b = game.board(); - assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); // object took player's cell - assert_eq!((b.player.x, b.player.y), (3, 0)); // player shoved east - } - - // 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")], - &[("m", "fn init() { move(East); }")], - ); - wall_at(&mut board, 3, 0); - let mut game = GameState::new(board); - game.run_init(); - let b = game.board(); - assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); // object blocked - assert_eq!((b.player.x, b.player.y), (2, 0)); // player not pushed - } -} diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index cd5cb5a..dd9fd25 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -15,4 +15,7 @@ mod archetype; mod object_def; mod board; -pub use board::Board; \ No newline at end of file +pub use board::Board; + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 96a3b9a..985dced 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -702,402 +702,3 @@ pub fn save(board: &Board, path: &Path) -> Result<(), Box std::fs::write(path, toml_str)?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::archetype::Archetype; - - /// Parses `toml` and converts it to a [`Board`], panicking on any hard error. - /// (Object/placement validation failures are non-fatal `eprintln`s, so the - /// board still loads — these tests assert on the resulting objects/cells.) - fn load_board(toml: &str) -> Board { - let mf: MapFile = toml::from_str(toml).expect("parse toml"); - Board::try_from(mf).expect("convert to board") - } - - /// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given - /// `[[objects]]` TOML appended. Keeps placement tests compact. - fn map_3x1(grid: &str, objects: &str) -> String { - format!( - r##" -[map] -name = "Test" -width = 3 -height = 1 -player_start = [2, 0] - -[palette] -"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} -"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} - -[grid] -content = """ -{grid} -""" -{objects} -"## - ) - } - - /// A standard `[[objects]]` block placed by palette char `ch`. - fn obj_palette(ch: &str, extra: &str) -> String { - format!( - "[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}" - ) - } - - #[test] - fn palette_placement_puts_object_on_empty_floor() { - // `G` appears once, isn't a palette key → object lands there, cell is Empty. - let board = load_board(&map_3x1("G..", &obj_palette("G", ""))); - assert_eq!(board.objects.len(), 1); - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - // The cell under the object is canonical Empty floor. - assert_eq!(board.get(0, 0).1, Archetype::Empty); - } - - #[test] - fn palette_char_absent_from_grid_drops_object() { - let board = load_board(&map_3x1("...", &obj_palette("G", ""))); - assert!(board.objects.is_empty()); - assert!(!board.is_valid()); // a dropped object is a recoverable error - } - - #[test] - fn palette_char_appearing_twice_uses_first() { - // `G` appears at (0,0) and (2,0): the object lands at the first, and the - // map is flagged invalid (an extra appearance was reported). - let board = load_board(&map_3x1("G.G", &obj_palette("G", ""))); - assert_eq!(board.objects.len(), 1); - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - assert!(!board.is_valid()); - } - - #[test] - fn palette_char_reused_by_two_objects_keeps_first() { - // Two objects claim `G`: the first keeps it, the later one is dropped. - let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", "")); - let board = load_board(&map_3x1("G..", &two)); - assert_eq!(board.objects.len(), 1); - assert!(!board.is_valid()); - } - - #[test] - fn palette_char_colliding_with_palette_key_drops_object() { - // `#` is a terrain palette key, so it can't be an object placeholder. - let board = load_board(&map_3x1("#..", &obj_palette("#", ""))); - assert!(board.objects.is_empty()); - } - - #[test] - fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() { - // Solid object at the wall cell (0,0): dropped for conflicting with the wall. - let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let board = load_board(&map_3x1("#..", solid)); - assert!(board.objects.is_empty()); - - // Same placement but non-solid: kept (it doesn't claim the cell's solidity). - let nonsolid = format!("{solid}solid = false\n"); - let board = load_board(&map_3x1("#..", &nonsolid)); - assert_eq!(board.objects.len(), 1); - } - - #[test] - fn second_solid_object_on_a_cell_is_dropped() { - let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let board = load_board(&map_3x1("...", &format!("{obj}{obj}"))); - assert_eq!(board.objects.len(), 1); - assert!(!board.is_valid()); - } - - /// A 1-row map of the given `width` with a raw `player_start` TOML value - /// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block. - fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String { - format!( - r##" -[map] -name = "Test" -width = {width} -height = 1 -player_start = {start} - -[palette] -"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} -"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} - -[grid] -content = """ -{grid} -""" -{objects} -"## - ) - } - - #[test] - fn player_coords_place_the_player() { - let b = load_board(&player_map(3, "[1, 0]", "...", "")); - assert_eq!((b.player.x, b.player.y), (1, 0)); - assert!(b.is_valid()); - } - - #[test] - fn player_char_places_on_empty_floor() { - let b = load_board(&player_map(3, "\"@\"", ".@.", "")); - assert_eq!((b.player.x, b.player.y), (1, 0)); - assert_eq!(b.get(1, 0).1, Archetype::Empty); - assert!(b.is_valid()); - } - - #[test] - fn player_wins_solid_terrain_silently() { - // Player coords land on a wall: the wall is cleared, no error reported. - let b = load_board(&player_map(3, "[0, 0]", "#..", "")); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(0, 0).1, Archetype::Empty); - assert!(b.is_valid()); - } - - #[test] - fn player_wins_against_a_solid_object_silently() { - // A solid object on the player's cell is dropped, silently (player wins). - let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let b = load_board(&player_map(3, "[1, 0]", "...", obj)); - assert!(b.objects.is_empty()); - assert!(b.is_valid()); - } - - #[test] - fn player_char_is_reserved_from_objects() { - // An object trying to use '@' is dropped; the player is still placed. - let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; - let b = load_board(&player_map(3, "\"@\"", "@..", obj)); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(b.objects.is_empty()); - assert!(!b.is_valid()); - } - - #[test] - fn player_char_appearing_twice_uses_first() { - let b = load_board(&player_map(3, "\"@\"", "@.@", "")); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(!b.is_valid()); - } - - #[test] - fn player_char_missing_falls_back_to_origin() { - let b = load_board(&player_map(3, "\"@\"", "...", "")); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert!(!b.is_valid()); - } - - #[test] - fn player_fallback_to_origin_clears_solid_terrain() { - // The '@' char is absent, so the player falls back to (0, 0) — which holds a - // wall. The player wins its cell: the wall is cleared so one-solid-per-cell - // holds (the player is itself solid). The fallback is still reported. - let b = load_board(&player_map(3, "\"@\"", "#..", "")); - assert_eq!((b.player.x, b.player.y), (0, 0)); - assert_eq!(b.get(0, 0).1, Archetype::Empty); // wall cleared under the player - assert!(!b.is_valid()); - } - - /// Minimal valid TOML with a configurable grid, used as a base for dimension tests. - fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String { - let content = grid_rows.join("\n"); - // Use r##"..."## so the "# in color strings does not close the raw string. - format!( - r##" -[map] -name = "Test" -width = {width} -height = {height} -player_start = [0, 0] - -[palette] -"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} - -[grid] -content = """ -{content} -""" -"## - ) - } - - #[test] - fn grid_wrong_row_count_returns_error() { - // height = 3 but only 2 rows in the grid - let toml = minimal_toml(3, 3, &["...", "..."]); - let mf: MapFile = toml::from_str(&toml).unwrap(); - let result = Board::try_from(mf); - assert!(result.is_err()); - // Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug. - let msg = result.err().unwrap(); - assert!( - msg.contains("2 rows"), - "expected row count in error, got: {msg}" - ); - assert!( - msg.contains("height = 3"), - "expected declared height in error, got: {msg}" - ); - } - - #[test] - fn grid_wrong_row_width_returns_error() { - // width = 4 but second row is only 3 characters - let toml = minimal_toml(4, 2, &["....", "..."]); - let mf: MapFile = toml::from_str(&toml).unwrap(); - let result = Board::try_from(mf); - assert!(result.is_err()); - let msg = result.err().unwrap(); - assert!( - msg.contains("3 characters"), - "expected col count in error, got: {msg}" - ); - assert!( - msg.contains("width = 4"), - "expected declared width in error, got: {msg}" - ); - } - - #[test] - fn object_archetype_in_palette_produces_error_block() { - // "object" is no longer a valid palette archetype; it should produce - // ErrorBlock. Player is placed away from the O cell so it isn't cleared. - let toml = r##" -[map] -name = "Test" -width = 2 -height = 1 -player_start = [1, 0] - -[palette] -"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -O. -""" -"##; - let mf: MapFile = toml::from_str(toml).unwrap(); - let board = Board::try_from(mf).unwrap(); - let (_, arch) = board.get(0, 0); - assert_eq!(*arch, Archetype::ErrorBlock); - } - - #[test] - fn object_glyph_round_trips_through_toml() { - // Objects must survive a save→load cycle with their glyph intact. - let toml = r##" -[map] -name = "Test" -width = 3 -height = 3 -player_start = [0, 0] - -[palette] -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -... -... -... -""" - -[[objects]] -x = 1 -y = 1 -tile = 64 -fg = "#00FFFF" -bg = "#000000" -"##; - let mf: MapFile = toml::from_str(toml).unwrap(); - let board = Board::try_from(mf).unwrap(); - assert_eq!(board.objects.len(), 1); - let obj = &board.objects[&1]; - assert_eq!(obj.x, 1); - assert_eq!(obj.y, 1); - assert_eq!(obj.glyph.tile, 64); - assert_eq!( - obj.glyph.fg, - Rgba8 { - r: 0x00, - g: 0xFF, - b: 0xFF, - a: 255 - } - ); - assert_eq!( - obj.glyph.bg, - Rgba8 { - r: 0, - g: 0, - b: 0, - a: 255 - } - ); - - // Save back to TOML and reload; glyph must survive. - let map_file = MapFile::from(&board); - let toml_out = toml::to_string_pretty(&map_file).unwrap(); - let mf2: MapFile = toml::from_str(&toml_out).unwrap(); - let board2 = Board::try_from(mf2).unwrap(); - let obj2 = &board2.objects[&1]; - assert_eq!(obj2.glyph.tile, obj.glyph.tile); - assert_eq!(obj2.glyph.fg, obj.glyph.fg); - assert_eq!(obj2.glyph.bg, obj.glyph.bg); - } - - #[test] - fn floor_spec_round_trips_through_toml() { - // A 2×2 board with a grid floor (a generator and a fixed glyph) must survive - // save→load with its `[floor]` declaration intact, and the reloaded board's - // computed floor must place the fixed glyph back at its declared cell. - let toml = r##" -[map] -name = "Test" -width = 2 -height = 2 -player_start = [0, 0] - -[palette] -"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } - -[grid] -content = """ -.. -.. -""" - -[floor] -content = """ -g. -.g -""" - -[floor.palette] -"g" = "grass" -"." = { tile = "#", fg = "#010203", bg = "#040506" } -"##; - let board = load_board(toml); - assert!(board.floor_spec.is_some()); - let fixed = Glyph { - tile: '#' as u32, - fg: parse_color("#010203"), - bg: parse_color("#040506"), - }; - // (1,0) is the fixed `.` floor glyph (the cell archetype is Empty). - assert_eq!(board.glyph_at(1, 0), fixed); - - // Save back to TOML and reload; the floor declaration must be preserved. - let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); - let board2 = load_board(&toml_out); - assert!(board2.floor_spec.is_some()); - assert_eq!(board2.glyph_at(1, 0), fixed); - } -} diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs new file mode 100644 index 0000000..48201d7 --- /dev/null +++ b/kiln-core/src/tests/actions.rs @@ -0,0 +1,253 @@ +use std::time::Duration; +use crate::archetype::Archetype; +use crate::board::tests::{crate_at, open_board, wall_at}; +use crate::game::GameState; +use crate::script::Direction; +use super::{scripted_object, log_texts}; + +#[test] +fn move_command_relocates_the_source_object() { + let board = open_board( + 5, + 3, + (0, 0), + vec![scripted_object(2, 1, "m")], + &[("m", "fn init() { move(East); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + let b = game.board(); + // East increments x by one; the object started at (2, 1). + assert_eq!((b.objects[&1].x, b.objects[&1].y), (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")], + &[("m", "fn init() { move(West); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[&1].x, 0); +} + +#[test] +fn set_tile_command_changes_the_source_glyph() { + let board = open_board( + 5, + 3, + (0, 0), + vec![scripted_object(2, 1, "s")], + &[("s", "fn init() { set_tile(7); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[&1].glyph.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")], + &[("m", "fn init() { move(East); }")], + ); + crate_at(&mut board, 2, 0); + let mut game = GameState::new(board); + game.run_init(); + 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::Crate); +} + +#[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")], + &[("m", "fn init() { move(East); }")], + ); + let mut game = GameState::new(board); + 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. + let mut board = open_board( + 4, + 1, + (2, 0), + vec![scripted_object(1, 0, "m")], + &[("m", "fn init() { move(East); }")], + ); + wall_at(&mut board, 3, 0); + let mut game = GameState::new(board); + 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)); +} + +#[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")], + &[("m", "fn init() { move(East); move(East); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[&1].x, 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); + + // Crossing the 250 ms mark releases the queued second move. + game.tick(Duration::from_millis(100)); + assert_eq!(game.board().objects[&1].x, 3); +} + +#[test] +fn blocked_move_still_costs_cooldown() { + // A move into a wall fails but still charges the 250 ms cooldown, so a second + // queued move (into open space) is delayed just as a successful move would be. + let mut board = open_board( + 3, + 3, + (0, 0), + vec![scripted_object(1, 1, "m")], + &[("m", "fn init() { move(East); move(South); }")], + ); + wall_at(&mut board, 2, 1); + let mut game = GameState::new(board); + 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)); + + // The blocked move charged the cooldown, so South is still pending here. + 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)); + + // 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)); +} + +#[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")], + &[( + "q", + "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", + )], + ); + let mut game = GameState::new(board); + 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 +} + +#[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")], + &[("c", "fn init() { move(East); move(East); Queue.clear(); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + game.tick(Duration::from_millis(300)); + assert_eq!(game.board().objects[&1].x, 1); // never moved +} + +#[test] +fn blocked_reports_solid_and_clear() { + // Solid ahead (a wall): blocked() is true. + let mut board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "b")], + &[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )], + ); + wall_at(&mut board, 2, 0); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[&1].glyph.tile, 9); + + // Open ahead, nothing pending: blocked() is false. + let board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "b")], + &[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[&1].glyph.tile, 7); +} + +#[test] +fn blocked_sees_earlier_objects_pending_move() { + // obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked() + // toward that same cell and must see the pending move. + let board = open_board( + 5, + 3, + (0, 0), + vec![ + scripted_object(1, 1, "mover"), + scripted_object(3, 1, "checker"), + ], + &[ + ("mover", "fn tick(dt) { move(East); }"), + ( + "checker", + "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }", + ), + ], + ); + let mut game = GameState::new(board); + game.tick(Duration::from_millis(16)); + let b = game.board(); + assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1)); + assert_eq!(b.objects[&2].glyph.tile, 1); // checker saw the pending move +} diff --git a/kiln-core/src/tests/collision.rs b/kiln-core/src/tests/collision.rs new file mode 100644 index 0000000..9e4d615 --- /dev/null +++ b/kiln-core/src/tests/collision.rs @@ -0,0 +1,39 @@ +use std::time::Duration; +use crate::board::tests::open_board; +use crate::game::GameState; +use super::{scripted_object, log_texts}; + +#[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")], + &[ + ( + "e", + "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", + ), + ( + "w", + "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", + ), + ], + ); + let mut game = GameState::new(board); + 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); + assert!(logs.iter().any(|t| t == "o0 by 2")); // obj0 bumped by obj1 + assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped +} diff --git a/kiln-core/src/tests/map_file/grid_errors.rs b/kiln-core/src/tests/map_file/grid_errors.rs new file mode 100644 index 0000000..0848b64 --- /dev/null +++ b/kiln-core/src/tests/map_file/grid_errors.rs @@ -0,0 +1,53 @@ +use crate::archetype::Archetype; +use crate::board::Board; +use crate::map_file::MapFile; +use super::minimal_toml; + +#[test] +fn grid_wrong_row_count_returns_error() { + // height = 3 but only 2 rows in the grid + let toml = minimal_toml(3, 3, &["...", "..."]); + let mf: MapFile = toml::from_str(&toml).unwrap(); + let result = Board::try_from(mf); + assert!(result.is_err()); + let msg = result.err().unwrap(); + assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}"); + assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}"); +} + +#[test] +fn grid_wrong_row_width_returns_error() { + // width = 4 but second row is only 3 characters + let toml = minimal_toml(4, 2, &["....", "..."]); + let mf: MapFile = toml::from_str(&toml).unwrap(); + let result = Board::try_from(mf); + assert!(result.is_err()); + let msg = result.err().unwrap(); + assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}"); + assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}"); +} + +#[test] +fn object_archetype_in_palette_produces_error_block() { + // "object" is no longer a valid palette archetype; it should produce ErrorBlock. + // Player is placed away from the O cell so it isn't cleared. + let toml = r##" +[map] +name = "Test" +width = 2 +height = 1 +player_start = [1, 0] + +[palette] +"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +O. +""" +"##; + let mf: MapFile = toml::from_str(toml).unwrap(); + let board = Board::try_from(mf).unwrap(); + assert_eq!(*board.get(0, 0), (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); +} diff --git a/kiln-core/src/tests/map_file/mod.rs b/kiln-core/src/tests/map_file/mod.rs new file mode 100644 index 0000000..133bfb7 --- /dev/null +++ b/kiln-core/src/tests/map_file/mod.rs @@ -0,0 +1,89 @@ +mod grid_errors; +mod object_placement; +mod player_placement; +mod round_trip; + +use crate::board::Board; +use crate::map_file::MapFile; + +fn load_board(toml: &str) -> Board { + let mf: MapFile = toml::from_str(toml).expect("parse toml"); + Board::try_from(mf).expect("convert to board") +} + +/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given +/// `[[objects]]` TOML appended. Keeps placement tests compact. +fn map_3x1(grid: &str, objects: &str) -> String { + format!( + r##" +[map] +name = "Test" +width = 3 +height = 1 +player_start = [2, 0] + +[palette] +"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} +"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} + +[grid] +content = """ +{grid} +""" +{objects} +"## + ) +} + +/// A standard `[[objects]]` block placed by palette char `ch`. +fn obj_palette(ch: &str, extra: &str) -> String { + format!( + "[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}" + ) +} + +/// A 1-row map of the given `width` with a raw `player_start` TOML value +/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block. +fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String { + format!( + r##" +[map] +name = "Test" +width = {width} +height = 1 +player_start = {start} + +[palette] +"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} +"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} + +[grid] +content = """ +{grid} +""" +{objects} +"## + ) +} + +/// Minimal valid TOML with a configurable grid, used as a base for dimension tests. +fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String { + let content = grid_rows.join("\n"); + format!( + r##" +[map] +name = "Test" +width = {width} +height = {height} +player_start = [0, 0] + +[palette] +"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} + +[grid] +content = """ +{content} +""" +"## + ) +} diff --git a/kiln-core/src/tests/map_file/object_placement.rs b/kiln-core/src/tests/map_file/object_placement.rs new file mode 100644 index 0000000..55b2489 --- /dev/null +++ b/kiln-core/src/tests/map_file/object_placement.rs @@ -0,0 +1,65 @@ +use crate::archetype::Archetype; +use super::{load_board, map_3x1, obj_palette}; + +#[test] +fn palette_placement_puts_object_on_empty_floor() { + // `G` appears once, isn't a palette key → object lands there, cell is Empty. + let board = load_board(&map_3x1("G..", &obj_palette("G", ""))); + assert_eq!(board.objects.len(), 1); + assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); + assert_eq!(board.get(0, 0).1, Archetype::Empty); +} + +#[test] +fn palette_char_absent_from_grid_drops_object() { + let board = load_board(&map_3x1("...", &obj_palette("G", ""))); + assert!(board.objects.is_empty()); + assert!(!board.is_valid()); +} + +#[test] +fn palette_char_appearing_twice_uses_first() { + // `G` appears at (0,0) and (2,0): the object lands at the first, and the + // map is flagged invalid (an extra appearance was reported). + let board = load_board(&map_3x1("G.G", &obj_palette("G", ""))); + assert_eq!(board.objects.len(), 1); + assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); + assert!(!board.is_valid()); +} + +#[test] +fn palette_char_reused_by_two_objects_keeps_first() { + // Two objects claim `G`: the first keeps it, the later one is dropped. + let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", "")); + let board = load_board(&map_3x1("G..", &two)); + assert_eq!(board.objects.len(), 1); + assert!(!board.is_valid()); +} + +#[test] +fn palette_char_colliding_with_palette_key_drops_object() { + // `#` is a terrain palette key, so it can't be an object placeholder. + let board = load_board(&map_3x1("#..", &obj_palette("#", ""))); + assert!(board.objects.is_empty()); +} + +#[test] +fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() { + // Solid object at the wall cell (0,0): dropped for conflicting with the wall. + let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; + let board = load_board(&map_3x1("#..", solid)); + assert!(board.objects.is_empty()); + + // Same placement but non-solid: kept (it doesn't claim the cell's solidity). + let nonsolid = format!("{solid}solid = false\n"); + let board = load_board(&map_3x1("#..", &nonsolid)); + assert_eq!(board.objects.len(), 1); +} + +#[test] +fn second_solid_object_on_a_cell_is_dropped() { + let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; + let board = load_board(&map_3x1("...", &format!("{obj}{obj}"))); + assert_eq!(board.objects.len(), 1); + assert!(!board.is_valid()); +} diff --git a/kiln-core/src/tests/map_file/player_placement.rs b/kiln-core/src/tests/map_file/player_placement.rs new file mode 100644 index 0000000..4ed5874 --- /dev/null +++ b/kiln-core/src/tests/map_file/player_placement.rs @@ -0,0 +1,70 @@ +use crate::archetype::Archetype; +use super::{load_board, player_map}; + +#[test] +fn player_coords_place_the_player() { + let b = load_board(&player_map(3, "[1, 0]", "...", "")); + assert_eq!((b.player.x, b.player.y), (1, 0)); + assert!(b.is_valid()); +} + +#[test] +fn player_char_places_on_empty_floor() { + let b = load_board(&player_map(3, "\"@\"", ".@.", "")); + assert_eq!((b.player.x, b.player.y), (1, 0)); + assert_eq!(b.get(1, 0).1, Archetype::Empty); + assert!(b.is_valid()); +} + +#[test] +fn player_wins_solid_terrain_silently() { + // Player coords land on a wall: the wall is cleared, no error reported. + let b = load_board(&player_map(3, "[0, 0]", "#..", "")); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert_eq!(b.get(0, 0).1, Archetype::Empty); + assert!(b.is_valid()); +} + +#[test] +fn player_wins_against_a_solid_object_silently() { + // A solid object on the player's cell is dropped, silently (player wins). + let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; + let b = load_board(&player_map(3, "[1, 0]", "...", obj)); + assert!(b.objects.is_empty()); + assert!(b.is_valid()); +} + +#[test] +fn player_char_is_reserved_from_objects() { + // An object trying to use '@' is dropped; the player is still placed. + let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; + let b = load_board(&player_map(3, "\"@\"", "@..", obj)); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert!(b.objects.is_empty()); + assert!(!b.is_valid()); +} + +#[test] +fn player_char_appearing_twice_uses_first() { + let b = load_board(&player_map(3, "\"@\"", "@.@", "")); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert!(!b.is_valid()); +} + +#[test] +fn player_char_missing_falls_back_to_origin() { + let b = load_board(&player_map(3, "\"@\"", "...", "")); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert!(!b.is_valid()); +} + +#[test] +fn player_fallback_to_origin_clears_solid_terrain() { + // The '@' char is absent, so the player falls back to (0, 0) — which holds a + // wall. The player wins its cell: the wall is cleared so one-solid-per-cell + // holds. The fallback is still reported. + let b = load_board(&player_map(3, "\"@\"", "#..", "")); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert_eq!(b.get(0, 0).1, Archetype::Empty); + assert!(!b.is_valid()); +} diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs new file mode 100644 index 0000000..7335b9b --- /dev/null +++ b/kiln-core/src/tests/map_file/round_trip.rs @@ -0,0 +1,100 @@ +use color::Rgba8; +use crate::board::Board; +use crate::glyph::Glyph; +use crate::map_file::{MapFile, parse_color}; +use super::load_board; + +#[test] +fn object_glyph_round_trips_through_toml() { + // Objects must survive a save→load cycle with their glyph intact. + let toml = r##" +[map] +name = "Test" +width = 3 +height = 3 +player_start = [0, 0] + +[palette] +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +... +... +... +""" + +[[objects]] +x = 1 +y = 1 +tile = 64 +fg = "#00FFFF" +bg = "#000000" +"##; + let mf: MapFile = toml::from_str(toml).unwrap(); + let board = Board::try_from(mf).unwrap(); + assert_eq!(board.objects.len(), 1); + let obj = &board.objects[&1]; + assert_eq!(obj.x, 1); + assert_eq!(obj.y, 1); + assert_eq!(obj.glyph.tile, 64); + assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 }); + assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 }); + + // Save back to TOML and reload; glyph must survive. + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + let mf2: MapFile = toml::from_str(&toml_out).unwrap(); + let board2 = Board::try_from(mf2).unwrap(); + let obj2 = &board2.objects[&1]; + assert_eq!(obj2.glyph.tile, obj.glyph.tile); + assert_eq!(obj2.glyph.fg, obj.glyph.fg); + assert_eq!(obj2.glyph.bg, obj.glyph.bg); +} + +#[test] +fn floor_spec_round_trips_through_toml() { + // A 2×2 board with a grid floor (a generator and a fixed glyph) must survive + // save→load with its `[floor]` declaration intact, and the reloaded board's + // computed floor must place the fixed glyph back at its declared cell. + let toml = r##" +[map] +name = "Test" +width = 2 +height = 2 +player_start = [0, 0] + +[palette] +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +.. +.. +""" + +[floor] +content = """ +g. +.g +""" + +[floor.palette] +"g" = "grass" +"." = { tile = "#", fg = "#010203", bg = "#040506" } +"##; + let board = load_board(toml); + assert!(board.floor_spec.is_some()); + let fixed = Glyph { + tile: '#' as u32, + fg: parse_color("#010203"), + bg: parse_color("#040506"), + }; + // (1,0) is the fixed `.` floor glyph (the cell archetype is Empty). + assert_eq!(board.glyph_at(1, 0), fixed); + + // Save back to TOML and reload; the floor declaration must be preserved. + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + let board2 = load_board(&toml_out); + assert!(board2.floor_spec.is_some()); + assert_eq!(board2.glyph_at(1, 0), fixed); +} diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs new file mode 100644 index 0000000..25daad1 --- /dev/null +++ b/kiln-core/src/tests/mod.rs @@ -0,0 +1,51 @@ +mod actions; +mod collision; +mod map_file; +mod movement; +mod scripting; + +use std::collections::BTreeMap; +use crate::archetype::Archetype; +use crate::board::Board; +use crate::object_def::ObjectDef; +use crate::utils::Player; +use crate::game::GameState; + +/// Builds a 1×1 board with a single object that optionally references a +/// script, plus the given `(name, source)` script table entries. +fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board { + let mut object = ObjectDef::new(0, 0); + object.script_name = object_script.map(str::to_string); + Board { + name: "test".into(), + width: 1, + height: 1, + cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)], + floor: vec![Archetype::Empty.default_glyph()], + floor_spec: None, + player: Player { x: 0, y: 0 }, + objects: BTreeMap::from([(1, object)]), + next_object_id: 2, + portals: Vec::new(), + font: None, + zoom: 1, + scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + board_script_name: None, + load_errors: Vec::new(), + } +} + +/// 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.script_name = Some(script.to_string()); + o +} + +/// Flattens each log line into a single string for easy assertions. +fn log_texts(game: &GameState) -> Vec { + game.log + .iter() + .map(|line| line.spans.iter().map(|s| s.text.as_str()).collect()) + .collect() +} diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs new file mode 100644 index 0000000..16df94f --- /dev/null +++ b/kiln-core/src/tests/movement.rs @@ -0,0 +1,152 @@ +use color::Rgba8; +use crate::archetype::Archetype; +use crate::board::tests::{crate_at, open_board, stamp, wall_at}; +use crate::game::GameState; +use crate::glyph::Glyph; +use crate::object_def::ObjectDef; +use crate::script::Direction; + +#[test] +fn pushing_a_crate_reveals_the_floor_underneath() { + // The cell a crate is pushed off of becomes Empty and glyph_at shows floor. + // After the push the player is at (1,0); check the cell the player vacated + // (0,0) — it is Empty and must reveal the floor glyph, not black-on-black. + let mut board = open_board(4, 1, (0, 0), vec![], &[]); + 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 }, + }; + board.floor = vec![floor_glyph; 4]; + 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::Crate); + assert_eq!(b.get(1, 0).1, Archetype::Empty); + 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![], &[]); + 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::Crate); +} + +#[test] +fn push_blocked_by_wall_moves_nothing() { + let mut board = open_board(4, 1, (0, 0), vec![], &[]); + 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::Crate); + assert_eq!(b.get(2, 0).1, Archetype::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![], &[]); + 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::Crate); +} + +#[test] +fn cascade_pushes_two_crates() { + let mut board = open_board(5, 1, (0, 0), vec![], &[]); + 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::Crate); + assert_eq!(b.get(3, 0).1, Archetype::Crate); +} + +#[test] +fn cascade_blocked_by_wall_moves_nothing() { + let mut board = open_board(5, 1, (0, 0), vec![], &[]); + 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::Crate); + assert_eq!(b.get(2, 0).1, Archetype::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.pushable = true; + let board = open_board(4, 1, (0, 0), vec![obj], &[]); + 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)); +} + +#[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::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::HCrate); + drop(b); + + // Pushing north into an HCrate: blocked, nothing moves. + let mut board = open_board(1, 4, (0, 3), vec![], &[]); + stamp(&mut board, 0, 2, Archetype::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::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::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::VCrate); + drop(b); + + // Pushing east into a VCrate: blocked, nothing moves. + let mut board = open_board(4, 1, (0, 0), vec![], &[]); + stamp(&mut board, 1, 0, Archetype::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::VCrate); +} diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs new file mode 100644 index 0000000..a5e3e93 --- /dev/null +++ b/kiln-core/src/tests/scripting.rs @@ -0,0 +1,139 @@ +use std::time::Duration; +use crate::board::tests::open_board; +use crate::game::GameState; +use crate::script::Direction; +use super::{board_with_object, scripted_object, log_texts}; + +#[test] +fn init_runs_only_on_run_init_not_at_construction() { + let board = board_with_object( + Some("greet"), + &[("greet", r#"fn init() { log("hello"); }"#)], + ); + let mut game = GameState::new(board); + // init must not fire during construction / deserialization. + assert!(game.log.is_empty()); + + game.run_init(); + assert_eq!(log_texts(&game), vec!["hello"]); +} + +#[test] +fn tick_calls_script_tick_with_elapsed_seconds() { + let board = board_with_object( + Some("t"), + &[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], + ); + let mut game = GameState::new(board); + game.run_init(); + assert!(game.log.is_empty()); + + game.tick(Duration::from_millis(500)); + assert_eq!(game.log.len(), 1); + let logged: f64 = log_texts(&game)[0].parse().unwrap(); + assert!((logged - 0.5).abs() < 1e-9); +} + +#[test] +fn missing_hooks_and_no_script_are_noops() { + // Object with no script: nothing happens. + let mut game = GameState::new(board_with_object(None, &[])); + 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 mut game = GameState::new(board_with_object( + Some("e"), + &[("e", "fn other() { log(\"x\"); }")], + )); + game.run_init(); + game.tick(Duration::from_millis(33)); + assert!(game.log.is_empty()); +} + +#[test] +fn compile_and_unknown_script_errors_are_logged() { + // A reference to a script name that isn't in the table. + let game = GameState::new(board_with_object(Some("ghost"), &[])); + assert!(log_texts(&game)[0].contains("unknown script 'ghost'")); + + // A script that fails to compile is reported at construction time. + let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")])); + 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")], + &[("r", "fn init() { log(Board.player_x.to_string()); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(log_texts(&game), vec!["3"]); +} + +#[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")], + &[ + ("e", "fn init() { move(East); }"), + ("w", "fn init() { move(West); }"), + ], + ); + let mut game = GameState::new(board); + 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 +} + +#[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. + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml"); + let board = crate::map_file::load(path).expect("load start.toml"); + let mut game = GameState::new(board); + game.run_init(); + assert!( + log_texts(&game).iter().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" + ); +} + +// Ensure try_move from the player side also triggers scripted bump +#[test] +fn player_bump_fires_with_negative_one() { + // The player walks into a solid scripted object; the object is bumped with -1 + // and the player does not move onto it. + let board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "b")], + &[("b", "fn bump(id) { log(`bumped by ${id}`); }")], + ); + let mut game = GameState::new(board); + 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!(log_texts(&game).iter().any(|t| t == "bumped by -1")); +}