spinners and fmt
This commit is contained in:
@@ -287,6 +287,17 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
}
|
||||
// push() self-checks can_push, so an in-bounds guard is all we add.
|
||||
Action::Push { x, y, dir } => {
|
||||
if board.in_bounds((x, y)) {
|
||||
board.push(x as usize, y as usize, dir);
|
||||
} else {
|
||||
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
|
||||
}
|
||||
}
|
||||
// apply_swap reads all sources then writes all destinations, so a
|
||||
// whole batch of moves resolves simultaneously (cycles included).
|
||||
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,3 +462,173 @@ fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<Object
|
||||
}
|
||||
bumped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GameState;
|
||||
use crate::archetype::Archetype;
|
||||
use crate::board::tests::{crate_at, open_board, wall_at};
|
||||
use crate::object_def::ObjectDef;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a `GameState` with one non-solid object running `src` as its script.
|
||||
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
|
||||
let mut obj = ObjectDef::new(0, 0);
|
||||
obj.solid = false;
|
||||
obj.script_name = Some("s".to_string());
|
||||
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_push_shoves_a_crate() {
|
||||
// The object pushes the crate at (2,0) one step east on its first tick.
|
||||
let mut game = game_with_object_script(
|
||||
5,
|
||||
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
|
||||
);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
|
||||
}
|
||||
|
||||
#[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.
|
||||
let mut game = game_with_object_script(
|
||||
5,
|
||||
"fn tick(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }",
|
||||
);
|
||||
game.tick(Duration::from_millis(16));
|
||||
let b = game.board();
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
|
||||
assert_eq!(b.get(0, 3, 0).1, Archetype::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.solid = false;
|
||||
obj.script_name = Some("s".to_string());
|
||||
let mut board = open_board(4, 1, (3, 0), vec![obj]);
|
||||
crate_at(&mut board, 2, 0);
|
||||
let src = "fn init() { \
|
||||
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
|
||||
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
|
||||
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
|
||||
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
|
||||
let mut game = GameState::with_scripts(board, scripts);
|
||||
game.run_init();
|
||||
|
||||
let lines: Vec<String> = game
|
||||
.log
|
||||
.iter()
|
||||
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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).
|
||||
fn spinner_board_dir(
|
||||
crates: &[(usize, usize)],
|
||||
walls: &[(usize, usize)],
|
||||
ccw: bool,
|
||||
) -> GameState {
|
||||
let mut obj = ObjectDef::new(1, 1);
|
||||
obj.solid = false;
|
||||
obj.script_name = Some("spinner".to_string());
|
||||
if ccw {
|
||||
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
|
||||
}
|
||||
let mut board = open_board(3, 3, (1, 1), vec![obj]);
|
||||
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);
|
||||
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.
|
||||
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(0, 1, 0).1, Archetype::Crate); // N kept
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
|
||||
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_rotates_an_arc_into_a_hole() {
|
||||
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
|
||||
// Clockwise, every crate advances one slot and the hole ends up at W.
|
||||
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(0, 2, 0).1, Archetype::Crate); // NE: filled by N
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
|
||||
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
|
||||
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_ccw_rotates_the_other_way() {
|
||||
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
|
||||
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
|
||||
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, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
|
||||
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
|
||||
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_animates_its_glyph_without_recoloring() {
|
||||
// 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)
|
||||
};
|
||||
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");
|
||||
}
|
||||
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user