From c70a19b8c16be38a66e53eb54edd2fd1a106fba5 Mon Sep 17 00:00:00 2001 From: Byte Date: Tue, 14 Jul 2026 22:32:11 -0400 Subject: [PATCH] fix: spawn food each turn in live games The live game loop (game_runner -> apply_turn) never called rules::food::maybe_spawn_food -- it was only wired into the run_game_with_random_moves simulation helper. Live games advertised food_spawn_chance=15 / minimum_food=1 on the wire but no food ever spawned after the initial board (confirmed in prod: game 54be71a6 had 5 food at turn 0 and zero from ~turn 30 onward). Add engine::spawn_food, called by the game runner after each turn and before the frame is recorded so the viewer and the snakes' next /move requests both see the new food. Constrictor games (in-flight PR #128) never spawn food, so the helper is mode-aware from the start. Co-Authored-By: Claude Fable 5 --- server/src/engine/mod.rs | 46 +++++++++++++++++++++++++++++++++++++++ server/src/game_runner.rs | 3 +++ 2 files changed, 49 insertions(+) diff --git a/server/src/engine/mod.rs b/server/src/engine/mod.rs index c8fa953..8dd21b8 100644 --- a/server/src/engine/mod.rs +++ b/server/src/engine/mod.rs @@ -250,6 +250,20 @@ pub fn apply_turn(game: &mut EngineGame, moves: &[(String, Direction)]) { } } +/// Spawn food for the next turn in modes that spawn food. +/// +/// Food spawning lives outside the per-turn rules pipeline (matching the Go +/// engine, where the game loop spawns food between turns), so every caller of +/// `apply_turn` must also call this. Constrictor games never spawn food; all +/// other modes use the standard chance/minimum settings. +pub fn spawn_food(game: &mut EngineGame) { + if game.meta.ruleset_name == "constrictor" { + return; + } + let mut rng = rand::thread_rng(); + rules::food::maybe_spawn_food(&mut rng, &mut game.board, &game.meta.settings); +} + #[cfg(test)] mod tests { use super::*; @@ -1275,6 +1289,38 @@ mod tests { } } + #[test] + fn test_spawn_food_standard_respects_minimum_food() { + let mut game = create_test_game(2); + game.board.food.clear(); + + spawn_food(&mut game); + + // minimum_food is 1, so an empty board must get food back. + assert!( + !game.board.food.is_empty(), + "standard games must respawn food up to minimum_food" + ); + } + + #[test] + fn test_spawn_food_constrictor_never_spawns() { + let mut game = create_test_game(2); + game.meta.ruleset_name = "constrictor".to_string(); + game.board.food.clear(); + + // food_spawn_chance is probabilistic; run enough iterations that a + // broken guard would virtually always spawn at least once. + for _ in 0..100 { + spawn_food(&mut game); + } + + assert!( + game.board.food.is_empty(), + "constrictor games must never spawn food" + ); + } + /// Test that create_initial_game assigns unique IDs when the same battlesnake /// appears multiple times (duplicate snakes in a game) #[test] diff --git a/server/src/game_runner.rs b/server/src/game_runner.rs index b806611..c35a697 100644 --- a/server/src/game_runner.rs +++ b/server/src/game_runner.rs @@ -236,6 +236,9 @@ pub async fn run_game(app_state: &AppState, game_id: Uuid) -> cja::Result<()> { // Apply the moves using the engine crate::engine::apply_turn(&mut engine_game, &moves); engine_game.board.turn += 1; + // Spawn food for the next turn before the frame is recorded, so the + // viewer and the snakes' next /move requests both see it. + crate::engine::spawn_food(&mut engine_game); // Track newly eliminated snakes for snake in &engine_game.board.snakes {