Merge branch 'feature/leaderboard'

This commit is contained in:
Ilion Beyst 2022-05-28 11:22:44 +02:00
commit 80c60ac69c
18 changed files with 429 additions and 112 deletions

View file

@ -38,7 +38,6 @@ pub struct PlayerInfo {
}
pub struct MatchPlayer {
pub name: String,
pub bot_spec: Box<dyn BotSpec>,
}
@ -52,7 +51,11 @@ pub trait BotSpec: Send + Sync {
) -> 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 {
map_file: config.map_path,
max_turns: 100,
@ -103,8 +106,18 @@ pub async fn run_match(config: MatchConfig) {
// )
// .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;
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

View file

@ -24,8 +24,8 @@ pub struct MatchConfig {
}
pub struct PwMatch {
match_ctx: MatchCtx,
match_state: PlanetWars,
pub match_ctx: MatchCtx,
pub match_state: PlanetWars,
}
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() {
let player_messages = self.prompt_players().await;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
drop table ratings;

View file

@ -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
)

View file

@ -11,7 +11,7 @@ pub struct NewBot<'a> {
pub name: &'a str,
}
#[derive(Queryable, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Queryable, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bot {
pub id: i32,
pub owner_id: Option<i32>,

View file

@ -1,4 +1,5 @@
pub mod bots;
pub mod matches;
pub mod ratings;
pub mod sessions;
pub mod users;

View 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)
}

View file

@ -8,11 +8,14 @@ pub mod routes;
pub mod schema;
pub mod util;
use std::net::SocketAddr;
use std::ops::Deref;
use bb8::{Pool, PooledConnection};
use bb8_diesel::{self, DieselConnectionManager};
use config::ConfigError;
use diesel::{Connection, PgConnection};
use modules::ranking::run_ranker;
use serde::Deserialize;
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 pool = bb8::Pool::builder().build(manager).await.unwrap();
seed_simplebot(&pool).await;
pool
}
pub async fn api(configuration: Configuration) -> Router {
let db_pool = prepare_db(&configuration.database_url).await;
pub fn api() -> Router {
Router::new()
.route("/register", post(routes::users::register))
.route("/login", post(routes::users::login))
@ -88,21 +91,34 @@ pub async fn api(configuration: Configuration) -> Router {
"/matches/:match_id/log",
get(routes::matches::get_match_log),
)
.route("/leaderboard", get(routes::bots::get_ranking))
.route("/submit_bot", post(routes::demo::submit_bot))
.route("/save_bot", post(routes::bots::save_bot))
.layer(AddExtensionLayer::new(db_pool))
}
pub async fn app() -> Router {
let configuration = config::Config::builder()
pub fn get_config() -> Result<Configuration, ConfigError> {
config::Config::builder()
.add_source(config::File::with_name("configuration.toml"))
.add_source(config::Environment::with_prefix("PLANETWARS"))
.build()
.unwrap()
.build()?
.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)]

View file

@ -1,16 +1,7 @@
use std::net::SocketAddr;
extern crate planetwars_server;
extern crate tokio;
#[tokio::main]
async fn main() {
let app = planetwars_server::app().await;
let addr = SocketAddr::from(([127, 0, 0, 1], 9000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
planetwars_server::run_app().await;
}

View 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;
}

View file

@ -1,3 +1,5 @@
// This module implements general domain logic, not directly
// tied to the database or API layers.
pub mod bots;
pub mod matches;
pub mod ranking;

View 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");
}
}

View file

@ -12,6 +12,7 @@ use std::path::PathBuf;
use thiserror;
use crate::db::bots::{self, CodeBundle};
use crate::db::ratings::{RankedBot, self};
use crate::db::users::User;
use crate::modules::bots::save_code_bundle;
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)
}
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
pub async fn upload_code_multipart(
conn: DatabaseConnection,

View file

@ -1,20 +1,16 @@
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::util::gen_alphanumeric;
use crate::{ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR};
use crate::modules::matches::RunMatch;
use crate::ConnectionPool;
use axum::extract::Extension;
use axum::Json;
use hyper::StatusCode;
use planetwars_matchrunner::BotSpec;
use planetwars_matchrunner::{docker_runner::DockerBotSpec, run_match, MatchConfig, MatchPlayer};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::matches::ApiMatch;
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
const OPPONENT_NAME: &str = "simplebot";
const DEFAULT_OPPONENT_NAME: &str = "simplebot";
#[derive(Serialize, Deserialize, Debug)]
pub struct SubmitBotParams {
@ -29,16 +25,6 @@ pub struct SubmitBotResponse {
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
/// with a demo bot. Return a played match.
pub async fn submit_bot(
@ -49,7 +35,7 @@ pub async fn submit_bot(
let opponent_name = params
.opponent_name
.unwrap_or_else(|| OPPONENT_NAME.to_string());
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
let opponent =
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?
.expect("could not save bot code");
let log_file_name = format!("{}.log", gen_alphanumeric(16));
// play the match
let match_config = MatchConfig {
map_path: PathBuf::from(MAPS_DIR).join("hex.json"),
map_name: "hex".to_string(),
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(),
));
let mut run_match = RunMatch::from_players(vec![&player_code_bundle, &opponent_code_bundle]);
let match_data = run_match
.store_in_database(&conn)
.expect("failed to save match");
run_match.spawn(pool.clone());
// TODO: avoid clones
let full_match_data = FullMatchData {
@ -123,13 +74,3 @@ pub async fn submit_bot(
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");
}

View file

@ -52,7 +52,6 @@ pub async fn play_match(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
players.push(MatchPlayer {
name: bot.name.clone(),
bot_spec: Box::new(DockerBotSpec {
code_path: PathBuf::from(BOTS_DIR).join(code_bundle.path),
image: "python:3.10-slim-buster".to_string(),

View file

@ -47,6 +47,16 @@ table! {
}
}
table! {
use diesel::sql_types::*;
use crate::db_types::*;
ratings (bot_id) {
bot_id -> Int4,
rating -> Float8,
}
}
table! {
use diesel::sql_types::*;
use crate::db_types::*;
@ -74,6 +84,15 @@ joinable!(bots -> users (owner_id));
joinable!(code_bundles -> bots (bot_id));
joinable!(match_players -> code_bundles (code_bundle_id));
joinable!(match_players -> matches (match_id));
joinable!(ratings -> bots (bot_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,
);

View 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>

View file

@ -13,11 +13,13 @@
import SubmitPane from "$lib/components/SubmitPane.svelte";
import OutputPane from "$lib/components/OutputPane.svelte";
import RulesView from "$lib/components/RulesView.svelte";
import Leaderboard from "$lib/components/Leaderboard.svelte";
enum ViewMode {
Editor,
MatchVisualizer,
Rules,
Leaderboard,
}
let matches = [];
@ -111,10 +113,10 @@
return log;
}
function selectEditor() {
function setViewMode(viewMode_: ViewMode) {
selectedMatchId = undefined;
selectedMatchLog = undefined;
viewMode = ViewMode.Editor;
viewMode = viewMode_;
}
function selectRules() {
@ -140,17 +142,24 @@
<div
class="editor-button sidebar-item"
class:selected={viewMode === ViewMode.Editor}
on:click={selectEditor}
on:click={() => setViewMode(ViewMode.Editor)}
>
Editor
</div>
<div
class="rules-button sidebar-item"
class:selected={viewMode === ViewMode.Rules}
on:click={selectRules}
on:click={() => setViewMode(ViewMode.Rules)}
>
Rules
</div>
<div
class="sidebar-item"
class:selected={viewMode === ViewMode.Leaderboard}
on:click={() => setViewMode(ViewMode.Leaderboard)}
>
Leaderboard
</div>
<div class="sidebar-header">match history</div>
<ul class="match-list">
{#each matches as match}
@ -175,6 +184,8 @@
<EditorView {editSession} />
{:else if viewMode === ViewMode.Rules}
<RulesView />
{:else if viewMode === ViewMode.Leaderboard}
<Leaderboard />
{/if}
</div>
<div class="sidebar-right">
@ -220,16 +231,9 @@
height: 100%;
}
.editor-button {
padding: 15px;
}
.rules-button {
padding: 15px;
}
.sidebar-item {
color: #eee;
padding: 15px;
}
.sidebar-item:hover {