diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 49c91d7..dbaeb43 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -995,110 +995,120 @@ pub(crate) mod tests { } #[test] - fn apply_swap_swaps_two_terrain_cells() { - // A crate and a wall trade places in one batch (read-all then write-all). - let mut board = open_board(3, 1, (1, 0), vec![]); - crate_at(&mut board, 0, 0); - wall_at(&mut board, 2, 0); - let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]); - assert!(errs.is_empty()); - assert_eq!(board.get(0, 0, 0).1, Archetype::Wall); - assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); - } - - #[test] - fn apply_swap_cycle_propagates_empty() { - // The spec example: move a->b and b->c with a empty ⇒ a empty, b empty, - // c holds what b held (the empty written into b doesn't block b->c). - let mut board = open_board(4, 1, (3, 0), vec![]); - // a = (0,0) empty, b = (1,0) crate, c = (2,0) wall. - crate_at(&mut board, 1, 0); - wall_at(&mut board, 2, 0); - let errs = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]); - assert!(errs.is_empty()); - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); - assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); - assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); - } - - #[test] - fn apply_swap_empty_onto_object_removes_it() { - // Moving an empty source onto an object despawns the object. - let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]); - let errs = board.apply_swap(&[(0, 0, 1, 0)]); - assert!(errs.is_empty()); - assert!(board.solid_object_id_at(1, 0).is_none()); - assert!(board.objects.is_empty()); - } - - #[test] - fn apply_swap_terrain_onto_terrain_removes_displaced() { - // Moving a crate onto a wall clears the source and removes the wall. + fn apply_shift_out_of_bounds_rejects_immediately() { + // apply_shift validates all cells upfront; any out-of-bounds cell causes immediate failure. let mut board = open_board(3, 1, (2, 0), vec![]); crate_at(&mut board, 0, 0); - wall_at(&mut board, 1, 0); - let errs = board.apply_swap(&[(0, 0, 1, 0)]); - assert!(errs.is_empty()); - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); - assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); - } - - #[test] - fn apply_swap_moves_object_and_player() { - // An object and the player relocate (swap places) in one batch. - let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]); - let errs = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]); - assert!(errs.is_empty()); - assert_eq!((board.player.x, board.player.y), (2, 0)); - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - } - - #[test] - fn apply_swap_out_of_bounds_skips_and_logs() { - let mut board = open_board(3, 1, (2, 0), vec![]); - crate_at(&mut board, 0, 0); - let errs = board.apply_swap(&[(0, 0, 9, 0)]); + let errs = board.apply_shift(&[(0, 0), (9, 0)]); assert_eq!(errs.len(), 1); assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged } #[test] - fn apply_swap_will_not_overwrite_player() { - // A crate moved onto the (non-relocating) player is rejected and logged. - let mut board = open_board(3, 1, (1, 0), vec![]); + fn apply_shift_wall_stops_cascade_but_empty_limits_it() { + // A non-pushable Wall is immobile. Backward cascade from the wall traces + // through preceding solids until it hits empty, marking those as blocked. + // Solids on the *other* side of the empty (outside the blocked region) still move. + let mut board = open_board(6, 1, (5, 0), vec![]); crate_at(&mut board, 0, 0); - let errs = board.apply_swap(&[(0, 0, 1, 0)]); - assert_eq!(errs.len(), 1); - assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated + // (1,0) stays empty + wall_at(&mut board, 2, 0); + crate_at(&mut board, 3, 0); + crate_at(&mut board, 4, 0); + + // Cycle: idx 0 → idx 1 → idx 2 → idx 3 → idx 4 → idx 0 (wrap) + // immobile = {2} (Wall, Pushable::No) + // Backward trace from idx 2: solids[2]=Some → blocked.insert(2), prev=1 + // solids[1]=None (empty) → break + // blocked = {2}; Crate at (0,0) is NOT blocked + // Result: Crate(0,0)→(1,0), empty→no-op, Wall stays at (2,0), + // Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap + let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]); + + assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // wrapped from (4,0) + assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // moved from (0,0) + assert_eq!(board.get(0, 2, 0).1, Archetype::Wall); // blocked, immobile + assert_eq!(board.get(0, 3, 0).1, Archetype::Empty); // cleared, crate moved + assert_eq!(board.get(0, 4, 0).1, Archetype::Crate); // moved from (3,0) } #[test] - fn apply_swap_grab_onto_player_is_refused_like_any_solid() { - // A grab gem swapped onto the player is no longer special: it is refused and - // logged (the player wins its cell), and left at its source. Grab fires only - // when the player walks onto a grab thing, not when one is swapped in. - let mut gem = ObjectDef::new(0, 0); - gem.grab = true; - gem.pushable = true; - let mut board = open_board(2, 1, (1, 0), vec![gem]); - let errs = board.apply_swap(&[(0, 0, 1, 0)]); - assert_eq!(errs.len(), 1); // refusal logged - assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem stayed + fn apply_shift_hcrate_moves_horizontally_vcrate_stays() { + // In a horizontal (same-row) adjacent cycle: + // - HCrate (Pushable::Horizontal) moves freely (hmove, not blocked). + // - VCrate (Pushable::Vertical) is immobile (Vertical && hmove). + // Both can share the same pattern without blocking each other's neighbors. + + // Subcase A: HCrate moves + { + let mut board = open_board(4, 1, (3, 0), vec![]); + stamp(&mut board, 0, 0, Archetype::HCrate); + crate_at(&mut board, 1, 0); + // (2,0) empty + + let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]); + + assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // HCrate moved out + assert_eq!(board.get(0, 1, 0).1, Archetype::HCrate); // moved from (0,0) + assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0) + } + + // Subcase B: VCrate stays; Crate behind it still moves + { + let mut board = open_board(4, 1, (3, 0), vec![]); + stamp(&mut board, 0, 0, Archetype::VCrate); + crate_at(&mut board, 1, 0); + // (2,0) empty + + // VCrate (idx 0): target (1,0), origin (0,0), hmove=true. + // Pushable::Vertical && hmove=true → immobile + // Backward: prev=idx 2, solids[2]=None → stop. blocked={0}. + // Crate at (1,0) is NOT in blocked, so it moves. + let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]); + + assert_eq!(board.get(0, 0, 0).1, Archetype::VCrate); // immobile + assert_eq!(board.get(0, 1, 0).1, Archetype::Empty); // crate moved out + assert_eq!(board.get(0, 2, 0).1, Archetype::Crate); // moved from (1,0) + } } #[test] - fn apply_swap_overlap_after_refused_player_write_deletes_the_other_solid() { - // object (0,0)→player (1,0) [refused, stays at (0,0)] while a crate (2,0)→(0,0) - // moves into the object's cell. The sweep keeps the object and deletes the - // crate; one error logs the refusal, one logs the overlap. - let mut board = open_board(3, 1, (1, 0), vec![ObjectDef::new(0, 0)]); - crate_at(&mut board, 2, 0); - let errs = board.apply_swap(&[(0, 0, 1, 0), (2, 0, 0, 0)]); - assert_eq!(errs.len(), 2); // the refusal and the overlap were logged - assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // object kept - assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // crate deleted - assert_eq!((board.player.x, board.player.y), (1, 0)); // player untouched + fn apply_shift_vcrate_moves_vertically_hcrate_stays() { + // In a vertical (same-column) adjacent cycle: + // - VCrate (Pushable::Vertical) moves freely (vmove, not blocked). + // - HCrate (Pushable::Horizontal) is immobile (Horizontal && vmove). + + // Subcase A: VCrate moves + { + let mut board = open_board(1, 4, (0, 3), vec![]); + stamp(&mut board, 0, 0, Archetype::VCrate); + stamp(&mut board, 0, 1, Archetype::Crate); + // (0,2) empty + + let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]); + + assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // VCrate moved out + assert_eq!(board.get(0, 0, 1).1, Archetype::VCrate); // moved from (0,0) + assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1) + } + + // Subcase B: HCrate stays; Crate behind it still moves + { + let mut board = open_board(1, 4, (0, 3), vec![]); + stamp(&mut board, 0, 0, Archetype::HCrate); + stamp(&mut board, 0, 1, Archetype::Crate); + // (0,2) empty + + // HCrate (idx 0): target (0,1), origin (0,0), vmove=true. + // Pushable::Horizontal && vmove=true → immobile + // Backward: prev=idx 2, solids[2]=None → stop. blocked={0}. + // Crate at (0,1) is NOT in blocked, so it moves. + let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]); + + assert_eq!(board.get(0, 0, 0).1, Archetype::HCrate); // immobile + assert_eq!(board.get(0, 0, 1).1, Archetype::Empty); // crate moved out + assert_eq!(board.get(0, 0, 2).1, Archetype::Crate); // moved from (0,1) + } } + } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 7ea0377..f6a7b14 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -582,12 +582,12 @@ mod tests { } #[test] - fn script_swap_moves_crates_simultaneously() { - // Swap the crate at (2,0) with the empty cell at (3,0): one ends up empty, - // the other holds the crate, applied in a single batch. + 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(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }", + "fn tick(dt) { if Queue.length() == 0 { shift([[2, 0], [3, 0]]); } }", ); game.tick(Duration::from_millis(16)); let b = game.board();