use crate::sim::board::{
Board, GULL_CAP, MAX_PLAYERS, PlayerId, Spawner, TICKS_PER_SECOND, TileKind,
VERSUS_GOLDEN_EVERY,
};
use crate::sim::crab::{CrabKind, Handedness};
use crate::sim::direction::Direction;
use crate::sim::rng::Pcg32;
const CLASSIC_AREA: u32 = 12 * 9;
pub fn set_versus_rules(board: &mut Board, period: u32) {
let area = u32::from(board.width()) * u32::from(board.height());
let area = area.max(CLASSIC_AREA);
board.set_gull_period((period * CLASSIC_AREA / area).max(1));
board.set_gull_cap((GULL_CAP as u32 * area / CLASSIC_AREA).min(u32::from(u8::MAX)) as u8);
board.set_gull_turnover(true);
board.set_golden_every(VERSUS_GOLDEN_EVERY);
board.set_timed_lock(true);
board.set_sweep_flight(true);
}
pub fn castle_spots(width: u8, height: u8) -> [(u8, u8); MAX_PLAYERS] {
let mid = width.saturating_sub(1) / 2;
let (far_x, far_y) = (width.saturating_sub(2), height.saturating_sub(2));
[
(1, 1),
(far_x, far_y),
(far_x, 1),
(1, far_y),
(mid, 1),
(width.saturating_sub(1 + mid), far_y),
]
}
fn seat_spots(seed: u64, seats: u8) -> [usize; MAX_PLAYERS] {
let mut rng = Pcg32::new(seed, 0x0c_a571);
let pairs = (seats.clamp(2, MAX_PLAYERS as u8) as usize).div_ceil(2);
let mut order: Vec<usize> = (0..pairs).collect();
for i in (1..pairs).rev() {
let j = (rng.next_u32() as usize) % (i + 1);
order.swap(i, j);
}
let mut spots = [0usize; MAX_PLAYERS];
for (seat_pair, &spot_pair) in order.iter().enumerate() {
let flip = usize::from(rng.next_u32().is_multiple_of(2));
spots[seat_pair * 2] = spot_pair * 2 + flip;
spots[seat_pair * 2 + 1] = spot_pair * 2 + (1 - flip);
}
for pair in pairs..MAX_PLAYERS / 2 {
spots[pair * 2] = pair * 2;
spots[pair * 2 + 1] = pair * 2 + 1;
}
spots
}
fn seats_the_long_edges_evenly(width: u8) -> bool {
!width.is_multiple_of(2)
}
fn side_spawners(width: u8, height: u8) -> [(u8, u8, Direction); 2] {
[
(0, height / 2, Direction::Right),
(width - 1, height / 2, Direction::Left),
]
}
fn end_spawners(width: u8, height: u8) -> [(u8, u8, Direction); 4] {
let col = width / 4;
[
(col, 0, Direction::Down),
(width - 1 - col, 0, Direction::Down),
(col, height - 1, Direction::Up),
(width - 1 - col, height - 1, Direction::Up),
]
}
fn quad(width: u8, height: u8, x: u8, y: u8) -> [(u8, u8); 4] {
[
(x, y),
(width - 1 - x, y),
(x, height - 1 - y),
(width - 1 - x, height - 1 - y),
]
}
const QUAD_FLIPS: [(bool, bool); 4] = [(false, false), (true, false), (false, true), (true, true)];
fn mirror_dir(dir: Direction, flip_x: bool, flip_y: bool) -> Direction {
let horizontal = matches!(dir, Direction::Left | Direction::Right);
if (flip_x && horizontal) || (flip_y && !horizontal) {
dir.reverse()
} else {
dir
}
}
pub(crate) fn mirrored_tile(kind: TileKind, flip_x: bool, flip_y: bool) -> TileKind {
let flipped = flip_x ^ flip_y; match kind {
TileKind::Spawner(spawner) => TileKind::Spawner(Spawner {
dir: mirror_dir(spawner.dir, flip_x, flip_y),
period: spawner.period,
}),
TileKind::Turnstile { next_right } => TileKind::Turnstile {
next_right: next_right ^ flipped,
},
TileKind::Empty
| TileKind::Rock
| TileKind::Castle(_)
| TileKind::Kelp
| TileKind::Pool => kind,
}
}
fn place_quad(board: &mut Board, x: u8, y: u8, kind: TileKind) -> bool {
let spots = quad(board.width(), board.height(), x, y);
if spots
.iter()
.any(|&(qx, qy)| board.tile_at(qx, qy) != TileKind::Empty)
{
return false;
}
for (&(qx, qy), (flip_x, flip_y)) in spots.iter().zip(QUAD_FLIPS) {
board.set_tile(qx, qy, mirrored_tile(kind, flip_x, flip_y));
}
true
}
fn wall_quad(width: u8, height: u8, x: u8, y: u8, dir: Direction) -> [(u8, u8); 4] {
let (mx, my) = match dir {
Direction::Down | Direction::Up => (width - 1 - x, height.saturating_sub(2) - y),
Direction::Left | Direction::Right => (width.saturating_sub(2) - x, height - 1 - y),
};
[(x, y), (mx, y), (x, my), (mx, my)]
}
fn place_wall_quad(board: &mut Board, x: u8, y: u8, dir: Direction) {
for (wx, wy) in wall_quad(board.width(), board.height(), x, y, dir) {
board.set_wall(wx, wy, dir, true);
}
}
pub fn classic_arena(preload_scores: bool, seats: u8) -> Board {
classic_arena_seeded(0x5EA51DE, preload_scores, seats)
}
pub fn classic_arena_seeded(seed: u64, preload_scores: bool, seats: u8) -> Board {
let mut board = Board::new(12, 9, seed);
for (seat, &(x, y)) in castle_spots(12, 9)
.iter()
.enumerate()
.take(seats.clamp(2, 4) as usize)
{
board.set_tile(x, y, TileKind::Castle(seat as PlayerId));
}
board.set_tile(
0,
4,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: 46,
}),
);
board.set_tile(
11,
4,
TileKind::Spawner(Spawner {
dir: Direction::Left,
period: 46,
}),
);
board.set_tile(5, 4, TileKind::Rock);
board.set_tile(6, 4, TileKind::Rock);
for (x, y, dir) in [
(2u8, 2u8, Direction::Down),
(3, 2, Direction::Down),
(9, 5, Direction::Down),
(8, 5, Direction::Down),
(5, 1, Direction::Right),
(5, 7, Direction::Right),
(8, 3, Direction::Right),
(2, 5, Direction::Right),
] {
board.set_wall(x, y, dir, true);
}
board.spawn_crab(3, 2, Direction::Right, Handedness::Left, CrabKind::Molting);
board.spawn_crab(8, 6, Direction::Left, Handedness::Left, CrabKind::Molting);
board.set_gull_period(240);
board.set_round_length(Some(3 * 60 * TICKS_PER_SECOND));
board.set_events_enabled(true);
if preload_scores {
board.set_score(0, 30);
board.set_score(1, 12);
}
board
}
const ROCK_AREA: u32 = 110;
const WALL_AREA: u32 = 120;
pub fn generate_arena(seed: u64, seats: u8, width: u8, height: u8) -> Board {
let (width, height) = (width.max(9), height.max(7));
let width = if seats >= 5 && !seats_the_long_edges_evenly(width) {
width + 1
} else {
width
};
let mut rng = Pcg32::new(seed, 0x0a_2e4a);
let mut board = Board::new(width, height, seed);
let spots = castle_spots(width, height);
for (seat, spot) in seat_spots(seed, seats)
.iter()
.enumerate()
.take(seats.clamp(2, MAX_PLAYERS as u8) as usize)
{
let (x, y) = spots[*spot];
board.set_tile(x, y, TileKind::Castle(seat as PlayerId));
}
let area = u32::from(width) * u32::from(height);
let roll = rng.next_u32() % 2;
let sides = side_spawners(width, height);
let ends = end_spawners(width, height);
let spots: Vec<(u8, u8, Direction)> = if area >= 240 {
sides.iter().chain(ends.iter()).copied().collect()
} else if roll == 0 {
sides.to_vec()
} else {
ends.to_vec()
};
let period = (34 + rng.next_u32() % 26) * spots.len() as u32 / 2;
for (x, y, dir) in spots {
board.set_tile(x, y, TileKind::Spawner(Spawner { dir, period }));
}
let groups = area / ROCK_AREA + 1 + rng.next_u32() % 2;
let (rock_w, rock_h) = (u32::from(width) - 6, u32::from(height) - 4);
let mut placed: Vec<(i32, i32)> = Vec::new();
let mut attempts = 0;
while (placed.len() as u32) < groups && attempts < 80 {
attempts += 1;
let x = 3 + (rng.next_u32() % rock_w) as i32;
let y = 2 + (rng.next_u32() % rock_h) as i32;
let images = quad(width, height, x as u8, y as u8);
if images.iter().any(|&(ix, iy)| {
placed
.iter()
.any(|&(px, py)| (px - i32::from(ix)).abs() + (py - i32::from(iy)).abs() <= 2)
}) {
continue;
}
if !place_quad(&mut board, x as u8, y as u8, TileKind::Rock) {
continue;
}
placed.extend(images.map(|(ix, iy)| (i32::from(ix), i32::from(iy))));
}
let wall_groups = 1 + area / WALL_AREA + rng.next_u32() % 2;
for _ in 0..wall_groups {
let x = 1 + (rng.next_u32() % u32::from(width - 3)) as u8;
let y = 1 + (rng.next_u32() % u32::from(height - 3)) as u8;
let dir = if rng.next_u32().is_multiple_of(2) {
Direction::Down
} else {
Direction::Right
};
place_wall_quad(&mut board, x, y, dir);
}
for kind in [TileKind::Pool, TileKind::Kelp] {
for _ in 0..8 {
let x = 2 + (rng.next_u32() % u32::from(width - 4)) as u8;
let y = 2 + (rng.next_u32() % u32::from(height - 4)) as u8;
if place_quad(&mut board, x, y, kind) {
break;
}
}
}
place_quad(
&mut board,
(width - 1) / 2 - 1,
(height - 1) / 2 - 1,
TileKind::Turnstile { next_right: true },
);
set_versus_rules(&mut board, 200 + rng.next_u32() % 80);
board.set_round_length(Some(3 * 60 * TICKS_PER_SECOND));
board.set_events_enabled(true);
board
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn castle_spots_never_subtract_past_zero() {
for (w, h) in [(1u8, 1u8), (2, 1), (1, 2), (3, 3), (4, 2)] {
let spots = castle_spots(w, h);
assert_eq!(spots.len(), MAX_PLAYERS);
for (x, y) in spots {
assert!(x <= w && y <= h, "({x},{y}) on a {w}x{h} board");
}
}
assert_eq!(
castle_spots(9, 7)[1],
(7, 5),
"and a real board is unchanged"
);
}
#[test]
fn classic_arena_seats_castles_in_join_order() {
for seats in 2..=4u8 {
let board = classic_arena(false, seats);
for seat in 0..4u8 {
let &(x, y) = &castle_spots(12, 9)[seat as usize];
let expect = if seat < seats {
TileKind::Castle(seat)
} else {
TileKind::Empty
};
assert_eq!(board.tile_at(x, y), expect, "{seats} seats, seat {seat}");
}
}
assert_eq!(
classic_arena(false, 2).state_hash(),
classic_arena(false, 2).state_hash()
);
assert_eq!(classic_arena(false, 2).scores()[0], 0);
assert_eq!(classic_arena(true, 2).scores()[0], 30);
}
#[test]
fn six_castles_mirror_both_ways() {
for &(w, h) in &[(9u8, 7u8), (12, 9), (16, 11), (20, 13)] {
let board = generate_arena(7, 6, w, h);
let (w, h) = (board.width(), board.height());
assert!(!w.is_multiple_of(2), "{w}x{h} has a centre column");
let spots = castle_spots(w, h);
let set: Vec<(u8, u8)> = spots.to_vec();
for &(x, y) in &spots {
assert!(set.contains(&(w - 1 - x, y)), "({x},{y}) flipped across");
assert!(set.contains(&(x, h - 1 - y)), "({x},{y}) flipped down");
}
for pair in 0..3usize {
let (x0, y0) = spots[pair * 2];
assert_eq!((w - 1 - x0, h - 1 - y0), spots[pair * 2 + 1], "{pair}");
}
assert_eq!(spots[4].0, spots[5].0, "{w}x{h} edge castles centred");
}
}
#[test]
fn only_a_beach_bigger_than_the_classic_one_takes_more_gulls() {
for (w, h) in [(9u8, 7u8), (12, 9)] {
let mut board = Board::new(w, h, 1);
set_versus_rules(&mut board, 240);
assert_eq!(board.gull_period(), 240, "{w}x{h}");
assert_eq!(usize::from(board.gull_cap()), GULL_CAP, "{w}x{h}");
}
let mut xl = Board::new(20, 13, 1);
set_versus_rules(&mut xl, 240);
assert_eq!(xl.gull_period(), 240 * 108 / 260);
assert_eq!(xl.gull_cap(), 14);
}
#[test]
fn every_castle_can_be_walked_to_from_a_spawner() {
for &(w, h) in &[(9u8, 7u8), (12, 9), (16, 11), (20, 13)] {
for seats in [2u8, 4, 6] {
for seed in 0..60u64 {
let board = generate_arena(seed, seats, w, h);
let (bw, bh) = (board.width(), board.height());
let index = |x: u8, y: u8| usize::from(y) * usize::from(bw) + usize::from(x);
let mut seen = vec![false; usize::from(bw) * usize::from(bh)];
let mut queue: Vec<(u8, u8)> = board
.tiles()
.filter(|(_, _, kind)| matches!(kind, TileKind::Spawner(_)))
.map(|(x, y, _)| (x, y))
.collect();
for &(x, y) in &queue {
seen[index(x, y)] = true;
}
while let Some((x, y)) = queue.pop() {
for dir in Direction::ALL {
if board.wall_at(x, y, dir) {
continue;
}
let (dx, dy) = dir.offset();
let (nx, ny) = (i32::from(x) + dx, i32::from(y) + dy);
if nx < 0 || ny < 0 || nx >= i32::from(bw) || ny >= i32::from(bh) {
continue;
}
let (nx, ny) = (nx as u8, ny as u8);
if seen[index(nx, ny)] || board.tile_at(nx, ny) == TileKind::Rock {
continue;
}
seen[index(nx, ny)] = true;
queue.push((nx, ny));
}
}
for player in board.castle_owners() {
let (x, y) = board.castle_of(player).expect("a castle");
assert!(
seen[index(x, y)],
"{w}x{h}, {seats} seats, seed {seed}: castle {player} walled off"
);
}
}
}
}
}
#[test]
fn same_seed_same_arena() {
let a = generate_arena(1234, 4, 12, 9);
let b = generate_arena(1234, 4, 12, 9);
assert_eq!(a.state_hash(), b.state_hash());
}
#[test]
fn different_seeds_differ() {
assert_ne!(
generate_arena(1, 4, 12, 9).state_hash(),
generate_arena(2, 4, 12, 9).state_hash()
);
}
#[test]
fn every_size_generates_sane_arenas() {
for &(w, h) in &[(9u8, 7u8), (12, 9), (16, 11), (20, 13)] {
for seed in 0..10u64 {
let board = generate_arena(seed, 4, w, h);
assert_eq!(board.width(), w);
assert_eq!(board.height(), h);
let mut castles = 0;
for y in 0..h {
for x in 0..w {
if let TileKind::Castle(_) = board.tile_at(x, y) {
castles += 1;
}
}
}
assert_eq!(castles, 4, "{w}x{h} seed {seed}");
}
}
}
#[test]
fn arenas_have_the_requested_castles_and_some_spawners() {
for seed in 0..20u64 {
let board = generate_arena(seed, 4, 12, 9);
let mut castles = 0;
let mut spawners = 0;
for y in 0..board.height() {
for x in 0..board.width() {
match board.tile_at(x, y) {
TileKind::Castle(_) => castles += 1,
TileKind::Spawner(_) => spawners += 1,
TileKind::Empty
| TileKind::Rock
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => {}
}
}
}
assert_eq!(castles, 4, "seed {seed}");
assert!(spawners >= 2, "seed {seed}");
assert!(board.remaining_ticks().is_some());
}
}
#[test]
fn every_seat_pair_still_sits_on_opposite_castles() {
for seed in 0..200u64 {
for seats in 2..=MAX_PLAYERS as u8 {
let board = generate_arena(seed, seats, 12, 9);
let (w, h) = (board.width(), board.height());
for pair in 0..usize::from(seats) / 2 {
let a = board
.castle_of(pair as u8 * 2)
.expect("a seat in the count has a castle");
let b = board
.castle_of(pair as u8 * 2 + 1)
.expect("and so does its partner");
assert_eq!(
(w - 1 - a.0, h - 1 - a.1),
b,
"seed {seed}, {seats} seats, pair {pair}"
);
}
}
}
}
#[test]
fn a_small_table_never_lands_on_a_long_edge() {
for seed in 0..200u64 {
for seats in 2..=4u8 {
let board = generate_arena(seed, seats, 12, 9);
let (w, h) = (board.width(), board.height());
let corners = [(1, 1), (w - 2, h - 2), (w - 2, 1), (1, h - 2)];
for seat in 0..seats {
let spot = board.castle_of(seat).expect("a castle");
assert!(
corners.contains(&spot),
"seed {seed}, {seats} seats: seat {seat} at {spot:?} is not a corner"
);
}
}
}
}
#[test]
fn the_edge_castles_do_not_always_fall_to_the_same_seats() {
let mut edge = 0;
let seeds = 300u64;
for seed in 0..seeds {
let board = generate_arena(seed, 6, 20, 13);
let (x, y) = board.castle_of(0).expect("the host has a castle");
let (w, h) = (board.width(), board.height());
let corner = (x == 1 || x == w - 2) && (y == 1 || y == h - 2);
if !corner {
edge += 1;
}
}
assert!(
(seeds / 6..seeds / 2).contains(&edge),
"seat zero took an edge castle {edge} times in {seeds}"
);
}
#[test]
fn generated_arenas_mirror_both_ways() {
for &(w, h) in &[(9u8, 7u8), (12, 9), (16, 11), (20, 13)] {
for seed in 0..25u64 {
let board = generate_arena(seed, 4, w, h);
for y in 0..h {
for x in 0..w {
let here = board.tile_at(x, y);
let same = |other: TileKind, flip_x: bool, flip_y: bool| match (
mirrored_tile(here, flip_x, flip_y),
other,
) {
(TileKind::Castle(_), TileKind::Castle(_)) => true,
(a, b) => a == b,
};
assert!(
same(board.tile_at(w - 1 - x, y), true, false),
"{w}x{h} seed {seed}: ({x},{y}) breaks the left-right mirror"
);
assert!(
same(board.tile_at(x, h - 1 - y), false, true),
"{w}x{h} seed {seed}: ({x},{y}) breaks the top-bottom mirror"
);
assert_eq!(
board.wall_at(x, y, Direction::Down),
board.wall_at(w - 1 - x, y, Direction::Down),
"{w}x{h} seed {seed}: wall under ({x},{y})"
);
assert_eq!(
board.wall_at(x, y, Direction::Right),
board.wall_at(x, h - 1 - y, Direction::Right),
"{w}x{h} seed {seed}: wall right of ({x},{y})"
);
if y + 2 <= h {
assert_eq!(
board.wall_at(x, y, Direction::Down),
board.wall_at(x, h - 2 - y, Direction::Down),
"{w}x{h} seed {seed}: wall under ({x},{y}), flipped"
);
}
if x + 2 <= w {
assert_eq!(
board.wall_at(x, y, Direction::Right),
board.wall_at(w - 2 - x, y, Direction::Right),
"{w}x{h} seed {seed}: wall right of ({x},{y}), flipped"
);
}
}
}
let holes: Vec<(u8, u8, Spawner)> = (0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.filter_map(|(x, y)| match board.tile_at(x, y) {
TileKind::Spawner(s) => Some((x, y, s)),
TileKind::Empty
| TileKind::Rock
| TileKind::Castle(_)
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => None,
})
.collect();
assert!(holes.len() >= 2, "{w}x{h} seed {seed}: too few spawners");
for &(x, y, hole) in &holes {
let twin = |flip_x: bool, flip_y: bool| Spawner {
dir: mirror_dir(hole.dir, flip_x, flip_y),
period: hole.period,
};
assert!(
holes.contains(&(w - 1 - x, y, twin(true, false)))
&& holes.contains(&(x, h - 1 - y, twin(false, true))),
"{w}x{h} seed {seed}: spawner ({x},{y}) has no mirror twin"
);
}
}
}
}
#[test]
fn generated_arenas_are_alive_with_bots() {
use crate::sim::{BotLevel, MAX_PLAYERS, PlayerAction, bot_action};
let mut board = generate_arena(777, 4, 12, 9);
for _ in 0..4000 {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
for seat in 0..4u8 {
actions[seat as usize] = bot_action(&board, seat, BotLevel::Normal);
}
board.tick(&actions);
}
assert!(
board.crabs_spawned() > 40,
"spawners ran ({} spawned)",
board.crabs_spawned()
);
assert!(
board.crabs_banked() > 0,
"bots routed crabs home (scores {:?})",
board.scores()
);
}
#[test]
fn an_open_ocean_arena_plays_a_round() {
use crate::sim::{BotLevel, MAX_PLAYERS, PlayerAction, bot_action};
for seed in 0..6u64 {
let mut board = generate_arena(seed, 4, 16, 11);
board.set_wrap(true);
for _ in 0..4000 {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
for seat in 0..4u8 {
actions[seat as usize] = bot_action(&board, seat, BotLevel::Normal);
}
board.tick(&actions);
}
assert!(board.crabs_spawned() > 40, "seed {seed} spawners ran");
assert!(
board.crabs_banked() > 0,
"seed {seed} bots routed crabs home across an open edge (scores {:?})",
board.scores()
);
let tiles = u16::from(board.width()) * u16::from(board.height());
assert!(board.gulls().iter().all(|g| g.tile < tiles), "seed {seed}");
}
}
#[test]
fn the_seat_draw_is_always_a_permutation_of_the_spots() {
for seats in 0..=(MAX_PLAYERS as u8 + 2) {
for seed in [0u64, 1, 7, 42, 0xDECAF, u64::MAX] {
let spots = seat_spots(seed, seats);
let mut sorted = spots;
sorted.sort_unstable();
assert_eq!(
sorted,
std::array::from_fn::<usize, MAX_PLAYERS, _>(|i| i),
"{seats} seats on seed {seed} drew {spots:?}"
);
}
}
}
#[test]
fn a_seed_draws_the_same_seats_every_time_and_a_different_one_does_not() {
for seats in 2..=MAX_PLAYERS as u8 {
assert_eq!(
seat_spots(0xC0FFEE, seats),
seat_spots(0xC0FFEE, seats),
"{seats} seats moved between two draws of one seed"
);
}
let drawn: std::collections::HashSet<_> =
(0..64u64).map(|seed| seat_spots(seed, 6)).collect();
assert!(drawn.len() > 1, "every seed drew the same six seats");
}
#[test]
fn a_mirror_group_is_four_places_on_the_board() {
for (w, h) in [(9u8, 7u8), (12, 9), (21, 13), (2, 2)] {
for y in 0..h {
for x in 0..w {
let four = quad(w, h, x, y);
assert!(
four.iter().all(|&(qx, qy)| qx < w && qy < h),
"{w}x{h} at ({x},{y}) left the board: {four:?}"
);
assert!(
four.contains(&(x, y)),
"the spot itself is in its own group"
);
let mut a = four;
let mut b = quad(w, h, four[3].0, four[3].1);
a.sort_unstable();
b.sort_unstable();
assert_eq!(a, b, "{w}x{h} at ({x},{y}) mirrors to another group");
}
}
}
}
}