use super::*;
use crate::sim::direction::Direction::*;
fn common(board: &mut Board, x: u8, y: u8, dir: Direction, handed: Handedness) {
board.spawn_crab(x, y, dir, handed, CrabKind::Common);
}
fn ticks_to_cross(tiles: u32, speed: u32) -> u32 {
(tiles * u32::from(SUBUNITS_PER_TILE)).div_ceil(speed)
}
fn lively(seed: u64, size: u8) -> Board {
let mut board = Board::new(size, size, seed);
board.set_events_enabled(true);
board.set_tile(size - 1, 0, TileKind::Castle(0));
board.set_wall(1, 1, Right, true);
common(&mut board, 0, 0, Right, Handedness::Left);
common(&mut board, 0, size - 1, Up, Handedness::Right);
board.spawn_gull(size - 1, size - 1, Left);
board.place_signpost(0, 1, 0, Down);
for _ in 0..20 {
board.tick_idle();
}
board
}
#[test]
fn copy_from_lands_exactly_where_clone_does() {
let source = lively(7, 6);
let mut copy = lively(99, 9);
copy.copy_from(&source);
assert_eq!(format!("{copy:?}"), format!("{source:?}"));
assert_eq!(copy.state_hash(), source.state_hash());
let mut clone = source.clone();
for _ in 0..40 {
copy.tick_idle();
clone.tick_idle();
}
assert_eq!(format!("{copy:?}"), format!("{clone:?}"));
}
#[test]
fn crab_crosses_a_tile_at_spec_speed() {
let mut board = Board::new(5, 1, 0);
common(&mut board, 0, 0, Right, Handedness::Left);
for _ in 0..21 {
board.tick_idle();
}
assert_eq!(board.crabs()[0].tile, 0);
assert_eq!(board.crabs()[0].progress, 252);
board.tick_idle();
assert_eq!(board.crabs()[0].tile, 1);
assert_eq!(board.crabs()[0].progress, 8);
}
#[test]
fn prev_fields_track_last_tick() {
let mut board = Board::new(5, 1, 0);
common(&mut board, 0, 0, Right, Handedness::Left);
board.tick_idle();
let crab = board.crabs()[0];
assert_eq!(crab.prev.progress, 0);
assert_eq!(crab.progress, 12);
board.tick_idle();
assert_eq!(board.crabs()[0].prev.progress, 12);
}
#[test]
fn handedness_splits_at_a_wall() {
for (handed, expected) in [(Handedness::Left, Left), (Handedness::Right, Right)] {
let mut board = Board::new(3, 3, 0);
common(&mut board, 1, 1, Up, handed);
for _ in 0..ticks_to_cross(1, 12) {
board.tick_idle();
}
let crab = board.crabs()[0];
assert_eq!((crab.tile, crab.dir), (1, expected), "{handed:?}");
}
}
#[test]
fn blocked_preferred_side_takes_other_side() {
let mut board = Board::new(3, 3, 0);
common(&mut board, 0, 1, Up, Handedness::Left);
for _ in 0..ticks_to_cross(1, 12) {
board.tick_idle();
}
assert_eq!(board.crabs()[0].dir, Right);
}
#[test]
fn dead_end_reverses() {
let mut board = Board::new(1, 3, 0);
common(&mut board, 0, 1, Down, Handedness::Left);
for _ in 0..ticks_to_cross(1, 12) {
board.tick_idle();
}
let crab = board.crabs()[0];
assert_eq!((crab.tile, crab.dir), (u16::from(board.width()) * 2, Up));
}
#[test]
fn fully_enclosed_crab_waits_without_panicking() {
let mut board = Board::new(1, 1, 0);
common(&mut board, 0, 0, Right, Handedness::Left);
for _ in 0..100 {
board.tick_idle();
}
assert_eq!(board.crabs()[0].tile, 0);
assert_eq!(board.crabs()[0].progress, 0);
}
#[test]
fn rocks_are_impassable() {
let mut board = Board::new(3, 1, 0);
board.set_tile(1, 0, TileKind::Rock);
common(&mut board, 0, 0, Right, Handedness::Left);
board.tick_idle();
assert_eq!(board.crabs()[0].dir, Left);
}
#[test]
fn signpost_redirects_crabs() {
let mut board = Board::new(5, 5, 0);
assert!(board.place_signpost(0, 2, 2, Up));
common(&mut board, 0, 2, Right, Handedness::Left);
for _ in 0..ticks_to_cross(2, 12) {
board.tick_idle();
}
let crab = board.crabs()[0];
assert_eq!(crab.dir, Up);
for _ in 0..ticks_to_cross(1, 12) {
board.tick_idle();
}
assert_eq!(board.crabs()[0].tile, u16::from(board.width()) + 2);
}
#[test]
fn signpost_into_wall_is_followed_then_resolved() {
for (handed, expected) in [(Handedness::Left, Left), (Handedness::Right, Right)] {
let mut board = Board::new(5, 5, 0);
assert!(board.place_signpost(0, 2, 0, Up)); common(&mut board, 0, 0, Right, handed);
for _ in 0..ticks_to_cross(2, 12) {
board.tick_idle();
}
let crab = board.crabs()[0];
assert_eq!((crab.tile, crab.dir), (2, expected), "{handed:?}");
}
}
#[test]
fn banking_awards_value_and_despawns() {
let mut board = Board::new(5, 1, 0);
board.set_tile(4, 0, TileKind::Castle(2));
common(&mut board, 0, 0, Right, Handedness::Left);
board.spawn_crab(1, 0, Right, Handedness::Right, CrabKind::Giant);
for _ in 0..ticks_to_cross(3, 7) {
board.tick_idle();
}
assert!(board.crabs().is_empty());
assert_eq!(board.scores()[2], 1 + 10);
assert_eq!(board.scores()[0], 0);
}
#[test]
fn juvenile_is_faster() {
let mut board = Board::new(5, 1, 0);
board.spawn_crab(0, 0, Right, Handedness::Left, CrabKind::Juvenile);
common(&mut board, 0, 0, Right, Handedness::Left);
for _ in 0..ticks_to_cross(1, 18) {
board.tick_idle();
}
assert_eq!(board.crabs()[0].tile, 1); assert_eq!(board.crabs()[1].tile, 0); }
#[test]
fn signpost_cap_evicts_oldest() {
let mut board = Board::new(6, 1, 0);
for x in 0..4 {
assert!(board.place_signpost(0, x, 0, Up));
}
assert!(board.signpost_at(0, 0).is_none(), "oldest evicted");
for x in 1..4 {
assert!(board.signpost_at(x, 0).is_some());
}
}
#[test]
fn repointing_refreshes_health_and_age() {
let mut board = Board::new(8, 1, 0);
for x in 0..3 {
assert!(board.place_signpost(0, x, 0, Up));
}
assert!(board.place_signpost(0, 0, 0, Down));
assert_eq!(board.signpost_count(0), 3);
assert!(board.place_signpost(0, 4, 0, Up));
assert!(board.signpost_at(0, 0).is_some());
assert!(board.signpost_at(1, 0).is_none());
}
#[test]
fn the_next_to_go_is_the_one_the_cap_takes() {
let mut board = Board::new(8, 1, 0);
board.set_signpost_rule(3, CapPolicy::Evict);
for x in 0..2 {
assert!(board.place_signpost(0, x, 0, Up));
}
assert_eq!(board.next_to_go(0), None, "room for one more");
assert!(board.place_signpost(0, 2, 0, Up));
assert_eq!(board.next_to_go(0), Some((0, 0)), "at the cap: the oldest");
assert_eq!(board.next_to_go(1), None, "a rival's count is its own");
assert!(board.place_signpost(0, 0, 0, Down));
assert_eq!(board.next_to_go(0), Some((1, 0)));
let marked = board.next_to_go(0).expect("at the cap");
assert!(board.place_signpost(0, 4, 0, Up));
assert!(board.signpost_at(marked.0, marked.1).is_none());
let mut puzzle = Board::new(8, 1, 0);
puzzle.set_signpost_rule(1, CapPolicy::Reject);
assert!(puzzle.place_signpost(0, 0, 0, Up));
assert_eq!(puzzle.next_to_go(0), None, "a full inventory refuses");
}
#[test]
fn signpost_lives_are_oldest_first() {
let mut board = Board::new(8, 1, 0);
let mut lives = Vec::new();
board.signpost_lives(0, &mut lives);
assert!(lives.is_empty());
assert!(board.place_signpost(0, 5, 0, Up));
for _ in 0..60 {
board.tick_idle();
}
assert!(board.place_signpost(0, 1, 0, Up));
board.signpost_lives(0, &mut lives);
assert_eq!(lives.len(), 2);
assert!(lives[0] < lives[1], "the older has less left: {lives:?}");
assert!((lives[1] - 1.0).abs() < f32::EPSILON, "fresh: {lives:?}");
}
#[test]
fn out_of_signposts_names_the_inventory_refusal_only() {
let mut board = Board::new(6, 2, 0);
board.set_signpost_rule(2, CapPolicy::Reject);
board.set_tile(5, 0, TileKind::Rock);
assert!(!board.out_of_signposts(0, 0, 0), "nothing placed yet");
assert!(board.place_signpost(0, 0, 0, Up));
assert!(board.place_signpost(0, 1, 0, Up));
assert!(!board.can_place_signpost(0, 2, 0));
assert!(board.out_of_signposts(0, 2, 0));
assert!(!board.can_place_signpost(0, 5, 0));
assert!(
!board.out_of_signposts(0, 5, 0),
"the tile, not the inventory"
);
assert!(!board.out_of_signposts(0, 0, 0));
assert!(board.place_signpost(0, 0, 0, Down));
assert!(!board.out_of_signposts(1, 2, 0));
let mut versus = Board::new(6, 2, 0);
for x in 0..3 {
assert!(versus.place_signpost(0, x, 0, Up));
}
assert!(versus.can_place_signpost(0, 4, 0));
assert!(!versus.out_of_signposts(0, 4, 0), "evicting, not refusing");
}
#[test]
fn signpost_cap_is_per_player() {
let mut board = Board::new(8, 1, 0);
for x in 0..3 {
assert!(board.place_signpost(0, x, 0, Up));
}
assert!(board.place_signpost(1, 3, 0, Up));
for x in 0..4 {
assert!(board.signpost_at(x, 0).is_some());
}
}
#[test]
fn signpost_placement_rules() {
let mut board = Board::new(5, 5, 0);
board.set_tile(0, 0, TileKind::Rock);
board.set_tile(1, 0, TileKind::Castle(0));
board.set_tile(
2,
0,
TileKind::Spawner(Spawner {
dir: Down,
period: 30,
}),
);
assert!(!board.place_signpost(0, 0, 0, Up));
assert!(!board.place_signpost(0, 1, 0, Up));
assert!(!board.place_signpost(0, 2, 0, Up));
assert!(board.place_signpost(0, 3, 0, Up));
assert!(!board.place_signpost(1, 3, 0, Down));
assert!(board.place_signpost(0, 3, 0, Down));
assert_eq!(board.signpost_at(3, 0).unwrap().dir, Down);
assert_eq!(board.signpost_count(0), 1, "re-pointing is not a new post");
assert!(!board.place_signpost(0, 200, 0, Up));
}
#[test]
fn only_the_owner_removes_a_signpost() {
let mut board = Board::new(5, 5, 0);
assert!(board.place_signpost(0, 2, 2, Up));
assert!(!board.remove_signpost(1, 2, 2));
assert!(board.signpost_at(2, 2).is_some());
assert!(board.remove_signpost(0, 2, 2));
assert!(board.signpost_at(2, 2).is_none());
}
#[test]
fn a_same_tile_conflict_goes_to_whoever_the_order_reaches_first() {
let mut board = Board::new(5, 5, 0);
let mut actions = [PlayerAction::None; MAX_PLAYERS];
actions[1] = PlayerAction::Place {
x: 2,
y: 2,
dir: Up,
};
actions[2] = PlayerAction::Place {
x: 2,
y: 2,
dir: Down,
};
board.tick(&actions);
let sp = board.signpost_at(2, 2).expect("someone placed");
assert_eq!(sp.owner, 1);
assert_eq!(sp.dir, Up);
}
#[test]
fn the_lead_rotates_so_one_seat_does_not_win_every_tie() {
let mut board = Board::new(5, 5, 0);
for (seat, &(x, y)) in [(0u8, 0u8), (4, 4), (4, 0), (0, 4)].iter().enumerate() {
board.set_tile(x, y, TileKind::Castle(seat as u8));
}
assert_eq!(board.seats_in_play(), 4);
board.tick_idle();
board.tick_idle();
assert_eq!(board.ticks(), 2);
let mut actions = [PlayerAction::None; MAX_PLAYERS];
actions[1] = PlayerAction::Place {
x: 2,
y: 2,
dir: Up,
};
actions[2] = PlayerAction::Place {
x: 2,
y: 2,
dir: Down,
};
board.tick(&actions);
let sp = board.signpost_at(2, 2).expect("someone placed");
assert_eq!(sp.owner, 2, "seat 2 leads this tick, so it takes the tile");
assert_eq!(sp.dir, Down);
}
#[test]
fn spawner_emits_on_schedule_with_seeded_handedness() {
let mut board = Board::new(3, 3, 7);
board.set_tile(
1,
1,
TileKind::Spawner(Spawner {
dir: Right,
period: 10,
}),
);
board.tick_idle(); assert_eq!(board.crabs().len(), 1);
for _ in 0..20 {
board.tick_idle(); }
assert_eq!(board.crabs().len(), 3);
let mut again = Board::new(3, 3, 7);
again.set_tile(
1,
1,
TileKind::Spawner(Spawner {
dir: Right,
period: 10,
}),
);
for _ in 0..21 {
again.tick_idle();
}
let handed: Vec<_> = board.crabs().iter().map(|c| c.handed).collect();
let handed_again: Vec<_> = again.crabs().iter().map(|c| c.handed).collect();
assert_eq!(handed, handed_again);
}
#[test]
fn walls_are_shared_between_neighbours() {
let mut board = Board::new(3, 3, 0);
board.set_wall(1, 1, Right, true);
assert!(board.wall_at(2, 1, Left));
board.set_wall(2, 1, Left, false);
assert!(!board.wall_at(1, 1, Right));
}
#[test]
fn an_event_fired_by_hand_holds_the_wheel_as_well() {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.force_tide_event(TideEvent::FreshSand, 0);
let first = board.last_event().expect("the forced event landed");
board.spin_tide_event(0);
assert_eq!(board.last_event(), Some(first), "the wheel was still held");
}
#[test]
fn a_held_spin_does_not_spend_a_draw() {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.force_tide_event(TideEvent::SpeedUp, 0);
let untouched = board.state_hash();
for _ in 0..25 {
board.spin_tide_event(0);
}
assert_eq!(
board.state_hash(),
untouched,
"a refused spin moved the board: the draw is being spent"
);
}
#[test]
fn the_wheel_comes_back_on_the_tick_the_cooldown_ends() {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.force_tide_event(TideEvent::SlowDown, 0);
let held = board.last_event().expect("the forced event landed");
for _ in 0..(EVENT_COOLDOWN - 1) {
board.tick_idle();
}
board.spin_tide_event(0);
assert_eq!(board.last_event(), Some(held), "it opened a tick early");
board.tick_idle();
board.spin_tide_event(0);
assert_ne!(board.last_event(), Some(held), "and never opened at all");
}
#[test]
fn the_roulette_will_not_spin_on_top_of_a_running_event() {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.force_tide_event(TideEvent::SpeedUp, 0);
let first = board.last_event().expect("the forced event landed");
assert_eq!(first.0, TideEvent::SpeedUp);
for _ in 0..40 {
board.spin_tide_event(0);
assert_eq!(
board.last_event(),
Some(first),
"a second event started on top of the first"
);
}
for _ in 0..EVENT_COOLDOWN {
board.tick_idle();
}
board.spin_tide_event(0);
assert_ne!(
board.last_event(),
Some(first),
"the wheel never came back: it is a mute button, not a cooldown"
);
}
#[test]
fn a_timed_lock_holds_the_wheel_only_for_an_event_that_lasts() {
for event in TideEvent::ALL {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.set_timed_lock(true);
board.force_tide_event(event, 0);
let first = board.last_event().expect("the forced event landed");
board.tick_idle();
board.spin_tide_event(0);
assert_eq!(
board.last_event() == Some(first),
event.lasts(),
"{event:?} held the wheel wrongly"
);
}
}
#[test]
fn a_timed_lock_outlasts_an_instant_event_called_on_top() {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.set_timed_lock(true);
board.force_tide_event(TideEvent::CrabMania, 0);
board.tick_idle();
board.force_tide_event(TideEvent::FreshSand, 0);
let fresh = board.last_event();
board.tick_idle();
board.spin_tide_event(0);
assert_eq!(board.last_event(), fresh, "the mania's lock was lifted");
}
#[test]
fn without_a_timed_lock_an_instant_event_holds_the_wheel() {
for event in TideEvent::ALL.into_iter().filter(|e| !e.lasts()) {
let mut board = Board::new(9, 7, 2);
board.set_events_enabled(true);
board.force_tide_event(event, 0);
let first = board.last_event();
board.tick_idle();
board.spin_tide_event(0);
assert_eq!(board.last_event(), first, "{event:?} left the wheel free");
}
}
#[test]
fn can_place_mirrors_place() {
for policy in [CapPolicy::Reject, CapPolicy::Evict] {
let mut board = Board::new(6, 5, 3);
board.set_signpost_rule(2, policy);
board.set_tile(4, 0, TileKind::Rock);
assert!(board.place_signpost(1, 5, 0, Down)); assert!(board.place_signpost(0, 0, 0, Right));
assert!(board.place_signpost(0, 1, 0, Right)); for y in 0..board.height() {
for x in 0..board.width() {
let predicted = board.can_place_signpost(0, x, y);
let mut probe = board.clone();
assert_eq!(
probe.place_signpost(0, x, y, Up),
predicted,
"({x},{y}) under {policy:?}"
);
}
}
}
}
#[test]
fn signpost_fade_is_full_under_puzzle_rules_and_decays_under_evict() {
let mut board = Board::new(5, 5, 0);
board.set_signpost_rule(3, CapPolicy::Reject);
assert!(board.place_signpost(0, 2, 2, Right));
let sp = board.signpost_at(2, 2).unwrap();
for _ in 0..SIGNPOST_LIFETIME {
board.tick_idle();
}
assert_eq!(board.signpost_fade(&sp), 1.0, "a puzzle post never fades");
assert!(board.signpost_at(2, 2).is_some(), "nor washes away");
let mut board = Board::new(5, 5, 0);
board.set_signpost_rule(3, CapPolicy::Evict);
assert!(board.place_signpost(0, 2, 2, Right));
let sp = board.signpost_at(2, 2).unwrap();
assert_eq!(board.signpost_fade(&sp), 1.0);
for _ in 0..SIGNPOST_LIFETIME / 2 {
board.tick_idle();
}
let sp = board.signpost_at(2, 2).unwrap();
let half = board.signpost_fade(&sp);
assert!((half - 0.5).abs() < 0.01, "half-life fade was {half}");
for _ in 0..SIGNPOST_LIFETIME / 2 + 1 {
board.tick_idle();
}
assert!(
board.signpost_at(2, 2).is_none(),
"post washed away at end of life"
);
}
#[test]
fn first_signpost_of_scans_in_reading_order() {
let mut board = Board::new(6, 6, 0);
assert_eq!(board.first_signpost_of(0), None);
assert!(board.place_signpost(0, 4, 3, Left));
assert!(board.place_signpost(0, 1, 3, Up));
assert!(board.place_signpost(0, 3, 1, Down));
assert_eq!(board.first_signpost_of(0), Some((3, 1)));
assert_eq!(board.first_signpost_of(1), None);
}
#[test]
fn gulls_eat_exactly_at_the_range_boundary() {
for (crab_progress, expect_eaten) in [(EAT_RANGE - 8, true), (EAT_RANGE - 7, false)] {
let mut board = Board::new(5, 5, 0);
common(&mut board, 2, 2, Down, Handedness::Left);
board.spawn_gull(2, 2, Right);
board.crabs[0].progress = crab_progress;
board.gulls[0].progress = 8;
board.gulls_eat();
assert_eq!(
board.crabs.is_empty(),
expect_eaten,
"distance {}",
8 + crab_progress
);
}
}
#[test]
fn gulls_eat_across_the_seam_of_a_wrapping_board() {
for wrap in [false, true] {
let mut board = Board::new(5, 3, 0);
board.set_wrap(wrap);
common(&mut board, 4, 1, Right, Handedness::Left);
board.spawn_gull(0, 1, Left);
board.crabs[0].progress = 120;
board.gulls[0].progress = 120;
board.gulls_eat();
assert_eq!(board.crabs.is_empty(), wrap, "wrap {wrap}");
}
}
#[test]
fn a_gull_may_stand_on_sand_and_in_a_pool_but_not_on_rock_or_kelp() {
let mut board = Board::new(5, 5, 0);
board.set_tile(1, 1, TileKind::Rock);
board.set_tile(2, 2, TileKind::Kelp);
board.set_tile(3, 3, TileKind::Pool);
assert!(board.gull_may_stand(0, 0), "sand");
assert!(board.gull_may_stand(3, 3), "a pool");
assert!(!board.gull_may_stand(1, 1), "rock");
assert!(!board.gull_may_stand(2, 2), "kelp");
assert!(!board.gull_may_stand(5, 0), "off the board");
}
#[test]
fn evicting_with_nothing_to_evict_refuses_rather_than_panics() {
let mut board = Board::new(5, 5, 0);
board.set_signpost_rule(0, CapPolicy::Evict);
assert!(
board.can_place_signpost(0, 2, 2),
"the rule says evict, so yes"
);
assert!(
!board.place_signpost(0, 2, 2, Right),
"but there is nothing to trade"
);
assert!(board.signpost_at(2, 2).is_none());
}
#[test]
#[should_panic(expected = "crab off the board")]
fn removing_crabs_off_the_board_is_refused_loudly() {
Board::new(5, 5, 0).remove_crabs_at(5, 0);
}
#[test]
#[should_panic(expected = "gull off the board")]
fn removing_gulls_off_the_board_is_refused_loudly() {
Board::new(5, 5, 0).remove_gulls_at(0, 5);
}
#[test]
fn a_lure_pulls_through_the_top_and_bottom_seam_as_well() {
for (wrap, expect) in [(false, Down), (true, Up)] {
let mut board = Board::new(3, 5, 0);
board.set_wrap(wrap);
board.set_tile(1, 4, TileKind::Castle(0));
board.force_lure(0);
let target = board.lure_target();
assert_eq!(
board.lure_step(board.index_of(1, 0), target),
Some(expect),
"wrap {wrap}"
);
}
}
#[test]
fn gulls_eat_across_the_top_and_bottom_seam_as_well() {
for wrap in [false, true] {
let mut board = Board::new(3, 5, 0);
board.set_wrap(wrap);
common(&mut board, 1, 4, Down, Handedness::Left);
board.spawn_gull(1, 0, Up);
board.crabs[0].progress = 120;
board.gulls[0].progress = 120;
board.gulls_eat();
assert_eq!(board.crabs.is_empty(), wrap, "wrap {wrap}");
}
}
#[test]
fn a_lure_pulls_through_the_seam_when_that_is_the_short_way() {
for (wrap, expect) in [(false, Right), (true, Left)] {
let mut board = Board::new(5, 3, 0);
board.set_wrap(wrap);
board.set_tile(4, 1, TileKind::Castle(0));
board.force_lure(0);
let target = board.lure_target();
assert_eq!(
board.lure_step(board.index_of(0, 1), target),
Some(expect),
"wrap {wrap}"
);
}
}
#[test]
fn turnstile_alternates_deflections() {
let mut board = Board::new(5, 5, 0);
board.set_tile(2, 2, TileKind::Turnstile { next_right: true });
common(&mut board, 0, 2, Right, Handedness::Left);
for _ in 0..ticks_to_cross(2, 12) {
board.tick_idle();
}
assert_eq!(board.crabs()[0].dir, Down);
common(&mut board, 0, 2, Right, Handedness::Left);
for _ in 0..ticks_to_cross(2, 12) {
board.tick_idle();
}
assert_eq!(board.crabs()[1].dir, Up);
}
#[test]
fn kelp_blocks_walking_gulls_but_not_crabs() {
let mut crabs = Board::new(3, 1, 0);
crabs.set_tile(1, 0, TileKind::Kelp);
common(&mut crabs, 0, 0, Right, Handedness::Left);
for _ in 0..ticks_to_cross(2, 12) + 1 {
crabs.tick_idle();
}
assert_eq!(crabs.crabs()[0].tile, 2, "crab crossed the kelp corridor");
let mut gulls = Board::new(3, 1, 0);
gulls.set_tile(1, 0, TileKind::Kelp);
gulls.spawn_gull(2, 0, Left);
gulls.tick_idle();
let gull = gulls.gulls()[0];
assert_eq!(gull.dir, Right, "gull reversed off the kelp");
assert_eq!(gull.tile, 2, "and stays put in the dead end");
}
#[test]
fn pools_halve_wading_speed() {
let mut dry = Board::new(5, 1, 0);
let mut wet = Board::new(5, 1, 0);
wet.set_tile(0, 0, TileKind::Pool);
common(&mut dry, 0, 0, Right, Handedness::Left);
common(&mut wet, 0, 0, Right, Handedness::Left);
dry.tick_idle();
wet.tick_idle();
assert_eq!(dry.crabs()[0].progress, 12);
assert_eq!(wet.crabs()[0].progress, 6, "wading is half speed");
}
#[test]
fn turnstile_deflects_walking_gulls_too() {
let mut board = Board::new(5, 5, 0);
board.set_tile(2, 2, TileKind::Turnstile { next_right: true });
board.spawn_gull(0, 2, Right);
for _ in 0..ticks_to_cross(2, u32::from(GULL_WALK_SPEED)) {
board.tick_idle();
}
assert_eq!(board.gulls()[0].dir, Down, "first crossing deflects right");
assert_eq!(
board.tile_at(2, 2),
TileKind::Turnstile { next_right: false },
"the log flipped"
);
}
#[test]
fn gulls_wade_pools_at_half_speed() {
let mut board = Board::new(5, 1, 0);
board.set_tile(0, 0, TileKind::Pool);
board.spawn_gull(0, 0, Right);
board.tick_idle();
assert_eq!(board.gulls()[0].progress, GULL_WALK_SPEED / 2);
}
#[test]
fn flying_gulls_glide_past_kelp() {
let mut board = Board::new(5, 1, 0);
board.set_tile(2, 0, TileKind::Kelp);
board.spawn_gull(1, 0, Right);
board.gulls[0].dir = Right;
board.gulls[0].state = GullState::Flying { remaining: 1 };
while matches!(board.gulls()[0].state, GullState::Flying { .. }) {
board.tick_idle();
}
assert_eq!(board.gulls()[0].tile, 3, "landed past the kelp, not in it");
}
#[test]
fn gull_obeys_and_degrades_signposts() {
let mut board = Board::new(7, 3, 0);
assert!(board.place_signpost(0, 3, 1, Up));
board.spawn_gull(0, 1, Right);
for _ in 0..ticks_to_cross(3, 8) {
board.tick_idle();
}
assert_eq!(board.gulls()[0].dir, Up, "gull follows the signpost");
assert_eq!(
board.signpost_at(3, 1).unwrap().health,
SignpostHealth::Worn
);
board.spawn_gull(2, 1, Right);
for _ in 0..ticks_to_cross(1, 8) {
board.tick_idle();
}
assert!(board.signpost_at(3, 1).is_none());
}
#[test]
fn walking_gull_eats_crab_on_shared_tile() {
let mut board = Board::new(5, 3, 0);
common(&mut board, 2, 1, Right, Handedness::Left);
board.spawn_gull(2, 1, Right);
board.tick_idle();
assert!(board.crabs().is_empty(), "crab within eat range is gone");
assert_eq!(board.gulls().len(), 1);
}
#[test]
fn distant_crab_survives_the_gull() {
let mut board = Board::new(9, 1, 0);
common(&mut board, 8, 0, Left, Handedness::Left);
board.spawn_gull(0, 0, Right);
board.tick_idle();
assert_eq!(board.crabs().len(), 1);
}
#[test]
fn gull_raid_halves_the_castle() {
let mut board = Board::new(7, 5, 0);
board.set_tile(4, 2, TileKind::Castle(1));
board.scores[1] = 30;
board.spawn_gull(0, 2, Right);
for _ in 0..ticks_to_cross(4, 8) {
board.tick_idle();
}
assert_eq!(board.scores()[1], 15, "half the banked crabs carried off");
assert_eq!(
board.crabs().len(),
SPILL_CAP as usize,
"the spill cap of live crabs lands on the sand"
);
assert!(
board.gulls().is_empty(),
"the raider departs the beach with its loot"
);
}
#[test]
fn a_raid_with_raids_off_takes_nothing_and_still_ends_the_visit() {
let mut board = Board::new(7, 5, 0);
board.set_tile(4, 2, TileKind::Castle(1));
board.set_castle_raids(false);
board.scores[1] = 30;
board.spawn_gull(0, 2, Right);
let spawned_before = board.crabs_spawned();
for _ in 0..ticks_to_cross(4, 8) {
board.tick_idle();
}
assert_eq!(board.scores()[1], 30, "the bank is not robbed");
assert_eq!(
board.crabs_spawned(),
spawned_before,
"nothing spills, so the level's target stays where it was"
);
assert!(
board.gulls().is_empty(),
"the visitor still leaves the beach, which is what the levels rely on"
);
let mut board = Board::new(7, 5, 0);
board.set_tile(4, 2, TileKind::Castle(1));
board.set_castle_raids(false);
board.spawn_crab(0, 2, Right, Handedness::Left, CrabKind::Common);
for _ in 0..ticks_to_cross(4, 12) {
board.tick_idle();
}
assert_eq!(board.crabs_banked(), 1, "the castle is still the goal");
}
#[test]
fn empty_castle_hit_is_harmless() {
let mut board = Board::new(7, 5, 0);
board.set_tile(4, 2, TileKind::Castle(1));
board.spawn_gull(0, 2, Right);
for _ in 0..ticks_to_cross(4, 8) {
board.tick_idle();
}
assert_eq!(board.scores()[1], 0);
assert!(board.crabs().is_empty());
}
#[test]
fn flight_crosses_walls_and_lands_on_sand() {
let mut board = Board::new(9, 1, 7);
board.set_wall(0, 0, Right, true); board.spawn_gull(0, 0, Right);
board.gulls[0].takeoff_in = 0; board.gulls[0].dir = Right; for _ in 0..80 {
board.tick_idle();
if board.gulls[0].state == GullState::Walking && board.gulls[0].tile > 0 {
break;
}
}
let gull = board.gulls()[0];
assert_eq!(gull.state, GullState::Walking, "flight ended");
assert!(
(2..=4).contains(&gull.tile),
"landed 2-4 tiles out, past the wall (tile {})",
gull.tile
);
}
#[test]
fn molting_bank_lures_loose_crabs_then_expires() {
let mut board = Board::new(7, 5, 0);
board.set_tile(6, 2, TileKind::Castle(2));
board.spawn_crab(5, 2, Right, Handedness::Left, CrabKind::Molting);
common(&mut board, 0, 2, Up, Handedness::Left);
let bank_tick = ticks_to_cross(1, 12);
for _ in 0..bank_tick {
board.tick_idle();
}
let (owner, _) = board.lure().expect("molting bank starts the lure");
assert_eq!(owner, 2);
assert_eq!(board.crabs()[0].dir, Right);
for _ in 0..LURE_TICKS {
board.tick_idle();
}
assert!(board.lure().is_none(), "lure expires after 10 s");
}
#[test]
fn a_lure_neither_stacks_nor_restarts_at_once() {
let mut board = Board::new(7, 5, 0);
board.set_tile(6, 2, TileKind::Castle(0));
board.set_tile(6, 4, TileKind::Castle(1));
board.spawn_crab(5, 2, Right, Handedness::Left, CrabKind::Molting);
board.spawn_crab(4, 4, Right, Handedness::Left, CrabKind::Molting);
while board.lure().is_none() {
board.tick_idle();
}
let (owner, started_with) = board.lure().expect("the first molt lures");
assert_eq!(owner, 0);
let mut lowest = started_with;
for _ in 0..LURE_TICKS - 1 {
board.tick_idle();
if let Some((who, left)) = board.lure() {
assert_eq!(who, 0, "a molt banked mid-lure must not steal it");
assert!(left <= lowest, "the clock only ever runs down");
lowest = left;
}
}
board.tick_idle();
assert!(board.lure().is_none(), "and it ends on time");
board.spawn_crab(5, 2, Right, Handedness::Left, CrabKind::Molting);
let before = board.scores()[0];
for _ in 0..LURE_COOLDOWN / 2 {
board.tick_idle();
}
assert!(board.scores()[0] > before, "it still banked");
assert!(board.lure().is_none(), "but started nothing");
for _ in 0..LURE_COOLDOWN {
board.tick_idle();
}
board.spawn_crab(5, 2, Right, Handedness::Left, CrabKind::Molting);
for _ in 0..ticks_to_cross(1, 12) + 2 {
board.tick_idle();
}
assert!(board.lure().is_some(), "the cooldown has passed");
}
#[test]
fn the_beach_fills_to_a_cap_and_mania_to_twice_it() {
let mut board = Board::new(9, 9, 5); for (x, y, dir) in [(0u8, 4u8, Right), (8, 4, Left)] {
board.set_tile(x, y, TileKind::Spawner(Spawner { dir, period: 2 }));
}
board.set_tile(4, 4, TileKind::Castle(0));
board.set_events_enabled(false);
for _ in 0..1200 {
board.tick_idle();
}
let cap = 81 / 3;
assert!(
board.crabs().len() <= cap + 2,
"ambient spawning stops at the cap, saw {}",
board.crabs().len()
);
assert!(
board.crabs_spawned() > 100,
"and it kept spawning as the banked ones made room, saw {}",
board.crabs_spawned()
);
board.apply_tide_event(TideEvent::CrabMania, 0);
let mut peak = board.crabs().len();
for _ in 0..600 {
board.tick_idle();
peak = peak.max(board.crabs().len());
}
assert!(peak > cap + 2, "the mania floods past the cap, saw {peak}");
assert!(peak <= cap * 2 + 4, "but not without bound, saw {peak}");
}
#[test]
fn the_roulette_keeps_gulls_out_of_the_surge() {
let mut board = party_board();
board.set_round_length(Some(1000));
assert!(!board.in_surge());
board.apply_tide_event(TideEvent::GullMania, 0);
assert_eq!(
board.last_event().map(|(e, _)| e),
Some(TideEvent::GullMania),
"outside the surge it stands"
);
let mut surging = party_board();
surging.set_round_length(Some(200));
surging.set_score(0, 1); for _ in 0..100 {
surging.tick_idle();
}
assert!(surging.in_surge());
assert_eq!(
surging.surge_safe(TideEvent::GullMania),
TideEvent::CrabMania
);
assert_eq!(
surging.surge_safe(TideEvent::GullAttack),
TideEvent::SpeedUp
);
for kept in [
TideEvent::CrabMania,
TideEvent::Monopoly,
TideEvent::SpeedUp,
TideEvent::SlowDown,
TideEvent::FreshSand,
TideEvent::CastleSwap,
] {
assert_eq!(surging.surge_safe(kept), kept, "{kept:?} stands");
}
assert_eq!(
board.surge_safe(TideEvent::GullMania),
TideEvent::GullMania,
"outside the surge"
);
surging.apply_tide_event(TideEvent::GullMania, 0);
assert_eq!(
surging.last_event().map(|(e, _)| e),
Some(TideEvent::GullMania)
);
}
#[test]
fn tide_freezes_the_sim_and_surges_gulls() {
let mut board = Board::new(7, 5, 3);
board.set_gull_period(500);
board.set_round_length(Some(2000));
common(&mut board, 0, 0, Right, Handedness::Left);
for _ in 0..1099 {
board.tick_idle();
}
assert!(!board.in_surge(), "901 ticks left: not yet the scramble");
board.tick_idle();
assert!(board.in_surge(), "900 ticks left: final scramble");
for _ in 0..900 {
board.tick_idle();
}
assert!(board.round_over());
let frozen = board.state_hash();
for _ in 0..10 {
board.tick_idle();
}
assert_eq!(board.state_hash(), frozen, "scores locked at the wave");
}
#[test]
fn versus_signposts_fade_away() {
let mut board = Board::new(5, 3, 0); assert!(board.place_signpost(0, 2, 1, Up));
for _ in 0..SIGNPOST_LIFETIME {
board.tick_idle();
}
assert!(
board.signpost_at(2, 1).is_some(),
"alive through its lifetime"
);
board.tick_idle();
assert!(board.signpost_at(2, 1).is_none(), "faded one tick after");
}
#[test]
fn puzzle_signposts_are_permanent() {
let mut board = Board::new(5, 3, 0);
board.set_signpost_rule(3, CapPolicy::Reject);
assert!(board.place_signpost(0, 2, 1, Up));
for _ in 0..SIGNPOST_LIFETIME * 2 {
board.tick_idle();
}
assert!(board.signpost_at(2, 1).is_some());
}
#[test]
fn wrapped_edges_carry_crabs_across() {
let mut board = Board::new(3, 3, 0);
board.set_wrap(true);
common(&mut board, 2, 1, Right, Handedness::Left);
for _ in 0..ticks_to_cross(1, 12) {
board.tick_idle();
}
let crab = board.crabs()[0];
assert_eq!(crab.tile, 3, "walked off the east edge onto (0,1)");
assert_eq!(crab.dir, Right);
}
#[test]
fn a_step_crosses_the_seam_exactly_when_the_beach_wraps() {
let mut board = Board::new(6, 5, 0);
let corner = 0u16; assert_eq!(board.step(corner, Left), None);
assert_eq!(board.step(corner, Up), None);
assert_eq!(board.step(corner, Right), Some(1));
assert_eq!(board.step(corner, Down), Some(6));
board.set_wrap(true);
assert_eq!(board.step(corner, Left), Some(5), "west seam to (5,0)");
assert_eq!(board.step(corner, Up), Some(24), "north seam to (0,4)");
}
#[test]
fn sparkling_bank_spins_events_only_when_enabled() {
let mut quiet = Board::new(5, 3, 9);
quiet.set_tile(4, 1, TileKind::Castle(0));
quiet.spawn_crab(3, 1, Right, Handedness::Left, CrabKind::Sparkling);
for _ in 0..ticks_to_cross(1, 12) {
quiet.tick_idle();
}
assert!(quiet.last_event().is_none(), "events off by default");
let mut party = Board::new(5, 3, 9);
party.set_events_enabled(true);
party.set_tile(4, 1, TileKind::Castle(0));
party.spawn_crab(3, 1, Right, Handedness::Left, CrabKind::Sparkling);
for _ in 0..ticks_to_cross(1, 12) {
party.tick_idle();
}
let (event, at) = party.last_event().expect("roulette spun");
assert!(at < party.ticks());
match event {
TideEvent::SpeedUp | TideEvent::SlowDown => {
assert_ne!(party.state_hash(), quiet.state_hash());
}
TideEvent::CrabMania
| TideEvent::GullMania
| TideEvent::Monopoly
| TideEvent::GullAttack
| TideEvent::FreshSand
| TideEvent::CastleSwap
| TideEvent::RightClaws => {}
}
}
fn party_board() -> Board {
let mut board = Board::new(7, 5, 11);
board.set_events_enabled(true);
board.set_tile(1, 1, TileKind::Castle(0));
board.set_tile(5, 3, TileKind::Castle(1));
board
}
#[test]
fn a_called_event_fires_from_a_seats_input() {
let mut board = party_board();
board.spawn_crab(3, 2, Right, Handedness::Right, CrabKind::Common);
board.place_signpost(0, 3, 1, Right);
assert_eq!(board.signpost_count(0), 1, "an arrow to wash away");
let mut actions = [PlayerAction::None; MAX_PLAYERS];
actions[0] = PlayerAction::CallEvent(TideEvent::FreshSand);
board.tick(&actions);
assert_eq!(board.signpost_count(0), 0, "fresh sand took it");
let (event, _) = board.last_event().expect("the call fired");
assert_eq!(event, TideEvent::FreshSand);
}
#[test]
fn a_called_event_is_held_to_the_surge_guard_too() {
let mut board = party_board();
board.set_round_length(Some(SURGE_TICKS));
assert!(board.in_surge(), "inside the last stretch");
let mut actions = [PlayerAction::None; MAX_PLAYERS];
actions[0] = PlayerAction::CallEvent(TideEvent::GullMania);
board.tick(&actions);
let (event, _) = board.last_event().expect("something fired");
assert_eq!(
event,
TideEvent::CrabMania,
"swapped, exactly as the wheel's own draw would be"
);
let mut calm = party_board();
let mut actions = [PlayerAction::None; MAX_PLAYERS];
actions[0] = PlayerAction::CallEvent(TideEvent::GullMania);
calm.tick(&actions);
assert_eq!(calm.last_event().expect("fired").0, TideEvent::GullMania);
}
#[test]
fn event_right_claws_pays_one_hand_and_charges_the_other() {
let mut board = party_board();
let bank = |board: &mut Board, handed: Handedness, kind: CrabKind| {
board.spawn_crab(2, 1, Left, handed, kind);
for _ in 0..ticks_to_cross(1, 12) * 16 {
board.tick_idle();
if board.crabs().is_empty() {
return;
}
}
panic!("the crab never reached the castle");
};
bank(&mut board, Handedness::Right, CrabKind::Common);
bank(&mut board, Handedness::Left, CrabKind::Common);
assert_eq!(board.scores()[0], 2, "two common crabs, two points");
board.apply_tide_event(TideEvent::RightClaws, 0);
bank(&mut board, Handedness::Right, CrabKind::Common);
assert_eq!(board.scores()[0], 4, "the right claw pays twice over");
bank(&mut board, Handedness::Left, CrabKind::Common);
assert_eq!(board.scores()[0], 3, "and the left one costs the same");
bank(&mut board, Handedness::Left, CrabKind::Giant);
assert_eq!(board.scores()[0], 0, "a giant costs 10 into a score of 3");
board.spawn_crab(5, 0, Up, Handedness::Right, CrabKind::Golden);
board.spawn_crab(5, 4, Down, Handedness::Left, CrabKind::Golden);
let worths: Vec<Option<u32>> = board.crabs().iter().map(|c| board.bank_worth(c)).collect();
assert_eq!(
worths,
vec![Some(100), None],
"the right claw doubles, the left one is worth leaving alone"
);
for _ in 0..EVENT_TICKS {
board.tick_idle();
}
let worths: Vec<Option<u32>> = board.crabs().iter().map(|c| board.bank_worth(c)).collect();
assert!(
worths.iter().all(|&w| w == Some(50)),
"worth what they say again: {worths:?}"
);
}
#[test]
fn event_crab_mania_clears_gulls_and_floods() {
let mut board = party_board();
board.set_tile(
0,
2,
TileKind::Spawner(Spawner {
dir: Right,
period: 100,
}),
);
board.spawn_gull(3, 2, Right);
board.apply_tide_event(TideEvent::CrabMania, 0);
assert!(board.gulls().is_empty(), "gulls washed away");
let before = board.crabs().len();
for _ in 0..64 {
board.tick_idle();
}
assert!(
board.crabs().len() >= before + 8,
"flood cadence: 8 spawns in 64 ticks"
);
}
#[test]
fn event_gull_mania_spawns_gulls_from_holes() {
let mut board = party_board();
board.set_tile(
0,
2,
TileKind::Spawner(Spawner {
dir: Right,
period: 100,
}),
);
board.apply_tide_event(TideEvent::GullMania, 0);
for _ in 0..64 {
board.tick_idle();
}
assert!(
board.gulls().len() >= 8,
"holes emit gulls during the mania"
);
}
#[test]
fn event_monopoly_banks_half_the_loose_crabs() {
let mut board = party_board();
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
board.apply_tide_event(TideEvent::Monopoly, 1);
assert_eq!(board.crabs().len(), 3);
assert_eq!(board.scores()[1], 3);
assert_eq!(board.crabs_banked(), 3);
}
#[test]
fn a_flying_sweep_banks_each_crab_as_it_lands() {
let mut board = party_board();
board.set_sweep_flight(true);
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
board.apply_tide_event(TideEvent::Monopoly, 1);
assert_eq!(board.crabs().len(), 3, "off the beach at once");
assert_eq!(board.scores()[1], 0, "and nothing banked yet");
let lands: Vec<u64> = board.sweeping().iter().map(|f| f.lands).collect();
let first = SWEEP_HOLD + SWEEP_FLIGHT;
assert_eq!(
lands,
[first, first + SWEEP_STAGGER, first + 2 * SWEEP_STAGGER]
);
let mut flying: Vec<(u32, u64)> = board
.sweeping()
.iter()
.map(|f| (f.crab.id, f.lands))
.collect();
while !board.sweeping().is_empty() {
let tick = board.ticks();
board.tick_idle();
flying.retain(|&(crab, lands)| {
let landed = board.sweeping().iter().all(|f| f.crab.id != crab);
if landed {
assert_eq!(lands, tick, "crab {crab} landed on its tick");
assert_eq!(board.swept_home(crab), Some(1), "for seat 1");
}
!landed
});
}
assert!(board.scores()[1] >= 3, "all three banked");
}
#[test]
fn a_flying_sweep_lands_everything_at_the_wave() {
let mut board = party_board();
board.set_sweep_flight(true);
board.set_round_length(Some(10));
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
board.apply_tide_event(TideEvent::Monopoly, 1);
while !board.round_over() {
board.tick_idle();
}
assert!(board.sweeping().is_empty(), "nothing left in the air");
assert_eq!(board.scores()[1], 3, "and every crab paid");
}
#[test]
fn a_sweep_survives_a_snapshot_mid_flight() {
let mut board = party_board();
board.set_sweep_flight(true);
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
board.apply_tide_event(TideEvent::Monopoly, 1);
for _ in 0..80 {
board.tick_idle();
}
assert!(!board.sweeping().is_empty(), "caught in the air");
let mut resumed = Board::parse_snapshot(&board.to_snapshot()).expect("snapshot");
assert_eq!(resumed.state_hash(), board.state_hash());
for _ in 0..200 {
board.tick_idle();
resumed.tick_idle();
}
assert_eq!(resumed.state_hash(), board.state_hash());
assert_eq!(resumed.scores(), board.scores());
}
#[test]
fn a_swept_crab_is_written_down_against_the_seat_that_got_it() {
let mut board = party_board();
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
let taken: Vec<u32> = board.crabs()[..3].iter().map(|crab| crab.id).collect();
let left: Vec<u32> = board.crabs()[3..].iter().map(|crab| crab.id).collect();
board.apply_tide_event(TideEvent::Monopoly, 1);
for crab in taken {
assert_eq!(
board.swept_home(crab),
Some(1),
"crab {crab} went to seat 1"
);
}
for crab in left {
assert_eq!(board.swept_home(crab), None, "crab {crab} is still walking");
}
assert_eq!(board.swept_home(9999), None);
}
#[test]
fn a_swept_crab_is_remembered_a_few_ticks_and_then_forgotten() {
let mut board = party_board();
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
let crab = board.crabs()[0].id;
board.apply_tide_event(TideEvent::Monopoly, 1);
assert_eq!(board.swept_home(crab), Some(1), "the tick it happened on");
board.tick_idle();
assert_eq!(board.swept_home(crab), Some(1), "and the frame after it");
for _ in 0..SWEEP_MEMORY {
board.tick_idle();
}
assert_eq!(board.swept_home(crab), None, "but not for the round");
}
#[test]
fn a_sweep_leaves_the_fingerprint_alone() {
let mut board = party_board();
for x in 0..6 {
common(&mut board, x, 4, Right, Handedness::Left);
}
let before = board.state_hash();
board.swept_home.push(Swept {
crab: 7,
owner: 1,
at: 0,
});
assert_eq!(board.swept_home(7), Some(1), "the record is really there");
assert_eq!(
board.state_hash(),
before,
"and the fingerprint cannot feel it"
);
}
#[test]
fn event_gull_attack_targets_rival_castles_only() {
let mut board = party_board();
board.apply_tide_event(TideEvent::GullAttack, 0);
assert_eq!(board.gulls().len(), 1, "one gull, at the rival's castle");
let (gx, gy) = (board.gulls()[0].tile % 7, board.gulls()[0].tile / 7);
let dist = (i32::from(gx) - 5).abs() + (i32::from(gy) - 3).abs();
assert_eq!(dist, 1, "landed adjacent to the rival castle");
}
#[test]
fn event_tempo_doubles_and_halves_movement() {
let mut fast = party_board();
common(&mut fast, 0, 4, Right, Handedness::Left);
fast.apply_tide_event(TideEvent::SpeedUp, 0);
for _ in 0..11 {
fast.tick_idle();
}
assert_eq!(fast.crabs()[0].tile, 4 * 7 + 1, "24/tick crosses in 11");
let mut slow = party_board();
common(&mut slow, 0, 4, Right, Handedness::Left);
slow.apply_tide_event(TideEvent::SlowDown, 0);
for _ in 0..42 {
slow.tick_idle();
}
assert_eq!(slow.crabs()[0].tile, 4 * 7, "6/tick still mid-tile at 42");
slow.tick_idle();
assert_eq!(slow.crabs()[0].tile, 4 * 7 + 1);
}
#[test]
fn event_fresh_sand_washes_all_signposts() {
let mut board = party_board();
assert!(board.place_signpost(0, 3, 2, Up));
assert!(board.place_signpost(1, 4, 2, Down));
board.apply_tide_event(TideEvent::FreshSand, 0);
assert_eq!(board.signpost_count(0) + board.signpost_count(1), 0);
}
#[test]
fn event_castle_swap_rotates_owners() {
let mut board = party_board();
board.apply_tide_event(TideEvent::CastleSwap, 0);
assert_eq!(board.tile_at(1, 1), TileKind::Castle(1));
assert_eq!(board.tile_at(5, 3), TileKind::Castle(0));
}
#[test]
fn event_timers_expire() {
let mut board = party_board();
board.apply_tide_event(TideEvent::SpeedUp, 0);
common(&mut board, 0, 4, Right, Handedness::Left);
for _ in 0..EVENT_TICKS + 1 {
board.tick_idle();
}
common(&mut board, 0, 2, Right, Handedness::Left);
let start = board.crabs().last().unwrap().tile;
for _ in 0..21 {
board.tick_idle();
}
assert_eq!(board.crabs().last().unwrap().tile, start, "12/tick again");
}
#[test]
fn repointing_restarts_the_fade_clock() {
let mut board = Board::new(5, 3, 0);
assert!(board.place_signpost(0, 2, 1, Up));
for _ in 0..200 {
board.tick_idle();
}
assert!(board.place_signpost(0, 2, 1, Down), "re-point in place");
for _ in 0..200 {
board.tick_idle();
}
assert!(
board.signpost_at(2, 1).is_some(),
"clock restarted at re-point"
);
for _ in 0..SIGNPOST_LIFETIME {
board.tick_idle();
}
assert!(board.signpost_at(2, 1).is_none());
}
#[test]
fn wrapped_flight_crosses_the_edge() {
let mut board = Board::new(5, 1, 3);
board.set_wrap(true);
board.spawn_gull(4, 0, Right);
board.gulls[0].takeoff_in = 0;
board.gulls[0].dir = Right;
for _ in 0..80 {
board.tick_idle();
if board.gulls[0].state == GullState::Walking {
break;
}
}
assert!(
board.gulls[0].tile < 4,
"flew across the open edge (tile {})",
board.gulls[0].tile
);
}
#[test]
fn castle_tier_boundaries() {
for (score, tier) in [
(0, 0),
(9, 0),
(10, 1),
(24, 1),
(25, 2),
(49, 2),
(50, 3),
(5000, 3),
] {
assert_eq!(castle_tier(score), tier, "score {score}");
}
}
#[test]
fn surge_doubles_gull_spawn_rate() {
let mut board = Board::new(7, 5, 3);
board.set_gull_period(100);
board.set_round_length(Some(1000));
let mut calm = Board::new(7, 5, 3);
calm.set_gull_period(100);
for _ in 0..320 {
board.tick_idle();
calm.tick_idle();
}
assert_eq!(
board.gulls().len(),
GULL_CAP,
"surged spawns hit the ambient flock cap"
);
assert_eq!(calm.gulls().len(), 4, "un-surged control spawns at period");
for _ in 0..300 {
board.tick_idle();
}
assert!(board.gulls().len() <= GULL_CAP);
}
#[test]
fn a_raised_gull_cap_is_the_one_the_spawner_stops_at() {
let mut board = Board::new(7, 5, 3);
board.set_gull_period(10);
board.set_gull_cap(9);
for _ in 0..400 {
board.tick_idle();
}
assert_eq!(board.gulls().len(), 9);
}
#[test]
fn a_full_flock_turns_over_from_its_oldest_gull_clear_of_the_castles() {
for turnover in [true, false] {
let mut board = Board::new(9, 7, 3);
board.set_tile(4, 3, TileKind::Castle(0));
board.set_gull_period(10);
board.set_gull_cap(3);
board.set_gull_turnover(turnover);
board.spawn_gull(4, 2, Left); board.spawn_gull(8, 6, Up); board.spawn_gull(0, 6, Up);
board.run_gull_spawner();
let ids: Vec<u32> = board.gulls().iter().map(|g| g.id).collect();
if turnover {
assert_eq!(ids.len(), 3, "one off, one on: {ids:?}");
assert!(ids.contains(&0), "the attack stays: {ids:?}");
assert!(!ids.contains(&1), "the oldest clear one left: {ids:?}");
assert!(ids.contains(&2), "{ids:?}");
} else {
assert_eq!(ids, [0, 1, 2]);
}
}
}
#[test]
fn the_final_rush_raises_a_turning_over_flocks_cap() {
for turnover in [true, false] {
let mut board = Board::new(9, 7, 3);
board.set_tile(4, 3, TileKind::Castle(0));
board.set_gull_period(10);
board.set_gull_cap(2);
board.set_gull_turnover(turnover);
board.set_round_length(Some(600));
assert!(board.in_surge());
board.spawn_gull(8, 6, Up);
board.spawn_gull(0, 6, Up);
board.run_gull_spawner();
let expected = if turnover { 3 } else { 2 };
assert_eq!(board.gulls().len(), expected, "turnover {turnover}");
}
}
fn calling_board(every: u32) -> Board {
let mut board = Board::new(9, 7, 11);
board.set_tile(1, 1, TileKind::Castle(0));
for (x, y, dir) in [(0, 3, Right), (8, 3, Left)] {
board.set_tile(x, y, TileKind::Spawner(Spawner { dir, period: 9 }));
}
board.set_golden_every(every);
board
}
#[test]
fn a_called_golden_crab_comes_out_where_and_when_it_was_called() {
let every = 600u32;
let mut board = calling_board(every);
let mut goldens = Vec::new();
let mut calls = Vec::new();
let mut seen = std::collections::HashSet::new();
while board.ticks() < 3 * u64::from(every) {
if let Some(call) = board.golden_call() {
assert!(
call.0 - board.ticks() <= u64::from(GOLDEN_NOTICE),
"told {} ticks early",
call.0 - board.ticks()
);
if calls.last() != Some(&call) {
calls.push(call);
}
}
board.tick_idle();
for crab in board.crabs() {
if crab.kind == CrabKind::Golden && seen.insert(crab.id) {
let (x, y) = board.coords_u8(crab.tile);
goldens.push((board.ticks() - 1, x, y));
}
}
}
assert_eq!(goldens.len(), 3, "one a stretch: {goldens:?}");
assert_eq!(goldens, calls, "out where and when it was called");
for (k, (at, ..)) in goldens.iter().enumerate() {
let into = at - k as u64 * u64::from(every);
assert!(
(200..400).contains(&into),
"a third to two thirds in: {into}"
);
}
assert_eq!(board.crab_value(CrabKind::Golden), GOLDEN_CALLED_VALUE);
assert_eq!(calling_board(0).crab_value(CrabKind::Golden), 50);
}
#[test]
fn a_golden_crab_is_not_called_past_the_end_of_the_round() {
let mut board = calling_board(600);
board.set_round_length(Some(150));
while !board.round_over() {
board.tick_idle();
assert_eq!(board.golden_call(), None);
}
assert!(board.crabs().iter().all(|c| c.kind != CrabKind::Golden));
}
#[test]
fn a_golden_call_survives_a_snapshot_before_it_is_public() {
let mut board = calling_board(600);
for _ in 0..50 {
board.tick_idle();
}
assert_eq!(board.golden_call(), None, "not yet public");
let mut back = Board::parse_snapshot(&board.to_snapshot()).expect("its own output");
for _ in 0..600 {
board.tick_idle();
back.tick_idle();
assert_eq!(back.state_hash(), board.state_hash());
}
assert!(board.crabs().iter().any(|c| c.kind == CrabKind::Golden) || board.golden_banked() > 0);
}
#[test]
fn gull_mania_ignores_the_flock_cap() {
let mut board = Board::new(9, 7, 5);
board.set_tile(
0,
3,
TileKind::Spawner(Spawner {
dir: Right,
period: 40,
}),
);
board.set_events_enabled(true);
board.apply_tide_event(TideEvent::GullMania, 0);
for _ in 0..200 {
board.tick_idle();
}
assert!(
board.gulls().len() > GULL_CAP,
"mania floods past the cap, got {}",
board.gulls().len()
);
}
#[test]
fn a_raid_on_an_odd_score_rounds_the_loss_up_and_spills_it() {
for (score, kept, spilled) in [(7, 3, 4), (3, 1, 2)] {
let mut board = Board::new(7, 5, 0);
board.set_tile(3, 2, TileKind::Castle(0));
let castle = board.index_of(3, 2);
board.scores[0] = score;
board.damage_castle(0, castle);
assert_eq!(board.scores()[0], kept, "{score} halves to {kept}");
assert_eq!(
board.crabs().len(),
spilled,
"{score}: the lost crabs spill"
);
}
}
#[test]
fn a_spill_into_a_cramped_corner_lands_only_on_the_open_sand() {
let mut board = Board::new(3, 3, 0);
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(1, 1, TileKind::Rock);
let castle = board.index_of(0, 0);
board.scores[0] = 7;
board.damage_castle(0, castle);
assert_eq!(board.scores()[0], 3, "the loss is the full half regardless");
let landed: Vec<(u8, u8)> = board
.crabs()
.iter()
.map(|c| board.coords_u8(c.tile))
.collect();
assert_eq!(
landed.len(),
2,
"only two ring tiles are open sand: {landed:?}"
);
for &(x, y) in &landed {
assert!(x < 3 && y < 3, "spilled crab off the board at ({x},{y})");
assert!(
matches!((x, y), (1, 0) | (0, 1)),
"spilled crab on the castle or the rock at ({x},{y})"
);
}
}
#[test]
fn the_gull_spawner_covers_the_whole_perimeter_and_faces_inward() {
let mut board = Board::new(4, 3, 7);
board.set_gull_period(1);
let mut hit = std::collections::BTreeSet::new();
for _ in 0..400 {
board.run_gull_spawner();
let gull = board.gulls()[0];
let (x, y) = board.coords(gull.tile);
assert!(
x == 0 || y == 0 || x == 3 || y == 2,
"spawned inside the board at ({x},{y})"
);
let (dx, dy) = gull.dir.offset();
assert!(
board.in_bounds(x + dx, y + dy),
"spawned at ({x},{y}) facing {:?}, off the board",
gull.dir
);
hit.insert((x, y));
board.gulls.clear();
}
assert_eq!(
hit.len(),
10,
"every perimeter tile of a 4x3 board: {hit:?}"
);
}
#[test]
fn the_gull_spawner_walks_the_length_of_a_strip() {
for (w, h) in [(1u8, 5u8), (5, 1)] {
let mut board = Board::new(w, h, 3);
board.set_gull_period(1);
let mut hit = std::collections::BTreeSet::new();
for _ in 0..200 {
board.run_gull_spawner();
let gull = board.gulls()[0];
let (x, y) = board.coords(gull.tile);
let (dx, dy) = gull.dir.offset();
assert!(
board.in_bounds(x + dx, y + dy),
"{w}x{h}: spawned at ({x},{y}) facing {:?}, off the board",
gull.dir
);
hit.insert((x, y));
board.gulls.clear();
}
assert_eq!(hit.len(), 5, "{w}x{h}: every tile of the strip: {hit:?}");
}
}
fn flying(board: &Board, x: u8, y: u8, dir: Direction, remaining: u8) -> Gull {
let tile = board.index_of(x, y);
Gull {
id: 99,
tile,
dir,
progress: 0,
prev: Pose {
tile,
dir,
progress: 0,
},
handed: Handedness::Left,
state: GullState::Flying { remaining },
takeoff_in: 0,
}
}
#[test]
fn a_flying_gull_bounces_off_the_edge_and_lands_on_sand() {
let mut board = Board::new(4, 1, 0);
board.set_tile(2, 0, TileKind::Rock);
board.set_tile(3, 0, TileKind::Rock);
let gull = flying(&board, 0, 0, Right, 2);
board.gulls.push(gull);
let hops = 5 * ticks_to_cross(1, u32::from(GULL_FLY_SPEED));
for _ in 0..hops {
board.tick_idle();
if board.gulls()[0].state == GullState::Walking {
break;
}
}
let gull = board.gulls()[0];
assert_eq!(
gull.state,
GullState::Walking,
"still airborne after {hops} ticks"
);
let (x, y) = board.coords_u8(gull.tile);
assert_eq!(board.tile_at(x, y), TileKind::Empty, "landed on a rock");
assert_eq!((x, y), (1, 0), "the first sand on the way back");
assert!(gull.takeoff_in > 0, "a fresh takeoff timer on landing");
}
#[test]
fn a_gull_on_a_one_tile_board_has_nowhere_to_fly_and_lands_where_it_is() {
let mut board = Board::new(1, 1, 0);
let gull = flying(&board, 0, 0, Right, 3);
board.gulls.push(gull);
for _ in 0..ticks_to_cross(1, u32::from(GULL_FLY_SPEED)) {
board.tick_idle();
}
let gull = board.gulls()[0];
assert_eq!(gull.state, GullState::Walking, "landed on the only tile");
assert_eq!(gull.tile, 0);
assert!(gull.takeoff_in > 0, "a fresh takeoff timer on landing");
}
#[test]
fn the_newest_signpost_is_by_sequence_and_ignores_rivals_and_evictions() {
let mut board = Board::new(6, 6, 0);
assert_eq!(board.newest_signpost_of(0), None, "nothing placed yet");
assert!(board.place_signpost(0, 1, 1, Right));
board.tick_idle();
assert!(board.place_signpost(0, 2, 2, Right));
assert_eq!(board.newest_signpost_of(0), Some((2, 2, 1)));
board.tick_idle();
assert!(board.place_signpost(0, 1, 1, Down));
assert_eq!(board.newest_signpost_of(0), Some((1, 1, 2)));
board.tick_idle();
assert!(board.place_signpost(1, 4, 4, Left));
assert_eq!(board.newest_signpost_of(0), Some((1, 1, 2)));
assert_eq!(board.newest_signpost_of(1), Some((4, 4, 3)));
assert!(board.place_signpost(0, 3, 3, Up));
assert!(board.place_signpost(0, 5, 5, Up));
assert!(board.signpost_at(2, 2).is_none(), "the oldest was evicted");
assert!(
board.signpost_at(1, 1).is_some(),
"the re-pointed post survived"
);
assert_eq!(board.newest_signpost_of(0), Some((5, 5, 3)));
assert!(board.remove_signpost(1, 4, 4));
assert_eq!(board.newest_signpost_of(0), Some((5, 5, 3)));
assert_eq!(board.newest_signpost_of(1), None);
}