diff --git a/rules/src/lib.rs b/rules/src/lib.rs index d423130..ccd4173 100644 --- a/rules/src/lib.rs +++ b/rules/src/lib.rs @@ -1,6 +1,7 @@ pub mod board; pub mod food; pub mod royale; +pub mod snail; pub mod standard; pub mod types; diff --git a/rules/src/snail.rs b/rules/src/snail.rs new file mode 100644 index 0000000..9fb87a1 --- /dev/null +++ b/rules/src/snail.rs @@ -0,0 +1,793 @@ +//! Snail Mode: snakes leave a decaying trail of stacked hazards behind +//! their tail as they move. +//! +//! Ported from the canonical Go implementation +//! (`BattlesnakeOfficial/rules`, `maps/snail_mode.go`, authored by coreyja +//! and jlafayette). Upstream this is a community *map* (`game.map = +//! "snail_mode"`) running on the standard ruleset, not a ruleset of its own; +//! the arena engine has no map layer, so it is wired up as a mode alongside +//! royale but keeps the map's exact semantics. +//! +//! # Mechanic +//! +//! After the standard pipeline resolves each turn: +//! 1. **Decay**: every on-board hazard stack loses one entry (hazard +//! stacking = repeated [`Point`]s in `board.hazards`; +//! [`crate::standard::damage_hazards`] applies damage once per entry, so a +//! stack of N deals N x `hazard_damage_per_turn`). +//! 2. **Store**: each live snake's tail square is recorded for *next* turn +//! with a stack count equal to the snake's length -- unless the tail is +//! doubled (tail == sub-tail, i.e. the snake just ate or hasn't unstacked +//! from spawn), in which case the tail isn't vacating and no trail is +//! recorded. Eliminated snakes record nothing. +//! 3. **Restore**: the *previous* turn's recorded tails become on-board +//! hazard stacks -- except squares currently occupied by a live snake's +//! head, which are skipped entirely (Go does this for board-viewer +//! clarity; the skipped stack is dropped, not deferred). +//! +//! The net effect: one turn after a snake vacates a square, that square gets +//! a hazard stack equal to the snake's length, which then fades by one per +//! turn. Food on a hazard square negates that square's damage (see +//! `damage_hazards`), and food spawning is unchanged from standard. +//! +//! # State representation +//! +//! Pending tails must survive from one turn to the next. Like the Go map, +//! this port stores them as *out-of-bounds* points inside `board.hazards` +//! (`y + board.height`, guaranteed off-board since on-board `y < +//! height`). This keeps `BoardState` fully self-describing -- no side +//! channel to thread through the engine's game loop -- and matches Go +//! behaviour exactly, including during the damage phase (pending points are +//! present in `hazards` while `damage_hazards` runs, but can only ever match +//! a head that is itself out of bounds and about to be eliminated, exactly +//! as in Go). +//! +//! Consumers that expose hazards to the outside world (snake /move payloads, +//! board-viewer frames) MUST filter to on-board points; see +//! [`BoardState::on_board_hazards`]. +//! +//! # Deviations from Go +//! +//! - Go's `doubleTail` indexes `body[len-2]` and would panic on a +//! single-segment snake; this port treats bodies shorter than 2 as doubled +//! (no trail). Unreachable in real games (snakes spawn at length 3). +//! - Go's map API rebuilds hazards through an editor with nondeterministic +//! map iteration order for the decay phase; this port keeps first-seen +//! order so results are deterministic. Hazard *multisets* are identical. + +use std::collections::HashMap; + +use crate::standard; +use crate::types::*; + +/// Convert an on-board tail square into its off-board storage point. +fn store_tail_location(point: Point, height: i32) -> Point { + Point::new(point.x, point.y + height) +} + +/// Convert an off-board storage point back to the on-board tail square. +fn restore_tail_location(point: Point, height: i32) -> Point { + Point::new(point.x, point.y - height) +} + +/// Is the point outside the playable board? +fn out_of_bounds(p: Point, width: i32, height: i32) -> bool { + p.x < 0 || p.y < 0 || p.x >= width || p.y >= height +} + +/// Does the snake currently have a doubled (stacked) tail? +/// +/// True when tail == sub-tail: the snake just ate (or hasn't unstacked from +/// its spawn point), so the tail square is not being vacated this turn. +/// Bodies shorter than 2 segments are treated as doubled (see module docs). +fn double_tail(snake: &Snake) -> bool { + let len = snake.body.len(); + if len < 2 { + return true; + } + snake.body[len - 1] == snake.body[len - 2] +} + +/// Run the Snail Mode post-turn hazard update (decay / store / restore). +/// +/// Faithful port of Go `SnailModeMap.PostUpdateBoard` (minus food spawning, +/// which the arena engine handles separately, same as every other mode): +/// +/// 1. Split the previous turn's `board.hazards` into on-board stacks and +/// off-board pending tails. +/// 2. Re-add each on-board stack with count - 1 (decay). +/// 3. Store each live, non-double-tail snake's tail off-board with count = +/// snake length (applied next turn). +/// 4. Restore the pending tails on-board at full stack, skipping (dropping) +/// any square occupied by a live snake's head. +pub fn post_update_board(board: &mut BoardState) { + let width = board.width; + let height = board.height; + + // Split last turn's hazards: on-board stacks (counted, first-seen order) + // vs off-board pending tails (converted back to their on-board squares). + let mut pending_tails: Vec = Vec::new(); + let mut stack_order: Vec = Vec::new(); + let mut stack_counts: HashMap = HashMap::new(); + for &hazard in &board.hazards { + if out_of_bounds(hazard, width, height) { + pending_tails.push(restore_tail_location(hazard, height)); + } else { + let count = stack_counts.entry(hazard).or_insert(0); + if *count == 0 { + stack_order.push(hazard); + } + *count += 1; + } + } + + let mut new_hazards: Vec = Vec::new(); + + // Decay: re-add existing on-board stacks with one entry fewer, so + // trails fade as snakes move away. + for point in stack_order { + let count = stack_counts[&point]; + for _ in 0..count - 1 { + new_hazards.push(point); + } + } + + // Store: record each live snake's tail off-board for next turn, stacked + // to the snake's current length. + for snake in &board.snakes { + if snake.eliminated_cause.is_eliminated() { + continue; + } + if double_tail(snake) { + continue; + } + + let tail = snake.body[snake.body.len() - 1]; + let off_board_tail = store_tail_location(tail, height); + for _ in 0..snake.body.len() { + new_hazards.push(off_board_tail); + } + } + + // Restore: last turn's pending tails become on-board stacks, unless a + // live snake's head sits on the square (dropped, matching Go). + for point in pending_tails { + let is_head = board + .snakes + .iter() + .filter(|s| !s.eliminated_cause.is_eliminated()) + .any(|s| s.head() == point); + if !is_head { + new_hazards.push(point); + } + } + + board.hazards = new_hazards; +} + +/// Execute one turn of the Snail Mode pipeline. +/// +/// Returns `true` if the game was already over BEFORE processing (early +/// exit), mirroring [`standard::execute_turn`]. +/// +/// Pipeline order (standard stages, then the snail post-update -- the same +/// position Go's `PostUpdateBoard` runs in): +/// 1. `is_game_over` check +/// 2. `move_snakes` +/// 3. `reduce_snake_health` +/// 4. `damage_hazards` (damages snakes on trail squares laid in PREVIOUS turns) +/// 5. `feed_snakes` +/// 6. `eliminate_snakes` +/// 7. `post_update_board` (decay / store / restore for NEXT turn) +/// 8. `board.turn += 1` +/// +/// NOTE: food spawning is NOT in this pipeline -- caller invokes it after, +/// same as with `standard::execute_turn`. Food on a trail square negates +/// that square's hazard damage entirely (handled in `damage_hazards`). +pub fn execute_turn( + board: &mut BoardState, + moves: &[SnakeMove], + settings: &StandardSettings, +) -> Result { + if standard::is_game_over(board) { + return Ok(true); + } + + standard::move_snakes(board, moves)?; + standard::reduce_snake_health(board); + standard::damage_hazards(board, settings); + standard::feed_snakes(board); + standard::eliminate_snakes(board)?; + post_update_board(board); + + board.turn += 1; + + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::board::eliminate_snake; + use crate::test_utils::{make_board, make_snake}; + use proptest::prelude::*; + + const H: i32 = 11; // board height used throughout + + /// Multiset of the board's ON-board hazards, as point -> stack count. + fn on_board_counts(board: &BoardState) -> HashMap { + let mut counts = HashMap::new(); + for h in board.on_board_hazards() { + *counts.entry(*h).or_insert(0) += 1; + } + counts + } + + /// Multiset of the board's OFF-board (pending tail) hazard entries. + fn off_board_counts(board: &BoardState) -> HashMap { + let mut counts = HashMap::new(); + for &h in &board.hazards { + if out_of_bounds(h, board.width, board.height) { + *counts.entry(h).or_insert(0) += 1; + } + } + counts + } + + fn counts_of(points: &[(i32, i32, i32)]) -> HashMap { + points + .iter() + .map(|&(x, y, n)| (Point::new(x, y), n)) + .collect() + } + + fn up(id: &str) -> SnakeMove { + SnakeMove { + id: id.to_string(), + direction: Direction::Up, + } + } + + /// Run the standard stages + snail post-update the way the engine's + /// `apply_turn` does (no game-over early exit, explicit turn increment), + /// so single-snake fixtures can be driven without a bystander. + fn run_stages(board: &mut BoardState, moves: &[SnakeMove], settings: &StandardSettings) { + standard::move_snakes(board, moves).unwrap(); + standard::reduce_snake_health(board); + standard::damage_hazards(board, settings); + standard::feed_snakes(board); + standard::eliminate_snakes(board).unwrap(); + post_update_board(board); + board.turn += 1; + } + + // === Unit tests === + + #[test] + fn trail_appears_one_turn_after_vacating_with_stack_equal_to_length() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + + // Turn 1: body becomes [(5,6),(5,5),(5,4)]. Nothing visible yet -- + // the current tail square (5,4) is only recorded (off-board) for + // next turn. + run_stages(&mut board, &[up("one")], &settings); + assert!(on_board_counts(&board).is_empty()); + assert_eq!(off_board_counts(&board), counts_of(&[(5, 4 + H, 3)])); + + // Turn 2: the snake vacates (5,4) and the record laid last turn + // lands there as an on-board stack of 3 (= snake length). + run_stages(&mut board, &[up("one")], &settings); + assert_eq!(on_board_counts(&board), counts_of(&[(5, 4, 3)])); + assert_eq!(off_board_counts(&board), counts_of(&[(5, 5 + H, 3)])); + } + + #[test] + fn trail_decays_by_exactly_one_per_turn_until_gone() { + // Seed an on-board stack of 3 with no live snakes: pure decay. + let mut board = make_board(11, H, vec![]); + board.hazards = vec![Point::new(2, 2); 3]; + + post_update_board(&mut board); + assert_eq!(on_board_counts(&board), counts_of(&[(2, 2, 2)])); + + post_update_board(&mut board); + assert_eq!(on_board_counts(&board), counts_of(&[(2, 2, 1)])); + + post_update_board(&mut board); + assert!(board.hazards.is_empty()); + + // Stays gone. + post_update_board(&mut board); + assert!(board.hazards.is_empty()); + } + + #[test] + fn double_tail_spawns_no_trail() { + // Tail == sub-tail (just ate / just spawned): tail isn't vacating. + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3), (5, 3)], 100)], + ); + post_update_board(&mut board); + assert!( + board.hazards.is_empty(), + "double-tailed snake must not record a trail" + ); + + // Spawn-stacked snake (3 copies of one point) is also doubled. + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 5), (5, 5)], 100)], + ); + post_update_board(&mut board); + assert!(board.hazards.is_empty()); + } + + #[test] + fn short_bodies_do_not_panic_and_leave_no_trail() { + // Go would panic indexing body[len-2]; we treat these as doubled. + let mut board = make_board(11, H, vec![make_snake("one", &[(5, 5)], 100)]); + post_update_board(&mut board); + assert!(board.hazards.is_empty()); + } + + #[test] + fn eliminated_snakes_leave_no_new_trail() { + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + eliminate_snake(&mut board.snakes[0], EliminationCause::OutOfHealth, "", 1); + post_update_board(&mut board); + assert!(board.hazards.is_empty()); + } + + #[test] + fn eliminated_snake_existing_pending_tail_still_restores() { + // A trail recorded while alive still lands after the snake dies: + // only NEW records are gated on being alive. + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + board.hazards = vec![Point::new(5, 2 + H); 3]; + eliminate_snake(&mut board.snakes[0], EliminationCause::OutOfHealth, "", 1); + + post_update_board(&mut board); + assert_eq!(on_board_counts(&board), counts_of(&[(5, 2, 3)])); + } + + #[test] + fn head_occupied_square_skips_placement_and_stack_is_dropped() { + // Pending tail at (5,5); a live snake's head sits there. + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 5), (5, 5)], 100)], + ); + board.hazards = vec![Point::new(5, 5 + H); 3]; + + post_update_board(&mut board); + assert!( + on_board_counts(&board).is_empty(), + "no hazard may be placed under a live head" + ); + + // The stack is dropped, not deferred: nothing appears later either. + // Move the snake away and update again. + board.snakes[0].body = vec![Point::new(7, 7), Point::new(7, 7), Point::new(7, 7)]; + post_update_board(&mut board); + assert!(on_board_counts(&board).is_empty()); + } + + #[test] + fn eliminated_snake_head_does_not_block_placement() { + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 5), (5, 5)], 100)], + ); + board.hazards = vec![Point::new(5, 5 + H); 2]; + eliminate_snake(&mut board.snakes[0], EliminationCause::OutOfHealth, "", 1); + + post_update_board(&mut board); + assert_eq!(on_board_counts(&board), counts_of(&[(5, 5, 2)])); + } + + #[test] + fn multiple_snakes_trails_stack_independently() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![ + make_snake("one", &[(2, 5), (2, 4), (2, 3)], 100), + make_snake("two", &[(8, 5), (8, 4), (8, 3), (8, 2)], 100), + ], + ); + + run_stages(&mut board, &[up("one"), up("two")], &settings); + run_stages(&mut board, &[up("one"), up("two")], &settings); + + // Each snake's first vacated square carries its own length. + assert_eq!(on_board_counts(&board), counts_of(&[(2, 4, 3), (8, 3, 4)]),); + } + + #[test] + fn trail_damage_applies_per_stack_entry() { + // A fresh length-3 trail square deals 3 x hazard_damage on entry. + let settings = StandardSettings::default(); // hazard damage 14 + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + board.hazards = vec![Point::new(5, 6); 3]; + + run_stages(&mut board, &[up("one")], &settings); + + // 1 (starvation) + 3 x 14 (stacked hazard) = 43 total damage. + assert_eq!(board.snakes[0].health, 100 - 1 - 3 * 14); + assert!(!board.snakes[0].eliminated_cause.is_eliminated()); + } + + #[test] + fn lethal_stacked_trail_eliminates_with_hazard_cause() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 40)], + ); + board.hazards = vec![Point::new(5, 6); 3]; // 42 damage > 39 remaining + + run_stages(&mut board, &[up("one")], &settings); + assert_eq!(board.snakes[0].eliminated_cause, EliminationCause::Hazard); + } + + #[test] + fn food_on_trail_square_negates_hazard_damage() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 50)], + ); + board.hazards = vec![Point::new(5, 6); 3]; + board.food.push(Point::new(5, 6)); + + run_stages(&mut board, &[up("one")], &settings); + + // No hazard damage; the snake eats and restores to full. + assert_eq!(board.snakes[0].health, SNAKE_MAX_HEALTH); + assert_eq!(board.snakes[0].body.len(), 4); + assert!(!board.food.contains(&Point::new(5, 6))); + } + + #[test] + fn snake_that_ate_skips_one_trail_then_resumes_with_new_length() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + board.food.push(Point::new(5, 6)); + + // Turn 1: eats at (5,6) -> tail duplicated at (5,4). The doubled + // tail isn't vacating next turn, so no trail is recorded this turn. + run_stages(&mut board, &[up("one")], &settings); + assert!(board.hazards.is_empty(), "no trail on the eating turn"); + assert_eq!(board.snakes[0].body.len(), 4); + + // Turn 2: tail unstacks; (5,4) is vacated and recorded at the NEW + // length (4). + run_stages(&mut board, &[up("one")], &settings); + assert_eq!(off_board_counts(&board), counts_of(&[(5, 4 + H, 4)])); + + // Turn 3: stack of 4 lands on-board. + run_stages(&mut board, &[up("one")], &settings); + assert_eq!(on_board_counts(&board)[&Point::new(5, 4)], 4); + } + + #[test] + fn execute_turn_early_exits_when_game_over() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + board.hazards = vec![Point::new(2, 2); 3]; + + let over = execute_turn(&mut board, &[up("one")], &settings).unwrap(); + assert!(over); + assert_eq!(board.turn, 0, "turn must not advance after game over"); + assert_eq!( + board.hazards, + vec![Point::new(2, 2); 3], + "hazards must not decay after game over" + ); + } + + #[test] + fn execute_turn_runs_full_pipeline_and_increments_turn() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![ + make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100), + make_snake("two", &[(8, 5), (8, 4), (8, 3)], 100), + ], + ); + + let over = execute_turn(&mut board, &[up("one"), up("two")], &settings).unwrap(); + assert!(!over); + assert_eq!(board.turn, 1); + assert_eq!(board.snakes[0].health, 99); + assert_eq!( + off_board_counts(&board), + counts_of(&[(5, 4 + H, 3), (8, 4 + H, 3)]), + ); + } + + /// The worked 5-turn example: a single length-3 snake walking straight + /// up column x=5 from body [(5,5),(5,4),(5,3)]. Each vacated square + /// gains a stack of 3 one turn after vacating, then fades by 1 per turn. + #[test] + fn worked_five_turn_example() { + let settings = StandardSettings::default(); + let mut board = make_board( + 11, + H, + vec![make_snake("one", &[(5, 5), (5, 4), (5, 3)], 100)], + ); + + let expected_on_board: [&[(i32, i32, i32)]; 5] = [ + // After turn 1: tail (5,4) recorded off-board only. + &[], + // After turn 2: (5,4) lands with stack 3. + &[(5, 4, 3)], + // After turn 3: (5,4) decays to 2; (5,5) lands with 3. + &[(5, 4, 2), (5, 5, 3)], + // After turn 4: 1 / 2 / 3. + &[(5, 4, 1), (5, 5, 2), (5, 6, 3)], + // After turn 5: (5,4) fully gone; 1 / 2 / 3 behind the snake. + &[(5, 5, 1), (5, 6, 2), (5, 7, 3)], + ]; + + for (turn, expected) in expected_on_board.iter().enumerate() { + run_stages(&mut board, &[up("one")], &settings); + assert_eq!( + on_board_counts(&board), + counts_of(expected), + "on-board hazards after turn {}", + turn + 1 + ); + // The just-recorded tail is always off-board with stack 3. + let tail = board.snakes[0].body[2]; + assert_eq!( + off_board_counts(&board), + counts_of(&[(tail.x, tail.y + H, 3)]), + "pending tail after turn {}", + turn + 1 + ); + // The snake never touches its own trail: only starvation damage. + assert_eq!(board.snakes[0].health, 100 - (turn as i32 + 1)); + } + + assert_eq!(board.turn, 5); + } + + #[test] + fn on_board_hazards_view_filters_bookkeeping_points() { + let mut board = make_board(11, H, vec![]); + board.hazards = vec![ + Point::new(5, 5), + Point::new(5, 5), + Point::new(5, 5 + H), + Point::new(0, H), + Point::new(10, 2 * H - 1), + ]; + let visible: Vec = board.on_board_hazards().copied().collect(); + assert_eq!(visible, vec![Point::new(5, 5), Point::new(5, 5)]); + } + + // === Property tests === + + fn arb_point(width: i32, height: i32) -> impl Strategy { + (0..width, 0..height).prop_map(|(x, y)| Point::new(x, y)) + } + + /// Arbitrary snake for post-update properties: body points anywhere on + /// the board (connectivity is irrelevant to the hazard update), length + /// 1..=6, possibly eliminated, possibly double-tailed. + fn arb_snake(idx: usize, width: i32, height: i32) -> impl Strategy { + ( + proptest::collection::vec(arb_point(width, height), 1..=6), + any::(), + any::(), + ) + .prop_map(move |(mut body, eliminated, force_double_tail)| { + if force_double_tail && body.len() >= 2 { + let tail = body[body.len() - 1]; + let sub = body.len() - 2; + body[sub] = tail; + } + Snake { + id: format!("snake-{idx}"), + body, + health: 100, + eliminated_cause: if eliminated { + EliminationCause::OutOfHealth + } else { + EliminationCause::NotEliminated + }, + eliminated_by: String::new(), + eliminated_on_turn: 0, + } + }) + } + + /// Arbitrary snail-mode board mid-game: on-board hazard stacks plus + /// well-formed off-board pending tails (`y + height`). + fn arb_board() -> impl Strategy { + (3..=15i32, 3..=15i32).prop_flat_map(|(width, height)| { + let snakes: Vec<_> = (0..3).map(|i| arb_snake(i, width, height)).collect(); + ( + snakes, + proptest::collection::vec((arb_point(width, height), 1..=4i32), 0..=6), + proptest::collection::vec((arb_point(width, height), 1..=6i32), 0..=3), + Just(width), + Just(height), + ) + .prop_map(|(snakes, stacks, pending, width, height)| { + let mut hazards = Vec::new(); + for (p, n) in stacks { + for _ in 0..n { + hazards.push(p); + } + } + for (p, n) in pending { + for _ in 0..n { + hazards.push(store_tail_location(p, height)); + } + } + BoardState { + turn: 0, + width, + height, + food: Vec::new(), + snakes, + hazards, + } + }) + }) + } + + fn live_heads(board: &BoardState) -> Vec { + board + .snakes + .iter() + .filter(|s| !s.eliminated_cause.is_eliminated()) + .map(|s| s.head()) + .collect() + } + + proptest! { + /// Independent oracle for the whole update, computed per-square: + /// new on-board count = max(old - 1, 0) + restored pending (unless a + /// live head occupies the square, in which case the pending stack is + /// dropped and only the decayed part remains). + #[test] + fn prop_on_board_counts_match_decay_plus_restore(mut board in arb_board()) { + let old_on = on_board_counts(&board); + let old_pending: HashMap = { + let mut m = HashMap::new(); + for &h in &board.hazards { + if out_of_bounds(h, board.width, board.height) { + *m.entry(restore_tail_location(h, board.height)).or_insert(0) += 1; + } + } + m + }; + let heads = live_heads(&board); + + post_update_board(&mut board); + let new_on = on_board_counts(&board); + + let mut squares: std::collections::HashSet = old_on.keys().copied().collect(); + squares.extend(old_pending.keys().copied()); + squares.extend(new_on.keys().copied()); + + for p in squares { + let decayed = (old_on.get(&p).copied().unwrap_or(0) - 1).max(0); + let restored = if heads.contains(&p) { + 0 + } else { + old_pending.get(&p).copied().unwrap_or(0) + }; + prop_assert_eq!( + new_on.get(&p).copied().unwrap_or(0), + decayed + restored, + "square {:?}: decay+restore mismatch", p + ); + } + } + + /// Off-board entries after the update are exactly one stack per + /// live, non-double-tail snake, at (tail.x, tail.y + height), with + /// count = snake length. + #[test] + fn prop_off_board_entries_are_exactly_live_snake_tails(mut board in arb_board()) { + let mut expected: HashMap = HashMap::new(); + for snake in &board.snakes { + if snake.eliminated_cause.is_eliminated() || double_tail(snake) { + continue; + } + let tail = snake.body[snake.body.len() - 1]; + *expected + .entry(store_tail_location(tail, board.height)) + .or_insert(0) += snake.body.len() as i32; + } + + post_update_board(&mut board); + prop_assert_eq!(off_board_counts(&board), expected); + } + + /// A square with no pending tail strictly decays: repeated updates + /// with no live snakes empty the board in max-stack turns. + #[test] + fn prop_hazards_fully_decay_without_snakes(mut board in arb_board()) { + board.snakes.clear(); + // One update flushes pending tails on-board; after that the + // largest possible per-square stack is bounded by the generator + // (up to 6 on-board stack entries of 4 plus 3 pending entries of + // 6 on one square = 42), so 45 further updates must empty it. + post_update_board(&mut board); + for _ in 0..45 { + post_update_board(&mut board); + } + prop_assert!( + board.hazards.is_empty(), + "hazards must fully decay, got {:?}", board.hazards + ); + } + + /// The update is deterministic, including entry order. + #[test] + fn prop_deterministic(board in arb_board()) { + let mut a = board.clone(); + let mut b = board; + post_update_board(&mut a); + post_update_board(&mut b); + prop_assert_eq!(a.hazards, b.hazards); + } + + /// After the update, no hazard entry sits in the dead zone: every + /// entry is either on-board or a well-formed pending tail + /// (x on-board, height <= y < 2 * height). + #[test] + fn prop_all_entries_on_board_or_well_formed_pending(mut board in arb_board()) { + post_update_board(&mut board); + for &h in &board.hazards { + let on = !out_of_bounds(h, board.width, board.height); + let pending = h.x >= 0 + && h.x < board.width + && h.y >= board.height + && h.y < 2 * board.height; + prop_assert!(on || pending, "malformed hazard entry {:?}", h); + } + } + } +} diff --git a/rules/src/types.rs b/rules/src/types.rs index 04bab87..b4ebf77 100644 --- a/rules/src/types.rs +++ b/rules/src/types.rs @@ -64,6 +64,21 @@ pub struct BoardState { pub hazards: Vec, } +impl BoardState { + /// Hazard entries that are actually on the board. + /// + /// Snail Mode (see [`crate::snail`]) stores pending-trail bookkeeping as + /// out-of-bounds points inside `hazards` (Go map parity). Anything + /// user-facing -- snake /move payloads, board-viewer frames -- must use + /// this view instead of raw `hazards` so bookkeeping points never leak. + /// Duplicates (stacked hazards) are preserved. + pub fn on_board_hazards(&self) -> impl Iterator { + self.hazards + .iter() + .filter(|p| p.x >= 0 && p.x < self.width && p.y >= 0 && p.y < self.height) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { Up, diff --git a/server/src/engine/frame.rs b/server/src/engine/frame.rs index 3616bef..f3b3626 100644 --- a/server/src/engine/frame.rs +++ b/server/src/engine/frame.rs @@ -183,7 +183,13 @@ pub fn game_to_frame( }) .collect(), food: game.board.food.iter().map(|p| (*p).into()).collect(), - hazards: game.board.hazards.iter().map(|p| (*p).into()).collect(), + // Snail Mode stores pending-trail bookkeeping as off-board points + // inside `board.hazards`. Frames feed the board viewer (which should + // only show real, on-board hazards -- stacked duplicates included) + // and are never read back into engine state (`run_game` retries wipe + // partial turns and replay from turn 0), so bookkeeping points are + // excluded from persistence. + hazards: game.board.on_board_hazards().map(|p| (*p).into()).collect(), } } @@ -389,6 +395,35 @@ mod tests { assert_eq!(frame.hazards[0].y, 0); } + /// Snail Mode keeps pending-trail bookkeeping as off-board points in + /// `board.hazards`. Frames must only carry real on-board hazards -- + /// including stacked duplicates -- and never the bookkeeping points. + #[test] + fn test_game_to_frame_excludes_off_board_bookkeeping_hazards() { + let mut game = create_test_game(); + game.board.hazards = vec![ + Point::new(4, 4), + Point::new(4, 4), + Point::new(4, 4), + // Off-board pending tails (y + height) and other out-of-bounds. + Point::new(4, 15), + Point::new(4, 15), + Point::new(0, 11), + Point::new(-1, 5), + ]; + + let frame = game_to_frame(&game, &[], &[], &std::collections::HashMap::new()); + + assert_eq!( + frame.hazards.len(), + 3, + "only the stacked on-board hazards belong in the frame" + ); + for h in &frame.hazards { + assert_eq!((h.x, h.y), (4, 4)); + } + } + #[test] fn test_game_to_frame_snake_body_coords() { let game = create_test_game(); diff --git a/server/src/engine/mod.rs b/server/src/engine/mod.rs index c8fa953..e37ab33 100644 --- a/server/src/engine/mod.rs +++ b/server/src/engine/mod.rs @@ -83,8 +83,10 @@ pub fn create_initial_game( // Hazard damage: the arena has historically used 15 for standard games // (where it is inert -- standard boards never spawn hazards), and we keep - // that unchanged. Royale uses 14, the default in the canonical Go rules - // (`hazardDamagePerTurn` in BattlesnakeOfficial/rules cli). + // that unchanged. Royale and Snail Mode use 14, the default in the + // canonical Go rules (`hazardDamagePerTurn` in BattlesnakeOfficial/rules + // cli, which is what map-driven hazards like snail_mode ran with on + // play.battlesnake.com). let (ruleset_name, settings, royale) = match game_type { GameType::Royale => ( "royale", @@ -98,7 +100,19 @@ pub fn create_initial_game( seed: game_seed(game_id), }), ), - // Constrictor / Snail Mode / Other currently run standard rules. + // Internal name only: on the wire snakes see ruleset "standard" with + // game.map = "snail_mode", matching play.battlesnake.com where Snail + // Mode was a community map on the standard ruleset (see wire.rs). + GameType::SnailMode => ( + "snail_mode", + StandardSettings { + food_spawn_chance: 15, + minimum_food: 1, + hazard_damage_per_turn: 14, + }, + None, + ), + // Constrictor / Other currently run standard rules. _ => ( "standard", StandardSettings { @@ -175,11 +189,19 @@ pub fn run_game_with_random_moves(mut game: EngineGame) -> GameResult { .collect(); // Apply the turn - let _game_over = match &game.meta.royale { - Some(royale) => { + let _game_over = match game.meta.ruleset_name.as_str() { + "royale" => { + let royale = game + .meta + .royale + .as_ref() + .expect("royale games carry RoyaleSettings"); rules::royale::execute_turn(&mut game.board, &moves, &game.meta.settings, royale) } - None => rules::standard::execute_turn(&mut game.board, &moves, &game.meta.settings), + "snail_mode" => { + rules::snail::execute_turn(&mut game.board, &moves, &game.meta.settings) + } + _ => rules::standard::execute_turn(&mut game.board, &moves, &game.meta.settings), } .expect("execute_turn failed"); @@ -240,13 +262,23 @@ pub fn apply_turn(game: &mut EngineGame, moves: &[(String, Direction)]) { rules::standard::feed_snakes(&mut game.board); let _ = rules::standard::eliminate_snakes(&mut game.board); - // Royale: spawn/grow hazards for the next turn (matches Go's - // StageSpawnHazardsShrinkMap, which runs after elimination). Uses - // `board.turn + 1` internally, so this must run before the caller - // increments `board.turn`. - if let Some(royale) = &game.meta.royale { - rules::royale::populate_hazards(&mut game.board, royale) - .expect("royale hazard population failed: invalid shrink frequency"); + // Mode-specific post-turn hazard updates. + match game.meta.ruleset_name.as_str() { + // Royale: spawn/grow hazards for the next turn (matches Go's + // StageSpawnHazardsShrinkMap, which runs after elimination). Uses + // `board.turn + 1` internally, so this must run before the caller + // increments `board.turn`. + "royale" => { + if let Some(royale) = &game.meta.royale { + rules::royale::populate_hazards(&mut game.board, royale) + .expect("royale hazard population failed: invalid shrink frequency"); + } + } + // Snail Mode: decay hazard trails and record vacated tail squares + // (matches Go's SnailModeMap.PostUpdateBoard, which runs after all + // ruleset stages). + "snail_mode" => rules::snail::post_update_board(&mut game.board), + _ => {} } } @@ -1530,4 +1562,128 @@ mod tests { assert_eq!(run(game_id), run(game_id)); } + + // === Snail Mode wiring tests === + + #[test] + fn test_create_initial_game_snail_mode_settings() { + let battlesnakes = make_battlesnake_details(2); + let game = create_initial_game( + Uuid::new_v4(), + GameBoardSize::Medium, + GameType::SnailMode, + &battlesnakes, + ); + + assert_eq!(game.meta.ruleset_name, "snail_mode"); + assert_eq!( + game.meta.settings.hazard_damage_per_turn, 14, + "snail mode uses the Go rules default hazard damage" + ); + assert_eq!(game.meta.settings.food_spawn_chance, 15); + assert_eq!(game.meta.settings.minimum_food, 1); + assert!(game.meta.royale.is_none()); + assert!(game.board.hazards.is_empty()); + } + + /// Snail Mode through the LIVE game path (`apply_turn`, as driven by + /// game_runner): trails appear in board-viewer frames one turn after the + /// tail square is recorded, stacked to snake length, and the off-board + /// bookkeeping points never reach a frame. + #[test] + fn test_snail_game_trails_appear_in_frames_without_bookkeeping_points() { + let battlesnakes = make_battlesnake_details(2); + let mut game = create_initial_game( + Uuid::new_v4(), + GameBoardSize::Medium, + GameType::SnailMode, + &battlesnakes, + ); + + // Deterministic fixture: two straight snakes walking up, no food. + let ids: Vec = game.board.snakes.iter().map(|s| s.id.clone()).collect(); + game.board.snakes[0].body = vec![Point::new(2, 5), Point::new(2, 4), Point::new(2, 3)]; + game.board.snakes[1].body = vec![Point::new(8, 5), Point::new(8, 4), Point::new(8, 3)]; + game.board.food.clear(); + + let count_at = |frame: &frame::EngineGameFrame, x: i32, y: i32| { + frame + .hazards + .iter() + .filter(|h| h.x == x && h.y == y) + .count() + }; + + for i in 0..4 { + let moves = vec![ + (ids[0].clone(), Direction::Up), + (ids[1].clone(), Direction::Up), + ]; + apply_turn(&mut game, &moves); + game.board.turn += 1; + + let frame = frame::game_to_frame(&game, &[], &[], &std::collections::HashMap::new()); + for h in &frame.hazards { + assert!( + h.x >= 0 && h.x < 11 && h.y >= 0 && h.y < 11, + "off-board bookkeeping point ({}, {}) leaked into a frame", + h.x, + h.y + ); + } + + match i { + 0 => { + // Tails recorded off-board only: engine state has + // hazards, the frame shows none. + assert!(frame.hazards.is_empty()); + assert!(!game.board.hazards.is_empty()); + } + 1 => { + // Each vacated tail square lands with stack = length 3. + assert_eq!(count_at(&frame, 2, 4), 3); + assert_eq!(count_at(&frame, 8, 4), 3); + assert_eq!(frame.hazards.len(), 6); + } + 2 => { + // First squares decay to 2; next squares land at 3. + assert_eq!(count_at(&frame, 2, 4), 2); + assert_eq!(count_at(&frame, 2, 5), 3); + assert_eq!(count_at(&frame, 8, 4), 2); + assert_eq!(count_at(&frame, 8, 5), 3); + assert_eq!(frame.hazards.len(), 10); + } + _ => { + // 1 / 2 / 3 fading trail behind each snake. + assert_eq!(count_at(&frame, 2, 4), 1); + assert_eq!(count_at(&frame, 2, 5), 2); + assert_eq!(count_at(&frame, 2, 6), 3); + assert_eq!(frame.hazards.len(), 12); + } + } + } + } + + /// Snail Mode games complete under the random-move runner (dispatch is + /// wired, no panics, all snakes placed). + #[test] + fn test_run_full_snail_mode_game() { + for _ in 0..5 { + let battlesnakes = make_battlesnake_details(4); + let game = create_initial_game( + Uuid::new_v4(), + GameBoardSize::Medium, + GameType::SnailMode, + &battlesnakes, + ); + let result = run_game_with_random_moves(game); + + assert_eq!(result.placements.len(), 4); + let mut ids = result.placements.clone(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 4); + assert!(result.final_turn > 0 && result.final_turn <= MAX_TURNS); + } + } } diff --git a/server/src/wire.rs b/server/src/wire.rs index e457efe..8752a70 100644 --- a/server/src/wire.rs +++ b/server/src/wire.rs @@ -215,11 +215,22 @@ impl Game { let settings = &engine_game.meta.settings; + // Internal `meta.ruleset_name` drives engine dispatch; the wire + // protocol mirrors play.battlesnake.com. Snail Mode upstream is a + // community *map* on the standard ruleset, so snakes see + // `ruleset.name = "standard"` with `game.map = "snail_mode"` + // (existing community snakes key off `game.map`). Other modes keep + // their ruleset name and an empty map, unchanged. + let (wire_ruleset_name, wire_map) = match engine_game.meta.ruleset_name.as_str() { + "snail_mode" => ("standard".to_string(), "snail_mode".to_string()), + name => (name.to_string(), String::new()), + }; + Game { game: NestedGame { id: engine_game.meta.game_id.clone(), ruleset: Ruleset { - name: engine_game.meta.ruleset_name.clone(), + name: wire_ruleset_name, version: "v1.0.0".to_string(), settings: RulesetSettings { food_spawn_chance: settings.food_spawn_chance, @@ -245,7 +256,7 @@ impl Game { }, }, timeout: engine_game.meta.timeout, - map: String::new(), + map: wire_map, source: String::new(), }, turn: engine_game.board.turn, @@ -259,10 +270,12 @@ impl Game { .iter() .map(&convert_snake) .collect(), + // Snail Mode stores pending-trail bookkeeping as off-board + // points inside `board.hazards`; snakes must only ever see + // real, on-board hazards (stacked duplicates included). hazards: engine_game .board - .hazards - .iter() + .on_board_hazards() .map(Position::from) .collect(), }, @@ -470,6 +483,84 @@ mod tests { assert_eq!(settings["hazardDamagePerTurn"], 14); } + /// Snail Mode wire parity with play.battlesnake.com: upstream it is a + /// community map on the standard ruleset, so snakes must see ruleset + /// "standard" with `game.map = "snail_mode"` (community snakes key off + /// `game.map`), even though the engine dispatches on the internal + /// ruleset name "snail_mode". + #[test] + fn test_snail_mode_wire_sends_standard_ruleset_and_snail_map() { + let mut engine_game = create_test_engine_game(); + engine_game.meta.ruleset_name = "snail_mode".to_string(); + engine_game.meta.settings.hazard_damage_per_turn = 14; + + let contexts = HashMap::new(); + let customizations = HashMap::new(); + let wire = Game::from_engine_game(&engine_game, "s1", &contexts, &customizations); + let json: Value = serde_json::to_value(&wire).unwrap(); + + assert_eq!(json["game"]["ruleset"]["name"], "standard"); + assert_eq!(json["game"]["map"], "snail_mode"); + let settings = &json["game"]["ruleset"]["settings"]; + assert_eq!(settings["hazardDamagePerTurn"], 14); + assert_eq!(settings["royale"]["shrinkEveryNTurns"], 0); + } + + /// Other modes are unchanged by the map-field wiring: ruleset name + /// passes through and the map stays empty. + #[test] + fn test_non_snail_modes_keep_ruleset_name_and_empty_map() { + for ruleset in ["standard", "royale"] { + let mut engine_game = create_test_engine_game(); + engine_game.meta.ruleset_name = ruleset.to_string(); + + let contexts = HashMap::new(); + let customizations = HashMap::new(); + let wire = Game::from_engine_game(&engine_game, "s1", &contexts, &customizations); + let json: Value = serde_json::to_value(&wire).unwrap(); + + assert_eq!(json["game"]["ruleset"]["name"], ruleset); + assert_eq!(json["game"]["map"], ""); + } + } + + /// HARD requirement: snakes must never receive out-of-bounds hazard + /// points in /move payloads. Snail Mode's pending-trail bookkeeping + /// lives as off-board points in `board.hazards` and must be filtered + /// out, while on-board stacked duplicates pass through intact. + #[test] + fn test_off_board_hazard_bookkeeping_never_reaches_snakes() { + let mut engine_game = create_test_engine_game(); + engine_game.meta.ruleset_name = "snail_mode".to_string(); + engine_game.board.hazards = vec![ + rules::Point::new(2, 3), + rules::Point::new(2, 3), + rules::Point::new(2, 3), + // Pending tails stored at y + height (board is 11x11). + rules::Point::new(2, 14), + rules::Point::new(2, 14), + rules::Point::new(2, 14), + ]; + + let contexts = HashMap::new(); + let customizations = HashMap::new(); + let wire = Game::from_engine_game(&engine_game, "s1", &contexts, &customizations); + + assert_eq!( + wire.board.hazards.len(), + 3, + "only on-board hazard entries may be serialized" + ); + for h in &wire.board.hazards { + assert!( + h.x >= 0 && h.x < 11 && h.y >= 0 && h.y < 11, + "out-of-bounds hazard ({}, {}) leaked to the wire", + h.x, + h.y + ); + } + } + #[test] fn test_missing_engine_fields_produce_defaults() { let engine_game = create_test_engine_game();