From 7227f1776f342a24b93dfecbbb61e880c2f10cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=A4nner?= Date: Mon, 14 Oct 2024 19:11:00 +0200 Subject: [PATCH] use library for simulation --- Cargo.lock | 50 +++- battlesnake/Cargo.toml | 7 +- battlesnake/benches/simulation.rs | 186 ------------- battlesnake/src/lib.rs | 183 +------------ battlesnake/src/logic.rs | 432 ++++++++++++------------------ battlesnake/src/main.rs | 87 +++--- battlesnake/src/simulation.rs | 326 ---------------------- 7 files changed, 294 insertions(+), 977 deletions(-) delete mode 100644 battlesnake/benches/simulation.rs delete mode 100644 battlesnake/src/simulation.rs diff --git a/Cargo.lock b/Cargo.lock index ce68f4e..888ef28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,7 +154,9 @@ dependencies = [ name = "battlesnake" version = "1.0.0" dependencies = [ + "battlesnake-game-types", "criterion2", + "dashmap", "enum-iterator", "env_logger", "iter_tools", @@ -166,6 +168,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "battlesnake-game-types" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370d3e9c1067908a33bb29721a19f0658eb3b923d046d6ad38f30494658b4659" +dependencies = [ + "fxhash", + "itertools 0.10.5", + "rand", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "binascii" version = "0.1.4" @@ -322,6 +338,20 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.11" @@ -529,6 +559,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generator" version = "0.7.5" @@ -728,7 +767,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27812bb0a056539d62930a899759af39dfab17ac73a17d5caf58365762657891" dependencies = [ "clone_dyn_types", - "itertools", + "itertools 0.11.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", ] [[package]] diff --git a/battlesnake/Cargo.toml b/battlesnake/Cargo.toml index f4517a6..95694a6 100644 --- a/battlesnake/Cargo.toml +++ b/battlesnake/Cargo.toml @@ -25,6 +25,9 @@ rand = "0.8.4" enum-iterator = "2.1" iter_tools = "0.21" ordered-float = "4.3.0" +dashmap = "6.1.0" + +battlesnake-game-types = "0.17.0" [dev-dependencies] # criterion = { version = "0.5.1", features = ["html_reports"] } @@ -34,7 +37,3 @@ criterion2 = "1.1.1" lto = "fat" codegen-units = 1 panic = "abort" - -[[bench]] -name = "simulation" -harness = false diff --git a/battlesnake/benches/simulation.rs b/battlesnake/benches/simulation.rs deleted file mode 100644 index 6487bb2..0000000 --- a/battlesnake/benches/simulation.rs +++ /dev/null @@ -1,186 +0,0 @@ -use battlesnake::{ - simulation::{self, SnakeToken}, - Coord, -}; -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use rand::{rngs::StdRng, SeedableRng}; - -fn random_moves(board: &battlesnake::Board) -> Option { - let token_map = SnakeToken::from_board(board); - let mut board = simulation::Board::from_game_board(board, &token_map, 0, 15, 1, false); - let mut rng = StdRng::seed_from_u64(0); - - board.simulate_until(&mut rng, |board| board.alive_snakes() <= 1); - - let winner = board.snakes().next(); - winner -} - -fn bench_duel_random_moves(c: &mut Criterion) { - c.bench_function("duel random moves", |b| { - b.iter(|| { - random_moves(black_box(&battlesnake::Board { - height: 11, - width: 11, - food: vec![Coord { x: 5, y: 5 }], - snakes: vec![ - battlesnake::Battlesnake { - id: "1".to_owned(), - name: "1".to_owned(), - health: 100, - body: vec![Coord { x: 5, y: 1 }; 3], - head: Coord { x: 5, y: 1 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "2".to_owned(), - name: "2".to_owned(), - health: 100, - body: vec![Coord { x: 5, y: 9 }; 3], - head: Coord { x: 5, y: 9 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - ], - hazards: vec![], - })) - }); - }); -} - -fn bench_standard_random_moves(c: &mut Criterion) { - c.bench_function("standard random moves", |b| { - b.iter(|| { - random_moves(black_box(&battlesnake::Board { - height: 11, - width: 11, - food: vec![Coord { x: 5, y: 5 }], - snakes: vec![ - battlesnake::Battlesnake { - id: "1".to_owned(), - name: "1".to_owned(), - health: 100, - body: vec![Coord { x: 5, y: 1 }; 3], - head: Coord { x: 5, y: 1 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "2".to_owned(), - name: "2".to_owned(), - health: 100, - body: vec![Coord { x: 5, y: 9 }; 3], - head: Coord { x: 5, y: 9 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "3".to_owned(), - name: "3".to_owned(), - health: 100, - body: vec![Coord { x: 1, y: 5 }; 3], - head: Coord { x: 1, y: 5 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "4".to_owned(), - name: "4".to_owned(), - health: 100, - body: vec![Coord { x: 9, y: 5 }; 3], - head: Coord { x: 9, y: 5 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - ], - hazards: vec![], - })) - }); - }); -} - -fn bench_board_clone(c: &mut Criterion) { - let board = battlesnake::Board { - height: 11, - width: 11, - food: vec![Coord { x: 5, y: 5 }], - snakes: vec![ - battlesnake::Battlesnake { - id: "1".to_owned(), - name: "1".to_owned(), - health: 100, - body: vec![Coord { x: 5, y: 1 }; 3], - head: Coord { x: 5, y: 1 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "2".to_owned(), - name: "2".to_owned(), - health: 100, - body: vec![Coord { x: 5, y: 9 }; 3], - head: Coord { x: 5, y: 9 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "3".to_owned(), - name: "3".to_owned(), - health: 100, - body: vec![Coord { x: 1, y: 5 }; 3], - head: Coord { x: 1, y: 5 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - battlesnake::Battlesnake { - id: "4".to_owned(), - name: "4".to_owned(), - health: 100, - body: vec![Coord { x: 9, y: 5 }; 3], - head: Coord { x: 9, y: 5 }, - length: 3, - latency: "0".to_owned(), - shout: None, - squad: String::new(), - }, - ], - hazards: vec![], - }; - let token_map = SnakeToken::from_board(&board); - let mut board = - battlesnake::simulation::Board::from_game_board(&board, &token_map, 0, 12, 1, false); - - let mut rng = StdRng::seed_from_u64(0); - board.simulate_until(&mut rng, |board| board.turn() > 25); - - c.bench_function("board clone", |b| { - b.iter(|| board.clone()); - }); -} - -criterion_group!( - benches, - bench_duel_random_moves, - bench_standard_random_moves, - bench_board_clone, -); -criterion_main!(benches); diff --git a/battlesnake/src/lib.rs b/battlesnake/src/lib.rs index 97a6ba1..04145df 100644 --- a/battlesnake/src/lib.rs +++ b/battlesnake/src/lib.rs @@ -1,183 +1,24 @@ -use enum_iterator::Sequence; +use battlesnake_game_types::types::Move; use serde::{Deserialize, Serialize}; pub mod logic; -pub mod simulation; - -pub const MAX_HEALTH: i32 = 100; - -// API and Response Objects -// See https://docs.battlesnake.com/api - -#[derive( - Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Deserialize, Serialize, Sequence, -)] -#[serde(rename_all = "lowercase")] -pub enum Direction { - /// Move left (-x) - Left, - /// Move up (+y) - Up, - /// Move right (+x) - Right, - /// Move down (-y) - Down, -} #[derive(Debug, Deserialize, Serialize)] -pub struct Action { +pub struct Response { /// In which direction the snake should move - pub r#move: Direction, - /// Say something to the other snakes - #[serde(default, skip_serializing_if = "is_default")] - pub shout: Option, + r#move: &'static str, } -fn is_default(value: &T) -> bool { - *value == T::default() -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct Game { - /// A unique identifier for this Game - pub id: String, - /// Information about the ruleset being used to run this game - pub ruleset: Ruleset, - /// The name of the map being played on. - pub map: String, - /// How much time your snake has to respond to requests for this Game - pub timeout: u32, - /// The source of this game. - /// - /// One of: - /// - "tournament" - /// - "league" - /// - "arena" - /// - "challenge" - /// - "custom" - /// - /// The values may change. - pub source: String, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct Ruleset { - /// Name of the ruleset being used to run this game. - pub name: String, - /// The release version of the [Rules](https://github.com/BattlesnakeOfficial/rules) module used in this game. - pub version: String, - /// A collection of specific settings being used by the current game that control how the rules - /// are applied. - pub settings: RulesetSettings, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct RulesetSettings { - /// Percentage chance of spawning a new food every round. - #[serde(rename = "foodSpawnChance")] - pub food_spawn_chance: u8, - /// Minimum food to keep on the board every turn. - #[serde(rename = "minimumFood")] - pub minimum_food: u8, - /// Health damage a snake will take when ending its turn in a hazard. This stacks on top of the - /// regular 1 damage a snake takes per turn. - #[serde(rename = "hazardDamagePerTurn")] - pub hazard_damage_per_turn: u8, - /// rules for the royale mode - pub royale: RulesetRoyale, - /// rules for the squad mode - pub squad: RulesetSquad, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct RulesetRoyale { - /// The number of turns between generating new hazards (shrinking the safe board space). - #[serde(rename = "shrinkEveryNTurns")] - pub shrink_every_n_turns: i32, -} - -#[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Deserialize, Serialize)] -pub struct RulesetSquad { - /// Allow members of the same squad to move over each other without dying. - #[serde(rename = "allowBodyCollisions")] - pub allow_body_collisions: bool, - /// All squad members are eliminated when one is eliminated. - #[serde(rename = "sharedElimination")] - pub shared_elimination: bool, - /// All squad members share health. - #[serde(rename = "sharedHealth")] - pub shared_health: bool, - /// All squad members share length. - #[serde(rename = "sharedLength")] - pub shared_length: bool, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct Board { - /// The number of rows in the y-axis of the game board. - pub height: i32, - /// The number of columns in the x-axis of the game board. - pub width: i32, - /// Array of coordinates representing food locations on the game board. - pub food: Vec, - /// Array of Battlesnakes representing all Battlesnakes remaining on the game board (including - /// yourself if you haven't been eliminated). - pub snakes: Vec, - /// Array of coordinates representing hazardous locations on the game board. - pub hazards: Vec, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct Battlesnake { - /// Unique identifier for this Battlesnake in the context of the current Game - pub id: String, - /// Name given to this Battlesnake by its author - pub name: String, - /// Health value of this Battlesnake, between 0 and 100 - pub health: i32, - /// Array of coordinates representing this Battlesnake's location on the game board. This array - /// is ordered from head to tail - pub body: Vec, - /// Coordinates for this Battlesnake's head. Equivalent to the first element of the body array. - pub head: Coord, - /// Length of this Battlesnake from head to tail. Equivalent to the length of the body array. - pub length: i32, - /// The previous response time of this Battlesnake, in milliseconds. If the Battlesnake timed - /// out and failed to respond, the game timeout will be returned. - pub latency: String, - /// Message shouted by this Battlesnake on the previous turn. - pub shout: Option, - /// The squad that the Battlesnake belongs to. Used to identify squad members in Squad Mode - /// games. - pub squad: String, - // /// The collection of customizations that control how this Battlesnake is displayed. - // customizations: {color, head, tail} -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Deserialize, Serialize)] -pub struct Coord { - pub x: i32, - pub y: i32, -} - -impl Coord { +impl Response { #[must_use] - pub const fn move_to(mut self, direction: Direction) -> Self { - match direction { - Direction::Left => self.x -= 1, - Direction::Up => self.y += 1, - Direction::Right => self.x += 1, - Direction::Down => self.y -= 1, + pub const fn new(r#move: Move) -> Self { + Self { + r#move: match r#move { + Move::Left => "left", + Move::Down => "down", + Move::Up => "up", + Move::Right => "right", + }, } - self } } - -#[derive(Deserialize, Serialize, Debug)] -pub struct GameState { - pub game: Game, - pub turn: i32, - pub board: Board, - pub you: Battlesnake, -} diff --git a/battlesnake/src/logic.rs b/battlesnake/src/logic.rs index 5db1008..af37884 100644 --- a/battlesnake/src/logic.rs +++ b/battlesnake/src/logic.rs @@ -11,22 +11,23 @@ // For more info see docs.battlesnake.com use core::f64; -use std::{ - collections::{BTreeMap, HashMap}, - sync::{Arc, LazyLock, Mutex}, - time::{Duration, Instant}, -}; +use std::{collections::HashMap, time::Instant}; -use log::{error, info, warn}; +use battlesnake_game_types::{ + compact_representation::standard::CellBoard4Snakes11x11, + types::{ + build_snake_id_map, LengthGettableGame, Move, RandomReasonableMovesGame, + ReasonableMovesGame, SimulableGame, SimulatorInstruments, SnakeIDMap, SnakeId, + VictorDeterminableGame, YouDeterminableGame, + }, + wire_representation::Game, +}; +use log::{error, info}; use ordered_float::OrderedFloat; use rand::{prelude::*, thread_rng}; +use rocket::time::{ext::NumericalDuration, Duration}; use serde_json::{json, Value}; -use crate::{ - simulation::{self, SnakeToken}, - Action, Battlesnake, Board, Direction, Game, -}; - // info is called when you create your Battlesnake on play.battlesnake.com // and controls your Battlesnake's appearance // TIP: If you open your Battlesnake URL in a browser you should see this data @@ -43,190 +44,99 @@ pub fn info() -> Value { }) } -#[derive(Debug, Clone)] -struct GameInfo { - calculation_time: Arc>, - token_mapping: Arc>, - my_token: SnakeToken, - tree: Arc>>, +#[derive(Debug)] +pub struct GameState { + calculation_time: Duration, + snake_id_map: SnakeIDMap, } -static GAME_INFOS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - // start is called when your Battlesnake begins a game -pub fn start(game: &Game, _turn: i32, board: &Board, you: &Battlesnake) { +#[must_use] +pub fn start(game: &Game) -> GameState { info!("GAME START"); - let token_mapping = Arc::new(SnakeToken::from_board(board)); - let my_token = token_mapping[&you.id]; - let Ok(mut game_infos) = GAME_INFOS.lock() else { - error!("unable to lock game infos"); - return; - }; - game_infos.insert( - (game.id.clone(), you.id.clone()), - GameInfo { - calculation_time: Arc::new(Mutex::new(Duration::from_millis( - u64::from(game.timeout) / 2, - ))), - token_mapping, - my_token, - tree: Arc::new(Mutex::new(None)), - }, - ); + let snake_id_map = build_snake_id_map(game); + let calculation_time = (game.game.timeout / 2).milliseconds(); + + GameState { + calculation_time, + snake_id_map, + } } // end is called when your Battlesnake finishes a game -pub fn end(game: &Game, turn: i32, _board: &Board, you: &Battlesnake) { - info!("GAME OVER after {turn} turns"); - let Ok(mut game_infos) = GAME_INFOS.lock() else { - error!("unable to lock game infos"); - return; - }; - game_infos.remove(&(game.id.clone(), you.id.clone())); +pub fn end(game: &Game, state: GameState) { + std::mem::drop(state); + info!("GAME OVER after {} turns", game.turn); } // move is called on every turn and returns your next move // Valid moves are "up", "down", "left", or "right" // See https://docs.battlesnake.com/api/example-move for available data -pub fn get_move( - game: &Game, - turn: i32, - board: &Board, - you: &Battlesnake, - start: &Instant, -) -> Option { +pub fn get_move(game: Game, state: &mut GameState, start: &Instant) -> Move { let calc_start = Instant::now(); - if calc_start - *start > Duration::from_millis(10) { + if calc_start - *start > 10.milliseconds() { error!( "The calculation was started long after the request ({}ms)", (calc_start - *start).as_millis() ); } - let game_info = GAME_INFOS - .lock() - .ok() - .and_then(|guard| guard.get(&(game.id.clone(), you.id.clone())).cloned()) - .unwrap_or_else(|| { - let token_mapping = Arc::new(SnakeToken::from_board(board)); - let my_token = token_mapping[&you.id]; - GameInfo { - calculation_time: Arc::new(Mutex::new(Duration::from_millis( - u64::from(game.timeout) / 2, - ))), - token_mapping, - my_token, - tree: Arc::new(Mutex::new(None)), - } - }); - let board = simulation::Board::from_game_board( - board, - &game_info.token_mapping, - turn, - game.ruleset.settings.food_spawn_chance, - game.ruleset.settings.minimum_food, - game.ruleset.name == "constrictor", - ); - let possible_actions = board.possible_actions().get(&game_info.my_token).cloned()?; - if possible_actions.is_empty() { - info!("No movement options in turn {turn}"); - return None; - } + let deadline = *start + state.calculation_time; - // do some latency compensation - let deadline = *start - + game_info.calculation_time.lock().map_or_else( - |_| Duration::from_millis(u64::from(game.timeout) / 2), - |mut guard| { - let target_latency = game.timeout / 2; - let latency = you.latency.parse().unwrap_or_else(|e| { - warn!("Unable to parse latency: {e}"); - target_latency - }); - let last_computation_time = u32::try_from(guard.as_millis()).unwrap_or(0); - let computation_time = - (last_computation_time + target_latency).saturating_sub(latency); - *guard = - Duration::from_millis(u64::from(computation_time.clamp(1, target_latency))); - *guard - }, - ); - - let mut tree_guard = game_info.tree.lock(); - let tree = match tree_guard { - Err(ref e) => { - error!("unable to lock tree: {e}"); - None - } - Ok(ref mut guard) => guard.as_mut(), + let name = game.you.name.clone(); + let turn = game.turn; + let solo = game.game.ruleset.name == "solo"; + let Ok(board) = CellBoard4Snakes11x11::convert_from_game(game, &state.snake_id_map) else { + error!("Unable to fit board"); + return Move::Down; + }; + + let mut tree = Node { + statistic: Statistics { + played: 0, + won: HashMap::new(), + }, + child_statistics: HashMap::new(), + childs: HashMap::new(), }; - let mut tree = tree - .and_then(|node| { - let snake_length_direction: BTreeMap<_, _> = board - .snakes() - .map(|snake| { - let length = board.snake_length(snake).unwrap_or_default(); - let action = board.last_action(snake).unwrap_or(Direction::Up); - (snake, (action, length)) - }) - .collect(); - let node_key = node - .childs - .keys() - .find(|child| { - child.iter().all(|(snake, (direction, length))| { - snake_length_direction - .get(snake) - .copied() - .unwrap_or((*direction, 0)) - == (*direction, *length) - }) - })? - .clone(); - let node = node.childs.remove(&node_key)?; - info!( - "using previous node with {} simulations", - node.statistic.played - ); - Some(node) - }) - .unwrap_or_default(); while Instant::now() < deadline { - let mut board = board.clone(); - if game.ruleset.name == "solo" { - let _ = tree.monte_carlo_step_solo(&mut board, &deadline); + if solo { + tree.monte_carlo_solo_step(&board); } else { - let _ = tree.monte_carlo_step(&mut board, &deadline); + tree.monte_carlo_step(&board); } } - let actions = tree.child_statistics.entry(game_info.my_token).or_default(); + let actions = tree.child_statistics.entry(*board.you_id()).or_default(); - info!("actions {}: {actions:?}", you.name); + info!("actions {}: {actions:?}", name); #[allow(clippy::cast_precision_loss)] let chosen = actions .iter() - .max_by_key(|(_, stat)| OrderedFloat(stat.won as f64 / stat.played as f64)) + .max_by_key(|(_, stat)| stat.played) .map(|(direction, _)| *direction) - .or_else(|| possible_actions.iter().choose(&mut thread_rng()).copied())?; - - if let Ok(ref mut guard) = tree_guard { - **guard = Some(tree); - } - std::mem::drop(tree_guard); + .or_else(|| { + board + .random_reasonable_move_for_each_snake(&mut thread_rng()) + .find(|(snake_id, _)| snake_id == board.you_id()) + .map(|(_, direction)| direction) + }) + .unwrap_or(Move::Down); info!( - "DIRECTION {turn}: {chosen:?} after {}ms ({})", + "DIRECTION {turn}: {chosen:?} after {}ms ({name})", start.elapsed().as_millis(), - you.name, ); - Some(Action { - r#move: chosen, - shout: None, - }) + chosen +} + +#[derive(Debug, Clone, Copy)] +struct Instruments; + +impl SimulatorInstruments for Instruments { + fn observe_simulation(&self, _duration: std::time::Duration) {} } #[derive(Debug, PartialEq, Eq, Clone, Default)] @@ -234,7 +144,7 @@ struct Statistics { /// Number of times this node was simulated played: usize, /// Number of times this node was simulated and the agent has won. - won: BTreeMap, + won: HashMap, } #[derive(Debug, PartialEq, Eq, Clone, Default)] @@ -243,46 +153,44 @@ struct ActionStatistic { won: usize, } -struct DeadlineError; - #[derive(Debug, PartialEq, Eq, Clone, Default)] struct Node { statistic: Statistics, - child_statistics: BTreeMap>, - childs: BTreeMap, Node>, + child_statistics: HashMap>, + childs: HashMap<[Option<(Move, u16)>; 4], Node>, } impl Node { /// Performs one monte carlo simulation step /// /// Returns the snake that has won the simulation - fn monte_carlo_step( - &mut self, - board: &mut simulation::Board, - deadline: &Instant, - ) -> Result, DeadlineError> { - let stop_condition = - |board: &simulation::Board| board.alive_snakes() <= 1 || Instant::now() >= *deadline; + fn monte_carlo_step(&mut self, board: &CellBoard4Snakes11x11) -> Option { + let stop_condition = CellBoard4Snakes11x11::is_over; let winner = if stop_condition(board) { - if Instant::now() >= *deadline { - return Err(DeadlineError); - } - board.snakes().next() + board.get_winner() } else if self.statistic.played == 0 { // We didn't simulate a game for this node yet. Do that - board.simulate_until(&mut thread_rng(), stop_condition); - if Instant::now() >= *deadline { - return Err(DeadlineError); + let mut board = *board; + while !stop_condition(&board) { + let rng = &mut thread_rng(); + let moves = board.random_reasonable_move_for_each_snake(rng); + let (_, new_board) = board + .simulate_with_moves( + &Instruments, + moves.map(|(snake_id, direction)| (snake_id, [direction])), + ) + .next() + .unwrap(); + board = new_board; } - board.snakes().next() + board.get_winner() } else { // select a node to simulate - let possible_actions = board.possible_actions(); + let possible_actions = board.reasonable_moves_for_each_snake(); let actions = possible_actions - .iter() .filter_map(|(token, actions)| { - let statistics = self.child_statistics.entry(*token).or_default(); + let statistics = self.child_statistics.entry(token).or_default(); let selected = actions.iter().copied().max_by_key(|direction| { let statistics = statistics.entry(*direction).or_default(); if statistics.played == 0 { @@ -297,20 +205,28 @@ impl Node { ); OrderedFloat(exploitation + exploration) })?; - Some((*token, selected)) + Some((token, [selected])) }) - .collect(); + .collect::>(); - board.simulate_actions(&actions, &mut thread_rng()); - let map_actions = actions - .iter() - .map(|(&snake, &action)| (snake, (action, board.snake_length(snake).unwrap_or(0)))) - .collect(); + let (_, board) = board + .simulate_with_moves(&Instruments, actions.iter().copied()) + .next() + .unwrap(); + let mut map_actions = [None; 4]; + for (i, action) in map_actions.iter_mut().enumerate() { + *action = actions + .iter() + .find(|(snake_id, _)| snake_id.as_usize() == i) + .and_then(|(snake_id, moves)| { + Some((*moves.first()?, board.get_length(snake_id))) + }); + } let winner = self .childs .entry(map_actions) .or_default() - .monte_carlo_step(board, deadline)?; + .monte_carlo_step(&board); // update child statistics for (token, action) in &actions { @@ -318,7 +234,7 @@ impl Node { .child_statistics .entry(*token) .or_default() - .entry(*action) + .entry(action[0]) .or_default(); entry.played += 1; if Some(*token) == winner { @@ -336,104 +252,108 @@ impl Node { .and_modify(|won| *won += 1) .or_insert(1); } - Ok(winner) + winner } /// Performs one monte carlo simulation step for a solo game /// /// Returns the lengths before death - fn monte_carlo_step_solo( - &mut self, - board: &mut simulation::Board, - deadline: &Instant, - ) -> Result, DeadlineError> { - let lengths = if self.statistic.played == 0 { + fn monte_carlo_solo_step(&mut self, board: &CellBoard4Snakes11x11) -> u16 { + let stop_condition = |board: &CellBoard4Snakes11x11| board.alive_snake_count() == 0; + let winner = if self.statistic.played == 0 { // We didn't simulate a game for this node yet. Do that - let mut lengths: BTreeMap<_, _> = board - .snakes() - .filter_map(|snake| Some((snake, board.snake_length(snake)?))) - .collect(); - board.simulate_until(&mut thread_rng(), |board| { - if Instant::now() >= *deadline { - return true; + let mut board = *board; + while !stop_condition(&board) { + let moves = + board + .reasonable_moves_for_each_snake() + .filter_map(|(snake_id, moves)| { + Some((snake_id, [*moves.choose(&mut thread_rng())?])) + }); + let Some((_, new_board)) = board.simulate_with_moves(&Instruments, moves).next() + else { + break; + }; + if stop_condition(&new_board) { + break; } - for snake in board.snakes() { - if let Some(length) = board.snake_length(snake) { - lengths.insert(snake, length); - } - } - board.alive_snakes() == 0 - }); - if Instant::now() >= *deadline { - return Err(DeadlineError); + board = new_board; } - lengths + let winner = board.get_length(board.you_id()); + winner } else { // select a node to simulate - let possible_actions = board.possible_actions(); + let possible_actions = board.reasonable_moves_for_each_snake(); let actions = possible_actions - .iter() .filter_map(|(token, actions)| { - let statistics = self.child_statistics.entry(*token).or_default(); + let statistics = self.child_statistics.entry(token).or_default(); let selected = actions.iter().copied().max_by_key(|direction| { let statistics = statistics.entry(*direction).or_default(); if statistics.played == 0 { return OrderedFloat(f64::INFINITY); } #[allow(clippy::cast_precision_loss)] - let exploitation = statistics.won as f64 - / board.spaces() as f64 - / statistics.played as f64; + let exploitation = statistics.won as f64 / statistics.played as f64; #[allow(clippy::cast_precision_loss)] let exploration = f64::consts::SQRT_2 * f64::sqrt( f64::ln(self.statistic.played as f64) / statistics.played as f64, - ); + ) + * 11.0 + * 11.0; OrderedFloat(exploitation + exploration) })?; - Some((*token, selected)) + Some((token, [selected])) }) - .collect(); + .collect::>(); - if Instant::now() >= *deadline { - return Err(DeadlineError); + let (_, new_board) = board + .simulate_with_moves(&Instruments, actions.iter().copied()) + .next() + .unwrap(); + let mut map_actions = [None; 4]; + for (i, action) in map_actions.iter_mut().enumerate() { + *action = actions + .iter() + .find(|(snake_id, _)| snake_id.as_usize() == i) + .and_then(|(snake_id, moves)| { + Some((*moves.first()?, new_board.get_length(snake_id))) + }); } - board.simulate_actions(&actions, &mut thread_rng()); - let map_actions = actions - .iter() - .map(|(&snake, &action)| (snake, (action, board.snake_length(snake).unwrap_or(0)))) - .collect(); - let lengths = self - .childs - .entry(map_actions) - .or_default() - .monte_carlo_step_solo(board, deadline)?; + let winner = if stop_condition(&new_board) { + board.get_length(board.you_id()) + } else { + self.childs + .entry(map_actions) + .or_default() + .monte_carlo_solo_step(&new_board) + }; // update child statistics - for (token, action) in &actions { - let entry = self - .child_statistics - .entry(*token) - .or_default() - .entry(*action) - .or_default(); - entry.played += 1; - if let Some(length) = lengths.get(token) { - entry.won += length; - } - } + let entry = self + .child_statistics + .entry(*new_board.you_id()) + .or_default() + .entry( + actions + .iter() + .find(|(snake_id, _)| snake_id == new_board.you_id()) + .map(|(_, action)| action[0]) + .unwrap(), + ) + .or_default(); + entry.played += 1; + entry.won += usize::from(winner); - lengths + winner }; self.statistic.played += 1; - for (token, length) in &lengths { - self.statistic - .won - .entry(*token) - .and_modify(|won| *won += length) - .or_insert(*length); - } - Ok(lengths) + self.statistic + .won + .entry(*board.you_id()) + .and_modify(|won| *won += usize::from(winner)) + .or_insert_with(|| usize::from(winner)); + winner } } diff --git a/battlesnake/src/main.rs b/battlesnake/src/main.rs index 14bbc51..675dd41 100644 --- a/battlesnake/src/main.rs +++ b/battlesnake/src/main.rs @@ -1,57 +1,77 @@ #![allow(clippy::needless_pass_by_value)] +use std::{env, sync::Arc, time::Instant}; -use battlesnake::{logic, Action, Direction, GameState}; +use battlesnake::{ + logic::{self, GameState}, + Response, +}; +use battlesnake_game_types::{types::Move, wire_representation::Game}; +use dashmap::DashMap; use log::{error, info}; use rocket::{ - fairing::AdHoc, get, http::Status, launch, post, routes, serde::json::Json, tokio::task, + fairing::AdHoc, get, http::Status, launch, post, routes, serde::json::Json, tokio::task, State, }; use serde_json::Value; -use std::{env, time::Instant}; + +type States = Arc>; #[get("/")] fn handle_index() -> Json { Json(logic::info()) } -#[post("/start", format = "json", data = "")] -fn handle_start(start_req: Json) -> Status { - logic::start( - &start_req.game, - start_req.turn, - &start_req.board, - &start_req.you, - ); +#[post("/start", format = "json", data = "")] +fn handle_start(state: &State, game: Json) -> Status { + if state + .insert( + (game.game.id.clone(), game.you.id.clone()), + logic::start(&game), + ) + .is_some() + { + error!("re-started game"); + } Status::Ok } -#[post("/move", format = "json", data = "")] -async fn handle_move(move_req: Json) -> Json { +#[post("/move", format = "json", data = "")] +async fn handle_move(state: &State, game: Json) -> Json { let start = Instant::now(); - let response = task::spawn_blocking(move || { - logic::get_move( - &move_req.game, - move_req.turn, - &move_req.board, - &move_req.you, - &start, - ) + let state = (*state).clone(); + let action = task::spawn_blocking(move || { + let mut game_state = state.get_mut(&(game.game.id.clone(), game.you.id.clone())); + while game_state.is_none() { + error!("move request without previous start"); + if state + .insert( + (game.game.id.clone(), game.you.id.clone()), + logic::start(&game), + ) + .is_some() + { + error!("re-started game"); + } + game_state = state.get_mut(&(game.game.id.clone(), game.you.id.clone())); + } + let Some(mut game_state) = game_state else { + std::mem::drop(game_state); + unreachable!() + }; + logic::get_move(game.0, &mut game_state, &start) }) .await - .inspect_err(|e| error!("failed to join compute thread: {e}")) - .ok() - .flatten() - .unwrap_or(Action { - r#move: Direction::Up, - shout: Some("I am so dead".to_owned()), - }); - - Json(response) + .unwrap_or(Move::Up); + Json(Response::new(action)) } -#[post("/end", format = "json", data = "")] -fn handle_end(end_req: Json) -> Status { - logic::end(&end_req.game, end_req.turn, &end_req.board, &end_req.you); +#[post("/end", format = "json", data = "")] +fn handle_end(state: &State, game: Json) -> Status { + if let Some((_key, game_state)) = state.remove(&(game.game.id.clone(), game.you.id.clone())) { + logic::end(&game, game_state); + } else { + error!("ended game without state"); + } Status::Ok } @@ -85,4 +105,5 @@ fn rocket() -> _ { "/", routes![handle_index, handle_start, handle_move, handle_end], ) + .manage(States::new(DashMap::new())) } diff --git a/battlesnake/src/simulation.rs b/battlesnake/src/simulation.rs deleted file mode 100644 index 4556111..0000000 --- a/battlesnake/src/simulation.rs +++ /dev/null @@ -1,326 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet, VecDeque}; - -use iter_tools::Itertools; -use rand::{seq::IteratorRandom, Rng}; - -use crate::{Coord, Direction}; - -#[allow(clippy::cast_possible_truncation)] -const MAX_HEALTH: u8 = crate::MAX_HEALTH as u8; - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub struct SnakeToken { - id: u8, -} - -impl SnakeToken { - /// create a token map from the current game board. - /// - /// # Panics - /// - /// This function panics when there are more than 256 snakes on the board. - #[must_use] - pub fn from_board(board: &crate::Board) -> BTreeMap { - board - .snakes - .iter() - .enumerate() - .map(|(i, snake)| { - ( - snake.id.clone(), - Self { - id: u8::try_from(i).expect("Way to many snakes for a single game"), - }, - ) - }) - .collect() - } -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct Board { - turn: i32, - /// Height of the board - height: i32, - /// Width of the board - width: i32, - /// Food on the board - food: BTreeSet, - /// Chance of new food spawning each round - food_chance: u8, - /// minimum quantity of food that must be on the board - min_food: u8, - /// Alive snakes - snakes: BTreeMap, - /// True when playing constrictor mode. In this mode the snakes don't loose health and grow - /// every turn - constrictor: bool, -} - -impl Board { - #[must_use] - pub fn from_game_board( - board: &crate::Board, - token_map: &BTreeMap, - turn: i32, - food_chance: u8, - min_food: u8, - constrictor: bool, - ) -> Self { - let width = board.width; - debug_assert!(width > 0); - let height = board.height; - debug_assert!(height > 0); - let food = board.food.iter().copied().collect(); - let snakes = board - .snakes - .iter() - .map(|snake| { - let token = token_map[&snake.id]; - (token, Battlesnake::from_game_snake(snake)) - }) - .collect(); - - Self { - turn, - height, - width, - food, - food_chance, - min_food, - snakes, - constrictor, - } - } - - #[must_use] - pub const fn turn(&self) -> i32 { - self.turn - } - - #[allow(clippy::cast_sign_loss)] - #[must_use] - pub const fn spaces(&self) -> usize { - self.height as usize * self.width as usize - } - - #[must_use] - pub fn alive_snakes(&self) -> usize { - self.snakes.len() - } - - pub fn snakes(&self) -> impl Iterator + '_ { - self.snakes.keys().copied() - } - - #[must_use] - pub fn snake_length(&self, token: SnakeToken) -> Option { - self.snakes.get(&token).map(|snake| snake.body.len()) - } - - #[must_use] - pub fn last_action(&self, token: SnakeToken) -> Option { - self.snakes.get(&token).and_then(|snake| { - enum_iterator::all::() - .find(|direction| snake.body[1].move_to(*direction) == *snake.head()) - }) - } - - pub fn simulate_actions( - &mut self, - actions: &BTreeMap, - rng: &mut impl Rng, - ) { - // move snakes - for (token, snake) in &mut self.snakes { - snake.perform_action(actions.get(token).copied().unwrap_or(Direction::Up)); - } - - // feed snakes - for snake in &mut self.snakes.values_mut() { - let head = snake.head(); - if self.constrictor || self.food.remove(head) { - snake.health = MAX_HEALTH; - } - } - - // kill snakes - let alive_ids = self - .snakes - .iter() - .filter(|(_, snake)| { - // snake must have enough health - snake.health != 0 - }) - .map(|(token, snake)| (*token, snake.body.len(), *snake.head())) - .filter(|(_, _, head)| { - // head in bounds - (0..self.width).contains(&head.x) && (0..self.height).contains(&head.y) - }) - .filter(|(_, _, head)| { - // body collision - !self - .snakes - .values() - .flat_map(|snake2| snake2.body.iter().skip(1)) - .any(|body| body == head) - }) - .filter(|(token, len, head)| { - // head to head collision - !self - .snakes - .iter() - .filter(|(token2, snake2)| *token2 != token && snake2.body.len() >= *len) - .any(|(_, snake2)| snake2.head() == head) - }) - .map(|(token, _, _)| token) - .collect::>(); - self.snakes.retain(|token, _| alive_ids.contains(token)); - - // spawn new food - if self.food.len() < usize::from(self.min_food) - || rng.gen_ratio(u32::from(self.food_chance), 100) - { - let free_fields = (0..self.width) - .flat_map(|x| (0..self.height).map(move |y| Coord { x, y })) - .filter(|coord| { - !self - .snakes - .values() - .flat_map(|snake| snake.body.iter()) - .any(|body| body == coord) - }) - .filter(|coord| self.food.contains(coord)); - if let Some(field) = free_fields.choose(rng) { - self.food.insert(field); - } - } - - self.turn += 1; - } - - pub fn simulate_until(&mut self, rng: &mut impl Rng, mut exit: impl FnMut(&Self) -> bool) { - while !exit(self) { - let actions = self - .possible_actions() - .iter() - .map(|(token, actions)| { - ( - *token, - actions.iter().choose(rng).copied().unwrap_or(Direction::Up), - ) - }) - .collect(); - self.simulate_actions(&actions, rng); - } - } - - #[must_use] - pub fn possible_actions(&self) -> BTreeMap> { - let mut actions: BTreeMap<_, BTreeSet<_>> = self - .snakes - .keys() - .map(|&token| (token, enum_iterator::all::().collect())) - .collect(); - - for (token, actions) in &mut actions { - let snake = &self.snakes[token]; - let head = snake.head(); - - actions.retain(|direction| { - let target = head.move_to(*direction); - - // don't move out of bounds - if !((0..self.width).contains(&target.x) && (0..self.height).contains(&target.y)) { - return false; - } - - // don't collide with other snakes - !self - .snakes - .values() - .flat_map(|snake| { - let has_eaten = snake.health == MAX_HEALTH; - snake - .body - .iter() - .take(snake.body.len() - usize::from(!has_eaten)) - }) - .any(|coord| *coord == target) - }); - } - - // don't move into bigger snakes heads with only one movement option - let bigger_snakes = self - .snakes - .iter() - .sorted_unstable_by(|(_, snake1), (_, snake2)| snake2.health.cmp(&snake1.health)) - .map(|(token, snake)| { - let actions = &actions[token]; - if actions.len() == 1 { - let Some(action) = actions.first() else { - unreachable!() - }; - (snake.body.len(), Some(snake.head().move_to(*action))) - } else { - (snake.body.len(), None) - } - }) - .collect::>(); - for (token, actions) in &mut actions { - let snake = &self.snakes[token]; - let head = snake.head(); - - actions.retain(|direction| { - let target = head.move_to(*direction); - !bigger_snakes - .iter() - .take_while(|(length, _)| *length > snake.body.len()) - .any(|(_, coord)| coord.map_or(false, |coord| coord == target)) - }); - } - - actions - } -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct Battlesnake { - /// health points - health: u8, - /// Body of the snake. The head is the first element in the queue - body: VecDeque, -} - -impl Battlesnake { - #[must_use] - pub fn from_game_snake(snake: &crate::Battlesnake) -> Self { - let body: VecDeque<_> = snake.body.iter().copied().collect(); - debug_assert_eq!(Ok(body.len()), usize::try_from(snake.length)); - debug_assert!(snake.health <= crate::MAX_HEALTH); - let Ok(health) = u8::try_from(snake.health.min(100)) else { - unreachable!() - }; - Self { health, body } - } - - pub fn perform_action(&mut self, direction: Direction) { - debug_assert!(!self.body.is_empty()); - // move the head along - self.body.push_front(self.head().move_to(direction)); - - // move tail - if self.health != MAX_HEALTH { - // only move the tail if we didn't eat - self.body.pop_back(); - } - - // decrease helth - self.health = self.health.saturating_sub(1); - } - - #[must_use] - pub fn head(&self) -> &Coord { - &self.body[0] - } -}