accept battlesnake requests

This commit is contained in:
Max Känner 2025-01-16 17:59:35 +01:00
parent 7c79c718d0
commit 932023451a
7 changed files with 512 additions and 1668 deletions

1473
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[package] [package]
authors = ["Max Känner"] authors = ["Max Känner"]
name = "battlesnake" name = "battlesnake"
version = "1.0.0" version = "2.0.0"
edition = "2021" edition = "2021"
readme = "README.md" readme = "README.md"
@ -16,22 +16,8 @@ pedantic = "warn"
nursery = "warn" nursery = "warn"
[dependencies] [dependencies]
rocket = { version = "0.5.0", features = ["json"] } tokio = { version = "1.43", features = ["net", "macros", "rt-multi-thread"] }
serde = { version = "1.0.117", features = ["derive"] } axum = { version = "0.8", features = ["http2", "multipart", "ws"] }
serde_json = "1.0.59" serde = { version = "1.0", features = ["derive"] }
log = "0.4.0" log = "0.4"
env_logger = "0.11.5" env_logger = "0.11"
rand = "0.8.4"
enum-iterator = "2.1"
iter_tools = "0.24"
ordered-float = "4.3.0"
dashmap = "6.1.0"
nalgebra = "0.33.2"
battlesnake-game-types = "0.17.0"
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"

View File

@ -1,24 +0,0 @@
use battlesnake_game_types::types::Move;
use serde::{Deserialize, Serialize};
pub mod logic;
#[derive(Debug, Deserialize, Serialize)]
pub struct Response {
/// In which direction the snake should move
r#move: &'static str,
}
impl Response {
#[must_use]
pub const fn new(value: Move) -> Self {
Self {
r#move: match value {
Move::Left => "left",
Move::Down => "down",
Move::Up => "up",
Move::Right => "right",
},
}
}
}

View File

@ -1,359 +0,0 @@
// Welcome to
// __________ __ __ .__ __
// \______ \_____ _/ |__/ |_| | ____ ______ ____ _____ | | __ ____
// | | _/\__ \\ __\ __\ | _/ __ \ / ___// \\__ \ | |/ // __ \
// | | \ / __ \| | | | | |_\ ___/ \___ \| | \/ __ \| <\ ___/
// |________/(______/__| |__| |____/\_____>______>___|__(______/__|__\\_____>
//
// This file can be a nice home for your Battlesnake logic and helper functions.
//
// To get you started we've included code to prevent your Battlesnake from moving backwards.
// For more info see docs.battlesnake.com
use core::f64;
use std::{collections::HashMap, time::Instant};
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};
// 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
#[must_use]
pub fn info() -> Value {
info!("INFO");
json!({
"apiversion": "1",
"author": "der-informatiker",
"color": "#00FFEE",
"head": "smart-caterpillar",
"tail": "mouse",
})
}
#[derive(Debug)]
pub struct GameState {
calculation_time: Duration,
snake_id_map: SnakeIDMap,
}
// start is called when your Battlesnake begins a game
#[must_use]
pub fn start(game: &Game) -> GameState {
info!("GAME START");
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, 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, state: &mut GameState, start: &Instant) -> Move {
let calc_start = Instant::now();
if calc_start - *start > 10.milliseconds() {
error!(
"The calculation was started long after the request ({}ms)",
(calc_start - *start).as_millis()
);
}
let deadline = *start + state.calculation_time;
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(),
};
while Instant::now() < deadline {
if solo {
tree.monte_carlo_solo_step(&board);
} else {
tree.monte_carlo_step(&board);
}
}
let actions = tree.child_statistics.entry(*board.you_id()).or_default();
info!("actions {}: {actions:?}", name);
#[allow(clippy::cast_precision_loss)]
let chosen = actions
.iter()
.max_by_key(|(_, stat)| stat.played)
.map(|(direction, _)| *direction)
.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 ({name})",
start.elapsed().as_millis(),
);
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)]
struct Statistics {
/// Number of times this node was simulated
played: usize,
/// Number of times this node was simulated and the agent has won.
won: HashMap<SnakeId, usize>,
}
#[derive(Debug, PartialEq, Eq, Clone, Default)]
struct ActionStatistic {
played: usize,
won: usize,
}
#[derive(Debug, PartialEq, Eq, Clone, Default)]
struct Node {
statistic: Statistics,
child_statistics: HashMap<SnakeId, HashMap<Move, ActionStatistic>>,
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: &CellBoard4Snakes11x11) -> Option<SnakeId> {
let stop_condition = CellBoard4Snakes11x11::is_over;
let winner = if stop_condition(board) {
board.get_winner()
} else if self.statistic.played == 0 {
// We didn't simulate a game for this node yet. Do that
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.get_winner()
} else {
// select a node to simulate
let possible_actions = board.reasonable_moves_for_each_snake();
let actions = possible_actions
.filter_map(|(token, actions)| {
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 / 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,
);
OrderedFloat(exploitation + exploration)
})?;
Some((token, [selected]))
})
.collect::<Vec<_>>();
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);
// update child statistics
for (token, action) in &actions {
let entry = self
.child_statistics
.entry(*token)
.or_default()
.entry(action[0])
.or_default();
entry.played += 1;
if Some(*token) == winner {
entry.won += 1;
}
}
winner
};
self.statistic.played += 1;
if let Some(token) = winner {
self.statistic
.won
.entry(token)
.and_modify(|won| *won += 1)
.or_insert(1);
}
winner
}
/// Performs one monte carlo simulation step for a solo game
///
/// Returns the lengths before death
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 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;
}
board = new_board;
}
let winner = board.get_length(board.you_id());
winner
} else {
// select a node to simulate
let possible_actions = board.reasonable_moves_for_each_snake();
let actions = possible_actions
.filter_map(|(token, actions)| {
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 / 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]))
})
.collect::<Vec<_>>();
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)))
});
}
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
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);
winner
};
self.statistic.played += 1;
self.statistic
.won
.entry(*board.you_id())
.and_modify(|won| *won += usize::from(winner))
.or_insert_with(|| usize::from(winner));
winner
}
}

View File

@ -1,109 +1,67 @@
#![allow(clippy::needless_pass_by_value)] mod types;
use std::{env, sync::Arc, time::Instant};
use battlesnake::{ use axum::{
logic::{self, GameState}, extract::Json,
Response, response,
routing::{get, post},
Router,
}; };
use battlesnake_game_types::{types::Move, wire_representation::Game}; use log::{debug, info};
use dashmap::DashMap; use serde::Serialize;
use log::{error, info}; use tokio::net::TcpListener;
use rocket::{ use types::wire::{Direction, Request, Response};
fairing::AdHoc, get, http::Status, launch, post, routes, serde::json::Json, tokio::task, State,
};
use serde_json::Value;
type States = Arc<DashMap<(String, String), GameState>>;
#[get("/")]
fn handle_index() -> Json<Value> {
Json(logic::info())
}
#[post("/start", format = "json", data = "<game>")]
fn handle_start(state: &State<States>, game: Json<Game>) -> 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 = "<game>")]
async fn handle_move(state: &State<States>, game: Json<Game>) -> Json<Response> {
let start = Instant::now();
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
.unwrap_or(Move::Up);
Json(Response::new(action))
}
#[post("/end", format = "json", data = "<game>")]
fn handle_end(state: &State<States>, game: Json<Game>) -> 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
}
#[launch]
fn rocket() -> _ {
// Lots of web hosting services expect you to bind to the port specified by the `PORT`
// environment variable. However, Rocket looks at the `ROCKET_PORT` environment variable.
// If we find a value for `PORT`, we set `ROCKET_PORT` to that value.
if let Ok(port) = env::var("PORT") {
env::set_var("ROCKET_PORT", &port);
}
// We default to 'info' level logging. But if the `RUST_LOG` environment variable is set,
// we keep that value instead.
if env::var("RUST_LOG").is_err() {
env::set_var("RUST_LOG", "info");
}
#[tokio::main]
async fn main() {
env_logger::init(); env_logger::init();
info!("Starting Battlesnake Server..."); debug!("Creating routes");
let app = Router::new()
.route("/", get(info))
.route("/start", post(start))
.route("/move", post(get_move))
.route("/end", post(end));
rocket::build() debug!("Creating listener");
.attach(AdHoc::on_response("Server ID Middleware", |_, res| { let listener = TcpListener::bind("0.0.0.0:8000").await.unwrap();
Box::pin(async move { debug!("Starting server");
res.set_raw_header("Server", "battlesnake/github/starter-snake-rust"); axum::serve(listener, app).await.unwrap();
}) }
}))
.mount( async fn info() -> response::Json<Info> {
"/", info!("got info request");
routes![handle_index, handle_start, handle_move, handle_end], response::Json(Info {
) apiversion: "1",
.manage(States::new(DashMap::new())) author: "der-informatiker",
color: "#00FFEE",
head: "smart-caterpillar",
tail: "mouse",
version: env!("CARGO_PKG_VERSION"),
})
}
#[derive(Debug, Clone, Serialize)]
struct Info {
apiversion: &'static str,
author: &'static str,
color: &'static str,
head: &'static str,
tail: &'static str,
version: &'static str,
}
async fn start(request: Json<Request>) {
info!("got start request: {request:#?}");
}
async fn get_move(request: Json<Request>) -> response::Json<Response> {
info!("got move request: {request:#?}");
response::Json(Response {
direction: Direction::Up,
shout: None,
})
}
async fn end(request: Json<Request>) {
info!("got end request: {request:#?}");
} }

View File

@ -0,0 +1 @@
pub mod wire;

View File

@ -0,0 +1,135 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct Request {
/// Game object describing the game being played.
game: Game,
/// Turn number for this move.
turn: u32,
/// Board object describing the initial state of the game board.
board: Board,
/// Battlesnake Object describing your Battlesnake.
you: Battlesnake,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct Game {
/// A unique identifier for this Game.
id: String,
/// Information about the ruleset being used to run this Game.
ruleset: Ruleset,
/// The name of the map being played on.
map: String,
/// How much time your snake has to respond to requests for this Game.
timeout: u16,
/// The source of this Game.
/// One of:
/// - tournament
/// - league
/// - arena
/// - challenge
/// - custom
source: String,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct Ruleset {
/// Name of the ruleset being used to run this game.
name: String,
/// The release version of the Rules module used in this game.
version: String,
/// A collection of specific settings being used by the current game that control how the rules
/// are applied.
settings: Settings,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
/// Percentage chance of spawning a new food every round.
food_spawn_chance: u8,
/// Minimum food to keep on the board every turn.
minimum_food: u16,
/// 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.
hazard_damage_per_turn: u8,
/// Settings for the royale game mode
royale: RoyaleSettings,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoyaleSettings {
/// The number of turns between generating new hazards (shrinking the safe board space).
shrink_every_n_turns: u8,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct Board {
/// The number of rows in the y-axis of the game board.
height: u16,
/// The number of rows in the x-axis of the game board.
width: u16,
/// Array of coordinates representing food locations on the game board.
food: Vec<Coord>,
/// Array of coordinates representing hazardous locations on the game board.
hazards: Vec<Coord>,
/// Array of Battlesnake objects representing all Battlesnakes remaining on the game board
/// (including yourself if you haven't been eliminated).
snakes: Vec<Battlesnake>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize)]
pub struct Coord {
x: u16,
y: u16,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct Battlesnake {
/// Unique identifier for this Battlesnake in the context of the current Game.
id: String,
/// Name given to this Battlesnake by its author
name: String,
/// Health value of this Battlesnake, between 0 and 100
health: u8,
/// Array of coordinates representing the Battlesnake's location on the game board.
/// This array is ordered from head to tail.
body: Vec<Coord>,
/// The previous response time of this Battlesnake, in milliseconds.
/// If the Battlesnake timed out and failed to respond, the game timeout will be returned
latency: String,
/// Coordinates for this Battlesnake's head.
/// Equivalent to the first element of the body array.
head: Coord,
/// Length of this Battlesnake from head to tail.
/// Equivalent to the length of the body array.
length: u32,
/// Message shouted by this Battlesnake on the previous turn
shout: String,
/// The squad that the Battlesnake belongs to.
/// Used to identify squad members in Squad Mode games.
squad: String,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
pub struct Response {
/// Your Battlesnake's move for this turn.
#[serde(rename = "move")]
pub direction: Direction,
/// An optional message sent to all other Battlesnakes on the next turn.
/// Must be 256 characters or less.
pub shout: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize)]
pub enum Direction {
/// Move in positive y direction
Up,
/// Move in negative y direction
Down,
/// Move in negative x direction
Left,
/// Move in positive x direction
Right,
}