Merge branch 'feature/leaderboard'
This commit is contained in:
commit
80c60ac69c
18 changed files with 429 additions and 112 deletions
|
@ -38,7 +38,6 @@ pub struct PlayerInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MatchPlayer {
|
pub struct MatchPlayer {
|
||||||
pub name: String,
|
|
||||||
pub bot_spec: Box<dyn BotSpec>,
|
pub bot_spec: Box<dyn BotSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +51,11 @@ pub trait BotSpec: Send + Sync {
|
||||||
) -> Box<dyn PlayerHandle>;
|
) -> Box<dyn PlayerHandle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_match(config: MatchConfig) {
|
pub struct MatchOutcome {
|
||||||
|
pub winner: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_match(config: MatchConfig) -> MatchOutcome {
|
||||||
let pw_config = PwConfig {
|
let pw_config = PwConfig {
|
||||||
map_file: config.map_path,
|
map_file: config.map_path,
|
||||||
max_turns: 100,
|
max_turns: 100,
|
||||||
|
@ -103,8 +106,18 @@ pub async fn run_match(config: MatchConfig) {
|
||||||
// )
|
// )
|
||||||
// .unwrap();
|
// .unwrap();
|
||||||
|
|
||||||
let match_state = pw_match::PwMatch::create(match_ctx, pw_config);
|
let mut match_state = pw_match::PwMatch::create(match_ctx, pw_config);
|
||||||
match_state.run().await;
|
match_state.run().await;
|
||||||
|
|
||||||
|
let final_state = match_state.match_state.state();
|
||||||
|
let survivors = final_state.living_players();
|
||||||
|
let winner = if survivors.len() == 1 {
|
||||||
|
Some(survivors[0])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
MatchOutcome { winner }
|
||||||
}
|
}
|
||||||
|
|
||||||
// writing this as a closure causes lifetime inference errors
|
// writing this as a closure causes lifetime inference errors
|
||||||
|
|
|
@ -24,8 +24,8 @@ pub struct MatchConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PwMatch {
|
pub struct PwMatch {
|
||||||
match_ctx: MatchCtx,
|
pub match_ctx: MatchCtx,
|
||||||
match_state: PlanetWars,
|
pub match_state: PlanetWars,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PwMatch {
|
impl PwMatch {
|
||||||
|
@ -39,7 +39,7 @@ impl PwMatch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(mut self) {
|
pub async fn run(&mut self) {
|
||||||
while !self.match_state.is_finished() {
|
while !self.match_state.is_finished() {
|
||||||
let player_messages = self.prompt_players().await;
|
let player_messages = self.prompt_players().await;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
drop table ratings;
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Your SQL goes here
|
||||||
|
-- this table could later be expanded to include more information,
|
||||||
|
-- such as rating state (eg. number of matches played) or scope (eg. map)
|
||||||
|
create table ratings (
|
||||||
|
bot_id integer PRIMARY KEY REFERENCES bots(id),
|
||||||
|
rating float NOT NULL
|
||||||
|
)
|
|
@ -11,7 +11,7 @@ pub struct NewBot<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Queryable, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Queryable, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Bot {
|
pub struct Bot {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub owner_id: Option<i32>,
|
pub owner_id: Option<i32>,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod bots;
|
pub mod bots;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
|
pub mod ratings;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
54
planetwars-server/src/db/ratings.rs
Normal file
54
planetwars-server/src/db/ratings.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
use diesel::{prelude::*, PgConnection, QueryResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db::bots::Bot;
|
||||||
|
use crate::schema::{bots, ratings, users};
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug, Insertable, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Rating {
|
||||||
|
pub bot_id: i32,
|
||||||
|
pub rating: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_rating(bot_id: i32, db_conn: &PgConnection) -> QueryResult<Option<f64>> {
|
||||||
|
ratings::table
|
||||||
|
.filter(ratings::bot_id.eq(bot_id))
|
||||||
|
.select(ratings::rating)
|
||||||
|
.first(db_conn)
|
||||||
|
.optional()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_rating(bot_id: i32, rating: f64, db_conn: &PgConnection) -> QueryResult<usize> {
|
||||||
|
diesel::insert_into(ratings::table)
|
||||||
|
.values(Rating { bot_id, rating })
|
||||||
|
.on_conflict(ratings::bot_id)
|
||||||
|
.do_update()
|
||||||
|
.set(ratings::rating.eq(rating))
|
||||||
|
.execute(db_conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Queryable, Serialize, Deserialize)]
|
||||||
|
pub struct Author {
|
||||||
|
id: i32,
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Queryable, Serialize, Deserialize)]
|
||||||
|
pub struct RankedBot {
|
||||||
|
pub bot: Bot,
|
||||||
|
pub author: Option<Author>,
|
||||||
|
pub rating: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_bot_ranking(db_conn: &PgConnection) -> QueryResult<Vec<RankedBot>> {
|
||||||
|
bots::table
|
||||||
|
.left_join(users::table)
|
||||||
|
.inner_join(ratings::table)
|
||||||
|
.select((
|
||||||
|
bots::all_columns,
|
||||||
|
(users::id, users::username).nullable(),
|
||||||
|
ratings::rating,
|
||||||
|
))
|
||||||
|
.order_by(ratings::rating.desc())
|
||||||
|
.get_results(db_conn)
|
||||||
|
}
|
|
@ -8,11 +8,14 @@ pub mod routes;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use bb8::{Pool, PooledConnection};
|
use bb8::{Pool, PooledConnection};
|
||||||
use bb8_diesel::{self, DieselConnectionManager};
|
use bb8_diesel::{self, DieselConnectionManager};
|
||||||
|
use config::ConfigError;
|
||||||
use diesel::{Connection, PgConnection};
|
use diesel::{Connection, PgConnection};
|
||||||
|
use modules::ranking::run_ranker;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -55,16 +58,16 @@ pub async fn seed_simplebot(pool: &ConnectionPool) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prepare_db(database_url: &str) -> Pool<DieselConnectionManager<PgConnection>> {
|
pub type DbPool = Pool<DieselConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
|
pub async fn prepare_db(database_url: &str) -> DbPool {
|
||||||
let manager = DieselConnectionManager::<PgConnection>::new(database_url);
|
let manager = DieselConnectionManager::<PgConnection>::new(database_url);
|
||||||
let pool = bb8::Pool::builder().build(manager).await.unwrap();
|
let pool = bb8::Pool::builder().build(manager).await.unwrap();
|
||||||
seed_simplebot(&pool).await;
|
seed_simplebot(&pool).await;
|
||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn api(configuration: Configuration) -> Router {
|
pub fn api() -> Router {
|
||||||
let db_pool = prepare_db(&configuration.database_url).await;
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/register", post(routes::users::register))
|
.route("/register", post(routes::users::register))
|
||||||
.route("/login", post(routes::users::login))
|
.route("/login", post(routes::users::login))
|
||||||
|
@ -88,21 +91,34 @@ pub async fn api(configuration: Configuration) -> Router {
|
||||||
"/matches/:match_id/log",
|
"/matches/:match_id/log",
|
||||||
get(routes::matches::get_match_log),
|
get(routes::matches::get_match_log),
|
||||||
)
|
)
|
||||||
|
.route("/leaderboard", get(routes::bots::get_ranking))
|
||||||
.route("/submit_bot", post(routes::demo::submit_bot))
|
.route("/submit_bot", post(routes::demo::submit_bot))
|
||||||
.route("/save_bot", post(routes::bots::save_bot))
|
.route("/save_bot", post(routes::bots::save_bot))
|
||||||
.layer(AddExtensionLayer::new(db_pool))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn app() -> Router {
|
pub fn get_config() -> Result<Configuration, ConfigError> {
|
||||||
let configuration = config::Config::builder()
|
config::Config::builder()
|
||||||
.add_source(config::File::with_name("configuration.toml"))
|
.add_source(config::File::with_name("configuration.toml"))
|
||||||
.add_source(config::Environment::with_prefix("PLANETWARS"))
|
.add_source(config::Environment::with_prefix("PLANETWARS"))
|
||||||
.build()
|
.build()?
|
||||||
.unwrap()
|
|
||||||
.try_deserialize()
|
.try_deserialize()
|
||||||
.unwrap();
|
}
|
||||||
let api = api(configuration).await;
|
|
||||||
Router::new().nest("/api", api)
|
pub async fn run_app() {
|
||||||
|
let configuration = get_config().unwrap();
|
||||||
|
let db_pool = prepare_db(&configuration.database_url).await;
|
||||||
|
|
||||||
|
tokio::spawn(run_ranker(db_pool.clone()));
|
||||||
|
|
||||||
|
let api_service = Router::new()
|
||||||
|
.nest("/api", api())
|
||||||
|
.layer(AddExtensionLayer::new(db_pool))
|
||||||
|
.into_make_service();
|
||||||
|
|
||||||
|
// TODO: put in config
|
||||||
|
let addr = SocketAddr::from(([127, 0, 0, 1], 9000));
|
||||||
|
|
||||||
|
axum::Server::bind(&addr).serve(api_service).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
@ -1,16 +1,7 @@
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
extern crate planetwars_server;
|
extern crate planetwars_server;
|
||||||
extern crate tokio;
|
extern crate tokio;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let app = planetwars_server::app().await;
|
planetwars_server::run_app().await;
|
||||||
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 9000));
|
|
||||||
|
|
||||||
axum::Server::bind(&addr)
|
|
||||||
.serve(app.into_make_service())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
102
planetwars-server/src/modules/matches.rs
Normal file
102
planetwars-server/src/modules/matches.rs
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use diesel::{PgConnection, QueryResult};
|
||||||
|
use planetwars_matchrunner::{self as runner, docker_runner::DockerBotSpec, BotSpec, MatchConfig};
|
||||||
|
use runner::MatchOutcome;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{self, matches::MatchData},
|
||||||
|
util::gen_alphanumeric,
|
||||||
|
ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
|
||||||
|
|
||||||
|
pub struct RunMatch<'a> {
|
||||||
|
log_file_name: String,
|
||||||
|
player_code_bundles: Vec<&'a db::bots::CodeBundle>,
|
||||||
|
match_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> RunMatch<'a> {
|
||||||
|
pub fn from_players(player_code_bundles: Vec<&'a db::bots::CodeBundle>) -> Self {
|
||||||
|
let log_file_name = format!("{}.log", gen_alphanumeric(16));
|
||||||
|
RunMatch {
|
||||||
|
log_file_name,
|
||||||
|
player_code_bundles,
|
||||||
|
match_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runner_config(&self) -> runner::MatchConfig {
|
||||||
|
runner::MatchConfig {
|
||||||
|
map_path: PathBuf::from(MAPS_DIR).join("hex.json"),
|
||||||
|
map_name: "hex".to_string(),
|
||||||
|
log_path: PathBuf::from(MATCHES_DIR).join(&self.log_file_name),
|
||||||
|
players: self
|
||||||
|
.player_code_bundles
|
||||||
|
.iter()
|
||||||
|
.map(|b| runner::MatchPlayer {
|
||||||
|
bot_spec: code_bundle_to_botspec(b),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_in_database(&mut self, db_conn: &PgConnection) -> QueryResult<MatchData> {
|
||||||
|
// don't store the same match twice
|
||||||
|
assert!(self.match_id.is_none());
|
||||||
|
|
||||||
|
let new_match_data = db::matches::NewMatch {
|
||||||
|
state: db::matches::MatchState::Playing,
|
||||||
|
log_path: &self.log_file_name,
|
||||||
|
};
|
||||||
|
let new_match_players = self
|
||||||
|
.player_code_bundles
|
||||||
|
.iter()
|
||||||
|
.map(|b| db::matches::MatchPlayerData {
|
||||||
|
code_bundle_id: b.id,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let match_data = db::matches::create_match(&new_match_data, &new_match_players, &db_conn)?;
|
||||||
|
self.match_id = Some(match_data.base.id);
|
||||||
|
Ok(match_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(self, pool: ConnectionPool) -> JoinHandle<MatchOutcome> {
|
||||||
|
let match_id = self.match_id.expect("match must be saved before running");
|
||||||
|
let runner_config = self.runner_config();
|
||||||
|
tokio::spawn(run_match_task(pool, runner_config, match_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn code_bundle_to_botspec(code_bundle: &db::bots::CodeBundle) -> Box<dyn BotSpec> {
|
||||||
|
let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
|
||||||
|
|
||||||
|
Box::new(DockerBotSpec {
|
||||||
|
code_path: bundle_path,
|
||||||
|
image: PYTHON_IMAGE.to_string(),
|
||||||
|
argv: vec!["python".to_string(), "bot.py".to_string()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_match_task(
|
||||||
|
connection_pool: ConnectionPool,
|
||||||
|
match_config: MatchConfig,
|
||||||
|
match_id: i32,
|
||||||
|
) -> MatchOutcome {
|
||||||
|
let outcome = runner::run_match(match_config).await;
|
||||||
|
|
||||||
|
// update match state in database
|
||||||
|
let conn = connection_pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.expect("could not get database connection");
|
||||||
|
|
||||||
|
db::matches::set_match_state(match_id, db::matches::MatchState::Finished, &conn)
|
||||||
|
.expect("could not update match state");
|
||||||
|
|
||||||
|
return outcome;
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
// This module implements general domain logic, not directly
|
// This module implements general domain logic, not directly
|
||||||
// tied to the database or API layers.
|
// tied to the database or API layers.
|
||||||
pub mod bots;
|
pub mod bots;
|
||||||
|
pub mod matches;
|
||||||
|
pub mod ranking;
|
||||||
|
|
87
planetwars-server/src/modules/ranking.rs
Normal file
87
planetwars-server/src/modules/ranking.rs
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
use crate::{db::bots::Bot, DbPool};
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
|
use crate::modules::matches::RunMatch;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio;
|
||||||
|
|
||||||
|
const RANKER_INTERVAL: u64 = 60;
|
||||||
|
const START_RATING: f64 = 0.0;
|
||||||
|
const SCALE: f64 = 100.0;
|
||||||
|
const MAX_UPDATE: f64 = 10.0;
|
||||||
|
|
||||||
|
pub async fn run_ranker(db_pool: DbPool) {
|
||||||
|
// TODO: make this configurable
|
||||||
|
// play at most one match every n seconds
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL));
|
||||||
|
let db_conn = db_pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.expect("could not get database connection");
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let bots = db::bots::find_all_bots(&db_conn).unwrap();
|
||||||
|
if bots.len() < 2 {
|
||||||
|
// not enough bots to play a match
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let selected_bots: Vec<Bot> = {
|
||||||
|
let mut rng = &mut rand::thread_rng();
|
||||||
|
bots.choose_multiple(&mut rng, 2).cloned().collect()
|
||||||
|
};
|
||||||
|
play_ranking_match(selected_bots, db_pool.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn play_ranking_match(selected_bots: Vec<Bot>, db_pool: DbPool) {
|
||||||
|
let db_conn = db_pool.get().await.expect("could not get db pool");
|
||||||
|
let mut code_bundles = Vec::new();
|
||||||
|
for bot in &selected_bots {
|
||||||
|
let code_bundle = db::bots::active_code_bundle(bot.id, &db_conn)
|
||||||
|
.expect("could not get active code bundle");
|
||||||
|
code_bundles.push(code_bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code_bundle_refs = code_bundles.iter().map(|b| b).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut run_match = RunMatch::from_players(code_bundle_refs);
|
||||||
|
run_match
|
||||||
|
.store_in_database(&db_conn)
|
||||||
|
.expect("could not store match in db");
|
||||||
|
let outcome = run_match
|
||||||
|
.spawn(db_pool.clone())
|
||||||
|
.await
|
||||||
|
.expect("running match failed");
|
||||||
|
|
||||||
|
let mut ratings = Vec::new();
|
||||||
|
for bot in &selected_bots {
|
||||||
|
let rating = db::ratings::get_rating(bot.id, &db_conn)
|
||||||
|
.expect("could not get bot rating")
|
||||||
|
.unwrap_or(START_RATING);
|
||||||
|
ratings.push(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
// simple elo rating
|
||||||
|
|
||||||
|
let scores = match outcome.winner {
|
||||||
|
None => vec![0.5; 2],
|
||||||
|
Some(player_num) => {
|
||||||
|
// TODO: please get rid of this offset
|
||||||
|
let player_ix = player_num - 1;
|
||||||
|
let mut scores = vec![0.0; 2];
|
||||||
|
scores[player_ix] = 1.0;
|
||||||
|
scores
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for i in 0..2 {
|
||||||
|
let j = 1 - i;
|
||||||
|
|
||||||
|
let scaled_difference = (ratings[j] - ratings[i]) / SCALE;
|
||||||
|
let expected = 1.0 / (1.0 + 10f64.powf(scaled_difference));
|
||||||
|
let new_rating = ratings[i] + MAX_UPDATE * (scores[i] - expected);
|
||||||
|
db::ratings::set_rating(selected_bots[i].id, new_rating, &db_conn)
|
||||||
|
.expect("could not update bot rating");
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ use std::path::PathBuf;
|
||||||
use thiserror;
|
use thiserror;
|
||||||
|
|
||||||
use crate::db::bots::{self, CodeBundle};
|
use crate::db::bots::{self, CodeBundle};
|
||||||
|
use crate::db::ratings::{RankedBot, self};
|
||||||
use crate::db::users::User;
|
use crate::db::users::User;
|
||||||
use crate::modules::bots::save_code_bundle;
|
use crate::modules::bots::save_code_bundle;
|
||||||
use crate::{DatabaseConnection, BOTS_DIR};
|
use crate::{DatabaseConnection, BOTS_DIR};
|
||||||
|
@ -170,6 +171,12 @@ pub async fn list_bots(conn: DatabaseConnection) -> Result<Json<Vec<Bot>>, Statu
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_ranking(conn: DatabaseConnection) -> Result<Json<Vec<RankedBot>>, StatusCode> {
|
||||||
|
ratings::get_bot_ranking(&conn)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: currently this only implements the happy flow
|
// TODO: currently this only implements the happy flow
|
||||||
pub async fn upload_code_multipart(
|
pub async fn upload_code_multipart(
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
|
|
|
@ -1,20 +1,16 @@
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::db::matches::{FullMatchData, FullMatchPlayerData, MatchPlayerData, MatchState};
|
use crate::db::matches::{FullMatchData, FullMatchPlayerData};
|
||||||
use crate::modules::bots::save_code_bundle;
|
use crate::modules::bots::save_code_bundle;
|
||||||
use crate::util::gen_alphanumeric;
|
use crate::modules::matches::RunMatch;
|
||||||
use crate::{ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR};
|
use crate::ConnectionPool;
|
||||||
use axum::extract::Extension;
|
use axum::extract::Extension;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use planetwars_matchrunner::BotSpec;
|
|
||||||
use planetwars_matchrunner::{docker_runner::DockerBotSpec, run_match, MatchConfig, MatchPlayer};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use super::matches::ApiMatch;
|
use super::matches::ApiMatch;
|
||||||
|
|
||||||
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
|
const DEFAULT_OPPONENT_NAME: &str = "simplebot";
|
||||||
const OPPONENT_NAME: &str = "simplebot";
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct SubmitBotParams {
|
pub struct SubmitBotParams {
|
||||||
|
@ -29,16 +25,6 @@ pub struct SubmitBotResponse {
|
||||||
pub match_data: ApiMatch,
|
pub match_data: ApiMatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn code_bundle_to_botspec(code_bundle: &db::bots::CodeBundle) -> Box<dyn BotSpec> {
|
|
||||||
let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
|
|
||||||
|
|
||||||
Box::new(DockerBotSpec {
|
|
||||||
code_path: bundle_path,
|
|
||||||
image: PYTHON_IMAGE.to_string(),
|
|
||||||
argv: vec!["python".to_string(), "bot.py".to_string()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// submit python code for a bot, which will face off
|
/// submit python code for a bot, which will face off
|
||||||
/// with a demo bot. Return a played match.
|
/// with a demo bot. Return a played match.
|
||||||
pub async fn submit_bot(
|
pub async fn submit_bot(
|
||||||
|
@ -49,7 +35,7 @@ pub async fn submit_bot(
|
||||||
|
|
||||||
let opponent_name = params
|
let opponent_name = params
|
||||||
.opponent_name
|
.opponent_name
|
||||||
.unwrap_or_else(|| OPPONENT_NAME.to_string());
|
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
|
||||||
|
|
||||||
let opponent =
|
let opponent =
|
||||||
db::bots::find_bot_by_name(&opponent_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
|
db::bots::find_bot_by_name(&opponent_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
@ -60,46 +46,11 @@ pub async fn submit_bot(
|
||||||
// TODO: can we recover from this?
|
// TODO: can we recover from this?
|
||||||
.expect("could not save bot code");
|
.expect("could not save bot code");
|
||||||
|
|
||||||
let log_file_name = format!("{}.log", gen_alphanumeric(16));
|
let mut run_match = RunMatch::from_players(vec![&player_code_bundle, &opponent_code_bundle]);
|
||||||
// play the match
|
let match_data = run_match
|
||||||
let match_config = MatchConfig {
|
.store_in_database(&conn)
|
||||||
map_path: PathBuf::from(MAPS_DIR).join("hex.json"),
|
.expect("failed to save match");
|
||||||
map_name: "hex".to_string(),
|
run_match.spawn(pool.clone());
|
||||||
log_path: PathBuf::from(MATCHES_DIR).join(&log_file_name),
|
|
||||||
players: vec![
|
|
||||||
MatchPlayer {
|
|
||||||
name: "player".to_string(),
|
|
||||||
bot_spec: code_bundle_to_botspec(&player_code_bundle),
|
|
||||||
},
|
|
||||||
MatchPlayer {
|
|
||||||
name: OPPONENT_NAME.to_string(),
|
|
||||||
bot_spec: code_bundle_to_botspec(&opponent_code_bundle),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// store match in database
|
|
||||||
let new_match_data = db::matches::NewMatch {
|
|
||||||
state: MatchState::Playing,
|
|
||||||
log_path: &log_file_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_match_players = [
|
|
||||||
MatchPlayerData {
|
|
||||||
code_bundle_id: player_code_bundle.id,
|
|
||||||
},
|
|
||||||
MatchPlayerData {
|
|
||||||
code_bundle_id: opponent_code_bundle.id,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let match_data = db::matches::create_match(&new_match_data, &new_match_players, &conn)
|
|
||||||
.expect("failed to create match");
|
|
||||||
|
|
||||||
tokio::spawn(run_match_task(
|
|
||||||
match_data.base.id,
|
|
||||||
match_config,
|
|
||||||
pool.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// TODO: avoid clones
|
// TODO: avoid clones
|
||||||
let full_match_data = FullMatchData {
|
let full_match_data = FullMatchData {
|
||||||
|
@ -123,13 +74,3 @@ pub async fn submit_bot(
|
||||||
match_data: api_match,
|
match_data: api_match,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_match_task(match_id: i32, match_config: MatchConfig, connection_pool: ConnectionPool) {
|
|
||||||
run_match(match_config).await;
|
|
||||||
let conn = connection_pool
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.expect("could not get database connection");
|
|
||||||
db::matches::set_match_state(match_id, MatchState::Finished, &conn)
|
|
||||||
.expect("failed to update match state");
|
|
||||||
}
|
|
||||||
|
|
|
@ -52,7 +52,6 @@ pub async fn play_match(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
players.push(MatchPlayer {
|
players.push(MatchPlayer {
|
||||||
name: bot.name.clone(),
|
|
||||||
bot_spec: Box::new(DockerBotSpec {
|
bot_spec: Box::new(DockerBotSpec {
|
||||||
code_path: PathBuf::from(BOTS_DIR).join(code_bundle.path),
|
code_path: PathBuf::from(BOTS_DIR).join(code_bundle.path),
|
||||||
image: "python:3.10-slim-buster".to_string(),
|
image: "python:3.10-slim-buster".to_string(),
|
||||||
|
|
|
@ -47,6 +47,16 @@ table! {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
use diesel::sql_types::*;
|
||||||
|
use crate::db_types::*;
|
||||||
|
|
||||||
|
ratings (bot_id) {
|
||||||
|
bot_id -> Int4,
|
||||||
|
rating -> Float8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
use diesel::sql_types::*;
|
use diesel::sql_types::*;
|
||||||
use crate::db_types::*;
|
use crate::db_types::*;
|
||||||
|
@ -74,6 +84,15 @@ joinable!(bots -> users (owner_id));
|
||||||
joinable!(code_bundles -> bots (bot_id));
|
joinable!(code_bundles -> bots (bot_id));
|
||||||
joinable!(match_players -> code_bundles (code_bundle_id));
|
joinable!(match_players -> code_bundles (code_bundle_id));
|
||||||
joinable!(match_players -> matches (match_id));
|
joinable!(match_players -> matches (match_id));
|
||||||
|
joinable!(ratings -> bots (bot_id));
|
||||||
joinable!(sessions -> users (user_id));
|
joinable!(sessions -> users (user_id));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(bots, code_bundles, match_players, matches, sessions, users,);
|
allow_tables_to_appear_in_same_query!(
|
||||||
|
bots,
|
||||||
|
code_bundles,
|
||||||
|
match_players,
|
||||||
|
matches,
|
||||||
|
ratings,
|
||||||
|
sessions,
|
||||||
|
users,
|
||||||
|
);
|
||||||
|
|
72
web/pw-server/src/lib/components/Leaderboard.svelte
Normal file
72
web/pw-server/src/lib/components/Leaderboard.svelte
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let leaderboard = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch("/api/leaderboard", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
leaderboard = await res.json();
|
||||||
|
console.log(leaderboard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatRating(entry: object): any {
|
||||||
|
const rating = entry["rating"];
|
||||||
|
if (rating != null) {
|
||||||
|
return rating.toFixed(0);
|
||||||
|
} else {
|
||||||
|
// why does this happen?
|
||||||
|
return "-inf";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<table class="leaderboard">
|
||||||
|
<tr class="leaderboard-row leaderboard-header">
|
||||||
|
<th class="leaderboard-rank" />
|
||||||
|
<th class="leaderboard-rating">Rating</th>
|
||||||
|
<th class="leaderboard-bot">Bot</th>
|
||||||
|
<th class="leaderboard-author">Author</th>
|
||||||
|
</tr>
|
||||||
|
{#each leaderboard as entry, index}
|
||||||
|
<tr class="leaderboard-row">
|
||||||
|
<td class="leaderboard-rank">{index + 1}</td>
|
||||||
|
<td class="leaderboard-rating">
|
||||||
|
{formatRating(entry)}
|
||||||
|
</td>
|
||||||
|
<td class="leaderboard-bot">{entry["bot"]["name"]}</td>
|
||||||
|
<td class="leaderboard-author">
|
||||||
|
{#if entry["author"]}
|
||||||
|
{entry["author"]["username"]}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
overflow-y: scroll;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.leaderboard {
|
||||||
|
margin: 18px auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th,
|
||||||
|
.leaderboard td {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.leaderboard-rank {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -13,11 +13,13 @@
|
||||||
import SubmitPane from "$lib/components/SubmitPane.svelte";
|
import SubmitPane from "$lib/components/SubmitPane.svelte";
|
||||||
import OutputPane from "$lib/components/OutputPane.svelte";
|
import OutputPane from "$lib/components/OutputPane.svelte";
|
||||||
import RulesView from "$lib/components/RulesView.svelte";
|
import RulesView from "$lib/components/RulesView.svelte";
|
||||||
|
import Leaderboard from "$lib/components/Leaderboard.svelte";
|
||||||
|
|
||||||
enum ViewMode {
|
enum ViewMode {
|
||||||
Editor,
|
Editor,
|
||||||
MatchVisualizer,
|
MatchVisualizer,
|
||||||
Rules,
|
Rules,
|
||||||
|
Leaderboard,
|
||||||
}
|
}
|
||||||
|
|
||||||
let matches = [];
|
let matches = [];
|
||||||
|
@ -111,10 +113,10 @@
|
||||||
return log;
|
return log;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEditor() {
|
function setViewMode(viewMode_: ViewMode) {
|
||||||
selectedMatchId = undefined;
|
selectedMatchId = undefined;
|
||||||
selectedMatchLog = undefined;
|
selectedMatchLog = undefined;
|
||||||
viewMode = ViewMode.Editor;
|
viewMode = viewMode_;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRules() {
|
function selectRules() {
|
||||||
|
@ -140,17 +142,24 @@
|
||||||
<div
|
<div
|
||||||
class="editor-button sidebar-item"
|
class="editor-button sidebar-item"
|
||||||
class:selected={viewMode === ViewMode.Editor}
|
class:selected={viewMode === ViewMode.Editor}
|
||||||
on:click={selectEditor}
|
on:click={() => setViewMode(ViewMode.Editor)}
|
||||||
>
|
>
|
||||||
Editor
|
Editor
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rules-button sidebar-item"
|
class="rules-button sidebar-item"
|
||||||
class:selected={viewMode === ViewMode.Rules}
|
class:selected={viewMode === ViewMode.Rules}
|
||||||
on:click={selectRules}
|
on:click={() => setViewMode(ViewMode.Rules)}
|
||||||
>
|
>
|
||||||
Rules
|
Rules
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="sidebar-item"
|
||||||
|
class:selected={viewMode === ViewMode.Leaderboard}
|
||||||
|
on:click={() => setViewMode(ViewMode.Leaderboard)}
|
||||||
|
>
|
||||||
|
Leaderboard
|
||||||
|
</div>
|
||||||
<div class="sidebar-header">match history</div>
|
<div class="sidebar-header">match history</div>
|
||||||
<ul class="match-list">
|
<ul class="match-list">
|
||||||
{#each matches as match}
|
{#each matches as match}
|
||||||
|
@ -175,6 +184,8 @@
|
||||||
<EditorView {editSession} />
|
<EditorView {editSession} />
|
||||||
{:else if viewMode === ViewMode.Rules}
|
{:else if viewMode === ViewMode.Rules}
|
||||||
<RulesView />
|
<RulesView />
|
||||||
|
{:else if viewMode === ViewMode.Leaderboard}
|
||||||
|
<Leaderboard />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-right">
|
<div class="sidebar-right">
|
||||||
|
@ -220,16 +231,9 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-button {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rules-button {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
color: #eee;
|
color: #eee;
|
||||||
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item:hover {
|
.sidebar-item:hover {
|
||||||
|
|
Loading…
Reference in a new issue