use library for simulation

This commit is contained in:
Max Känner 2024-10-14 19:11:00 +02:00
parent 2b0b97cba8
commit 7227f1776f
7 changed files with 294 additions and 977 deletions

50
Cargo.lock generated
View File

@ -154,7 +154,9 @@ dependencies = [
name = "battlesnake" name = "battlesnake"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"battlesnake-game-types",
"criterion2", "criterion2",
"dashmap",
"enum-iterator", "enum-iterator",
"env_logger", "env_logger",
"iter_tools", "iter_tools",
@ -166,6 +168,20 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "binascii" name = "binascii"
version = "0.1.4" version = "0.1.4"
@ -322,6 +338,20 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" 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]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.11" version = "0.3.11"
@ -529,6 +559,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generator" name = "generator"
version = "0.7.5" version = "0.7.5"
@ -728,7 +767,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27812bb0a056539d62930a899759af39dfab17ac73a17d5caf58365762657891" checksum = "27812bb0a056539d62930a899759af39dfab17ac73a17d5caf58365762657891"
dependencies = [ dependencies = [
"clone_dyn_types", "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]] [[package]]

View File

@ -25,6 +25,9 @@ rand = "0.8.4"
enum-iterator = "2.1" enum-iterator = "2.1"
iter_tools = "0.21" iter_tools = "0.21"
ordered-float = "4.3.0" ordered-float = "4.3.0"
dashmap = "6.1.0"
battlesnake-game-types = "0.17.0"
[dev-dependencies] [dev-dependencies]
# criterion = { version = "0.5.1", features = ["html_reports"] } # criterion = { version = "0.5.1", features = ["html_reports"] }
@ -34,7 +37,3 @@ criterion2 = "1.1.1"
lto = "fat" lto = "fat"
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"
[[bench]]
name = "simulation"
harness = false

View File

@ -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<SnakeToken> {
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);

View File

@ -1,183 +1,24 @@
use enum_iterator::Sequence; use battlesnake_game_types::types::Move;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod logic; 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)] #[derive(Debug, Deserialize, Serialize)]
pub struct Action { pub struct Response {
/// In which direction the snake should move /// In which direction the snake should move
pub r#move: Direction, r#move: &'static str,
/// Say something to the other snakes
#[serde(default, skip_serializing_if = "is_default")]
pub shout: Option<String>,
} }
fn is_default<T: Default + PartialEq>(value: &T) -> bool { impl Response {
*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<Coord>,
/// Array of Battlesnakes representing all Battlesnakes remaining on the game board (including
/// yourself if you haven't been eliminated).
pub snakes: Vec<Battlesnake>,
/// Array of coordinates representing hazardous locations on the game board.
pub hazards: Vec<Coord>,
}
#[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<Coord>,
/// 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<String>,
/// 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 {
#[must_use] #[must_use]
pub const fn move_to(mut self, direction: Direction) -> Self { pub const fn new(r#move: Move) -> Self {
match direction { Self {
Direction::Left => self.x -= 1, r#move: match r#move {
Direction::Up => self.y += 1, Move::Left => "left",
Direction::Right => self.x += 1, Move::Down => "down",
Direction::Down => self.y -= 1, 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,
}

View File

@ -11,22 +11,23 @@
// For more info see docs.battlesnake.com // For more info see docs.battlesnake.com
use core::f64; use core::f64;
use std::{ use std::{collections::HashMap, time::Instant};
collections::{BTreeMap, HashMap},
sync::{Arc, LazyLock, Mutex},
time::{Duration, 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 ordered_float::OrderedFloat;
use rand::{prelude::*, thread_rng}; use rand::{prelude::*, thread_rng};
use rocket::time::{ext::NumericalDuration, Duration};
use serde_json::{json, Value}; 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 // info is called when you create your Battlesnake on play.battlesnake.com
// and controls your Battlesnake's appearance // and controls your Battlesnake's appearance
// TIP: If you open your Battlesnake URL in a browser you should see this data // 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)] #[derive(Debug)]
struct GameInfo { pub struct GameState {
calculation_time: Arc<Mutex<Duration>>, calculation_time: Duration,
token_mapping: Arc<BTreeMap<String, SnakeToken>>, snake_id_map: SnakeIDMap,
my_token: SnakeToken,
tree: Arc<Mutex<Option<Node>>>,
} }
static GAME_INFOS: LazyLock<Mutex<HashMap<(String, String), GameInfo>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
// start is called when your Battlesnake begins a game // 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"); info!("GAME START");
let token_mapping = Arc::new(SnakeToken::from_board(board)); let snake_id_map = build_snake_id_map(game);
let my_token = token_mapping[&you.id]; let calculation_time = (game.game.timeout / 2).milliseconds();
let Ok(mut game_infos) = GAME_INFOS.lock() else {
error!("unable to lock game infos"); GameState {
return; calculation_time,
}; snake_id_map,
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)),
},
);
} }
// end is called when your Battlesnake finishes a game // end is called when your Battlesnake finishes a game
pub fn end(game: &Game, turn: i32, _board: &Board, you: &Battlesnake) { pub fn end(game: &Game, state: GameState) {
info!("GAME OVER after {turn} turns"); std::mem::drop(state);
let Ok(mut game_infos) = GAME_INFOS.lock() else { info!("GAME OVER after {} turns", game.turn);
error!("unable to lock game infos");
return;
};
game_infos.remove(&(game.id.clone(), you.id.clone()));
} }
// move is called on every turn and returns your next move // move is called on every turn and returns your next move
// Valid moves are "up", "down", "left", or "right" // Valid moves are "up", "down", "left", or "right"
// See https://docs.battlesnake.com/api/example-move for available data // See https://docs.battlesnake.com/api/example-move for available data
pub fn get_move( pub fn get_move(game: Game, state: &mut GameState, start: &Instant) -> Move {
game: &Game,
turn: i32,
board: &Board,
you: &Battlesnake,
start: &Instant,
) -> Option<Action> {
let calc_start = Instant::now(); let calc_start = Instant::now();
if calc_start - *start > Duration::from_millis(10) { if calc_start - *start > 10.milliseconds() {
error!( error!(
"The calculation was started long after the request ({}ms)", "The calculation was started long after the request ({}ms)",
(calc_start - *start).as_millis() (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( let deadline = *start + state.calculation_time;
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;
}
// do some latency compensation let name = game.you.name.clone();
let deadline = *start let turn = game.turn;
+ game_info.calculation_time.lock().map_or_else( let solo = game.game.ruleset.name == "solo";
|_| Duration::from_millis(u64::from(game.timeout) / 2), let Ok(board) = CellBoard4Snakes11x11::convert_from_game(game, &state.snake_id_map) else {
|mut guard| { error!("Unable to fit board");
let target_latency = game.timeout / 2; return Move::Down;
let latency = you.latency.parse().unwrap_or_else(|e| { };
warn!("Unable to parse latency: {e}");
target_latency let mut tree = Node {
}); statistic: Statistics {
let last_computation_time = u32::try_from(guard.as_millis()).unwrap_or(0); played: 0,
let computation_time = won: HashMap::new(),
(last_computation_time + target_latency).saturating_sub(latency); },
*guard = child_statistics: HashMap::new(),
Duration::from_millis(u64::from(computation_time.clamp(1, target_latency))); childs: HashMap::new(),
*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 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 { while Instant::now() < deadline {
let mut board = board.clone(); if solo {
if game.ruleset.name == "solo" { tree.monte_carlo_solo_step(&board);
let _ = tree.monte_carlo_step_solo(&mut board, &deadline);
} else { } 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)] #[allow(clippy::cast_precision_loss)]
let chosen = actions let chosen = actions
.iter() .iter()
.max_by_key(|(_, stat)| OrderedFloat(stat.won as f64 / stat.played as f64)) .max_by_key(|(_, stat)| stat.played)
.map(|(direction, _)| *direction) .map(|(direction, _)| *direction)
.or_else(|| possible_actions.iter().choose(&mut thread_rng()).copied())?; .or_else(|| {
board
if let Ok(ref mut guard) = tree_guard { .random_reasonable_move_for_each_snake(&mut thread_rng())
**guard = Some(tree); .find(|(snake_id, _)| snake_id == board.you_id())
} .map(|(_, direction)| direction)
std::mem::drop(tree_guard); })
.unwrap_or(Move::Down);
info!( info!(
"DIRECTION {turn}: {chosen:?} after {}ms ({})", "DIRECTION {turn}: {chosen:?} after {}ms ({name})",
start.elapsed().as_millis(), start.elapsed().as_millis(),
you.name,
); );
Some(Action { chosen
r#move: chosen, }
shout: None,
}) #[derive(Debug, Clone, Copy)]
struct Instruments;
impl SimulatorInstruments for Instruments {
fn observe_simulation(&self, _duration: std::time::Duration) {}
} }
#[derive(Debug, PartialEq, Eq, Clone, Default)] #[derive(Debug, PartialEq, Eq, Clone, Default)]
@ -234,7 +144,7 @@ struct Statistics {
/// Number of times this node was simulated /// Number of times this node was simulated
played: usize, played: usize,
/// Number of times this node was simulated and the agent has won. /// Number of times this node was simulated and the agent has won.
won: BTreeMap<SnakeToken, usize>, won: HashMap<SnakeId, usize>,
} }
#[derive(Debug, PartialEq, Eq, Clone, Default)] #[derive(Debug, PartialEq, Eq, Clone, Default)]
@ -243,46 +153,44 @@ struct ActionStatistic {
won: usize, won: usize,
} }
struct DeadlineError;
#[derive(Debug, PartialEq, Eq, Clone, Default)] #[derive(Debug, PartialEq, Eq, Clone, Default)]
struct Node { struct Node {
statistic: Statistics, statistic: Statistics,
child_statistics: BTreeMap<SnakeToken, BTreeMap<Direction, ActionStatistic>>, child_statistics: HashMap<SnakeId, HashMap<Move, ActionStatistic>>,
childs: BTreeMap<BTreeMap<SnakeToken, (Direction, usize)>, Node>, childs: HashMap<[Option<(Move, u16)>; 4], Node>,
} }
impl Node { impl Node {
/// Performs one monte carlo simulation step /// Performs one monte carlo simulation step
/// ///
/// Returns the snake that has won the simulation /// Returns the snake that has won the simulation
fn monte_carlo_step( fn monte_carlo_step(&mut self, board: &CellBoard4Snakes11x11) -> Option<SnakeId> {
&mut self, let stop_condition = CellBoard4Snakes11x11::is_over;
board: &mut simulation::Board,
deadline: &Instant,
) -> Result<Option<SnakeToken>, DeadlineError> {
let stop_condition =
|board: &simulation::Board| board.alive_snakes() <= 1 || Instant::now() >= *deadline;
let winner = if stop_condition(board) { let winner = if stop_condition(board) {
if Instant::now() >= *deadline { board.get_winner()
return Err(DeadlineError);
}
board.snakes().next()
} else if self.statistic.played == 0 { } else if self.statistic.played == 0 {
// We didn't simulate a game for this node yet. Do that // We didn't simulate a game for this node yet. Do that
board.simulate_until(&mut thread_rng(), stop_condition); let mut board = *board;
if Instant::now() >= *deadline { while !stop_condition(&board) {
return Err(DeadlineError); 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 { } else {
// select a node to simulate // select a node to simulate
let possible_actions = board.possible_actions(); let possible_actions = board.reasonable_moves_for_each_snake();
let actions = possible_actions let actions = possible_actions
.iter()
.filter_map(|(token, actions)| { .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 selected = actions.iter().copied().max_by_key(|direction| {
let statistics = statistics.entry(*direction).or_default(); let statistics = statistics.entry(*direction).or_default();
if statistics.played == 0 { if statistics.played == 0 {
@ -297,20 +205,28 @@ impl Node {
); );
OrderedFloat(exploitation + exploration) OrderedFloat(exploitation + exploration)
})?; })?;
Some((*token, selected)) Some((token, [selected]))
}) })
.collect(); .collect::<Vec<_>>();
board.simulate_actions(&actions, &mut thread_rng()); let (_, board) = board
let map_actions = actions .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() .iter()
.map(|(&snake, &action)| (snake, (action, board.snake_length(snake).unwrap_or(0)))) .find(|(snake_id, _)| snake_id.as_usize() == i)
.collect(); .and_then(|(snake_id, moves)| {
Some((*moves.first()?, board.get_length(snake_id)))
});
}
let winner = self let winner = self
.childs .childs
.entry(map_actions) .entry(map_actions)
.or_default() .or_default()
.monte_carlo_step(board, deadline)?; .monte_carlo_step(&board);
// update child statistics // update child statistics
for (token, action) in &actions { for (token, action) in &actions {
@ -318,7 +234,7 @@ impl Node {
.child_statistics .child_statistics
.entry(*token) .entry(*token)
.or_default() .or_default()
.entry(*action) .entry(action[0])
.or_default(); .or_default();
entry.played += 1; entry.played += 1;
if Some(*token) == winner { if Some(*token) == winner {
@ -336,104 +252,108 @@ impl Node {
.and_modify(|won| *won += 1) .and_modify(|won| *won += 1)
.or_insert(1); .or_insert(1);
} }
Ok(winner) winner
} }
/// Performs one monte carlo simulation step for a solo game /// Performs one monte carlo simulation step for a solo game
/// ///
/// Returns the lengths before death /// Returns the lengths before death
fn monte_carlo_step_solo( fn monte_carlo_solo_step(&mut self, board: &CellBoard4Snakes11x11) -> u16 {
&mut self, let stop_condition = |board: &CellBoard4Snakes11x11| board.alive_snake_count() == 0;
board: &mut simulation::Board, let winner = if self.statistic.played == 0 {
deadline: &Instant,
) -> Result<BTreeMap<SnakeToken, usize>, DeadlineError> {
let lengths = if self.statistic.played == 0 {
// We didn't simulate a game for this node yet. Do that // We didn't simulate a game for this node yet. Do that
let mut lengths: BTreeMap<_, _> = board let mut board = *board;
.snakes() while !stop_condition(&board) {
.filter_map(|snake| Some((snake, board.snake_length(snake)?))) let moves =
.collect(); board
board.simulate_until(&mut thread_rng(), |board| { .reasonable_moves_for_each_snake()
if Instant::now() >= *deadline { .filter_map(|(snake_id, moves)| {
return true; Some((snake_id, [*moves.choose(&mut thread_rng())?]))
}
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 { let Some((_, new_board)) = board.simulate_with_moves(&Instruments, moves).next()
return Err(DeadlineError); else {
break;
};
if stop_condition(&new_board) {
break;
} }
lengths board = new_board;
}
let winner = board.get_length(board.you_id());
winner
} else { } else {
// select a node to simulate // select a node to simulate
let possible_actions = board.possible_actions(); let possible_actions = board.reasonable_moves_for_each_snake();
let actions = possible_actions let actions = possible_actions
.iter()
.filter_map(|(token, actions)| { .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 selected = actions.iter().copied().max_by_key(|direction| {
let statistics = statistics.entry(*direction).or_default(); let statistics = statistics.entry(*direction).or_default();
if statistics.played == 0 { if statistics.played == 0 {
return OrderedFloat(f64::INFINITY); return OrderedFloat(f64::INFINITY);
} }
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
let exploitation = statistics.won as f64 let exploitation = statistics.won as f64 / statistics.played as f64;
/ board.spaces() as f64
/ statistics.played as f64;
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
let exploration = f64::consts::SQRT_2 let exploration = f64::consts::SQRT_2
* f64::sqrt( * f64::sqrt(
f64::ln(self.statistic.played as f64) / statistics.played as f64, f64::ln(self.statistic.played as f64) / statistics.played as f64,
); )
* 11.0
* 11.0;
OrderedFloat(exploitation + exploration) OrderedFloat(exploitation + exploration)
})?; })?;
Some((*token, selected)) Some((token, [selected]))
}) })
.collect(); .collect::<Vec<_>>();
if Instant::now() >= *deadline { let (_, new_board) = board
return Err(DeadlineError); .simulate_with_moves(&Instruments, actions.iter().copied())
} .next()
board.simulate_actions(&actions, &mut thread_rng()); .unwrap();
let map_actions = actions let mut map_actions = [None; 4];
for (i, action) in map_actions.iter_mut().enumerate() {
*action = actions
.iter() .iter()
.map(|(&snake, &action)| (snake, (action, board.snake_length(snake).unwrap_or(0)))) .find(|(snake_id, _)| snake_id.as_usize() == i)
.collect(); .and_then(|(snake_id, moves)| {
let lengths = self Some((*moves.first()?, new_board.get_length(snake_id)))
.childs });
}
let winner = if stop_condition(&new_board) {
board.get_length(board.you_id())
} else {
self.childs
.entry(map_actions) .entry(map_actions)
.or_default() .or_default()
.monte_carlo_step_solo(board, deadline)?; .monte_carlo_solo_step(&new_board)
};
// update child statistics // update child statistics
for (token, action) in &actions {
let entry = self let entry = self
.child_statistics .child_statistics
.entry(*token) .entry(*new_board.you_id())
.or_default() .or_default()
.entry(*action) .entry(
actions
.iter()
.find(|(snake_id, _)| snake_id == new_board.you_id())
.map(|(_, action)| action[0])
.unwrap(),
)
.or_default(); .or_default();
entry.played += 1; entry.played += 1;
if let Some(length) = lengths.get(token) { entry.won += usize::from(winner);
entry.won += length;
}
}
lengths winner
}; };
self.statistic.played += 1; self.statistic.played += 1;
for (token, length) in &lengths {
self.statistic self.statistic
.won .won
.entry(*token) .entry(*board.you_id())
.and_modify(|won| *won += length) .and_modify(|won| *won += usize::from(winner))
.or_insert(*length); .or_insert_with(|| usize::from(winner));
} winner
Ok(lengths)
} }
} }

View File

@ -1,57 +1,77 @@
#![allow(clippy::needless_pass_by_value)] #![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 log::{error, info};
use rocket::{ 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 serde_json::Value;
use std::{env, time::Instant};
type States = Arc<DashMap<(String, String), GameState>>;
#[get("/")] #[get("/")]
fn handle_index() -> Json<Value> { fn handle_index() -> Json<Value> {
Json(logic::info()) Json(logic::info())
} }
#[post("/start", format = "json", data = "<start_req>")] #[post("/start", format = "json", data = "<game>")]
fn handle_start(start_req: Json<GameState>) -> Status { fn handle_start(state: &State<States>, game: Json<Game>) -> Status {
logic::start( if state
&start_req.game, .insert(
start_req.turn, (game.game.id.clone(), game.you.id.clone()),
&start_req.board, logic::start(&game),
&start_req.you, )
); .is_some()
{
error!("re-started game");
}
Status::Ok Status::Ok
} }
#[post("/move", format = "json", data = "<move_req>")] #[post("/move", format = "json", data = "<game>")]
async fn handle_move(move_req: Json<GameState>) -> Json<Action> { async fn handle_move(state: &State<States>, game: Json<Game>) -> Json<Response> {
let start = Instant::now(); let start = Instant::now();
let response = task::spawn_blocking(move || { let state = (*state).clone();
logic::get_move( let action = task::spawn_blocking(move || {
&move_req.game, let mut game_state = state.get_mut(&(game.game.id.clone(), game.you.id.clone()));
move_req.turn, while game_state.is_none() {
&move_req.board, error!("move request without previous start");
&move_req.you, if state
&start, .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 .await
.inspect_err(|e| error!("failed to join compute thread: {e}")) .unwrap_or(Move::Up);
.ok() Json(Response::new(action))
.flatten()
.unwrap_or(Action {
r#move: Direction::Up,
shout: Some("I am so dead".to_owned()),
});
Json(response)
} }
#[post("/end", format = "json", data = "<end_req>")] #[post("/end", format = "json", data = "<game>")]
fn handle_end(end_req: Json<GameState>) -> Status { fn handle_end(state: &State<States>, game: Json<Game>) -> Status {
logic::end(&end_req.game, end_req.turn, &end_req.board, &end_req.you); 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 Status::Ok
} }
@ -85,4 +105,5 @@ fn rocket() -> _ {
"/", "/",
routes![handle_index, handle_start, handle_move, handle_end], routes![handle_index, handle_start, handle_move, handle_end],
) )
.manage(States::new(DashMap::new()))
} }

View File

@ -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<String, Self> {
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<Coord>,
/// 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<SnakeToken, Battlesnake>,
/// 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<String, SnakeToken>,
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<Item = SnakeToken> + '_ {
self.snakes.keys().copied()
}
#[must_use]
pub fn snake_length(&self, token: SnakeToken) -> Option<usize> {
self.snakes.get(&token).map(|snake| snake.body.len())
}
#[must_use]
pub fn last_action(&self, token: SnakeToken) -> Option<Direction> {
self.snakes.get(&token).and_then(|snake| {
enum_iterator::all::<Direction>()
.find(|direction| snake.body[1].move_to(*direction) == *snake.head())
})
}
pub fn simulate_actions(
&mut self,
actions: &BTreeMap<SnakeToken, Direction>,
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::<Vec<_>>();
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<SnakeToken, BTreeSet<Direction>> {
let mut actions: BTreeMap<_, BTreeSet<_>> = self
.snakes
.keys()
.map(|&token| (token, enum_iterator::all::<Direction>().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::<Vec<_>>();
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<Coord>,
}
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]
}
}