Compare commits

..

No commits in common. "main" and "bot-api" have entirely different histories.

115 changed files with 2433 additions and 4530 deletions

View file

@ -1,31 +1,9 @@
# planetwars # mozaic4
Planetwars is a competitive programming game. You implement a bot that will be pitted against all other bots. Because third time's the charm!
Try it out at https://planetwars.dev ! Project layout:
Current features:
- write and publish a python bot in the demo web interface
- develop a bot locally and publish it as a docker container
- published bots will be ranked in the background
## Creating a bot locally
For development, you can play a game with a locally running bot using [`planetwars-client`](https://github.com/iasoon/planetwars.dev/tree/main/planetwars-client). \
Once you are happy with your bot, you can publish it to the planetwars server as a docker container.
1. Register your bot. In order to publish a bot version, you first have to register a bot name. You can do this by navigating to your profile after logging in (click your name in the navbar).
2. Bake your bot into a docker container. If you'd like to test whether your container works, you can try running it using `planetwars-client` by using `docker run -it my-bot-tag` as the run command.
3. Log in to the planetwars docker registry: `docker login registry.planetwars.dev`
4. Tag and push your bot to `registry.planetwars.dev/my-bot-name:latest`.
5. Your bot should be up and running now! Feel free to play a game against it to test whether all is well. Shortly, your bot should appear in the rankings.
## Project
The repository contains these components:
- `planetwars-server`: rust webserver - `planetwars-server`: rust webserver
- `planetwars-matchrunner`: code for running matches - `planetwars-matchrunner`: implements the game
- `planetwars-rules`: implements the game rules
- `planetwars-client`: for running your bot locally
- `web/pw-server`: frontend - `web/pw-server`: frontend
- `web/pw-visualizer`: code for the visualizer - `web/pw-visualizer`: code for the visualizer

25
planetwars-cli/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "planetwars-cli"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "pwcli"
[dependencies]
futures-core = "0.3"
futures = "0.3"
tokio = { version = "1", features = ["full"] }
rand = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.5"
clap = { version = "3.0.0-rc.8", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
shlex = "1.1"
planetwars-matchrunner = { path = "../planetwars-matchrunner" }
rust-embed = "6.3.0"
axum = { version = "0.4", features = ["ws"] }
mime_guess = "2"

57
planetwars-cli/README.md Normal file
View file

@ -0,0 +1,57 @@
# planetwars-cli
ATTENTION: this package is currently out-of-date.
Note: this project is under active development. All file and configuration formats will take some time to stabilize, so be prepared for breakage when you upgrade to a new version.
## Building
The cli comes with a local webserver for visualizing matches.
Therefore, you'll have to build the web application first, so that it can be embedded in the binary.
You will need:
- rust
- wasm-pack
- npm
First, build the frontend:
```bash
cd web/pw-frontend
npm install
npm run build-wasm
npm run build
```
Then build the backend:
```bash
cd planetwars-cli
cargo build --bin pwcli --release
```
You can install the binary by running
```bash
cargo install --path .
```
## Getting started
First, initialize your workspace:
```bash
pwcli init my-planetwars-workspace
```
This creates all required files and directories for your planetwars workspace:
- `pw_workspace.toml`: workspace configuration
- `maps/`: for storing maps
- `matches/`: match logs will be written here
- `bots/simplebot/` an example bot to get started
All subsequent commands should be run from the root directory of your workspace.
Try playing an example match:
```bash
pwcli run-match hex simplebot simplebot
```
You can now watch a visualization of the match in the web interface:
```bash
pwcli serve
```
You can now try writing your own bot by copying the `simplebot` example. Don't forget to add it in your workspace configuration!

View file

@ -0,0 +1,43 @@
{
"planets": [
{
"name": "protos",
"x": -6,
"y": 0,
"owner": 1,
"ship_count": 6
},
{
"name": "duteros",
"x": -3,
"y": 5,
"ship_count": 6
},
{
"name": "tritos",
"x": 3,
"y": 5,
"ship_count": 6
},
{
"name": "tetartos",
"x": 6,
"y": 0,
"owner": 2,
"ship_count": 6
},
{
"name": "pemptos",
"x": 3,
"y": -5,
"ship_count": 6
},
{
"name": "extos",
"x": -3,
"y": -5,
"ship_count": 6
}
]
}

View file

@ -0,0 +1,6 @@
[paths]
maps_dir = "maps"
matches_dir = "matches"
[bots.simplebot]
path = "bots/simplebot"

View file

@ -0,0 +1,2 @@
name = "simplebot"
run_command = "python3 simplebot.py"

View file

@ -0,0 +1,33 @@
import sys, json
def move(command):
""" print a command record to stdout """
moves = []
if command is not None:
moves.append(command)
print(json.dumps({ 'moves': moves }))
# flush the buffer, so that the gameserver can receive the json-encoded line.
sys.stdout.flush()
for line in sys.stdin:
state = json.loads(line)
# you are always player 1.
my_planets = [p for p in state['planets'] if p['owner'] == 1]
other_planets = [p for p in state['planets'] if p['owner'] != 1]
if not my_planets or not other_planets:
# we don't own any planets, so we can't make any moves.
move(None)
else:
# find my planet that has the most ships
planet = max(my_planets, key=lambda p: p['ship_count'])
# find enemy planet that has the least ships
destination = min(other_planets, key=lambda p: p['ship_count'])
# attack!
move({
'origin': planet['name'],
'destination': destination['name'],
'ship_count': planet['ship_count'] - 1
})

View file

@ -0,0 +1,6 @@
use planetwars_cli;
#[tokio::main]
async fn main() {
planetwars_cli::run().await
}

View file

@ -0,0 +1,27 @@
use clap::Parser;
use std::io;
use tokio::process;
use crate::workspace::Workspace;
#[derive(Parser)]
pub struct BuildCommand {
/// Name of the bot to build
bot: String,
}
impl BuildCommand {
pub async fn run(self) -> io::Result<()> {
let workspace = Workspace::open_current_dir()?;
let bot = workspace.get_bot(&self.bot)?;
if let Some(argv) = bot.config.get_build_argv() {
process::Command::new(&argv[0])
.args(&argv[1..])
.current_dir(&bot.path)
.spawn()?
.wait()
.await?;
}
Ok(())
}
}

View file

@ -0,0 +1,38 @@
use std::path::PathBuf;
use clap::Parser;
use futures::io;
#[derive(Parser)]
pub struct InitCommand {
/// workspace root directory
path: String,
}
macro_rules! copy_asset {
($path:expr, $file_name:literal) => {
::std::fs::write(
$path.join($file_name),
include_bytes!(concat!("../../assets/", $file_name)),
)?;
};
}
impl InitCommand {
pub async fn run(self) -> io::Result<()> {
let path = PathBuf::from(&self.path);
// create directories
std::fs::create_dir_all(&path)?;
std::fs::create_dir(path.join("maps"))?;
std::fs::create_dir(path.join("matches"))?;
std::fs::create_dir_all(path.join("bots/simplebot"))?;
// create files
copy_asset!(path, "pw_workspace.toml");
copy_asset!(path.join("maps"), "hex.json");
copy_asset!(path.join("bots/"), "simplebot/botconfig.toml");
copy_asset!(path.join("bots/"), "simplebot/simplebot.py");
Ok(())
}
}

View file

@ -0,0 +1,40 @@
mod build;
mod init;
mod run_match;
mod serve;
use clap::{Parser, Subcommand};
use std::io;
#[derive(Parser)]
#[clap(name = "pwcli")]
#[clap(author, version, about)]
pub struct Cli {
#[clap(subcommand)]
command: Command,
}
impl Cli {
pub async fn run() -> io::Result<()> {
let cli = Self::parse();
match cli.command {
Command::Init(command) => command.run().await,
Command::RunMatch(command) => command.run().await,
Command::Serve(command) => command.run().await,
Command::Build(command) => command.run().await,
}
}
}
#[derive(Subcommand)]
enum Command {
/// Initialize a new workspace
Init(init::InitCommand),
/// Run a match
RunMatch(run_match::RunMatchCommand),
/// Host local webserver
Serve(serve::ServeCommand),
/// Run build command for a bot
Build(build::BuildCommand),
}

View file

@ -0,0 +1,51 @@
use std::io;
use clap::Parser;
use planetwars_matchrunner::{run_match, MatchConfig, MatchPlayer};
use crate::workspace::Workspace;
#[derive(Parser)]
pub struct RunMatchCommand {
/// map name
map: String,
/// bot names
bots: Vec<String>,
}
impl RunMatchCommand {
pub async fn run(self) -> io::Result<()> {
let workspace = Workspace::open_current_dir()?;
let map_path = workspace.map_path(&self.map);
let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S");
let log_path = workspace.match_path(&format!("{}-{}", &self.map, &timestamp));
let mut players = Vec::new();
for bot_name in &self.bots {
let bot = workspace.get_bot(&bot_name)?;
players.push(MatchPlayer {
name: bot_name.clone(),
path: bot.path.clone(),
argv: bot.config.get_run_argv(),
});
}
let match_config = MatchConfig {
map_name: self.map,
map_path,
log_path: log_path.clone(),
players,
};
run_match(match_config).await;
println!("match completed successfully");
// TODO: maybe print the match result as well?
let relative_path = match log_path.strip_prefix(&workspace.root_path) {
Ok(path) => path.to_str().unwrap(),
Err(_) => log_path.to_str().unwrap(),
};
println!("wrote match log to {}", relative_path);
Ok(())
}
}

View file

@ -0,0 +1,17 @@
use std::io;
use clap::Parser;
use crate::web;
use crate::workspace::Workspace;
#[derive(Parser)]
pub struct ServeCommand;
impl ServeCommand {
pub async fn run(self) -> io::Result<()> {
let workspace = Workspace::open_current_dir()?;
web::run(workspace).await;
Ok(())
}
}

11
planetwars-cli/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
mod commands;
mod web;
mod workspace;
pub async fn run() {
let res = commands::Cli::run().await;
if let Err(err) = res {
eprintln!("{}", err);
std::process::exit(1);
}
}

View file

@ -0,0 +1,175 @@
use axum::{
body::{boxed, Full},
extract::{ws::WebSocket, Extension, Path, WebSocketUpgrade},
handler::Handler,
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
routing::{get, Router},
AddExtensionLayer, Json,
};
use mime_guess;
use planetwars_matchrunner::MatchMeta;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::{
fs,
io::{self, BufRead},
net::SocketAddr,
path,
sync::Arc,
};
use crate::workspace::Workspace;
struct State {
workspace: Workspace,
}
impl State {
fn new(workspace: Workspace) -> Self {
Self { workspace }
}
}
pub async fn run(workspace: Workspace) {
let shared_state = Arc::new(State::new(workspace));
// build our application with a route
let app = Router::new()
.route("/", get(index_handler))
.route("/ws", get(ws_handler))
.route("/api/matches", get(list_matches))
.route("/api/matches/:match_id", get(get_match))
.fallback(static_handler.into_service())
.layer(AddExtensionLayer::new(shared_state));
// run it
let addr = SocketAddr::from(([127, 0, 0, 1], 5000));
println!("serving at http://{}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(msg) = socket.recv().await {
let msg = if let Ok(msg) = msg {
msg
} else {
// client disconnected
return;
};
if socket.send(msg).await.is_err() {
// client disconnected
return;
}
}
}
#[derive(Serialize, Deserialize)]
struct MatchData {
name: String,
#[serde(flatten)]
meta: MatchMeta,
}
async fn list_matches(Extension(state): Extension<Arc<State>>) -> Json<Vec<MatchData>> {
let mut matches = state
.workspace
.matches_dir()
.read_dir()
.unwrap()
.filter_map(|entry| {
let entry = entry.unwrap();
get_match_data(&entry).ok()
})
.collect::<Vec<_>>();
matches.sort_by(|a, b| {
let a = a.meta.timestamp;
let b = b.meta.timestamp;
a.cmp(&b).reverse()
});
Json(matches)
}
// extracts 'filename' if the entry matches'$filename.log'.
fn get_match_data(entry: &fs::DirEntry) -> io::Result<MatchData> {
let file_name = entry.file_name();
let path = path::Path::new(&file_name);
let name = get_match_name(&path)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid match name"))?;
let meta = read_match_meta(&entry.path())?;
Ok(MatchData { name, meta })
}
fn get_match_name(path: &path::Path) -> Option<String> {
if path.extension() != Some("log".as_ref()) {
return None;
}
path.file_stem()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
}
fn read_match_meta(path: &path::Path) -> io::Result<MatchMeta> {
let file = fs::File::open(path)?;
let mut reader = io::BufReader::new(file);
let mut line = String::new();
reader.read_line(&mut line)?;
let meta: MatchMeta = serde_json::from_str(&line)?;
Ok(meta)
}
async fn get_match(Extension(state): Extension<Arc<State>>, Path(id): Path<String>) -> String {
let mut match_path = state.workspace.matches_dir().join(id);
match_path.set_extension("log");
fs::read_to_string(match_path).unwrap()
}
async fn index_handler() -> impl IntoResponse {
static_handler("/index.html".parse::<Uri>().unwrap()).await
}
// static_handler is a handler that serves static files from the
async fn static_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/').to_string();
StaticFile(path)
}
#[derive(RustEmbed)]
#[folder = "../web/pw-frontend/dist/"]
struct Asset;
pub struct StaticFile<T>(pub T);
impl<T> IntoResponse for StaticFile<T>
where
T: Into<String>,
{
fn into_response(self) -> Response {
let path = self.0.into();
match Asset::get(path.as_str()) {
Some(content) => {
let body = boxed(Full::from(content.data));
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.body(body)
.unwrap()
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(boxed(Full::from("404")))
.unwrap(),
}
}
}

View file

@ -0,0 +1,50 @@
use shlex;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const BOT_CONFIG_FILENAME: &str = "botconfig.toml";
pub struct WorkspaceBot {
pub path: PathBuf,
pub config: BotConfig,
}
impl WorkspaceBot {
pub fn open(path: &Path) -> io::Result<Self> {
let config_path = path.join(BOT_CONFIG_FILENAME);
let config_str = fs::read_to_string(config_path)?;
let bot_config: BotConfig = toml::from_str(&config_str)?;
Ok(WorkspaceBot {
path: path.to_path_buf(),
config: bot_config,
})
}
}
#[derive(Serialize, Deserialize)]
pub struct BotConfig {
pub name: String,
pub run_command: String,
pub build_command: Option<String>,
}
impl BotConfig {
// TODO: these commands should not be here
pub fn get_run_argv(&self) -> Vec<String> {
// TODO: proper error handling
shlex::split(&self.run_command)
.expect("Failed to parse bot run command. Check for unterminated quotes.")
}
pub fn get_build_argv(&self) -> Option<Vec<String>> {
// TODO: proper error handling
self.build_command.as_ref().map(|cmd| {
shlex::split(cmd)
.expect("Failed to parse bot build command. Check for unterminated quotes.")
})
}
}

View file

@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use self::bot::WorkspaceBot;
const WORKSPACE_CONFIG_FILENAME: &str = "pw_workspace.toml";
pub mod bot;
pub struct Workspace {
pub root_path: PathBuf,
pub config: WorkspaceConfig,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspaceConfig {
paths: WorkspacePaths,
bots: HashMap<String, BotEntry>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspacePaths {
maps_dir: PathBuf,
matches_dir: PathBuf,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct BotEntry {
path: PathBuf,
}
impl Workspace {
pub fn open(root_path: &Path) -> io::Result<Workspace> {
let config_path = root_path.join(WORKSPACE_CONFIG_FILENAME);
let config_str = fs::read_to_string(config_path)?;
let workspace_config: WorkspaceConfig = toml::from_str(&config_str)?;
Ok(Workspace {
root_path: root_path.to_path_buf(),
config: workspace_config,
})
}
pub fn open_current_dir() -> io::Result<Workspace> {
Workspace::open(&env::current_dir()?)
}
pub fn maps_dir(&self) -> PathBuf {
self.root_path.join(&self.config.paths.maps_dir)
}
pub fn map_path(&self, map_name: &str) -> PathBuf {
self.maps_dir().join(format!("{}.json", map_name))
}
pub fn matches_dir(&self) -> PathBuf {
self.root_path.join(&self.config.paths.matches_dir)
}
pub fn match_path(&self, match_name: &str) -> PathBuf {
self.matches_dir().join(format!("{}.log", match_name))
}
pub fn get_bot(&self, bot_name: &str) -> io::Result<WorkspaceBot> {
let bot_entry = self.config.bots.get(bot_name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("no such bot: {}", bot_name),
)
})?;
WorkspaceBot::open(&self.root_path.join(&bot_entry.path))
}
}

View file

@ -9,12 +9,10 @@ edition = "2021"
tokio = { version = "1.15", features = ["full"] } tokio = { version = "1.15", features = ["full"] }
tokio-stream = "0.1.9" tokio-stream = "0.1.9"
prost = "0.10" prost = "0.10"
tonic = { version = "0.7.2", features = ["tls", "tls-roots"] } tonic = "0.7.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "0.5" toml = "0.5"
planetwars-matchrunner = { path = "../planetwars-matchrunner" } planetwars-matchrunner = { path = "../planetwars-matchrunner" }
clap = { version = "3.2", features = ["derive", "env"]}
shlex = "1.1"
[build-dependencies] [build-dependencies]
tonic-build = "0.7.2" tonic-build = "0.7.2"

View file

@ -1,24 +0,0 @@
# planetwars-client
`planetwars-client` can be used to play a match with your bot running on your own machine.
## Usage
First, create a config `mybot.toml`:
```toml
# Comand to run when starting the bot.
# Argv style also supported: ["python", "simplebot.py"]
command = "python simplebot.py"
# Directory in which to run the command.
# It is recommended to use an absolute path here.
working_directory = "/home/user/simplebot"
```
Then play a match: `planetwars-client /path/to/mybot.toml opponent_name`
## Building
- Obtain rust compiler through https://rustup.rs/ or your package manager
- Checkout this repository
- Run `cargo install --path .` in the `planetwars-client` directory

View file

@ -4,6 +4,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure() tonic_build::configure()
.build_server(false) .build_server(false)
.build_client(true) .build_client(true)
.compile(&["../proto/client_api.proto"], &["../proto"])?; .compile(&["../proto/bot_api.proto"], &["../proto"])?;
Ok(()) Ok(())
} }

View file

@ -1,12 +1,8 @@
pub mod pb { pub mod pb {
tonic::include_proto!("grpc.planetwars.client_api"); tonic::include_proto!("grpc.planetwars.bot_api");
pub use player_api_client_message::ClientMessage as PlayerApiClientMessageType;
pub use player_api_server_message::ServerMessage as PlayerApiServerMessageType;
} }
use clap::Parser; use pb::bot_api_service_client::BotApiServiceClient;
use pb::client_api_service_client::ClientApiServiceClient;
use planetwars_matchrunner::bot_runner::Bot; use planetwars_matchrunner::bot_runner::Bot;
use serde::Deserialize; use serde::Deserialize;
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, time::Duration};
@ -14,131 +10,63 @@ use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use tonic::{metadata::MetadataValue, transport::Channel, Request, Status}; use tonic::{metadata::MetadataValue, transport::Channel, Request, Status};
#[derive(clap::Parser)]
struct PlayMatch {
#[clap(value_parser)]
bot_config_path: String,
#[clap(value_parser)]
opponent_name: String,
#[clap(value_parser, long = "map")]
map_name: Option<String>,
#[clap(
value_parser,
long,
default_value = "https://planetwars.dev:7492",
env = "PLANETWARS_GRPC_SERVER_URL"
)]
grpc_server_url: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct BotConfig { struct BotConfig {
#[allow(dead_code)] #[allow(dead_code)]
name: Option<String>, name: String,
command: Command, command: Vec<String>,
working_directory: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Command {
String(String),
Argv(Vec<String>),
}
impl Command {
pub fn to_argv(&self) -> Vec<String> {
match self {
Command::Argv(vec) => vec.clone(),
Command::String(s) => shlex::split(s).expect("invalid command string"),
}
}
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let play_match = PlayMatch::parse(); let content = std::fs::read_to_string("simplebot.toml").unwrap();
let content = std::fs::read_to_string(play_match.bot_config_path).unwrap();
let bot_config: BotConfig = toml::from_str(&content).unwrap(); let bot_config: BotConfig = toml::from_str(&content).unwrap();
let uri = play_match let channel = Channel::from_static("http://localhost:50051")
.grpc_server_url .connect()
.parse()
.expect("invalid grpc url");
let channel = Channel::builder(uri).connect().await.unwrap();
let created_match = create_match(
channel.clone(),
play_match.opponent_name,
play_match.map_name,
)
.await .await
.unwrap(); .unwrap();
let created_match = create_match(channel.clone()).await.unwrap();
run_player(bot_config, created_match.player_key, channel).await; run_player(bot_config, created_match.player_key, channel).await;
println!(
"Match completed. Watch the replay at {}",
created_match.match_url
);
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
async fn create_match( async fn create_match(channel: Channel) -> Result<pb::CreatedMatch, Status> {
channel: Channel, let mut client = BotApiServiceClient::new(channel);
opponent_name: String,
map_name: Option<String>,
) -> Result<pb::CreateMatchResponse, Status> {
let mut client = ClientApiServiceClient::new(channel);
let res = client let res = client
.create_match(Request::new(pb::CreateMatchRequest { .create_match(Request::new(pb::MatchRequest {
opponent_name, opponent_name: "simplebot".to_string(),
map_name: map_name.unwrap_or_default(),
})) }))
.await; .await;
res.map(|response| response.into_inner()) res.map(|response| response.into_inner())
} }
async fn run_player(bot_config: BotConfig, player_key: String, channel: Channel) { async fn run_player(bot_config: BotConfig, player_key: String, channel: Channel) {
let mut client = ClientApiServiceClient::with_interceptor(channel, |mut req: Request<()>| { let mut client = BotApiServiceClient::with_interceptor(channel, |mut req: Request<()>| {
let player_key: MetadataValue<_> = player_key.parse().unwrap(); let player_key: MetadataValue<_> = player_key.parse().unwrap();
req.metadata_mut().insert("player_key", player_key); req.metadata_mut().insert("player_key", player_key);
Ok(req) Ok(req)
}); });
let mut bot_process = Bot { let mut bot_process = Bot {
working_dir: PathBuf::from( working_dir: PathBuf::from("."),
bot_config argv: bot_config.command,
.working_directory
.unwrap_or_else(|| ".".to_string()),
),
argv: bot_config.command.to_argv(),
} }
.spawn_process(); .spawn_process();
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
let mut stream = client let mut stream = client
.connect_player(UnboundedReceiverStream::new(rx)) .connect_bot(UnboundedReceiverStream::new(rx))
.await .await
.unwrap() .unwrap()
.into_inner(); .into_inner();
while let Some(message) = stream.message().await.unwrap() { while let Some(message) = stream.message().await.unwrap() {
match message.server_message { let moves = bot_process.communicate(&message.content).await.unwrap();
Some(pb::PlayerApiServerMessageType::ActionRequest(req)) => { tx.send(pb::PlayerRequestResponse {
let moves = bot_process.communicate(&req.content).await.unwrap(); request_id: message.request_id,
let action = pb::PlayerAction {
action_request_id: req.action_request_id,
content: moves.as_bytes().to_vec(), content: moves.as_bytes().to_vec(),
}; })
let msg = pb::PlayerApiClientMessage { .unwrap();
client_message: Some(pb::PlayerApiClientMessageType::Action(action)),
};
tx.send(msg).unwrap();
}
_ => {} // pass
}
} }
} }

View file

@ -5,6 +5,9 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "testmatch"
[dependencies] [dependencies]
futures-core = "0.3" futures-core = "0.3"

View file

@ -0,0 +1,44 @@
use std::{env, path::PathBuf};
use planetwars_matchrunner::{docker_runner::DockerBotSpec, run_match, MatchConfig, MatchPlayer};
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
assert!(args.len() >= 2);
let map_path = args[1].clone();
_run_match(map_path).await;
}
const IMAGE: &str = "python:3.10-slim-buster";
async fn _run_match(map_path: String) {
run_match(MatchConfig {
map_path: PathBuf::from(map_path),
map_name: "hex".to_string(),
log_path: PathBuf::from("match.log"),
players: vec![
MatchPlayer {
name: "a".to_string(),
bot_spec: Box::new(DockerBotSpec {
image: IMAGE.to_string(),
// code_path: PathBuf::from("../simplebot"),
code_path: PathBuf::from("./bots/simplebot"),
argv: vec!["python".to_string(), "simplebot.py".to_string()],
}),
},
MatchPlayer {
name: "b".to_string(),
bot_spec: Box::new(DockerBotSpec {
image: IMAGE.to_string(),
code_path: PathBuf::from("./bots/broken_bot"),
argv: vec!["python".to_string(), "bot.py".to_string()],
}),
},
],
})
.await;
// TODO: use a joinhandle to wait for the logger to finish
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}

View file

@ -1,4 +1,5 @@
use std::io; use std::io;
use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -15,22 +16,11 @@ use crate::match_context::{EventBus, PlayerHandle, RequestError, RequestMessage}
use crate::match_log::{MatchLogMessage, MatchLogger, StdErrMessage}; use crate::match_log::{MatchLogMessage, MatchLogger, StdErrMessage};
use crate::BotSpec; use crate::BotSpec;
// TODO: this API needs a better design with respect to pulling
// and general container management
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DockerBotSpec { pub struct DockerBotSpec {
pub image: String, pub image: String,
pub binds: Option<Vec<String>>, pub code_path: PathBuf,
pub argv: Option<Vec<String>>, pub argv: Vec<String>,
pub working_dir: Option<String>,
pub pull: bool,
pub credentials: Option<Credentials>,
}
#[derive(Clone, Debug)]
pub struct Credentials {
pub username: String,
pub password: String,
} }
#[async_trait] #[async_trait]
@ -52,47 +42,26 @@ async fn spawn_docker_process(
params: &DockerBotSpec, params: &DockerBotSpec,
) -> Result<ContainerProcess, bollard::errors::Error> { ) -> Result<ContainerProcess, bollard::errors::Error> {
let docker = Docker::connect_with_socket_defaults()?; let docker = Docker::connect_with_socket_defaults()?;
let bot_code_dir = std::fs::canonicalize(&params.code_path).unwrap();
if params.pull { let code_dir_str = bot_code_dir.as_os_str().to_str().unwrap();
let mut create_image_stream = docker.create_image(
Some(bollard::image::CreateImageOptions {
from_image: params.image.as_str(),
..Default::default()
}),
None,
params
.credentials
.as_ref()
.map(|credentials| bollard::auth::DockerCredentials {
username: Some(credentials.username.clone()),
password: Some(credentials.password.clone()),
..Default::default()
}),
);
while let Some(item) = create_image_stream.next().await {
// just consume the stream for now,
// and make noise when something breaks
let _info = item.expect("hit error in docker pull");
}
}
let memory_limit = 512 * 1024 * 1024; // 512MB let memory_limit = 512 * 1024 * 1024; // 512MB
let config = container::Config { let config = container::Config {
image: Some(params.image.clone()), image: Some(params.image.clone()),
host_config: Some(bollard::models::HostConfig { host_config: Some(bollard::models::HostConfig {
binds: params.binds.clone(), binds: Some(vec![format!("{}:{}", code_dir_str, "/workdir")]),
network_mode: Some("none".to_string()), network_mode: Some("none".to_string()),
memory: Some(memory_limit), memory: Some(memory_limit),
memory_swap: Some(memory_limit), memory_swap: Some(memory_limit),
// TODO: this seems to have caused weird delays when executing bots // TODO: this applies a limit to how much cpu one bot can use.
// on the production server. A solution should still be found, though. // when running multiple bots concurrently though, the server
// cpu_period: Some(100_000), // could still become resource-starved.
// cpu_quota: Some(10_000), cpu_period: Some(100_000),
cpu_quota: Some(10_000),
..Default::default() ..Default::default()
}), }),
working_dir: params.working_dir.clone(), working_dir: Some("/workdir".to_string()),
cmd: params.argv.clone(), cmd: Some(params.argv.clone()),
attach_stdin: Some(true), attach_stdin: Some(true),
attach_stdout: Some(true), attach_stdout: Some(true),
attach_stderr: Some(true), attach_stderr: Some(true),

View file

@ -58,7 +58,7 @@ pub struct MatchOutcome {
pub async fn run_match(config: MatchConfig) -> MatchOutcome { 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: 500, max_turns: 100,
}; };
let event_bus = Arc::new(Mutex::new(EventBus::new())); let event_bus = Arc::new(Mutex::new(EventBus::new()));

View file

@ -6,8 +6,6 @@ use tokio::{fs::File, io::AsyncWriteExt};
use planetwars_rules::protocol::State; use planetwars_rules::protocol::State;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::pw_match::PlayerCommand;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum MatchLogMessage { pub enum MatchLogMessage {
@ -15,19 +13,6 @@ pub enum MatchLogMessage {
GameState(State), GameState(State),
#[serde(rename = "stderr")] #[serde(rename = "stderr")]
StdErr(StdErrMessage), StdErr(StdErrMessage),
#[serde(rename = "timeout")]
Timeout { player_id: u32 },
#[serde(rename = "bad_command")]
BadCommand {
player_id: u32,
command: String,
error: String,
},
#[serde(rename = "dispatches")]
Dispatches {
player_id: u32,
dispatches: Vec<PlayerCommand>,
},
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]

View file

@ -12,7 +12,7 @@ use std::convert::TryInto;
pub use planetwars_rules::config::{Config, Map}; pub use planetwars_rules::config::{Config, Map};
use planetwars_rules::protocol as proto; use planetwars_rules::protocol::{self as proto, PlayerAction};
use planetwars_rules::serializer as pw_serializer; use planetwars_rules::serializer as pw_serializer;
use planetwars_rules::{PlanetWars, PwConfig}; use planetwars_rules::{PlanetWars, PwConfig};
@ -40,18 +40,22 @@ impl PwMatch {
} }
pub async fn run(&mut self) { pub async fn run(&mut self) {
// log initial state
self.log_game_state();
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;
for (player_id, turn) in player_messages { for (player_id, turn) in player_messages {
let player_action = self.execute_action(player_id, turn); let res = self.execute_action(player_id, turn);
self.log_player_action(player_id, player_action); if let Some(err) = action_errors(res) {
let _info_str = serde_json::to_string(&err).unwrap();
// TODO
// println!("player {}: {}", player_id, info_str);
}
} }
self.match_state.step(); self.match_state.step();
self.log_game_state();
// Log state
let state = self.match_state.serialize_state();
self.match_ctx.log(MatchLogMessage::GameState(state));
} }
} }
@ -84,14 +88,18 @@ impl PwMatch {
.await .await
} }
fn execute_action(&mut self, player_num: usize, turn: RequestResult<Vec<u8>>) -> PlayerAction { fn execute_action(
let data = match turn { &mut self,
Err(_timeout) => return PlayerAction::Timeout, player_num: usize,
turn: RequestResult<Vec<u8>>,
) -> proto::PlayerAction {
let turn = match turn {
Err(_timeout) => return proto::PlayerAction::Timeout,
Ok(data) => data, Ok(data) => data,
}; };
let action: proto::Action = match serde_json::from_slice(&data) { let action: proto::Action = match serde_json::from_slice(&turn) {
Err(error) => return PlayerAction::ParseError { data, error }, Err(err) => return proto::PlayerAction::ParseError(err.to_string()),
Ok(action) => action, Ok(action) => action,
}; };
@ -100,64 +108,15 @@ impl PwMatch {
.into_iter() .into_iter()
.map(|command| { .map(|command| {
let res = self.match_state.execute_command(player_num, &command); let res = self.match_state.execute_command(player_num, &command);
PlayerCommand { proto::PlayerCommand {
command, command,
error: res.err(), error: res.err(),
} }
}) })
.collect(); .collect();
PlayerAction::Commands(commands) proto::PlayerAction::Commands(commands)
} }
fn log_game_state(&mut self) {
let state = self.match_state.serialize_state();
self.match_ctx.log(MatchLogMessage::GameState(state));
}
fn log_player_action(&mut self, player_id: usize, player_action: PlayerAction) {
match player_action {
PlayerAction::Timeout => self.match_ctx.log(MatchLogMessage::Timeout {
player_id: player_id as u32,
}),
PlayerAction::ParseError { data, error } => {
// TODO: can this be handled better?
let command =
String::from_utf8(data).unwrap_or_else(|_| "<invalid utf-8>".to_string());
self.match_ctx.log(MatchLogMessage::BadCommand {
player_id: player_id as u32,
command,
error: error.to_string(),
});
}
PlayerAction::Commands(dispatches) => {
self.match_ctx.log(MatchLogMessage::Dispatches {
player_id: player_id as u32,
dispatches,
});
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerCommand {
#[serde(flatten)]
pub command: proto::Command,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<proto::CommandError>,
}
/// the action a player performed.
// TODO: can we name this better? Is this a "play"?
pub enum PlayerAction {
Timeout,
ParseError {
data: Vec<u8>,
error: serde_json::Error,
},
Commands(Vec<PlayerCommand>),
} }
fn action_errors(action: PlayerAction) -> Option<PlayerAction> { fn action_errors(action: PlayerAction) -> Option<PlayerAction> {

View file

@ -84,6 +84,7 @@ impl PlanetWars {
.ok_or(CommandError::DestinationDoesNotExist)?; .ok_or(CommandError::DestinationDoesNotExist)?;
if self.state.planets[origin_id].owner() != Some(player_id - 1) { if self.state.planets[origin_id].owner() != Some(player_id - 1) {
println!("owner was {:?}", self.state.planets[origin_id].owner());
return Err(CommandError::OriginNotOwned); return Err(CommandError::OriginNotOwned);
} }

View file

@ -49,3 +49,31 @@ pub enum CommandError {
OriginDoesNotExist, OriginDoesNotExist,
DestinationDoesNotExist, DestinationDoesNotExist,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerCommand {
pub command: Command,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<CommandError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "type", content = "value")]
pub enum PlayerAction {
Timeout,
ParseError(String),
Commands(Vec<PlayerCommand>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "type", content = "content")]
pub enum ServerMessage {
/// Game state in current turn
GameState(State),
/// The action that was performed
PlayerAction(PlayerAction),
/// The game is over, and this is the concluding state.
FinalState(State),
}

View file

@ -2,23 +2,14 @@
name = "planetwars-server" name = "planetwars-server"
version = "0.0.0" version = "0.0.0"
edition = "2021" edition = "2021"
default-run = "planetwars-server"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "planetwars-server"
path = "src/main.rs"
[[bin]]
name = "planetwars-server-cli"
path = "src/cli.rs"
[dependencies] [dependencies]
futures = "0.3"
tokio = { version = "1.15", features = ["full"] } tokio = { version = "1.15", features = ["full"] }
tokio-stream = "0.1.9" tokio-stream = "0.1.9"
hyper = "0.14" hyper = "0.14"
axum = { version = "0.5", features = ["json", "headers", "multipart"] } axum = { version = "0.4", features = ["json", "headers", "multipart"] }
diesel = { version = "1.4.4", features = ["postgres", "chrono"] } diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
diesel-derive-enum = { version = "1.1", features = ["postgres"] } diesel-derive-enum = { version = "1.1", features = ["postgres"] }
bb8 = "0.7" bb8 = "0.7"
@ -36,11 +27,8 @@ toml = "0.5"
planetwars-matchrunner = { path = "../planetwars-matchrunner" } planetwars-matchrunner = { path = "../planetwars-matchrunner" }
config = { version = "0.12", features = ["toml"] } config = { version = "0.12", features = ["toml"] }
thiserror = "1.0.31" thiserror = "1.0.31"
sha2 = "0.10"
tokio-util = { version="0.7.3", features=["io"] }
prost = "0.10" prost = "0.10"
tonic = "0.7.2" tonic = "0.7.2"
clap = { version = "3.2", features = ["derive", "env"]}
# TODO: remove me # TODO: remove me
shlex = "1.1" shlex = "1.1"

View file

@ -4,6 +4,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure() tonic_build::configure()
.build_server(true) .build_server(true)
.build_client(false) .build_client(false)
.compile(&["../proto/client_api.proto"], &["../proto"])?; .compile(&["../proto/bot_api.proto"], &["../proto"])?;
Ok(()) Ok(())
} }

View file

@ -1,16 +1 @@
database_url = "postgresql://planetwars:planetwars@localhost/planetwars" database_url = "postgresql://planetwars:planetwars@localhost/planetwars"
# front-end is served here in development, which proxies to the backend
root_url = "http://localhost:3000"
python_runner_image = "python:3.10-slim-buster"
container_registry_url = "localhost:9001"
bots_directory = "./data/bots"
match_logs_directory = "./data/matches"
maps_directory = "./data/maps"
registry_directory = "./data/registry"
registry_admin_password ="verysecretadminpassword"
ranker_enabled = false

View file

@ -1,6 +0,0 @@
ALTER TABLE match_players RENAME COLUMN bot_version_id TO code_bundle_id;
ALTER TABLE bot_versions DROP COLUMN container_digest;
ALTER TABLE bot_versions RENAME COLUMN code_bundle_path TO path;
ALTER TABLE bot_versions ALTER COLUMN path SET NOT NULL;
ALTER TABLE bot_versions RENAME TO code_bundles;

View file

@ -1,6 +0,0 @@
ALTER TABLE code_bundles RENAME TO bot_versions;
ALTER TABLE bot_versions RENAME COLUMN path to code_bundle_path;
ALTER TABLE bot_versions ALTER COLUMN code_bundle_path DROP NOT NULL;
ALTER TABLE bot_versions ADD COLUMN container_digest TEXT;
ALTER TABLE match_players RENAME COLUMN code_bundle_id TO bot_version_id;

View file

@ -1,2 +0,0 @@
-- This file should undo anything in `up.sql`
ALTER TABLE bots DROP COLUMN active_version;

View file

@ -1,12 +0,0 @@
-- Your SQL goes here
ALTER TABLE bots ADD COLUMN active_version INTEGER REFERENCES bot_versions(id);
-- set most recent bot verison as active
UPDATE bots
SET active_version = most_recent.id
FROM (
SELECT DISTINCT ON (bot_id) id, bot_id
FROM bot_versions
ORDER BY bot_id, created_at DESC
) most_recent
WHERE bots.id = most_recent.bot_id;

View file

@ -1 +0,0 @@
ALTER TABLE matches DROP COLUMN is_public;

View file

@ -1 +0,0 @@
ALTER TABLE matches ADD COLUMN is_public boolean NOT NULL DEFAULT false;

View file

@ -1,3 +0,0 @@
ALTER TABLE matches DROP COLUMN map_id;
DROP TABLE maps;

View file

@ -1,7 +0,0 @@
CREATE TABLE maps (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
file_path TEXT NOT NULL
);
ALTER TABLE matches ADD COLUMN map_id INTEGER REFERENCES maps(id);

View file

@ -1,54 +0,0 @@
extern crate planetwars_server;
extern crate tokio;
use clap::Parser;
use planetwars_server::db;
use planetwars_server::{create_db_pool, get_config};
#[derive(clap::Parser)]
struct Args {
#[clap(subcommand)]
action: Action,
}
#[derive(clap::Subcommand)]
enum Action {
SetPassword(SetPassword),
}
impl Action {
async fn run(self) {
match self {
Action::SetPassword(set_password) => set_password.run().await,
}
}
}
#[derive(clap::Parser)]
struct SetPassword {
#[clap(value_parser)]
username: String,
#[clap(value_parser)]
new_password: String,
}
impl SetPassword {
async fn run(self) {
let global_config = get_config().unwrap();
let pool = create_db_pool(&global_config).await;
let conn = pool.get().await.expect("could not get database connection");
let credentials = db::users::Credentials {
username: &self.username,
password: &self.new_password,
};
db::users::set_user_password(credentials, &conn).expect("could not set password");
}
}
#[tokio::main]
pub async fn main() {
let args = Args::parse();
args.action.run().await;
}

View file

@ -1,7 +1,7 @@
use diesel::prelude::*; use diesel::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::schema::{bot_versions, bots}; use crate::schema::{bots, code_bundles};
use chrono; use chrono;
#[derive(Insertable)] #[derive(Insertable)]
@ -16,7 +16,6 @@ pub struct Bot {
pub id: i32, pub id: i32,
pub owner_id: Option<i32>, pub owner_id: Option<i32>,
pub name: String, pub name: String,
pub active_version: Option<i32>,
} }
pub fn create_bot(new_bot: &NewBot, conn: &PgConnection) -> QueryResult<Bot> { pub fn create_bot(new_bot: &NewBot, conn: &PgConnection) -> QueryResult<Bot> {
@ -39,79 +38,44 @@ pub fn find_bot_by_name(name: &str, conn: &PgConnection) -> QueryResult<Bot> {
bots::table.filter(bots::name.eq(name)).first(conn) bots::table.filter(bots::name.eq(name)).first(conn)
} }
pub fn find_bot_with_version_by_name(
bot_name: &str,
conn: &PgConnection,
) -> QueryResult<(Bot, BotVersion)> {
bots::table
.inner_join(bot_versions::table.on(bots::active_version.eq(bot_versions::id.nullable())))
.filter(bots::name.eq(bot_name))
.first(conn)
}
pub fn all_active_bots_with_version(conn: &PgConnection) -> QueryResult<Vec<(Bot, BotVersion)>> {
bots::table
.inner_join(bot_versions::table.on(bots::active_version.eq(bot_versions::id.nullable())))
.get_results(conn)
}
pub fn find_all_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> { pub fn find_all_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> {
// TODO: filter out bots that cannot be run (have no valid code bundle associated with them)
bots::table.get_results(conn) bots::table.get_results(conn)
} }
/// Find all bots that have an associated active version.
/// These are the bots that can be run.
pub fn find_active_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> {
bots::table
.filter(bots::active_version.is_not_null())
.get_results(conn)
}
#[derive(Insertable)] #[derive(Insertable)]
#[table_name = "bot_versions"] #[table_name = "code_bundles"]
pub struct NewBotVersion<'a> { pub struct NewCodeBundle<'a> {
pub bot_id: Option<i32>, pub bot_id: Option<i32>,
pub code_bundle_path: Option<&'a str>, pub path: &'a str,
pub container_digest: Option<&'a str>,
} }
#[derive(Queryable, Serialize, Deserialize, Clone, Debug)] #[derive(Queryable, Serialize, Deserialize, Debug)]
pub struct BotVersion { pub struct CodeBundle {
pub id: i32, pub id: i32,
pub bot_id: Option<i32>, pub bot_id: Option<i32>,
pub code_bundle_path: Option<String>, pub path: String,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
pub container_digest: Option<String>,
} }
pub fn create_bot_version( pub fn create_code_bundle(
new_bot_version: &NewBotVersion, new_code_bundle: &NewCodeBundle,
conn: &PgConnection, conn: &PgConnection,
) -> QueryResult<BotVersion> { ) -> QueryResult<CodeBundle> {
diesel::insert_into(bot_versions::table) diesel::insert_into(code_bundles::table)
.values(new_bot_version) .values(new_code_bundle)
.get_result(conn) .get_result(conn)
} }
pub fn set_active_version( pub fn find_bot_code_bundles(bot_id: i32, conn: &PgConnection) -> QueryResult<Vec<CodeBundle>> {
bot_id: i32, code_bundles::table
version_id: Option<i32>, .filter(code_bundles::bot_id.eq(bot_id))
conn: &PgConnection,
) -> QueryResult<()> {
diesel::update(bots::table.filter(bots::id.eq(bot_id)))
.set(bots::active_version.eq(version_id))
.execute(conn)?;
Ok(())
}
pub fn find_bot_version(version_id: i32, conn: &PgConnection) -> QueryResult<BotVersion> {
bot_versions::table
.filter(bot_versions::id.eq(version_id))
.first(conn)
}
pub fn find_bot_versions(bot_id: i32, conn: &PgConnection) -> QueryResult<Vec<BotVersion>> {
bot_versions::table
.filter(bot_versions::bot_id.eq(bot_id))
.get_results(conn) .get_results(conn)
} }
pub fn active_code_bundle(bot_id: i32, conn: &PgConnection) -> QueryResult<CodeBundle> {
code_bundles::table
.filter(code_bundles::bot_id.eq(bot_id))
.order(code_bundles::created_at.desc())
.first(conn)
}

View file

@ -1,35 +0,0 @@
use diesel::prelude::*;
use crate::schema::maps;
#[derive(Insertable)]
#[table_name = "maps"]
pub struct NewMap<'a> {
pub name: &'a str,
pub file_path: &'a str,
}
#[derive(Queryable, Clone, Debug)]
pub struct Map {
pub id: i32,
pub name: String,
pub file_path: String,
}
pub fn create_map(new_map: NewMap, conn: &PgConnection) -> QueryResult<Map> {
diesel::insert_into(maps::table)
.values(new_map)
.get_result(conn)
}
pub fn find_map(id: i32, conn: &PgConnection) -> QueryResult<Map> {
maps::table.find(id).get_result(conn)
}
pub fn find_map_by_name(name: &str, conn: &PgConnection) -> QueryResult<Map> {
maps::table.filter(maps::name.eq(name)).first(conn)
}
pub fn list_maps(conn: &PgConnection) -> QueryResult<Vec<Map>> {
maps::table.get_results(conn)
}

View file

@ -1,27 +1,20 @@
pub use crate::db_types::MatchState; pub use crate::db_types::MatchState;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use diesel::associations::BelongsTo; use diesel::associations::BelongsTo;
use diesel::pg::Pg;
use diesel::query_builder::BoxedSelectStatement;
use diesel::query_source::{AppearsInFromClause, Once};
use diesel::{ use diesel::{
BelongingToDsl, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl, BelongingToDsl, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl,
}; };
use diesel::{Connection, GroupedBy, PgConnection, QueryResult}; use diesel::{Connection, GroupedBy, PgConnection, QueryResult};
use std::collections::{HashMap, HashSet};
use crate::schema::{bot_versions, bots, maps, match_players, matches}; use crate::schema::{bots, code_bundles, match_players, matches};
use super::bots::{Bot, BotVersion}; use super::bots::{Bot, CodeBundle};
use super::maps::Map;
#[derive(Insertable)] #[derive(Insertable)]
#[table_name = "matches"] #[table_name = "matches"]
pub struct NewMatch<'a> { pub struct NewMatch<'a> {
pub state: MatchState, pub state: MatchState,
pub log_path: &'a str, pub log_path: &'a str,
pub is_public: bool,
pub map_id: Option<i32>,
} }
#[derive(Insertable)] #[derive(Insertable)]
@ -32,7 +25,7 @@ pub struct NewMatchPlayer {
/// player id within the match /// player id within the match
pub player_id: i32, pub player_id: i32,
/// id of the bot behind this player /// id of the bot behind this player
pub bot_version_id: Option<i32>, pub code_bundle_id: Option<i32>,
} }
#[derive(Queryable, Identifiable)] #[derive(Queryable, Identifiable)]
@ -43,8 +36,6 @@ pub struct MatchBase {
pub log_path: String, pub log_path: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub winner: Option<i32>, pub winner: Option<i32>,
pub is_public: bool,
pub map_id: Option<i32>,
} }
#[derive(Queryable, Identifiable, Associations, Clone)] #[derive(Queryable, Identifiable, Associations, Clone)]
@ -76,7 +67,7 @@ pub fn create_match(
.map(|(num, player_data)| NewMatchPlayer { .map(|(num, player_data)| NewMatchPlayer {
match_id: match_base.id, match_id: match_base.id,
player_id: num as i32, player_id: num as i32,
bot_version_id: player_data.code_bundle_id, code_bundle_id: player_data.code_bundle_id,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -96,29 +87,16 @@ pub struct MatchData {
pub match_players: Vec<MatchPlayer>, pub match_players: Vec<MatchPlayer>,
} }
/// Add player information to MatchBase instances pub fn list_matches(conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
fn fetch_full_match_data( conn.transaction(|| {
matches: Vec<MatchBase>, let matches = matches::table.get_results::<MatchBase>(conn)?;
conn: &PgConnection,
) -> QueryResult<Vec<FullMatchData>> {
let map_ids: HashSet<i32> = matches.iter().filter_map(|m| m.map_id).collect();
let maps_by_id: HashMap<i32, Map> = maps::table
.filter(maps::id.eq_any(map_ids))
.load::<Map>(conn)?
.into_iter()
.map(|m| (m.id, m))
.collect();
let match_players = MatchPlayer::belonging_to(&matches) let match_players = MatchPlayer::belonging_to(&matches)
.left_join( .left_join(
bot_versions::table.on(match_players::bot_version_id.eq(bot_versions::id.nullable())), code_bundles::table
.on(match_players::code_bundle_id.eq(code_bundles::id.nullable())),
) )
.left_join(bots::table.on(bot_versions::bot_id.eq(bots::id.nullable()))) .left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
.order_by((
match_players::match_id.asc(),
match_players::player_id.asc(),
))
.load::<FullMatchPlayerData>(conn)? .load::<FullMatchPlayerData>(conn)?
.grouped_by(&matches); .grouped_by(&matches);
@ -126,103 +104,18 @@ fn fetch_full_match_data(
.into_iter() .into_iter()
.zip(match_players.into_iter()) .zip(match_players.into_iter())
.map(|(base, players)| FullMatchData { .map(|(base, players)| FullMatchData {
match_players: players.into_iter().collect(),
map: base
.map_id
.and_then(|map_id| maps_by_id.get(&map_id).cloned()),
base, base,
match_players: players.into_iter().collect(),
}) })
.collect(); .collect();
Ok(res) Ok(res)
}
// TODO: this method should disappear
pub fn list_matches(amount: i64, conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
conn.transaction(|| {
let matches = matches::table
.filter(matches::state.eq(MatchState::Finished))
.order_by(matches::created_at.desc())
.limit(amount)
.get_results::<MatchBase>(conn)?;
fetch_full_match_data(matches, conn)
}) })
} }
pub fn list_public_matches(
amount: i64,
before: Option<NaiveDateTime>,
after: Option<NaiveDateTime>,
conn: &PgConnection,
) -> QueryResult<Vec<FullMatchData>> {
conn.transaction(|| {
// TODO: how can this common logic be abstracted?
let query = matches::table
.filter(matches::state.eq(MatchState::Finished))
.filter(matches::is_public.eq(true))
.into_boxed();
let matches =
select_matches_page(query, amount, before, after).get_results::<MatchBase>(conn)?;
fetch_full_match_data(matches, conn)
})
}
pub fn list_bot_matches(
bot_id: i32,
amount: i64,
before: Option<NaiveDateTime>,
after: Option<NaiveDateTime>,
conn: &PgConnection,
) -> QueryResult<Vec<FullMatchData>> {
let query = matches::table
.filter(matches::state.eq(MatchState::Finished))
.filter(matches::is_public.eq(true))
.order_by(matches::created_at.desc())
.inner_join(match_players::table)
.inner_join(
bot_versions::table.on(match_players::bot_version_id.eq(bot_versions::id.nullable())),
)
.filter(bot_versions::bot_id.eq(bot_id))
.select(matches::all_columns)
.into_boxed();
let matches =
select_matches_page(query, amount, before, after).get_results::<MatchBase>(conn)?;
fetch_full_match_data(matches, conn)
}
fn select_matches_page<QS>(
query: BoxedSelectStatement<'static, matches::SqlType, QS, Pg>,
amount: i64,
before: Option<NaiveDateTime>,
after: Option<NaiveDateTime>,
) -> BoxedSelectStatement<'static, matches::SqlType, QS, Pg>
where
QS: AppearsInFromClause<matches::table, Count = Once>,
{
// TODO: this is not nice. Replace this with proper cursor logic.
match (before, after) {
(None, None) => query.order_by(matches::created_at.desc()),
(Some(before), None) => query
.filter(matches::created_at.lt(before))
.order_by(matches::created_at.desc()),
(None, Some(after)) => query
.filter(matches::created_at.gt(after))
.order_by(matches::created_at.asc()),
(Some(before), Some(after)) => query
.filter(matches::created_at.lt(before))
.filter(matches::created_at.gt(after))
.order_by(matches::created_at.desc()),
}
.limit(amount)
}
// TODO: maybe unify this with matchdata? // TODO: maybe unify this with matchdata?
pub struct FullMatchData { pub struct FullMatchData {
pub base: MatchBase, pub base: MatchBase,
pub map: Option<Map>,
pub match_players: Vec<FullMatchPlayerData>, pub match_players: Vec<FullMatchPlayerData>,
} }
@ -230,7 +123,7 @@ pub struct FullMatchData {
// #[primary_key(base.match_id, base::player_id)] // #[primary_key(base.match_id, base::player_id)]
pub struct FullMatchPlayerData { pub struct FullMatchPlayerData {
pub base: MatchPlayer, pub base: MatchPlayer,
pub bot_version: Option<BotVersion>, pub code_bundle: Option<CodeBundle>,
pub bot: Option<Bot>, pub bot: Option<Bot>,
} }
@ -251,24 +144,17 @@ pub fn find_match(id: i32, conn: &PgConnection) -> QueryResult<FullMatchData> {
conn.transaction(|| { conn.transaction(|| {
let match_base = matches::table.find(id).get_result::<MatchBase>(conn)?; let match_base = matches::table.find(id).get_result::<MatchBase>(conn)?;
let map = match match_base.map_id {
None => None,
Some(map_id) => Some(super::maps::find_map(map_id, conn)?),
};
let match_players = MatchPlayer::belonging_to(&match_base) let match_players = MatchPlayer::belonging_to(&match_base)
.left_join( .left_join(
bot_versions::table code_bundles::table
.on(match_players::bot_version_id.eq(bot_versions::id.nullable())), .on(match_players::code_bundle_id.eq(code_bundles::id.nullable())),
) )
.left_join(bots::table.on(bot_versions::bot_id.eq(bots::id.nullable()))) .left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
.order_by(match_players::player_id.asc())
.load::<FullMatchPlayerData>(conn)?; .load::<FullMatchPlayerData>(conn)?;
let res = FullMatchData { let res = FullMatchData {
base: match_base, base: match_base,
match_players, match_players,
map,
}; };
Ok(res) Ok(res)

View file

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

View file

@ -42,17 +42,11 @@ fn argon2_config() -> argon2::Config<'static> {
} }
} }
pub fn hash_password(password: &str) -> (Vec<u8>, [u8; 32]) {
let argon_config = argon2_config();
let salt: [u8; 32] = rand::thread_rng().gen();
let hash = argon2::hash_raw(password.as_bytes(), &salt, &argon_config).unwrap();
(hash, salt)
}
pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> { pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> {
let (hash, salt) = hash_password(&credentials.password); let argon_config = argon2_config();
let salt: [u8; 32] = rand::thread_rng().gen();
let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
let new_user = NewUser { let new_user = NewUser {
username: credentials.username, username: credentials.username,
password_salt: &salt, password_salt: &salt,
@ -63,36 +57,14 @@ pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResul
.get_result::<User>(conn) .get_result::<User>(conn)
} }
pub fn find_user(user_id: i32, db_conn: &PgConnection) -> QueryResult<User> { pub fn find_user(username: &str, db_conn: &PgConnection) -> QueryResult<User> {
users::table
.filter(users::id.eq(user_id))
.first::<User>(db_conn)
}
pub fn find_user_by_name(username: &str, db_conn: &PgConnection) -> QueryResult<User> {
users::table users::table
.filter(users::username.eq(username)) .filter(users::username.eq(username))
.first::<User>(db_conn) .first::<User>(db_conn)
} }
pub fn set_user_password(credentials: Credentials, db_conn: &PgConnection) -> QueryResult<()> {
let (hash, salt) = hash_password(&credentials.password);
let n_changes = diesel::update(users::table.filter(users::username.eq(&credentials.username)))
.set((
users::password_salt.eq(salt.as_slice()),
users::password_hash.eq(hash.as_slice()),
))
.execute(db_conn)?;
if n_changes == 0 {
Err(diesel::result::Error::NotFound)
} else {
Ok(())
}
}
pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> { pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> {
find_user_by_name(credentials.username, db_conn) find_user(credentials.username, db_conn)
.optional() .optional()
.unwrap() .unwrap()
.and_then(|user| { .and_then(|user| {

View file

@ -8,67 +8,33 @@ 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 std::path::PathBuf;
use std::sync::Arc;
use std::{fs, net::SocketAddr};
use bb8::{Pool, PooledConnection}; use bb8::{Pool, PooledConnection};
use bb8_diesel::{self, DieselConnectionManager}; use bb8_diesel::{self, DieselConnectionManager};
use config::ConfigError; use config::ConfigError;
use diesel::{Connection, PgConnection}; use diesel::{Connection, PgConnection};
use modules::client_api::run_client_api;
use modules::ranking::run_ranker; use modules::ranking::run_ranker;
use modules::registry::registry_service; use serde::Deserialize;
use serde::{Deserialize, Serialize};
use axum::{ use axum::{
async_trait, async_trait,
extract::{Extension, FromRequest, RequestParts}, extract::{Extension, FromRequest, RequestParts},
http::StatusCode, http::StatusCode,
routing::{get, post}, routing::{get, post},
Router, AddExtensionLayer, Router,
}; };
// TODO: make these configurable
const BOTS_DIR: &str = "./data/bots";
const MATCHES_DIR: &str = "./data/matches";
const MAPS_DIR: &str = "./data/maps";
const SIMPLEBOT_PATH: &str = "../simplebot/simplebot.py";
type ConnectionPool = bb8::Pool<DieselConnectionManager<PgConnection>>; type ConnectionPool = bb8::Pool<DieselConnectionManager<PgConnection>>;
// this should probably be modularized a bit as the config grows pub async fn seed_simplebot(pool: &ConnectionPool) {
#[derive(Serialize, Deserialize)]
pub struct GlobalConfig {
/// url for the postgres database
pub database_url: String,
/// which image to use for running python bots
pub python_runner_image: String,
/// url for the internal container registry
/// this will be used when running bots
pub container_registry_url: String,
/// webserver root url, used to construct links
pub root_url: String,
/// directory where bot code will be stored
pub bots_directory: String,
/// directory where match logs will be stored
pub match_logs_directory: String,
/// directory where map files will be stored
pub maps_directory: String,
/// base directory for registry data
pub registry_directory: String,
/// secret admin password for internal docker login
/// used to pull bots when running matches
pub registry_admin_password: String,
/// Whether to run the ranker
pub ranker_enabled: bool,
}
// TODO: do we still need this? Is there a better way?
const SIMPLEBOT_PATH: &str = "../simplebot/simplebot.py";
pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) {
let conn = pool.get().await.expect("could not get database connection"); let conn = pool.get().await.expect("could not get database connection");
// This transaction is expected to fail when simplebot already exists. // This transaction is expected to fail when simplebot already exists.
let _res = conn.transaction::<(), diesel::result::Error, _>(|| { let _res = conn.transaction::<(), diesel::result::Error, _>(|| {
@ -84,7 +50,7 @@ pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) {
let simplebot_code = let simplebot_code =
std::fs::read_to_string(SIMPLEBOT_PATH).expect("could not read simplebot code"); std::fs::read_to_string(SIMPLEBOT_PATH).expect("could not read simplebot code");
modules::bots::save_code_string(&simplebot_code, Some(simplebot.id), &conn, config)?; modules::bots::save_code_bundle(&simplebot_code, Some(simplebot.id), &conn)?;
println!("initialized simplebot"); println!("initialized simplebot");
@ -94,22 +60,11 @@ pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) {
pub type DbPool = Pool<DieselConnectionManager<PgConnection>>; pub type DbPool = Pool<DieselConnectionManager<PgConnection>>;
pub async fn create_db_pool(config: &GlobalConfig) -> DbPool { pub async fn prepare_db(database_url: &str) -> DbPool {
let manager = DieselConnectionManager::<PgConnection>::new(&config.database_url); let manager = DieselConnectionManager::<PgConnection>::new(database_url);
bb8::Pool::builder().build(manager).await.unwrap() let pool = bb8::Pool::builder().build(manager).await.unwrap();
} seed_simplebot(&pool).await;
pool
// create all directories required for further operation
fn init_directories(config: &GlobalConfig) -> std::io::Result<()> {
fs::create_dir_all(&config.bots_directory)?;
fs::create_dir_all(&config.maps_directory)?;
fs::create_dir_all(&config.match_logs_directory)?;
let registry_path = PathBuf::from(&config.registry_directory);
fs::create_dir_all(registry_path.join("sha256"))?;
fs::create_dir_all(registry_path.join("manifests"))?;
fs::create_dir_all(registry_path.join("uploads"))?;
Ok(())
} }
pub fn api() -> Router { pub fn api() -> Router {
@ -117,30 +72,31 @@ pub fn api() -> Router {
.route("/register", post(routes::users::register)) .route("/register", post(routes::users::register))
.route("/login", post(routes::users::login)) .route("/login", post(routes::users::login))
.route("/users/me", get(routes::users::current_user)) .route("/users/me", get(routes::users::current_user))
.route("/users/:user/bots", get(routes::bots::get_user_bots))
.route( .route(
"/bots", "/bots",
get(routes::bots::list_bots).post(routes::bots::create_bot), get(routes::bots::list_bots).post(routes::bots::create_bot),
) )
.route("/bots/:bot_name", get(routes::bots::get_bot)) .route("/bots/my_bots", get(routes::bots::get_my_bots))
.route("/bots/:bot_id", get(routes::bots::get_bot))
.route( .route(
"/bots/:bot_name/upload", "/bots/:bot_id/upload",
post(routes::bots::upload_code_multipart), post(routes::bots::upload_code_multipart),
) )
.route("/code/:version_id", get(routes::bots::get_code)) .route(
.route("/matches", get(routes::matches::list_recent_matches)) "/matches",
get(routes::matches::list_matches).post(routes::matches::play_match),
)
.route("/matches/:match_id", get(routes::matches::get_match_data)) .route("/matches/:match_id", get(routes::matches::get_match_data))
.route( .route(
"/matches/:match_id/log", "/matches/:match_id/log",
get(routes::matches::get_match_log), get(routes::matches::get_match_log),
) )
.route("/maps", get(routes::maps::list_maps))
.route("/leaderboard", get(routes::bots::get_ranking)) .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))
} }
pub fn get_config() -> Result<GlobalConfig, ConfigError> { pub fn get_config() -> Result<Configuration, ConfigError> {
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"))
@ -148,37 +104,15 @@ pub fn get_config() -> Result<GlobalConfig, ConfigError> {
.try_deserialize() .try_deserialize()
} }
async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) {
// TODO: put in config
let addr = SocketAddr::from(([127, 0, 0, 1], 9001));
axum::Server::bind(&addr)
.serve(
registry_service()
.layer(Extension(db_pool))
.layer(Extension(config))
.into_make_service(),
)
.await
.unwrap();
}
pub async fn run_app() { pub async fn run_app() {
let global_config = Arc::new(get_config().unwrap()); let configuration = get_config().unwrap();
let db_pool = create_db_pool(&global_config).await; let db_pool = prepare_db(&configuration.database_url).await;
seed_simplebot(&global_config, &db_pool).await;
init_directories(&global_config).unwrap();
if global_config.ranker_enabled { tokio::spawn(run_ranker(db_pool.clone()));
tokio::spawn(run_ranker(global_config.clone(), db_pool.clone()));
}
tokio::spawn(run_registry(global_config.clone(), db_pool.clone()));
tokio::spawn(run_client_api(global_config.clone(), db_pool.clone()));
let api_service = Router::new() let api_service = Router::new()
.nest("/api", api()) .nest("/api", api())
.layer(Extension(db_pool)) .layer(AddExtensionLayer::new(db_pool))
.layer(Extension(global_config))
.into_make_service(); .into_make_service();
// TODO: put in config // TODO: put in config
@ -187,6 +121,11 @@ pub async fn run_app() {
axum::Server::bind(&addr).serve(api_service).await.unwrap(); axum::Server::bind(&addr).serve(api_service).await.unwrap();
} }
#[derive(Deserialize)]
pub struct Configuration {
pub database_url: String,
}
// we can also write a custom extractor that grabs a connection from the pool // we can also write a custom extractor that grabs a connection from the pool
// which setup is appropriate depends on your application // which setup is appropriate depends on your application
pub struct DatabaseConnection(PooledConnection<'static, DieselConnectionManager<PgConnection>>); pub struct DatabaseConnection(PooledConnection<'static, DieselConnectionManager<PgConnection>>);

View file

@ -0,0 +1,272 @@
pub mod pb {
tonic::include_proto!("grpc.planetwars.bot_api");
}
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use runner::match_context::{EventBus, PlayerHandle, RequestError, RequestMessage};
use runner::match_log::MatchLogger;
use tokio::sync::{mpsc, oneshot};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tonic;
use tonic::transport::Server;
use tonic::{Request, Response, Status, Streaming};
use planetwars_matchrunner as runner;
use crate::db;
use crate::util::gen_alphanumeric;
use crate::ConnectionPool;
use super::matches::{MatchPlayer, RunMatch};
pub struct BotApiServer {
conn_pool: ConnectionPool,
router: PlayerRouter,
}
/// Routes players to their handler
#[derive(Clone)]
struct PlayerRouter {
routing_table: Arc<Mutex<HashMap<String, SyncThingData>>>,
}
impl PlayerRouter {
pub fn new() -> Self {
PlayerRouter {
routing_table: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Default for PlayerRouter {
fn default() -> Self {
Self::new()
}
}
// TODO: implement a way to expire entries
impl PlayerRouter {
fn put(&self, player_key: String, entry: SyncThingData) {
let mut routing_table = self.routing_table.lock().unwrap();
routing_table.insert(player_key, entry);
}
fn take(&self, player_key: &str) -> Option<SyncThingData> {
// TODO: this design does not allow for reconnects. Is this desired?
let mut routing_table = self.routing_table.lock().unwrap();
routing_table.remove(player_key)
}
}
#[tonic::async_trait]
impl pb::bot_api_service_server::BotApiService for BotApiServer {
type ConnectBotStream = UnboundedReceiverStream<Result<pb::PlayerRequest, Status>>;
async fn connect_bot(
&self,
req: Request<Streaming<pb::PlayerRequestResponse>>,
) -> Result<Response<Self::ConnectBotStream>, Status> {
// TODO: clean up errors
let player_key = req
.metadata()
.get("player_key")
.ok_or_else(|| Status::unauthenticated("no player_key provided"))?;
let player_key_str = player_key
.to_str()
.map_err(|_| Status::invalid_argument("unreadable string"))?;
let sync_data = self
.router
.take(player_key_str)
.ok_or_else(|| Status::not_found("player_key not found"))?;
let stream = req.into_inner();
sync_data.tx.send(stream).unwrap();
Ok(Response::new(UnboundedReceiverStream::new(
sync_data.server_messages,
)))
}
async fn create_match(
&self,
req: Request<pb::MatchRequest>,
) -> Result<Response<pb::CreatedMatch>, Status> {
// TODO: unify with matchrunner module
let conn = self.conn_pool.get().await.unwrap();
let match_request = req.get_ref();
let opponent = db::bots::find_bot_by_name(&match_request.opponent_name, &conn)
.map_err(|_| Status::not_found("opponent not found"))?;
let opponent_code_bundle = db::bots::active_code_bundle(opponent.id, &conn)
.map_err(|_| Status::not_found("opponent has no code"))?;
let player_key = gen_alphanumeric(32);
let remote_bot_spec = Box::new(RemoteBotSpec {
player_key: player_key.clone(),
router: self.router.clone(),
});
let mut run_match = RunMatch::from_players(vec![
MatchPlayer::from_bot_spec(remote_bot_spec),
MatchPlayer::from_code_bundle(&opponent_code_bundle),
]);
let created_match = run_match
.store_in_database(&conn)
.expect("failed to save match");
run_match.spawn(self.conn_pool.clone());
Ok(Response::new(pb::CreatedMatch {
match_id: created_match.base.id,
player_key,
}))
}
}
// TODO: please rename me
struct SyncThingData {
tx: oneshot::Sender<Streaming<pb::PlayerRequestResponse>>,
server_messages: mpsc::UnboundedReceiver<Result<pb::PlayerRequest, Status>>,
}
struct RemoteBotSpec {
player_key: String,
router: PlayerRouter,
}
#[tonic::async_trait]
impl runner::BotSpec for RemoteBotSpec {
async fn run_bot(
&self,
player_id: u32,
event_bus: Arc<Mutex<EventBus>>,
_match_logger: MatchLogger,
) -> Box<dyn PlayerHandle> {
let (tx, rx) = oneshot::channel();
let (server_msg_snd, server_msg_recv) = mpsc::unbounded_channel();
self.router.put(
self.player_key.clone(),
SyncThingData {
tx,
server_messages: server_msg_recv,
},
);
let fut = tokio::time::timeout(Duration::from_secs(10), rx);
match fut.await {
Ok(Ok(client_messages)) => {
// let client_messages = rx.await.unwrap();
tokio::spawn(handle_bot_messages(
player_id,
event_bus.clone(),
client_messages,
));
}
_ => {
// ensure router cleanup
self.router.take(&self.player_key);
}
};
// If the player did not connect, the receiving half of `sender`
// will be dropped here, resulting in a time-out for every turn.
// This is fine for now, but
// TODO: provide a formal mechanism for player startup failure
Box::new(RemoteBotHandle {
sender: server_msg_snd,
player_id,
event_bus,
})
}
}
async fn handle_bot_messages(
player_id: u32,
event_bus: Arc<Mutex<EventBus>>,
mut messages: Streaming<pb::PlayerRequestResponse>,
) {
while let Some(message) = messages.message().await.unwrap() {
let request_id = (player_id, message.request_id as u32);
event_bus
.lock()
.unwrap()
.resolve_request(request_id, Ok(message.content));
}
}
struct RemoteBotHandle {
sender: mpsc::UnboundedSender<Result<pb::PlayerRequest, Status>>,
player_id: u32,
event_bus: Arc<Mutex<EventBus>>,
}
impl PlayerHandle for RemoteBotHandle {
fn send_request(&mut self, r: RequestMessage) {
let res = self.sender.send(Ok(pb::PlayerRequest {
request_id: r.request_id as i32,
content: r.content,
}));
match res {
Ok(()) => {
// schedule a timeout. See comments at method implementation
tokio::spawn(schedule_timeout(
(self.player_id, r.request_id),
r.timeout,
self.event_bus.clone(),
));
}
Err(_send_error) => {
// cannot contact the remote bot anymore;
// directly mark all requests as timed out.
// TODO: create a dedicated error type for this.
// should it be logged?
println!("send error: {:?}", _send_error);
self.event_bus
.lock()
.unwrap()
.resolve_request((self.player_id, r.request_id), Err(RequestError::Timeout));
}
}
}
}
// TODO: this will spawn a task for every request, which might not be ideal.
// Some alternatives:
// - create a single task that manages all time-outs.
// - intersperse timeouts with incoming client messages
// - push timeouts upwards, into the matchrunner logic (before we hit the playerhandle).
// This was initially not done to allow timer start to be delayed until the message actually arrived
// with the player. Is this still needed, or is there a different way to do this?
//
async fn schedule_timeout(
request_id: (u32, u32),
duration: Duration,
event_bus: Arc<Mutex<EventBus>>,
) {
tokio::time::sleep(duration).await;
event_bus
.lock()
.unwrap()
.resolve_request(request_id, Err(RequestError::Timeout));
}
pub async fn run_bot_api(pool: ConnectionPool) {
let router = PlayerRouter::new();
let server = BotApiServer {
router,
conn_pool: pool.clone(),
};
let addr = SocketAddr::from(([127, 0, 0, 1], 50051));
Server::builder()
.add_service(pb::bot_api_service_server::BotApiServiceServer::new(server))
.serve(addr)
.await
.unwrap()
}

View file

@ -2,32 +2,22 @@ use std::path::PathBuf;
use diesel::{PgConnection, QueryResult}; use diesel::{PgConnection, QueryResult};
use crate::{db, util::gen_alphanumeric, GlobalConfig}; use crate::{db, util::gen_alphanumeric, BOTS_DIR};
/// Save a string containing bot code as a code bundle. pub fn save_code_bundle(
/// If a bot was provided, set the saved bundle as its active version.
pub fn save_code_string(
bot_code: &str, bot_code: &str,
bot_id: Option<i32>, bot_id: Option<i32>,
conn: &PgConnection, conn: &PgConnection,
config: &GlobalConfig, ) -> QueryResult<db::bots::CodeBundle> {
) -> QueryResult<db::bots::BotVersion> {
let bundle_name = gen_alphanumeric(16); let bundle_name = gen_alphanumeric(16);
let code_bundle_dir = PathBuf::from(&config.bots_directory).join(&bundle_name); let code_bundle_dir = PathBuf::from(BOTS_DIR).join(&bundle_name);
std::fs::create_dir(&code_bundle_dir).unwrap(); std::fs::create_dir(&code_bundle_dir).unwrap();
std::fs::write(code_bundle_dir.join("bot.py"), bot_code).unwrap(); std::fs::write(code_bundle_dir.join("bot.py"), bot_code).unwrap();
let new_code_bundle = db::bots::NewBotVersion { let new_code_bundle = db::bots::NewCodeBundle {
bot_id, bot_id,
code_bundle_path: Some(&bundle_name), path: &bundle_name,
container_digest: None,
}; };
let version = db::bots::create_bot_version(&new_code_bundle, conn)?; db::bots::create_code_bundle(&new_code_bundle, conn)
// Leave this coupled for now - this is how the behaviour was bevore.
// It would be cleaner to separate version setting and bot selection, though.
if let Some(bot_id) = bot_id {
db::bots::set_active_version(bot_id, Some(version.id), conn)?;
}
Ok(version)
} }

View file

@ -1,390 +0,0 @@
pub mod pb {
tonic::include_proto!("grpc.planetwars.client_api");
pub use player_api_client_message::ClientMessage as PlayerApiClientMessageType;
pub use player_api_server_message::ServerMessage as PlayerApiServerMessageType;
}
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use runner::match_context::{EventBus, PlayerHandle, RequestError, RequestMessage};
use runner::match_log::MatchLogger;
use tokio::sync::{mpsc, oneshot};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tonic;
use tonic::transport::Server;
use tonic::{Request, Response, Status, Streaming};
use planetwars_matchrunner as runner;
use crate::db;
use crate::util::gen_alphanumeric;
use crate::ConnectionPool;
use crate::GlobalConfig;
use super::matches::{MatchPlayer, RunMatch};
pub struct ClientApiServer {
conn_pool: ConnectionPool,
runner_config: Arc<GlobalConfig>,
router: PlayerRouter,
}
type ClientMessages = Streaming<pb::PlayerApiClientMessage>;
type ServerMessages = mpsc::UnboundedReceiver<Result<pb::PlayerApiServerMessage, Status>>;
enum PlayerConnectionState {
Reserved,
ClientConnected {
tx: oneshot::Sender<ServerMessages>,
client_messages: ClientMessages,
},
ServerConnected {
tx: oneshot::Sender<ClientMessages>,
server_messages: ServerMessages,
},
// In connected state, the connection is removed from the PlayerRouter
}
/// Routes players to their handler
#[derive(Clone)]
struct PlayerRouter {
routing_table: Arc<Mutex<HashMap<String, PlayerConnectionState>>>,
}
impl PlayerRouter {
pub fn new() -> Self {
PlayerRouter {
routing_table: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Default for PlayerRouter {
fn default() -> Self {
Self::new()
}
}
// TODO: implement a way to expire entries
impl PlayerRouter {
fn put(&self, player_key: String, entry: PlayerConnectionState) {
let mut routing_table = self.routing_table.lock().unwrap();
routing_table.insert(player_key, entry);
}
fn take(&self, player_key: &str) -> Option<PlayerConnectionState> {
// TODO: this design does not allow for reconnects. Is this desired?
let mut routing_table = self.routing_table.lock().unwrap();
routing_table.remove(player_key)
}
}
#[tonic::async_trait]
impl pb::client_api_service_server::ClientApiService for ClientApiServer {
type ConnectPlayerStream = UnboundedReceiverStream<Result<pb::PlayerApiServerMessage, Status>>;
async fn connect_player(
&self,
req: Request<Streaming<pb::PlayerApiClientMessage>>,
) -> Result<Response<Self::ConnectPlayerStream>, Status> {
// TODO: clean up errors
let player_key = req
.metadata()
.get("player_key")
.ok_or_else(|| Status::unauthenticated("no player_key provided"))?;
let player_key_string = player_key
.to_str()
.map_err(|_| Status::invalid_argument("unreadable string"))?
.to_string();
let client_messages = req.into_inner();
let server_messages_promise = {
// during this block, a lack is held on the routing table
let mut routing_table = self.router.routing_table.lock().unwrap();
let connection_state = routing_table
.remove(&player_key_string)
.ok_or_else(|| Status::not_found("player_key not found"))?;
match connection_state {
PlayerConnectionState::Reserved => {
let (tx, rx) = oneshot::channel();
routing_table.insert(
player_key_string,
PlayerConnectionState::ClientConnected {
tx,
client_messages,
},
);
Promise::Awaiting(rx)
}
PlayerConnectionState::ServerConnected {
tx,
server_messages,
} => {
tx.send(client_messages).unwrap();
Promise::Resolved(server_messages)
}
PlayerConnectionState::ClientConnected { .. } => panic!("player already connected"),
}
};
let server_messages = server_messages_promise
.get_value()
.await
.map_err(|_| Status::internal("failed to connect player to game"))?;
Ok(Response::new(UnboundedReceiverStream::new(server_messages)))
}
async fn create_match(
&self,
req: Request<pb::CreateMatchRequest>,
) -> Result<Response<pb::CreateMatchResponse>, Status> {
// TODO: unify with matchrunner module
let conn = self.conn_pool.get().await.unwrap();
let match_request = req.get_ref();
let (opponent_bot, opponent_bot_version) =
db::bots::find_bot_with_version_by_name(&match_request.opponent_name, &conn)
.map_err(|_| Status::not_found("opponent not found"))?;
let map_name = match match_request.map_name.as_str() {
"" => "hex",
name => name,
};
let map = db::maps::find_map_by_name(map_name, &conn)
.map_err(|_| Status::not_found("map not found"))?;
let player_key = gen_alphanumeric(32);
// ensure that the player key is registered in the router when we send a response
self.router
.put(player_key.clone(), PlayerConnectionState::Reserved);
let remote_bot_spec = Box::new(RemoteBotSpec {
player_key: player_key.clone(),
router: self.router.clone(),
});
let run_match = RunMatch::new(
self.runner_config.clone(),
false,
map,
vec![
MatchPlayer::BotSpec {
spec: remote_bot_spec,
},
MatchPlayer::BotVersion {
bot: Some(opponent_bot),
version: opponent_bot_version,
},
],
);
let (created_match, _) = run_match
.run(self.conn_pool.clone())
.await
.expect("failed to create match");
Ok(Response::new(pb::CreateMatchResponse {
match_id: created_match.base.id,
player_key,
// TODO: can we avoid hardcoding this?
match_url: format!(
"{}/matches/{}",
self.runner_config.root_url, created_match.base.id
),
}))
}
}
struct RemoteBotSpec {
player_key: String,
router: PlayerRouter,
}
#[tonic::async_trait]
impl runner::BotSpec for RemoteBotSpec {
async fn run_bot(
&self,
player_id: u32,
event_bus: Arc<Mutex<EventBus>>,
_match_logger: MatchLogger,
) -> Box<dyn PlayerHandle> {
let (server_msg_snd, server_msg_recv) = mpsc::unbounded_channel();
let client_messages_promise = {
// during this block, we hold a lock on the routing table.
let mut routing_table = self.router.routing_table.lock().unwrap();
let connection_state = routing_table
.remove(&self.player_key)
.expect("player key not found in routing table");
match connection_state {
PlayerConnectionState::Reserved => {
let (tx, rx) = oneshot::channel();
routing_table.insert(
self.player_key.clone(),
PlayerConnectionState::ServerConnected {
tx,
server_messages: server_msg_recv,
},
);
Promise::Awaiting(rx)
}
PlayerConnectionState::ClientConnected {
tx,
client_messages,
} => {
tx.send(server_msg_recv).unwrap();
Promise::Resolved(client_messages)
}
PlayerConnectionState::ServerConnected { .. } => panic!("server already connected"),
}
};
let client_messages_future =
tokio::time::timeout(Duration::from_secs(10), client_messages_promise.get_value());
if let Ok(Ok(client_messages)) = client_messages_future.await {
tokio::spawn(handle_bot_messages(
player_id,
event_bus.clone(),
client_messages,
));
}
// ensure router cleanup
self.router.take(&self.player_key);
// If the player did not connect, the receiving half of `sender`
// will be dropped here, resulting in a time-out for every turn.
// This is fine for now, but
// TODO: provide a formal mechanism for player startup failure
Box::new(RemoteBotHandle {
sender: server_msg_snd,
player_id,
event_bus,
})
}
}
async fn handle_bot_messages(
player_id: u32,
event_bus: Arc<Mutex<EventBus>>,
mut messages: Streaming<pb::PlayerApiClientMessage>,
) {
// TODO: can this be written more nicely?
while let Some(message) = messages.message().await.unwrap() {
match message.client_message {
Some(pb::PlayerApiClientMessageType::Action(resp)) => {
let request_id = (player_id, resp.action_request_id as u32);
event_bus
.lock()
.unwrap()
.resolve_request(request_id, Ok(resp.content));
}
_ => (),
}
}
}
struct RemoteBotHandle {
sender: mpsc::UnboundedSender<Result<pb::PlayerApiServerMessage, Status>>,
player_id: u32,
event_bus: Arc<Mutex<EventBus>>,
}
impl PlayerHandle for RemoteBotHandle {
fn send_request(&mut self, r: RequestMessage) {
let req = pb::PlayerActionRequest {
action_request_id: r.request_id as i32,
content: r.content,
};
let server_message = pb::PlayerApiServerMessage {
server_message: Some(pb::PlayerApiServerMessageType::ActionRequest(req)),
};
let res = self.sender.send(Ok(server_message));
match res {
Ok(()) => {
// schedule a timeout. See comments at method implementation
tokio::spawn(schedule_timeout(
(self.player_id, r.request_id),
r.timeout,
self.event_bus.clone(),
));
}
Err(_send_error) => {
// cannot contact the remote bot anymore;
// directly mark all requests as timed out.
// TODO: create a dedicated error type for this.
// should it be logged?
println!("send error: {:?}", _send_error);
self.event_bus
.lock()
.unwrap()
.resolve_request((self.player_id, r.request_id), Err(RequestError::Timeout));
}
}
}
}
// TODO: this will spawn a task for every request, which might not be ideal.
// Some alternatives:
// - create a single task that manages all time-outs.
// - intersperse timeouts with incoming client messages
// - push timeouts upwards, into the matchrunner logic (before we hit the playerhandle).
// This was initially not done to allow timer start to be delayed until the message actually arrived
// with the player. Is this still needed, or is there a different way to do this?
//
async fn schedule_timeout(
request_id: (u32, u32),
duration: Duration,
event_bus: Arc<Mutex<EventBus>>,
) {
tokio::time::sleep(duration).await;
event_bus
.lock()
.unwrap()
.resolve_request(request_id, Err(RequestError::Timeout));
}
pub async fn run_client_api(runner_config: Arc<GlobalConfig>, pool: ConnectionPool) {
let router = PlayerRouter::new();
let server = ClientApiServer {
router,
conn_pool: pool,
runner_config,
};
let addr = SocketAddr::from(([127, 0, 0, 1], 50051));
Server::builder()
.add_service(pb::client_api_service_server::ClientApiServiceServer::new(
server,
))
.serve(addr)
.await
.unwrap()
}
enum Promise<T> {
Resolved(T),
Awaiting(oneshot::Receiver<T>),
}
impl<T> Promise<T> {
async fn get_value(self) -> Result<T, oneshot::error::RecvError> {
match self {
Promise::Resolved(val) => Ok(val),
Promise::Awaiting(rx) => rx.await,
}
}
}

View file

@ -1,161 +1,109 @@
use std::path::PathBuf;
use diesel::{PgConnection, QueryResult}; use diesel::{PgConnection, QueryResult};
use planetwars_matchrunner::{self as runner, docker_runner::DockerBotSpec, BotSpec, MatchConfig}; use planetwars_matchrunner::{self as runner, docker_runner::DockerBotSpec, BotSpec, MatchConfig};
use runner::MatchOutcome; use runner::MatchOutcome;
use std::{path::PathBuf, sync::Arc};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::{ use crate::{
db::{ db::{
self, self,
maps::Map,
matches::{MatchData, MatchResult}, matches::{MatchData, MatchResult},
}, },
util::gen_alphanumeric, util::gen_alphanumeric,
ConnectionPool, GlobalConfig, ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
}; };
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
pub struct RunMatch { pub struct RunMatch {
log_file_name: String, log_file_name: String,
players: Vec<MatchPlayer>, players: Vec<MatchPlayer>,
config: Arc<GlobalConfig>, match_id: Option<i32>,
is_public: bool,
// Map is mandatory for now.
// It would be nice to allow "anonymous" (eg. randomly generated) maps
// in the future, too.
map: Map,
} }
pub enum MatchPlayer { pub struct MatchPlayer {
BotVersion { bot_spec: Box<dyn BotSpec>,
bot: Option<db::bots::Bot>, // meta that will be passed on to database
version: db::bots::BotVersion, code_bundle_id: Option<i32>,
}, }
BotSpec {
spec: Box<dyn BotSpec>, impl MatchPlayer {
}, pub fn from_code_bundle(code_bundle: &db::bots::CodeBundle) -> Self {
MatchPlayer {
bot_spec: code_bundle_to_botspec(code_bundle),
code_bundle_id: Some(code_bundle.id),
}
}
pub fn from_bot_spec(bot_spec: Box<dyn BotSpec>) -> Self {
MatchPlayer {
bot_spec,
code_bundle_id: None,
}
}
} }
impl RunMatch { impl RunMatch {
// TODO: create a MatchParams struct pub fn from_players(players: Vec<MatchPlayer>) -> Self {
pub fn new(
config: Arc<GlobalConfig>,
is_public: bool,
map: Map,
players: Vec<MatchPlayer>,
) -> Self {
let log_file_name = format!("{}.log", gen_alphanumeric(16)); let log_file_name = format!("{}.log", gen_alphanumeric(16));
RunMatch { RunMatch {
config,
log_file_name, log_file_name,
players, players,
is_public, match_id: None,
map,
} }
} }
fn into_runner_config(self) -> runner::MatchConfig { pub fn into_runner_config(self) -> runner::MatchConfig {
runner::MatchConfig { runner::MatchConfig {
map_path: PathBuf::from(&self.config.maps_directory).join(self.map.file_path), map_path: PathBuf::from(MAPS_DIR).join("hex.json"),
map_name: self.map.name, map_name: "hex".to_string(),
log_path: PathBuf::from(&self.config.match_logs_directory).join(&self.log_file_name), log_path: PathBuf::from(MATCHES_DIR).join(&self.log_file_name),
players: self players: self
.players .players
.into_iter() .into_iter()
.map(|player| runner::MatchPlayer { .map(|player| runner::MatchPlayer {
bot_spec: match player { bot_spec: player.bot_spec,
MatchPlayer::BotVersion { bot, version } => {
bot_version_to_botspec(&self.config, bot.as_ref(), &version)
}
MatchPlayer::BotSpec { spec } => spec,
},
}) })
.collect(), .collect(),
} }
} }
pub async fn run( pub fn store_in_database(&mut self, db_conn: &PgConnection) -> QueryResult<MatchData> {
self, // don't store the same match twice
conn_pool: ConnectionPool, assert!(self.match_id.is_none());
) -> QueryResult<(MatchData, JoinHandle<MatchOutcome>)> {
let match_data = {
// TODO: it would be nice to get an already-open connection here when possible.
// Maybe we need an additional abstraction, bundling a connection and connection pool?
let db_conn = conn_pool.get().await.expect("could not get a connection");
self.store_in_database(&db_conn)?
};
let runner_config = self.into_runner_config();
let handle = tokio::spawn(run_match_task(conn_pool, runner_config, match_data.base.id));
Ok((match_data, handle))
}
fn store_in_database(&self, db_conn: &PgConnection) -> QueryResult<MatchData> {
let new_match_data = db::matches::NewMatch { let new_match_data = db::matches::NewMatch {
state: db::matches::MatchState::Playing, state: db::matches::MatchState::Playing,
log_path: &self.log_file_name, log_path: &self.log_file_name,
is_public: self.is_public,
map_id: Some(self.map.id),
}; };
let new_match_players = self let new_match_players = self
.players .players
.iter() .iter()
.map(|p| db::matches::MatchPlayerData { .map(|p| db::matches::MatchPlayerData {
code_bundle_id: match p { code_bundle_id: p.code_bundle_id,
MatchPlayer::BotVersion { version, .. } => Some(version.id),
MatchPlayer::BotSpec { .. } => None,
},
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
db::matches::create_match(&new_match_data, &new_match_players, db_conn) 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.into_runner_config();
tokio::spawn(run_match_task(pool, runner_config, match_id))
} }
} }
pub fn bot_version_to_botspec( pub fn code_bundle_to_botspec(code_bundle: &db::bots::CodeBundle) -> Box<dyn BotSpec> {
runner_config: &GlobalConfig, let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
bot: Option<&db::bots::Bot>,
bot_version: &db::bots::BotVersion,
) -> Box<dyn BotSpec> {
if let Some(code_bundle_path) = &bot_version.code_bundle_path {
python_docker_bot_spec(runner_config, code_bundle_path)
} else if let (Some(container_digest), Some(bot)) = (&bot_version.container_digest, bot) {
Box::new(DockerBotSpec { Box::new(DockerBotSpec {
image: format!( code_path: bundle_path,
"{}/{}@{}", image: PYTHON_IMAGE.to_string(),
runner_config.container_registry_url, bot.name, container_digest argv: vec!["python".to_string(), "bot.py".to_string()],
),
binds: None,
argv: None,
working_dir: None,
pull: true,
credentials: Some(runner::docker_runner::Credentials {
username: "admin".to_string(),
password: runner_config.registry_admin_password.clone(),
}),
})
} else {
// TODO: ideally this would not be possible
panic!("bad bot version")
}
}
fn python_docker_bot_spec(config: &GlobalConfig, code_bundle_path: &str) -> Box<dyn BotSpec> {
let code_bundle_rel_path = PathBuf::from(&config.bots_directory).join(code_bundle_path);
let code_bundle_abs_path = std::fs::canonicalize(&code_bundle_rel_path).unwrap();
let code_bundle_path_str = code_bundle_abs_path.as_os_str().to_str().unwrap();
// TODO: it would be good to simplify this configuration
Box::new(DockerBotSpec {
image: config.python_runner_image.clone(),
binds: Some(vec![format!("{}:{}", code_bundle_path_str, "/workdir")]),
argv: Some(vec!["python".to_string(), "bot.py".to_string()]),
working_dir: Some("/workdir".to_string()),
// This would be a pull from dockerhub at the moment, let's avoid that for now.
// Maybe the best course of action would be to replicate all images in the dedicated
// registry, so that we only have to provide credentials to that one.
pull: false,
credentials: None,
}) })
} }
@ -178,5 +126,5 @@ async fn run_match_task(
db::matches::save_match_result(match_id, result, &conn).expect("could not save match result"); db::matches::save_match_result(match_id, result, &conn).expect("could not save match result");
outcome return outcome;
} }

View file

@ -1,7 +1,6 @@
// 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 bot_api;
pub mod bots; pub mod bots;
pub mod client_api;
pub mod matches; pub mod matches;
pub mod ranking; pub mod ranking;
pub mod registry;

View file

@ -1,22 +1,17 @@
use crate::db::bots::BotVersion; use crate::{db::bots::Bot, DbPool};
use crate::db::maps::Map;
use crate::{db::bots::Bot, DbPool, GlobalConfig};
use crate::db; use crate::db;
use crate::modules::matches::{MatchPlayer, RunMatch}; use crate::modules::matches::{MatchPlayer, RunMatch};
use diesel::{PgConnection, QueryResult};
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use std::collections::HashMap; use std::time::Duration;
use std::mem;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio; use tokio;
// TODO: put these in a config
const RANKER_INTERVAL: u64 = 60; const RANKER_INTERVAL: u64 = 60;
const RANKER_NUM_MATCHES: i64 = 10_000; const START_RATING: f64 = 0.0;
const SCALE: f64 = 100.0;
const MAX_UPDATE: f64 = 0.1;
pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) { pub async fn run_ranker(db_pool: DbPool) {
// TODO: make this configurable // TODO: make this configurable
// play at most one match every n seconds // play at most one match every n seconds
let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL)); let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL));
@ -26,316 +21,70 @@ pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) {
.expect("could not get database connection"); .expect("could not get database connection");
loop { loop {
interval.tick().await; interval.tick().await;
let bots = db::bots::all_active_bots_with_version(&db_conn).expect("could not load bots"); let bots = db::bots::find_all_bots(&db_conn).unwrap();
if bots.len() < 2 { if bots.len() < 2 {
// not enough bots to play a match // not enough bots to play a match
continue; continue;
} }
let selected_bots: Vec<Bot> = {
let selected_bots: Vec<(Bot, BotVersion)> = bots let mut rng = &mut rand::thread_rng();
.choose_multiple(&mut rand::thread_rng(), 2) bots.choose_multiple(&mut rng, 2).cloned().collect()
.cloned()
.collect();
let maps = db::maps::list_maps(&db_conn).expect("could not load map");
let map = match maps.choose(&mut rand::thread_rng()).cloned() {
None => continue, // no maps available
Some(map) => map,
}; };
play_ranking_match(selected_bots, db_pool.clone()).await;
play_ranking_match(config.clone(), map, selected_bots, db_pool.clone()).await;
recalculate_ratings(&db_conn).expect("could not recalculate ratings");
} }
} }
async fn play_ranking_match( async fn play_ranking_match(selected_bots: Vec<Bot>, db_pool: DbPool) {
config: Arc<GlobalConfig>, let db_conn = db_pool.get().await.expect("could not get db pool");
map: Map, let mut code_bundles = Vec::new();
selected_bots: Vec<(Bot, BotVersion)>, for bot in &selected_bots {
db_pool: DbPool, let code_bundle = db::bots::active_code_bundle(bot.id, &db_conn)
) { .expect("could not get active code bundle");
let mut players = Vec::new(); code_bundles.push(code_bundle);
for (bot, bot_version) in selected_bots {
let player = MatchPlayer::BotVersion {
bot: Some(bot),
version: bot_version,
};
players.push(player);
} }
let (_, handle) = RunMatch::new(config, true, map, players) let players = code_bundles
.run(db_pool.clone()) .iter()
.map(MatchPlayer::from_code_bundle)
.collect::<Vec<_>>();
let mut run_match = RunMatch::from_players(players);
run_match
.store_in_database(&db_conn)
.expect("could not store match in db");
let outcome = run_match
.spawn(db_pool.clone())
.await .await
.expect("failed to run match"); .expect("running match failed");
// wait for match to complete, so that only one ranking match can be running
let _outcome = handle.await; 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);
} }
fn recalculate_ratings(db_conn: &PgConnection) -> QueryResult<()> { // simple elo rating
let start = Instant::now();
let match_stats = fetch_match_stats(db_conn)?;
let ratings = estimate_ratings_from_stats(match_stats);
for (bot_id, rating) in ratings { let scores = match outcome.winner {
db::ratings::set_rating(bot_id, rating, db_conn).expect("could not update bot rating"); 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
} }
let elapsed = Instant::now() - start;
// TODO: set up proper logging infrastructure
println!("computed ratings in {} ms", elapsed.subsec_millis());
Ok(())
}
#[derive(Default)]
struct MatchStats {
total_score: f64,
num_matches: usize,
}
fn fetch_match_stats(db_conn: &PgConnection) -> QueryResult<HashMap<(i32, i32), MatchStats>> {
let matches = db::matches::list_matches(RANKER_NUM_MATCHES, db_conn)?;
let mut match_stats = HashMap::<(i32, i32), MatchStats>::new();
for m in matches {
if m.match_players.len() != 2 {
continue;
}
let (mut a_id, mut b_id) = match (&m.match_players[0].bot, &m.match_players[1].bot) {
(Some(ref a), Some(ref b)) => (a.id, b.id),
_ => continue,
};
// score of player a
let mut score = match m.base.winner {
None => 0.5,
Some(0) => 1.0,
Some(1) => 0.0,
_ => panic!("invalid winner"),
}; };
// put players in canonical order: smallest id first for i in 0..2 {
if b_id < a_id { let j = 1 - i;
mem::swap(&mut a_id, &mut b_id);
score = 1.0 - score;
}
let entry = match_stats.entry((a_id, b_id)).or_default(); let scaled_difference = (ratings[j] - ratings[i]) / SCALE;
entry.num_matches += 1; let expected = 1.0 / (1.0 + 10f64.powf(scaled_difference));
entry.total_score += score; let new_rating = ratings[i] + MAX_UPDATE * (scores[i] - expected);
} db::ratings::set_rating(selected_bots[i].id, new_rating, &db_conn)
Ok(match_stats) .expect("could not update bot rating");
}
/// Tokenizes player ids to a set of consecutive numbers
struct PlayerTokenizer {
id_to_ix: HashMap<i32, usize>,
ids: Vec<i32>,
}
impl PlayerTokenizer {
fn new() -> Self {
PlayerTokenizer {
id_to_ix: HashMap::new(),
ids: Vec::new(),
}
}
fn tokenize(&mut self, id: i32) -> usize {
match self.id_to_ix.get(&id) {
Some(&ix) => ix,
None => {
let ix = self.ids.len();
self.ids.push(id);
self.id_to_ix.insert(id, ix);
ix
}
}
}
fn detokenize(&self, ix: usize) -> i32 {
self.ids[ix]
}
fn player_count(&self) -> usize {
self.ids.len()
}
}
fn sigmoid(logit: f64) -> f64 {
1.0 / (1.0 + (-logit).exp())
}
fn estimate_ratings_from_stats(match_stats: HashMap<(i32, i32), MatchStats>) -> Vec<(i32, f64)> {
// map player ids to player indexes in the ratings array
let mut input_records = Vec::<RatingInputRecord>::with_capacity(match_stats.len());
let mut player_tokenizer = PlayerTokenizer::new();
for ((a_id, b_id), stats) in match_stats.into_iter() {
input_records.push(RatingInputRecord {
p1_ix: player_tokenizer.tokenize(a_id),
p2_ix: player_tokenizer.tokenize(b_id),
score: stats.total_score / stats.num_matches as f64,
weight: stats.num_matches as f64,
})
}
let mut ratings = vec![0f64; player_tokenizer.player_count()];
// TODO: fetch these from config
let params = OptimizeRatingsParams::default();
optimize_ratings(&mut ratings, &input_records, &params);
ratings
.into_iter()
.enumerate()
.map(|(ix, rating)| {
(
player_tokenizer.detokenize(ix),
rating * 100f64 / 10f64.ln(),
)
})
.collect()
}
struct RatingInputRecord {
/// index of first player
p1_ix: usize,
/// index of secord player
p2_ix: usize,
/// score of player 1 (= 1 - score of player 2)
score: f64,
/// weight of this record
weight: f64,
}
struct OptimizeRatingsParams {
tolerance: f64,
learning_rate: f64,
max_iterations: usize,
regularization_weight: f64,
}
impl Default for OptimizeRatingsParams {
fn default() -> Self {
OptimizeRatingsParams {
tolerance: 10f64.powi(-8),
learning_rate: 0.1,
max_iterations: 10_000,
regularization_weight: 10.0,
}
}
}
fn optimize_ratings(
ratings: &mut [f64],
input_records: &[RatingInputRecord],
params: &OptimizeRatingsParams,
) {
let total_weight =
params.regularization_weight + input_records.iter().map(|r| r.weight).sum::<f64>();
for _iteration in 0..params.max_iterations {
let mut gradients = vec![0f64; ratings.len()];
// calculate gradients
for record in input_records.iter() {
let predicted = sigmoid(ratings[record.p1_ix] - ratings[record.p2_ix]);
let gradient = record.weight * (predicted - record.score);
gradients[record.p1_ix] += gradient;
gradients[record.p2_ix] -= gradient;
}
// apply update step
let mut converged = true;
for (rating, gradient) in ratings.iter_mut().zip(&gradients) {
let update = params.learning_rate * (gradient + params.regularization_weight * *rating)
/ total_weight;
if update > params.tolerance {
converged = false;
}
*rating -= update;
}
if converged {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn is_close(a: f64, b: f64) -> bool {
(a - b).abs() < 10f64.powi(-6)
}
#[test]
fn test_optimize_ratings() {
let input_records = vec![RatingInputRecord {
p1_ix: 0,
p2_ix: 1,
score: 0.8,
weight: 1.0,
}];
let mut ratings = vec![0.0; 2];
optimize_ratings(
&mut ratings,
&input_records,
&OptimizeRatingsParams {
regularization_weight: 0.0,
..Default::default()
},
);
assert!(is_close(sigmoid(ratings[0] - ratings[1]), 0.8));
}
#[test]
fn test_optimize_ratings_weight() {
let input_records = vec![
RatingInputRecord {
p1_ix: 0,
p2_ix: 1,
score: 1.0,
weight: 1.0,
},
RatingInputRecord {
p1_ix: 1,
p2_ix: 0,
score: 1.0,
weight: 3.0,
},
];
let mut ratings = vec![0.0; 2];
optimize_ratings(
&mut ratings,
&input_records,
&OptimizeRatingsParams {
regularization_weight: 0.0,
..Default::default()
},
);
assert!(is_close(sigmoid(ratings[0] - ratings[1]), 0.25));
}
#[test]
fn test_optimize_ratings_regularization() {
let input_records = vec![RatingInputRecord {
p1_ix: 0,
p2_ix: 1,
score: 0.8,
weight: 100.0,
}];
let mut ratings = vec![0.0; 2];
optimize_ratings(
&mut ratings,
&input_records,
&OptimizeRatingsParams {
regularization_weight: 1.0,
..Default::default()
},
);
let predicted = sigmoid(ratings[0] - ratings[1]);
assert!(0.5 < predicted && predicted < 0.8);
} }
} }

View file

@ -1,445 +0,0 @@
// TODO: this module is functional, but it needs a good refactor for proper error handling.
use axum::body::{Body, StreamBody};
use axum::extract::{BodyStream, FromRequest, Path, Query, RequestParts, TypedHeader};
use axum::headers::authorization::Basic;
use axum::headers::Authorization;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, head, post, put};
use axum::{async_trait, Extension, Router};
use futures::StreamExt;
use hyper::StatusCode;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use crate::db::bots::NewBotVersion;
use crate::util::gen_alphanumeric;
use crate::{db, DatabaseConnection, GlobalConfig};
use crate::db::users::{authenticate_user, Credentials, User};
pub fn registry_service() -> Router {
Router::new()
// The docker API requires this trailing slash
.nest("/v2/", registry_api_v2())
}
fn registry_api_v2() -> Router {
Router::new()
.route("/", get(get_root))
.route(
"/:name/manifests/:reference",
get(get_manifest).put(put_manifest),
)
.route(
"/:name/blobs/:digest",
head(check_blob_exists).get(get_blob),
)
.route("/:name/blobs/uploads/", post(create_upload))
.route(
"/:name/blobs/uploads/:uuid",
put(put_upload).patch(patch_upload),
)
}
const ADMIN_USERNAME: &str = "admin";
type AuthorizationHeader = TypedHeader<Authorization<Basic>>;
enum RegistryAuth {
User(User),
Admin,
}
enum RegistryAuthError {
NoAuthHeader,
InvalidCredentials,
}
impl IntoResponse for RegistryAuthError {
fn into_response(self) -> Response {
// TODO: create enum for registry errors
let err = RegistryErrors {
errors: vec![RegistryError {
code: "UNAUTHORIZED".to_string(),
message: "please log in".to_string(),
detail: serde_json::Value::Null,
}],
};
(
StatusCode::UNAUTHORIZED,
[
("Docker-Distribution-API-Version", "registry/2.0"),
("WWW-Authenticate", "Basic"),
],
serde_json::to_string(&err).unwrap(),
)
.into_response()
}
}
#[async_trait]
impl<B> FromRequest<B> for RegistryAuth
where
B: Send,
{
type Rejection = RegistryAuthError;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(basic)) = AuthorizationHeader::from_request(req)
.await
.map_err(|_| RegistryAuthError::NoAuthHeader)?;
// TODO: Into<Credentials> would be nice
let credentials = Credentials {
username: basic.username(),
password: basic.password(),
};
let Extension(config) = Extension::<Arc<GlobalConfig>>::from_request(req)
.await
.unwrap();
if credentials.username == ADMIN_USERNAME {
if credentials.password == config.registry_admin_password {
Ok(RegistryAuth::Admin)
} else {
Err(RegistryAuthError::InvalidCredentials)
}
} else {
let db_conn = DatabaseConnection::from_request(req).await.unwrap();
let user = authenticate_user(&credentials, &db_conn)
.ok_or(RegistryAuthError::InvalidCredentials)?;
Ok(RegistryAuth::User(user))
}
}
}
// Since async file io just calls spawn_blocking internally, it does not really make sense
// to make this an async function
fn file_sha256_digest(path: &std::path::Path) -> std::io::Result<String> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let _n = std::io::copy(&mut file, &mut hasher)?;
Ok(format!("{:x}", hasher.finalize()))
}
/// Get the index of the last byte in a file
async fn last_byte_pos(file: &tokio::fs::File) -> std::io::Result<u64> {
let n_bytes = file.metadata().await?.len();
let pos = if n_bytes == 0 { 0 } else { n_bytes - 1 };
Ok(pos)
}
async fn get_root(_auth: RegistryAuth) -> impl IntoResponse {
// root should return 200 OK to confirm api compliance
Response::builder()
.status(StatusCode::OK)
.header("Docker-Distribution-API-Version", "registry/2.0")
.body(Body::empty())
.unwrap()
}
#[derive(Serialize)]
pub struct RegistryErrors {
errors: Vec<RegistryError>,
}
#[derive(Serialize)]
pub struct RegistryError {
code: String,
message: String,
detail: serde_json::Value,
}
async fn check_blob_exists(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, raw_digest)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let digest = raw_digest.strip_prefix("sha256:").unwrap();
let blob_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
if blob_path.exists() {
let metadata = std::fs::metadata(&blob_path).unwrap();
Ok((StatusCode::OK, [("Content-Length", metadata.len())]))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn get_blob(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, raw_digest)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let digest = raw_digest.strip_prefix("sha256:").unwrap();
let blob_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
if !blob_path.exists() {
return Err(StatusCode::NOT_FOUND);
}
let file = tokio::fs::File::open(&blob_path).await.unwrap();
let reader_stream = ReaderStream::new(file);
let stream_body = StreamBody::new(reader_stream);
Ok(stream_body)
}
async fn create_upload(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path(repository_name): Path<String>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let uuid = gen_alphanumeric(16);
tokio::fs::File::create(
PathBuf::from(&config.registry_directory)
.join("uploads")
.join(&uuid),
)
.await
.unwrap();
Ok(Response::builder()
.status(StatusCode::ACCEPTED)
.header(
"Location",
format!("/v2/{}/blobs/uploads/{}", repository_name, uuid),
)
.header("Docker-Upload-UUID", uuid)
.header("Range", "bytes=0-0")
.body(Body::empty())
.unwrap())
}
async fn patch_upload(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, uuid)): Path<(String, String)>,
mut stream: BodyStream,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
// TODO: support content range header in request
let upload_path = PathBuf::from(&config.registry_directory)
.join("uploads")
.join(&uuid);
let mut file = tokio::fs::OpenOptions::new()
.read(false)
.write(true)
.append(true)
.create(false)
.open(upload_path)
.await
.unwrap();
while let Some(Ok(chunk)) = stream.next().await {
file.write_all(&chunk).await.unwrap();
}
let last_byte = last_byte_pos(&file).await.unwrap();
Ok(Response::builder()
.status(StatusCode::ACCEPTED)
.header(
"Location",
format!("/v2/{}/blobs/uploads/{}", repository_name, uuid),
)
.header("Docker-Upload-UUID", uuid)
// range indicating current progress of the upload
.header("Range", format!("0-{}", last_byte))
.body(Body::empty())
.unwrap())
}
use serde::Deserialize;
#[derive(Deserialize)]
struct UploadParams {
digest: String,
}
async fn put_upload(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, uuid)): Path<(String, String)>,
Query(params): Query<UploadParams>,
mut stream: BodyStream,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let upload_path = PathBuf::from(&config.registry_directory)
.join("uploads")
.join(&uuid);
let mut file = tokio::fs::OpenOptions::new()
.read(false)
.write(true)
.append(true)
.create(false)
.open(&upload_path)
.await
.unwrap();
let range_begin = last_byte_pos(&file).await.unwrap();
while let Some(Ok(chunk)) = stream.next().await {
file.write_all(&chunk).await.unwrap();
}
let range_end = last_byte_pos(&file).await.unwrap();
// Close the file to ensure all data has been flushed to the kernel.
// If we don't do this, calculating the checksum can fail.
std::mem::drop(file);
let expected_digest = params.digest.strip_prefix("sha256:").unwrap();
let digest = file_sha256_digest(&upload_path).unwrap();
if digest != expected_digest {
// TODO: return a docker error body
return Err(StatusCode::BAD_REQUEST);
}
let target_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
tokio::fs::rename(&upload_path, &target_path).await.unwrap();
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(
"Location",
format!("/v2/{}/blobs/{}", repository_name, digest),
)
.header("Docker-Upload-UUID", uuid)
// content range for bytes that were in the body of this request
.header("Content-Range", format!("{}-{}", range_begin, range_end))
.header("Docker-Content-Digest", params.digest)
.body(Body::empty())
.unwrap())
}
async fn get_manifest(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, reference)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let manifest_path = PathBuf::from(&config.registry_directory)
.join("manifests")
.join(&repository_name)
.join(&reference)
.with_extension("json");
let data = tokio::fs::read(&manifest_path).await.unwrap();
let manifest: serde_json::Map<String, serde_json::Value> =
serde_json::from_slice(&data).unwrap();
let media_type = manifest.get("mediaType").unwrap().as_str().unwrap();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", media_type)
.body(axum::body::Full::from(data))
.unwrap())
}
async fn put_manifest(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, reference)): Path<(String, String)>,
mut stream: BodyStream,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
let bot = check_access(&repository_name, &auth, &db_conn)?;
let repository_dir = PathBuf::from(&config.registry_directory)
.join("manifests")
.join(&repository_name);
tokio::fs::create_dir_all(&repository_dir).await.unwrap();
let mut hasher = Sha256::new();
let manifest_path = repository_dir.join(&reference).with_extension("json");
{
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&manifest_path)
.await
.unwrap();
while let Some(Ok(chunk)) = stream.next().await {
hasher.update(&chunk);
file.write_all(&chunk).await.unwrap();
}
}
let digest = hasher.finalize();
// TODO: store content-adressable manifests separately
let content_digest = format!("sha256:{:x}", digest);
let digest_path = repository_dir.join(&content_digest).with_extension("json");
tokio::fs::copy(manifest_path, digest_path).await.unwrap();
// Register the new image as a bot version
// TODO: how should tags be handled?
let new_version = NewBotVersion {
bot_id: Some(bot.id),
code_bundle_path: None,
container_digest: Some(&content_digest),
};
let version =
db::bots::create_bot_version(&new_version, &db_conn).expect("could not save bot version");
db::bots::set_active_version(bot.id, Some(version.id), &db_conn)
.expect("could not update bot version");
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(
"Location",
format!("/v2/{}/manifests/{}", repository_name, reference),
)
.header("Docker-Content-Digest", content_digest)
.body(Body::empty())
.unwrap())
}
/// Ensure that the accessed repository exists
/// and the user is allowed to access it.
/// Returns the associated bot.
fn check_access(
repository_name: &str,
auth: &RegistryAuth,
db_conn: &DatabaseConnection,
) -> Result<db::bots::Bot, StatusCode> {
use diesel::OptionalExtension;
// TODO: it would be nice to provide the found repository
// to the route handlers
let bot = db::bots::find_bot_by_name(repository_name, db_conn)
.optional()
.expect("could not run query")
.ok_or(StatusCode::NOT_FOUND)?;
match &auth {
RegistryAuth::Admin => Ok(bot),
RegistryAuth::User(user) => {
if bot.owner_id == Some(user.id) {
Ok(bot)
} else {
Err(StatusCode::FORBIDDEN)
}
}
}
}

View file

@ -1,7 +1,7 @@
use axum::extract::{Multipart, Path}; use axum::extract::{Multipart, Path};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{body, Extension, Json}; use axum::{body, Json};
use diesel::OptionalExtension; use diesel::OptionalExtension;
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use rand::Rng; use rand::Rng;
@ -9,19 +9,15 @@ use serde::{Deserialize, Serialize};
use serde_json::{self, json, value::Value as JsonValue}; use serde_json::{self, json, value::Value as JsonValue};
use std::io::Cursor; use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use thiserror; use thiserror;
use crate::db; use crate::db::bots::{self, CodeBundle};
use crate::db::bots::{self, BotVersion};
use crate::db::ratings::{self, RankedBot}; use crate::db::ratings::{self, RankedBot};
use crate::db::users::User; use crate::db::users::User;
use crate::modules::bots::save_code_string; use crate::modules::bots::save_code_bundle;
use crate::{DatabaseConnection, GlobalConfig}; use crate::{DatabaseConnection, BOTS_DIR};
use bots::Bot; use bots::Bot;
use super::users::UserData;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct SaveBotParams { pub struct SaveBotParams {
pub bot_name: String, pub bot_name: String,
@ -100,7 +96,6 @@ pub async fn save_bot(
Json(params): Json<SaveBotParams>, Json(params): Json<SaveBotParams>,
user: User, user: User,
conn: DatabaseConnection, conn: DatabaseConnection,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<Json<Bot>, SaveBotError> { ) -> Result<Json<Bot>, SaveBotError> {
let res = bots::find_bot_by_name(&params.bot_name, &conn) let res = bots::find_bot_by_name(&params.bot_name, &conn)
.optional() .optional()
@ -124,8 +119,8 @@ pub async fn save_bot(
bots::create_bot(&new_bot, &conn).expect("could not create bot") bots::create_bot(&new_bot, &conn).expect("could not create bot")
} }
}; };
let _code_bundle = save_code_string(&params.code, Some(bot.id), &conn, &config) let _code_bundle =
.expect("failed to save code bundle"); save_code_bundle(&params.code, Some(bot.id), &conn).expect("failed to save code bundle");
Ok(Json(bot)) Ok(Json(bot))
} }
@ -134,64 +129,44 @@ pub struct BotParams {
name: String, name: String,
} }
// TODO: can we unify this with save_bot?
pub async fn create_bot( pub async fn create_bot(
conn: DatabaseConnection, conn: DatabaseConnection,
user: User, user: User,
params: Json<BotParams>, params: Json<BotParams>,
) -> Result<(StatusCode, Json<Bot>), SaveBotError> { ) -> (StatusCode, Json<Bot>) {
validate_bot_name(&params.name)?;
let existing_bot = bots::find_bot_by_name(&params.name, &conn)
.optional()
.expect("could not run query");
if existing_bot.is_some() {
return Err(SaveBotError::BotNameTaken);
}
let bot_params = bots::NewBot { let bot_params = bots::NewBot {
owner_id: Some(user.id), owner_id: Some(user.id),
name: &params.name, name: &params.name,
}; };
let bot = bots::create_bot(&bot_params, &conn).unwrap(); let bot = bots::create_bot(&bot_params, &conn).unwrap();
Ok((StatusCode::CREATED, Json(bot))) (StatusCode::CREATED, Json(bot))
} }
// TODO: handle errors // TODO: handle errors
pub async fn get_bot( pub async fn get_bot(
conn: DatabaseConnection, conn: DatabaseConnection,
Path(bot_name): Path<String>, Path(bot_id): Path<i32>,
) -> Result<Json<JsonValue>, StatusCode> { ) -> Result<Json<JsonValue>, StatusCode> {
let bot = db::bots::find_bot_by_name(&bot_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?; let bot = bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
let owner: Option<UserData> = match bot.owner_id { let bundles = bots::find_bot_code_bundles(bot.id, &conn)
Some(user_id) => {
let user = db::users::find_user(user_id, &conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Some(user.into())
}
None => None,
};
let versions =
bots::find_bot_versions(bot.id, &conn).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({ Ok(Json(json!({
"bot": bot, "bot": bot,
"owner": owner, "bundles": bundles,
"versions": versions,
}))) })))
} }
pub async fn get_user_bots( pub async fn get_my_bots(
conn: DatabaseConnection, conn: DatabaseConnection,
Path(user_name): Path<String>, user: User,
) -> Result<Json<Vec<Bot>>, StatusCode> { ) -> Result<Json<Vec<Bot>>, StatusCode> {
let user = bots::find_bots_by_owner(user.id, &conn)
db::users::find_user_by_name(&user_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
db::bots::find_bots_by_owner(user.id, &conn)
.map(Json) .map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
/// List all active bots
pub async fn list_bots(conn: DatabaseConnection) -> Result<Json<Vec<Bot>>, StatusCode> { pub async fn list_bots(conn: DatabaseConnection) -> Result<Json<Vec<Bot>>, StatusCode> {
bots::find_active_bots(&conn) bots::find_all_bots(&conn)
.map(Json) .map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
@ -206,13 +181,12 @@ pub async fn get_ranking(conn: DatabaseConnection) -> Result<Json<Vec<RankedBot>
pub async fn upload_code_multipart( pub async fn upload_code_multipart(
conn: DatabaseConnection, conn: DatabaseConnection,
user: User, user: User,
Path(bot_name): Path<String>, Path(bot_id): Path<i32>,
mut multipart: Multipart, mut multipart: Multipart,
Extension(config): Extension<Arc<GlobalConfig>>, ) -> Result<Json<CodeBundle>, StatusCode> {
) -> Result<Json<BotVersion>, StatusCode> { let bots_dir = PathBuf::from(BOTS_DIR);
let bots_dir = PathBuf::from(&config.bots_directory);
let bot = bots::find_bot_by_name(&bot_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?; let bot = bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
if Some(user.id) != bot.owner_id { if Some(user.id) != bot.owner_id {
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
@ -239,39 +213,12 @@ pub async fn upload_code_multipart(
.extract(bots_dir.join(&folder_name)) .extract(bots_dir.join(&folder_name))
.map_err(|_| StatusCode::BAD_REQUEST)?; .map_err(|_| StatusCode::BAD_REQUEST)?;
let bot_version = bots::NewBotVersion { let bundle = bots::NewCodeBundle {
bot_id: Some(bot.id), bot_id: Some(bot.id),
code_bundle_path: Some(&folder_name), path: &folder_name,
container_digest: None,
}; };
let code_bundle = let code_bundle =
bots::create_bot_version(&bot_version, &conn).expect("Failed to create code bundle"); bots::create_code_bundle(&bundle, &conn).expect("Failed to create code bundle");
Ok(Json(code_bundle)) Ok(Json(code_bundle))
} }
pub async fn get_code(
conn: DatabaseConnection,
user: User,
Path(bundle_id): Path<i32>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<Vec<u8>, StatusCode> {
let version =
db::bots::find_bot_version(bundle_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
let bot_id = version.bot_id.ok_or(StatusCode::FORBIDDEN)?;
let bot = db::bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if bot.owner_id != Some(user.id) {
return Err(StatusCode::FORBIDDEN);
}
let bundle_path = version.code_bundle_path.ok_or(StatusCode::NOT_FOUND)?;
// TODO: avoid hardcoding paths
let full_bundle_path = PathBuf::from(&config.bots_directory)
.join(&bundle_path)
.join("bot.py");
let bot_code =
std::fs::read(full_bundle_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(bot_code)
}

View file

@ -1,11 +1,8 @@
use std::sync::Arc;
use crate::db; use crate::db;
use crate::db::matches::{FullMatchData, FullMatchPlayerData}; use crate::db::matches::{FullMatchData, FullMatchPlayerData};
use crate::modules::bots::save_code_string; use crate::modules::bots::save_code_bundle;
use crate::modules::matches::{MatchPlayer, RunMatch}; use crate::modules::matches::{MatchPlayer, RunMatch};
use crate::ConnectionPool; use crate::ConnectionPool;
use crate::GlobalConfig;
use axum::extract::Extension; use axum::extract::Extension;
use axum::Json; use axum::Json;
use hyper::StatusCode; use hyper::StatusCode;
@ -14,13 +11,12 @@ use serde::{Deserialize, Serialize};
use super::matches::ApiMatch; use super::matches::ApiMatch;
const DEFAULT_OPPONENT_NAME: &str = "simplebot"; const DEFAULT_OPPONENT_NAME: &str = "simplebot";
const DEFAULT_MAP_NAME: &str = "hex";
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct SubmitBotParams { pub struct SubmitBotParams {
pub code: String, pub code: String,
// TODO: would it be better to pass an ID here?
pub opponent_name: Option<String>, pub opponent_name: Option<String>,
pub map_name: Option<String>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -29,11 +25,11 @@ pub struct SubmitBotResponse {
pub match_data: ApiMatch, pub match_data: ApiMatch,
} }
/// Submit bot code and opponent name to play a match /// submit python code for a bot, which will face off
/// with a demo bot. Return a played match.
pub async fn submit_bot( pub async fn submit_bot(
Json(params): Json<SubmitBotParams>, Json(params): Json<SubmitBotParams>,
Extension(pool): Extension<ConnectionPool>, Extension(pool): Extension<ConnectionPool>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<Json<SubmitBotResponse>, StatusCode> { ) -> Result<Json<SubmitBotResponse>, StatusCode> {
let conn = pool.get().await.expect("could not get database connection"); let conn = pool.get().await.expect("could not get database connection");
@ -41,39 +37,23 @@ pub async fn submit_bot(
.opponent_name .opponent_name
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string()); .unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
let map_name = params let opponent =
.map_name db::bots::find_bot_by_name(&opponent_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
.unwrap_or_else(|| DEFAULT_MAP_NAME.to_string()); let opponent_code_bundle =
db::bots::active_code_bundle(opponent.id, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
let (opponent_bot, opponent_bot_version) = let player_code_bundle = save_code_bundle(&params.code, None, &conn)
db::bots::find_bot_with_version_by_name(&opponent_name, &conn)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let map = db::maps::find_map_by_name(&map_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
let player_bot_version = save_code_string(&params.code, None, &conn, &config)
// 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 run_match = RunMatch::new( let mut run_match = RunMatch::from_players(vec![
config, MatchPlayer::from_code_bundle(&player_code_bundle),
false, MatchPlayer::from_code_bundle(&opponent_code_bundle),
map.clone(), ]);
vec![ let match_data = run_match
MatchPlayer::BotVersion { .store_in_database(&conn)
bot: None, .expect("failed to save match");
version: player_bot_version.clone(), run_match.spawn(pool.clone());
},
MatchPlayer::BotVersion {
bot: Some(opponent_bot.clone()),
version: opponent_bot_version.clone(),
},
],
);
let (match_data, _) = run_match
.run(pool.clone())
.await
.expect("failed to run match");
// TODO: avoid clones // TODO: avoid clones
let full_match_data = FullMatchData { let full_match_data = FullMatchData {
@ -81,16 +61,15 @@ pub async fn submit_bot(
match_players: vec![ match_players: vec![
FullMatchPlayerData { FullMatchPlayerData {
base: match_data.match_players[0].clone(), base: match_data.match_players[0].clone(),
bot_version: Some(player_bot_version), code_bundle: Some(player_code_bundle),
bot: None, bot: None,
}, },
FullMatchPlayerData { FullMatchPlayerData {
base: match_data.match_players[1].clone(), base: match_data.match_players[1].clone(),
bot_version: Some(opponent_bot_version), code_bundle: Some(opponent_code_bundle),
bot: Some(opponent_bot), bot: Some(opponent),
}, },
], ],
map: Some(map),
}; };
let api_match = super::matches::match_data_to_api(full_match_data); let api_match = super::matches::match_data_to_api(full_match_data);

View file

@ -1,19 +0,0 @@
use crate::{db, DatabaseConnection};
use axum::Json;
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct ApiMap {
pub name: String,
}
pub async fn list_maps(conn: DatabaseConnection) -> Result<Json<Vec<ApiMap>>, StatusCode> {
let maps = db::maps::list_maps(&conn).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let api_maps = maps
.into_iter()
.map(|map| ApiMap { name: map.name })
.collect();
Ok(Json(api_maps))
}

View file

@ -1,21 +1,101 @@
use std::path::PathBuf;
use axum::{ use axum::{
extract::{Path, Query}, extract::{Extension, Path},
Extension, Json, Json,
}; };
use chrono::NaiveDateTime;
use hyper::StatusCode; use hyper::StatusCode;
use planetwars_matchrunner::{docker_runner::DockerBotSpec, run_match, MatchConfig, MatchPlayer};
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{path::PathBuf, sync::Arc};
use crate::{ use crate::{
db::{ db::{
self, bots,
matches::{self, MatchState}, matches::{self, MatchState},
users::User,
}, },
DatabaseConnection, GlobalConfig, ConnectionPool, DatabaseConnection, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
}; };
use super::maps::ApiMap; #[derive(Serialize, Deserialize, Debug)]
pub struct MatchParams {
// Just bot ids for now
players: Vec<i32>,
}
pub async fn play_match(
_user: User,
Extension(pool): Extension<ConnectionPool>,
Json(params): Json<MatchParams>,
) -> Result<(), StatusCode> {
let conn = pool.get().await.expect("could not get database connection");
let map_path = PathBuf::from(MAPS_DIR).join("hex.json");
let slug: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let log_file_name = format!("{}.log", slug);
let mut players = Vec::new();
let mut bot_ids = Vec::new();
for bot_name in params.players {
let bot = bots::find_bot(bot_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
let code_bundle =
bots::active_code_bundle(bot.id, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
let bot_config: BotConfig = std::fs::read_to_string(bundle_path.join("botconfig.toml"))
.and_then(|config_str| toml::from_str(&config_str).map_err(|e| e.into()))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
players.push(MatchPlayer {
bot_spec: Box::new(DockerBotSpec {
code_path: PathBuf::from(BOTS_DIR).join(code_bundle.path),
image: "python:3.10-slim-buster".to_string(),
argv: shlex::split(&bot_config.run_command)
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?,
}),
});
bot_ids.push(matches::MatchPlayerData {
code_bundle_id: Some(code_bundle.id),
});
}
let match_config = MatchConfig {
map_name: "hex".to_string(),
map_path,
log_path: PathBuf::from(MATCHES_DIR).join(&log_file_name),
players,
};
tokio::spawn(run_match_task(
match_config,
log_file_name,
bot_ids,
pool.clone(),
));
Ok(())
}
async fn run_match_task(
config: MatchConfig,
log_file_name: String,
match_players: Vec<matches::MatchPlayerData>,
pool: ConnectionPool,
) {
let match_data = matches::NewMatch {
state: MatchState::Finished,
log_path: &log_file_name,
};
run_match(config).await;
let conn = pool.get().await.expect("could not get database connection");
matches::create_match(&match_data, &match_players, &conn).expect("could not create match");
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ApiMatch { pub struct ApiMatch {
@ -23,71 +103,19 @@ pub struct ApiMatch {
timestamp: chrono::NaiveDateTime, timestamp: chrono::NaiveDateTime,
state: MatchState, state: MatchState,
players: Vec<ApiMatchPlayer>, players: Vec<ApiMatchPlayer>,
winner: Option<i32>,
map: Option<ApiMap>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ApiMatchPlayer { pub struct ApiMatchPlayer {
bot_version_id: Option<i32>, code_bundle_id: Option<i32>,
bot_id: Option<i32>, bot_id: Option<i32>,
bot_name: Option<String>, bot_name: Option<String>,
} }
#[derive(Serialize, Deserialize)] pub async fn list_matches(conn: DatabaseConnection) -> Result<Json<Vec<ApiMatch>>, StatusCode> {
pub struct ListRecentMatchesParams { matches::list_matches(&conn)
count: Option<usize>, .map_err(|_| StatusCode::BAD_REQUEST)
// TODO: should timezone be specified here? .map(|matches| Json(matches.into_iter().map(match_data_to_api).collect()))
before: Option<NaiveDateTime>,
after: Option<NaiveDateTime>,
bot: Option<String>,
}
const MAX_NUM_RETURNED_MATCHES: usize = 100;
const DEFAULT_NUM_RETURNED_MATCHES: usize = 50;
#[derive(Serialize, Deserialize)]
pub struct ListMatchesResponse {
matches: Vec<ApiMatch>,
has_next: bool,
}
pub async fn list_recent_matches(
Query(params): Query<ListRecentMatchesParams>,
conn: DatabaseConnection,
) -> Result<Json<ListMatchesResponse>, StatusCode> {
let requested_count = std::cmp::min(
params.count.unwrap_or(DEFAULT_NUM_RETURNED_MATCHES),
MAX_NUM_RETURNED_MATCHES,
);
// fetch one additional record to check whether a next page exists
let count = (requested_count + 1) as i64;
let matches_result = match params.bot {
Some(bot_name) => {
let bot = db::bots::find_bot_by_name(&bot_name, &conn)
.map_err(|_| StatusCode::BAD_REQUEST)?;
matches::list_bot_matches(bot.id, count, params.before, params.after, &conn)
}
None => matches::list_public_matches(count, params.before, params.after, &conn),
};
let mut matches = matches_result.map_err(|_| StatusCode::BAD_REQUEST)?;
let mut has_next = false;
if matches.len() > requested_count {
has_next = true;
matches.truncate(requested_count);
}
let api_matches = matches.into_iter().map(match_data_to_api).collect();
Ok(Json(ListMatchesResponse {
matches: api_matches,
has_next,
}))
} }
pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch { pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch {
@ -99,16 +127,23 @@ pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch {
.match_players .match_players
.iter() .iter()
.map(|_p| ApiMatchPlayer { .map(|_p| ApiMatchPlayer {
bot_version_id: _p.bot_version.as_ref().map(|cb| cb.id), code_bundle_id: _p.code_bundle.as_ref().map(|cb| cb.id),
bot_id: _p.bot.as_ref().map(|b| b.id), bot_id: _p.bot.as_ref().map(|b| b.id),
bot_name: _p.bot.as_ref().map(|b| b.name.clone()), bot_name: _p.bot.as_ref().map(|b| b.name.clone()),
}) })
.collect(), .collect(),
winner: data.base.winner,
map: data.map.map(|m| ApiMap { name: m.name }),
} }
} }
// TODO: this is duplicated from planetwars-cli
// clean this up and move to matchrunner crate
#[derive(Serialize, Deserialize)]
pub struct BotConfig {
pub name: String,
pub run_command: String,
pub build_command: Option<String>,
}
pub async fn get_match_data( pub async fn get_match_data(
Path(match_id): Path<i32>, Path(match_id): Path<i32>,
conn: DatabaseConnection, conn: DatabaseConnection,
@ -122,11 +157,10 @@ pub async fn get_match_data(
pub async fn get_match_log( pub async fn get_match_log(
Path(match_id): Path<i32>, Path(match_id): Path<i32>,
conn: DatabaseConnection, conn: DatabaseConnection,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<Vec<u8>, StatusCode> { ) -> Result<Vec<u8>, StatusCode> {
let match_base = let match_base =
matches::find_match_base(match_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?; matches::find_match_base(match_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
let log_path = PathBuf::from(&config.match_logs_directory).join(&match_base.log_path); let log_path = PathBuf::from(MATCHES_DIR).join(&match_base.log_path);
let log_contents = std::fs::read(log_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let log_contents = std::fs::read(log_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(log_contents) Ok(log_contents)
} }

View file

@ -1,5 +1,4 @@
pub mod bots; pub mod bots;
pub mod demo; pub mod demo;
pub mod maps;
pub mod matches; pub mod matches;
pub mod users; pub mod users;

View file

@ -5,14 +5,12 @@ use axum::extract::{FromRequest, RequestParts, TypedHeader};
use axum::headers::authorization::Bearer; use axum::headers::authorization::Bearer;
use axum::headers::Authorization; use axum::headers::Authorization;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{Headers, IntoResponse, Response};
use axum::{async_trait, Json}; use axum::{async_trait, Json};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use thiserror::Error; use thiserror::Error;
const RESERVED_USERNAMES: &[&str] = &["admin", "system"];
type AuthorizationHeader = TypedHeader<Authorization<Bearer>>; type AuthorizationHeader = TypedHeader<Authorization<Bearer>>;
#[async_trait] #[async_trait]
@ -91,11 +89,7 @@ impl RegistrationParams {
errors.push("password must be at least 8 characters".to_string()); errors.push("password must be at least 8 characters".to_string());
} }
if RESERVED_USERNAMES.contains(&self.username.as_str()) { if users::find_user(&self.username, &conn).is_ok() {
errors.push("that username is not allowed".to_string());
}
if users::find_user_by_name(&self.username, &conn).is_ok() {
errors.push("username is already taken".to_string()); errors.push("username is already taken".to_string());
} }
@ -169,9 +163,9 @@ pub async fn login(conn: DatabaseConnection, params: Json<LoginParams>) -> Respo
Some(user) => { Some(user) => {
let session = sessions::create_session(&user, &conn); let session = sessions::create_session(&user, &conn);
let user_data: UserData = user.into(); let user_data: UserData = user.into();
let headers = [("Token", &session.token)]; let headers = Headers(vec![("Token", &session.token)]);
(StatusCode::OK, headers, Json(user_data)).into_response() (headers, Json(user_data)).into_response()
} }
} }
} }

View file

@ -1,19 +1,6 @@
// This file is autogenerated by diesel // This file is autogenerated by diesel
#![allow(unused_imports)] #![allow(unused_imports)]
table! {
use diesel::sql_types::*;
use crate::db_types::*;
bot_versions (id) {
id -> Int4,
bot_id -> Nullable<Int4>,
code_bundle_path -> Nullable<Text>,
created_at -> Timestamp,
container_digest -> Nullable<Text>,
}
}
table! { table! {
use diesel::sql_types::*; use diesel::sql_types::*;
use crate::db_types::*; use crate::db_types::*;
@ -22,7 +9,6 @@ table! {
id -> Int4, id -> Int4,
owner_id -> Nullable<Int4>, owner_id -> Nullable<Int4>,
name -> Text, name -> Text,
active_version -> Nullable<Int4>,
} }
} }
@ -30,10 +16,11 @@ table! {
use diesel::sql_types::*; use diesel::sql_types::*;
use crate::db_types::*; use crate::db_types::*;
maps (id) { code_bundles (id) {
id -> Int4, id -> Int4,
name -> Text, bot_id -> Nullable<Int4>,
file_path -> Text, path -> Text,
created_at -> Timestamp,
} }
} }
@ -44,7 +31,7 @@ table! {
match_players (match_id, player_id) { match_players (match_id, player_id) {
match_id -> Int4, match_id -> Int4,
player_id -> Int4, player_id -> Int4,
bot_version_id -> Nullable<Int4>, code_bundle_id -> Nullable<Int4>,
} }
} }
@ -58,8 +45,6 @@ table! {
log_path -> Text, log_path -> Text,
created_at -> Timestamp, created_at -> Timestamp,
winner -> Nullable<Int4>, winner -> Nullable<Int4>,
is_public -> Bool,
map_id -> Nullable<Int4>,
} }
} }
@ -97,16 +82,15 @@ table! {
} }
joinable!(bots -> users (owner_id)); joinable!(bots -> users (owner_id));
joinable!(match_players -> bot_versions (bot_version_id)); joinable!(code_bundles -> bots (bot_id));
joinable!(match_players -> code_bundles (code_bundle_id));
joinable!(match_players -> matches (match_id)); joinable!(match_players -> matches (match_id));
joinable!(matches -> maps (map_id));
joinable!(ratings -> bots (bot_id)); joinable!(ratings -> bots (bot_id));
joinable!(sessions -> users (user_id)); joinable!(sessions -> users (user_id));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
bot_versions,
bots, bots,
maps, code_bundles,
match_players, match_players,
matches, matches,
ratings, ratings,

36
proto/bot_api.proto Normal file
View file

@ -0,0 +1,36 @@
syntax = "proto3";
package grpc.planetwars.bot_api;
message Hello {
string hello_message = 1;
}
message HelloResponse {
string response = 1;
}
message PlayerRequest {
int32 request_id = 1;
bytes content = 2;
}
message PlayerRequestResponse {
int32 request_id = 1;
bytes content = 2;
}
message MatchRequest {
string opponent_name = 1;
}
message CreatedMatch {
int32 match_id = 1;
string player_key = 2;
}
service BotApiService {
rpc CreateMatch(MatchRequest) returns (CreatedMatch);
// server sends requests to the player, player responds
rpc ConnectBot(stream PlayerRequestResponse) returns (stream PlayerRequest);
}

View file

@ -1,46 +0,0 @@
syntax = "proto3";
package grpc.planetwars.client_api;
// Provides the planetwars client API, allowing for remote play.
service ClientApiService {
rpc CreateMatch(CreateMatchRequest) returns (CreateMatchResponse);
rpc ConnectPlayer(stream PlayerApiClientMessage) returns (stream PlayerApiServerMessage);
}
message CreateMatchRequest {
string opponent_name = 1;
string map_name = 2;
}
message CreateMatchResponse {
int32 match_id = 1;
string player_key = 2;
string match_url = 3;
}
// Server messages
message PlayerApiServerMessage {
oneof server_message {
PlayerActionRequest action_request = 1;
}
}
message PlayerActionRequest {
int32 action_request_id = 1;
bytes content = 2;
}
// Player messages
message PlayerApiClientMessage {
oneof client_message {
PlayerAction action = 1;
}
}
message PlayerAction {
int32 action_request_id = 1;
bytes content = 2;
}

View file

@ -22,7 +22,6 @@
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^3.2.1", "eslint-plugin-svelte3": "^3.2.1",
"luxon": "^2.3.0", "luxon": "^2.3.0",
"mdsvex": "^0.10.6",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"prettier-plugin-svelte": "^2.4.0", "prettier-plugin-svelte": "^2.4.0",
"sass": "^1.49.7", "sass": "^1.49.7",

View file

@ -1,80 +0,0 @@
import { browser } from "$app/env";
import { get_session_token } from "./auth";
export type FetchFn = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
export class ApiError extends Error {
constructor(public status: number, message?: string) {
super(message);
}
}
export class ApiClient {
private fetch_fn: FetchFn;
private sessionToken?: string;
constructor(fetch_fn?: FetchFn) {
if (fetch_fn) {
this.fetch_fn = fetch_fn;
} else if (browser) {
this.fetch_fn = fetch.bind(window);
}
// TODO: maybe it is cleaner to pass this as a parameter
this.sessionToken = get_session_token();
}
async get(url: string, params?: Record<string, string>): Promise<any> {
const response = await this.getRequest(url, params);
this.checkResponse(response);
return await response.json();
}
async getText(url: string, params?: Record<string, string>): Promise<any> {
const response = await this.getRequest(url, params);
this.checkResponse(response);
return await response.text();
}
async post(url: string, data: any): Promise<any> {
const headers = { "Content-Type": "application/json" };
const token = get_session_token();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await this.fetch_fn(url, {
method: "POST",
headers,
body: JSON.stringify(data),
});
this.checkResponse(response);
return await response.json();
}
private async getRequest(url: string, params: Record<string, string>): Promise<Response> {
const headers = { "Content-Type": "application/json" };
if (this.sessionToken) {
headers["Authorization"] = `Bearer ${this.sessionToken}`;
}
if (params) {
let searchParams = new URLSearchParams(params);
url = `${url}?${searchParams}`;
}
return await this.fetch_fn(url, {
method: "GET",
headers,
});
}
private checkResponse(response: Response) {
if (!response.ok) {
throw new ApiError(response.status, response.statusText);
}
}
}

View file

@ -1,5 +1,20 @@
<script lang="ts"> <script lang="ts">
export let leaderboard; 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 { function formatRating(entry: object): any {
const rating = entry["rating"]; const rating = entry["rating"];
@ -26,17 +41,10 @@
<td class="leaderboard-rating"> <td class="leaderboard-rating">
{formatRating(entry)} {formatRating(entry)}
</td> </td>
<td class="leaderboard-bot"> <td class="leaderboard-bot">{entry["bot"]["name"]}</td>
<a class="leaderboard-href" href="/bots/{entry['bot']['name']}"
>{entry["bot"]["name"]}
</a></td
>
<td class="leaderboard-author"> <td class="leaderboard-author">
{#if entry["author"]} {#if entry["author"]}
<!-- TODO: remove duplication --> {entry["author"]["username"]}
<a class="leaderboard-href" href="/users/{entry['author']['username']}"
>{entry["author"]["username"]}</a
>
{/if} {/if}
</td> </td>
</tr> </tr>
@ -61,9 +69,4 @@
.leaderboard-rank { .leaderboard-rank {
color: #333; color: #333;
} }
.leaderboard-href {
text-decoration: none;
color: black;
}
</style> </style>

View file

@ -1,7 +0,0 @@
<script lang="ts">
export let href: string | null;
$: isDisabled = !href;
</script>
<a class="btn" class:btn-disabled={isDisabled} {href}><slot /></a>

View file

@ -1,80 +1,32 @@
<script lang="ts"> <script lang="ts">
import { parsePlayerLog, PlayerLog } from "$lib/log_parser";
export let matchLog: string; export let matchLog: string;
let playerLog: PlayerLog;
let showRawStderr = false; function getStdErr(botId: number, log?: string): string {
if (!log) {
const PLURAL_MAP = { return "";
dispatch: "dispatches",
ship: "ships",
};
function pluralize(num: number, word: string): string {
if (num == 1) {
return `1 ${word}`;
} else {
return `${num} ${PLURAL_MAP[word]}`;
}
} }
$: if (matchLog) { let output = [];
playerLog = parsePlayerLog(1, matchLog); log
} else { .split("\n")
playerLog = []; .slice(0, -1)
.forEach((line) => {
let message = JSON.parse(line);
if (message["type"] === "stderr" && message["player_id"] === botId) {
output.push(message["message"]);
} }
});
return output.join("\n");
}
$: botStdErr = getStdErr(1, matchLog);
</script> </script>
<div class="output"> <div class="output">
<h3 class="output-header">Player log</h3> {#if botStdErr.length > 0}
{#if showRawStderr} <h3 class="output-header">stderr:</h3>
<div class="output-text stderr-text">
{playerLog.flatMap((turn) => turn.stderr).join("\n")}
</div>
{:else}
<div class="output-text"> <div class="output-text">
{#each playerLog as logTurn, i} {botStdErr}
<div class="turn">
<div class="turn-header">
<span class="turn-header-text">Turn {i}</span>
{#if logTurn.action?.type === "dispatches"}
{pluralize(logTurn.action.dispatches.length, "dispatch")}
{:else if logTurn.action?.type === "timeout"}
<span class="turn-error">timeout</span>
{:else if logTurn.action?.type === "bad_command"}
<span class="turn-error">invalid command</span>
{/if}
</div>
{#if logTurn.action?.type === "dispatches"}
<div class="dispatches-container">
{#each logTurn.action.dispatches as dispatch}
<div class="dispatch">
<div class="dispatch-text">
{pluralize(dispatch.ship_count, "ship")} from {dispatch.origin} to {dispatch.destination}
</div>
{#if dispatch.error}
<span class="dispatch-error">{dispatch.error}</span>
{/if}
</div>
{/each}
</div>
{:else if logTurn.action?.type === "bad_command"}
<div class="bad-command-container">
<div class="bad-command-text">{logTurn.action.command}</div>
<div class="bad-command-error">Parse error: {logTurn.action.error}</div>
</div>
{/if}
{#if logTurn.stderr.length > 0}
<div class="stderr-header">stderr</div>
<div class="stderr-text-box">
{#each logTurn.stderr as stdErrMsg}
<div class="stderr-text">{stdErrMsg}</div>
{/each}
</div>
{/if}
</div>
{/each}
</div> </div>
{/if} {/if}
</div> </div>
@ -87,71 +39,12 @@
padding: 15px; padding: 15px;
} }
.turn {
margin: 16px 4px;
}
.output-text { .output-text {
color: #ccc; color: #ccc;
} font-family: monospace;
.turn-header {
display: flex;
justify-content: space-between;
}
.turn-header-text {
color: #eee;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
}
.turn-error {
color: red;
}
.dispatch {
display: flex;
justify-content: space-between;
}
.dispatch-error {
color: red;
}
.bad-command-container {
border-left: 1px solid red;
margin-left: 4px;
padding-left: 8px;
}
.bad-command-text {
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
padding-bottom: 4px;
}
.bad-command-error {
color: whitesmoke;
}
.stderr-text {
// font-family: monospace;
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
white-space: pre-wrap; white-space: pre-wrap;
} }
.stderr-header {
color: #eee;
padding-top: 4px;
}
.stderr-text-box {
border-left: 1px solid #ccc;
margin-left: 4px;
padding-left: 8px;
}
.output-header { .output-header {
color: #eee; color: #eee;
padding-bottom: 20px; padding-bottom: 20px;

View file

@ -0,0 +1,129 @@
<div class="container">
<div class="game-rules">
<h2 class="title">Welcome to planetwars!</h2>
<p>
Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that
will conquer the galaxy for you, while you take care of more important stuff.
</p>
<p>
In every game turn, your bot will receive a json-encoded line on stdin, describing the current
state of the game. Each state will hold a set of planets, and a set of spaceship fleets
traveling between the planets (<em>expeditions</em>).
</p>
<p>Example game state:</p>
<pre>{`
{
"planets": [
{
"ship_count": 2,
"x": -2.0,
"y": 0.0,
"owner": 1,
"name": "your planet"
},
{
"ship_count": 4,
"x": 2.0,
"y": 0.0,
"owner": 2,
"name": "enemy planet"
},
{
"ship_count": 2,
"x": 0.0,
"y": 2.0,
"owner": null,
"name": "neutral planet"
}
],
"expeditions": [
{
"id": 169,
"ship_count": 8,
"origin": "your planet",
"destination": "enemy planet",
"owner": 1,
"turns_remaining": 2
}
]
}
`}</pre>
<p>
The <code>owner</code> field holds a player number when the planet is held by a player, and is
<code>null</code> otherwise. Your bot is always referred to as player 1.<br />
Each turn, every player-owned planet will gain one additional ship. <br />
Planets will never move during the game.
</p>
<p>
Every turn, you may send out expeditions to conquer other planets. You can do this by writing
a json-encoded line to stdout:
</p>
<p>Example command:</p>
<pre>{`
{
"moves": [
{
"origin": "your planet",
"target": "enemy planet",
"ship_count": 2
}
]
}
`}
</pre>
<p>
All players send out their commands simultaneously, so there is no turn order. You may send as
many commands as you please.
</p>
<p>
The amount of turns an expedition will travel is equal to the ceiled euclidean distance
between its origin and target planet.
</p>
<p>
Ships will only battle on planets. Combat resolution is simple: every ship destroys one enemy
ship, last man standing gets to keep the planet.
</p>
<p>
The game will end when no enemy player ships remain (neutral ships may survive), or when the
turn limit is reached. The default limit is 100 turns.
</p>
<p>
You can code your bot in python 3.10. You have the entire stdlib at your disposal. <br />
If you'd like additional libraries or a different programming language, feel free to nag the administrator.
</p>
<h3 class="tldr">TL;DR</h3>
<p>
Head over to the editor view to get started - a working example is provided. <br />
Feel free to just hit the play button to see how it works!
</p>
</div>
</div>
<style lang="scss">
.container {
overflow-y: scroll;
height: 100%;
box-sizing: border-box;
}
.game-rules {
padding: 15px 30px;
max-width: 800px;
}
.game-rules p {
padding-top: 1.5em;
}
.game-rules .tldr {
padding-top: 3em;
}
</style>

View file

@ -1,20 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ApiClient } from "$lib/api_client";
import { get_session_token } from "$lib/auth"; import { get_session_token } from "$lib/auth";
import { getBotName, saveBotName } from "$lib/bot_code"; import { getBotName, saveBotName } from "$lib/bot_code";
import { currentUser } from "$lib/stores/current_user"; import { currentUser } from "$lib/stores/current_user";
import { selectedOpponent, selectedMap } from "$lib/stores/editor_state";
import { createEventDispatcher, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import Select from "svelte-select"; import Select from "svelte-select";
export let editSession; export let editSession;
let availableBots: object[] = []; let availableBots: object[] = [];
let maps: object[] = []; let selectedOpponent = undefined;
let botName: string | undefined = undefined; let botName: string | undefined = undefined;
// whether to show the "save succesful" message // whether to show the "save succesful" message
let saveSuccesful = false; let saveSuccesful = false;
@ -23,28 +18,24 @@
onMount(async () => { onMount(async () => {
botName = getBotName(); botName = getBotName();
const apiClient = new ApiClient();
const [_bots, _maps] = await Promise.all([ const res = await fetch("/api/bots", {
apiClient.get("/api/bots"), headers: {
apiClient.get("/api/maps"), "Content-Type": "application/json",
]); },
});
availableBots = _bots; if (res.ok) {
maps = _maps; availableBots = await res.json();
selectedOpponent = availableBots.find((b) => b["name"] === "simplebot");
if (!$selectedOpponent) {
selectedOpponent.set(availableBots.find((b) => b["name"] === "simplebot"));
}
if (!$selectedMap) {
selectedMap.set(maps.find((m) => m["name"] === "hex"));
} }
}); });
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
async function submitBot() { async function submitBot() {
const opponentName = selectedOpponent["name"];
let response = await fetch("/api/submit_bot", { let response = await fetch("/api/submit_bot", {
method: "POST", method: "POST",
headers: { headers: {
@ -52,8 +43,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
code: editSession.getDocument().getValue(), code: editSession.getDocument().getValue(),
opponent_name: $selectedOpponent["name"], opponent_name: opponentName,
map_name: $selectedMap["name"],
}), }),
}); });
@ -110,23 +100,13 @@
<div class="submit-pane"> <div class="submit-pane">
<div class="match-form"> <div class="match-form">
<h4>Play a match</h4> <h4>Play a match</h4>
<div class="play-text">Opponent</div> <div class="play-text">Select an opponent to test your bot</div>
<div class="opponent-select"> <div class="opponentSelect">
<Select <Select
optionIdentifier="name" optionIdentifier="name"
labelIdentifier="name" labelIdentifier="name"
items={availableBots} items={availableBots}
bind:value={$selectedOpponent} bind:value={selectedOpponent}
isClearable={false}
/>
</div>
<span>Map</span>
<div class="map-select">
<Select
optionIdentifier="name"
labelIdentifier="name"
items={maps}
bind:value={$selectedMap}
isClearable={false} isClearable={false}
/> />
</div> </div>
@ -165,9 +145,8 @@
margin-bottom: 0.3em; margin-bottom: 0.3em;
} }
.opponent-select, .opponentSelect {
.map-select { margin: 20px 0;
margin: 8px 0;
} }
.save-form { .save-form {

View file

@ -1,21 +0,0 @@
<script lang="ts">
export let href: string;
export let text: string;
</script>
<div class="toc-entry">
<a {href}>{text}</a>
</div>
<style scoped lang="scss">
@use "src/styles/variables";
.toc-entry {
font-size: 16px;
padding: 4px;
}
.toc-entry a {
color: variables.$blue-primary;
}
</style>

View file

@ -1,104 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import dayjs from "dayjs";
export let matches: object[];
function match_url(match: object) {
return `/matches/${match["id"]}`;
}
</script>
<table class="matches-table">
<tr>
<th class="header-timestamp">timestamp</th>
<th class="col-player-1">player 1</th>
<th />
<th />
<th class="col-player-2">player 2</th>
<th class="col-map">map</th>
</tr>
{#each matches as match}
<tr class="match-table-row" on:click={() => goto(match_url(match))}>
<td class="col-timestamp">
{dayjs(match["timestamp"]).format("YYYY-MM-DD HH:mm")}
</td>
<td class="col-player-1">
{match["players"][0]["bot_name"]}
</td>
{#if match["winner"] == null}
<td class="col-score-player-1"> TIE </td>
<td class="col-score-player-2"> TIE </td>
{:else if match["winner"] == 0}
<td class="col-score-player-1"> WIN </td>
<td class="col-score-player-2"> LOSS </td>
{:else if match["winner"] == 1}
<td class="col-score-player-1"> LOSS </td>
<td class="col-score-player-2"> WIN </td>
{/if}
<td class="col-player-2">
{match["players"][1]["bot_name"]}
</td>
<td class="col-map">
{match["map"]?.name || ""}
</td>
</tr>
{/each}
</table>
<style lang="scss">
.matches-table {
width: 100%;
}
.matches-table td,
.matches-table th {
padding: 8px 16px;
}
.header-timestamp {
text-align: left;
}
.col-timestamp {
color: #555;
}
.col-player-1 {
text-align: left;
}
.col-player-2 {
text-align: right;
}
@mixin col-player-score {
text-transform: uppercase;
font-weight: 600;
font-size: 14px;
font-family: "Open Sans", sans-serif;
}
.col-score-player-1 {
@include col-player-score;
text-align: right;
}
.col-score-player-2 {
@include col-player-score;
text-align: left;
}
.col-map {
text-align: right;
}
.matches-table {
margin: 12px auto;
border-collapse: collapse;
}
.match-table-row:hover {
cursor: pointer;
background-color: #eee;
}
</style>

View file

@ -36,13 +36,13 @@
<div class="user-controls"> <div class="user-controls">
{#if $currentUser} {#if $currentUser}
<a class="current-user-name" href="/users/{$currentUser['username']}"> <div class="current-user-name">
{$currentUser["username"]} {$currentUser["username"]}
</a> </div>
<div class="sign-out" on:click={signOut}>Sign out</div> <div class="sign-out" on:click={signOut}>Sign out</div>
{:else} {:else}
<a class="account-href" href="/login">Sign in</a> <a class="account-href" href="login">Sign in</a>
<a class="account-href" href="/register">Sign up</a> <a class="account-href" href="register">Sign up</a>
{/if} {/if}
</div> </div>
@ -61,7 +61,6 @@
.current-user-name { .current-user-name {
@include navbar-item; @include navbar-item;
text-decoration: none;
color: #fff; color: #fff;
} }

View file

@ -1,75 +0,0 @@
export type PlayerLog = PlayerLogTurn[];
export type PlayerLogTurn = {
action?: PlayerAction;
stderr: string[];
};
type PlayerAction = Timeout | BadCommand | Dispatches;
type Timeout = {
type: "timeout";
};
type BadCommand = {
type: "bad_command";
command: string;
error: string;
};
type Dispatches = {
type: "dispatches";
dispatches: Dispatch[];
};
type Dispatch = {
origin: string;
destination: string;
ship_count: number;
error?: string;
};
function createEmptyLogTurn(): PlayerLogTurn {
return {
stderr: [],
};
}
export function parsePlayerLog(playerId: number, logText: string): PlayerLog {
const logLines = logText.split("\n").slice(0, -1);
const playerLog: PlayerLog = [];
let turn = null;
logLines.forEach((logLine) => {
const logMessage = JSON.parse(logLine);
if (logMessage["type"] === "gamestate") {
if (turn) {
playerLog.push(turn);
turn = createEmptyLogTurn();
}
} else if (logMessage["player_id"] === playerId) {
if (!turn) {
// older match logs don't have an initial game state due to a bug.
turn = createEmptyLogTurn();
}
switch (logMessage["type"]) {
case "stderr": {
let msg = logMessage["message"];
turn.stderr.push(msg);
break;
}
case "timeout":
case "bad_command":
case "dispatches": {
turn.action = logMessage;
break;
}
}
}
});
return playerLog;
}

View file

@ -1,27 +0,0 @@
import { writable } from "svelte/store";
const MAX_MATCHES = 100;
function createMatchHistory() {
const { subscribe, update } = writable([]);
function pushMatch(match: object) {
update((matches) => {
if (matches.length == MAX_MATCHES) {
matches.pop();
}
matches.unshift(match);
return matches;
});
}
return {
subscribe,
pushMatch,
};
}
export const matchHistory = createMatchHistory();
export const selectedOpponent = writable(null);
export const selectedMap = writable(null);

View file

@ -1,4 +1,4 @@
import { ApiClient, FetchFn } from "./api_client"; import { get_session_token } from "./auth";
export function debounce(func: Function, timeout: number = 300) { export function debounce(func: Function, timeout: number = 300) {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
@ -10,12 +10,35 @@ export function debounce(func: Function, timeout: number = 300) {
}; };
} }
export async function get(url: string, params?: Record<string, string>, fetch_fn: FetchFn = fetch) { export async function get(url: string, fetch_fn: Function = fetch) {
const client = new ApiClient(fetch_fn); const headers = { "Content-Type": "application/json" };
return await client.get(url, params);
const token = get_session_token();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
} }
export async function post(url: string, data: any, fetch_fn: FetchFn = fetch) { const response = await fetch_fn(url, {
const client = new ApiClient(fetch_fn); method: "GET",
return await client.post(url, data); headers,
});
return JSON.parse(response);
}
export async function post(url: string, data: any, fetch_fn: Function = fetch) {
const headers = { "Content-Type": "application/json" };
const token = get_session_token();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch_fn(url, {
method: "POST",
headers,
body: JSON.stringify(data),
});
return JSON.parse(response);
} }

View file

@ -6,29 +6,16 @@
<div class="outer-container"> <div class="outer-container">
<div class="navbar"> <div class="navbar">
<div class="navbar-left"> <div class="navbar-main">
<div class="navbar-header">
<a href="/">PlanetWars</a> <a href="/">PlanetWars</a>
</div> </div>
<div class="navbar-item">
<a href="/editor">Editor</a>
</div>
<div class="navbar-item">
<a href="/leaderboard">Leaderboard</a>
</div>
<div class="navbar-item">
<a href="/docs/rules">How to play</a>
</div>
</div>
<div class="navbar-right">
<UserControls /> <UserControls />
</div> </div>
</div>
<slot /> <slot />
</div> </div>
<style lang="scss" global> <style lang="scss">
@import "src/styles/global.scss"; @import "src/styles/variables.scss";
.outer-container { .outer-container {
width: 100vw; width: 100vw;
@ -47,33 +34,13 @@
padding: 0 15px; padding: 0 15px;
} }
.navbar-left { .navbar-main {
display: flex;
}
.navbar-right {
display: flex;
}
.navbar-header {
margin: auto 0; margin: auto 0;
padding-right: 24px;
} }
.navbar-header a { .navbar-main a {
font-size: 20px; font-size: 20px;
color: #fff; color: #eee;
text-decoration: none; text-decoration: none;
} }
.navbar-item {
margin: auto 0;
padding: 0 8px;
}
.navbar-item a {
font-size: 14px;
color: #fff;
text-decoration: none;
font-weight: 600;
}
</style> </style>

View file

@ -0,0 +1,74 @@
<script lang="ts" context="module">
import { get_session_token } from "$lib/auth";
export async function load({ page }) {
const token = get_session_token();
const res = await fetch(`/api/bots/${page.params["bot_id"]}`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
const data = await res.json();
return {
props: {
bot: data["bot"],
bundles: data["bundles"],
},
};
}
return {
status: res.status,
error: new Error("Could not load bot"),
};
}
</script>
<script lang="ts">
import dayjs from "dayjs";
export let bot: object;
export let bundles: object[];
let files;
async function submitCode() {
console.log("click");
const token = get_session_token();
const formData = new FormData();
formData.append("File", files[0]);
const res = await fetch(`/api/bots/${bot["id"]}/upload`, {
method: "POST",
headers: {
// the content type header will be set by the browser
Authorization: `Bearer ${token}`,
},
body: formData,
});
console.log(res.statusText);
}
</script>
<div>
{bot["name"]}
</div>
<div>Upload code</div>
<form on:submit|preventDefault={submitCode}>
<input type="file" bind:files />
<button type="submit">Submit</button>
</form>
<ul>
{#each bundles as bundle}
<li>
bundle created at {dayjs(bundle["created_at"]).format("YYYY-MM-DD HH:mm")}
</li>
{/each}
</ul>

View file

@ -1,173 +0,0 @@
<script lang="ts" context="module">
import { ApiClient } from "$lib/api_client";
export async function load({ params, fetch }) {
const apiClient = new ApiClient(fetch);
try {
const [botData, matchesPage] = await Promise.all([
apiClient.get(`/api/bots/${params["bot_name"]}`),
apiClient.get("/api/matches", { bot: params["bot_name"], count: "20" }),
]);
const { bot, owner, versions } = botData;
versions.sort((a: string, b: string) =>
dayjs(a["created_at"]).isAfter(b["created_at"]) ? -1 : 1
);
return {
props: {
bot,
owner,
versions,
matches: matchesPage["matches"],
},
};
} catch (error) {
return {
status: error.status,
error: error,
};
}
}
</script>
<script lang="ts">
import dayjs from "dayjs";
import { currentUser } from "$lib/stores/current_user";
import MatchList from "$lib/components/matches/MatchList.svelte";
import LinkButton from "$lib/components/LinkButton.svelte";
export let bot: object;
export let owner: object;
export let versions: object[];
export let matches: object[];
// function last_updated() {
// versions.sort()
// }
// let files;
// async function submitCode() {
// console.log("click");
// const token = get_session_token();
// const formData = new FormData();
// formData.append("File", files[0]);
// const res = await fetch(`/api/bots/${bot["id"]}/upload`, {
// method: "POST",
// headers: {
// // the content type header will be set by the browser
// Authorization: `Bearer ${token}`,
// },
// body: formData,
// });
// console.log(res.statusText);
// }
</script>
<!--
<div>Upload code</div>
<form on:submit|preventDefault={submitCode}>
<input type="file" bind:files />
<button type="submit">Submit</button>
</form> -->
<div class="container">
<div class="header">
<h1 class="bot-name">{bot["name"]}</h1>
{#if owner}
<a class="owner-name" href="/users/{owner['username']}">
{owner["username"]}
</a>
{/if}
</div>
{#if $currentUser && $currentUser["user_id"] === bot["owner_id"]}
<div>
<!-- TODO: can we avoid hardcoding the url? -->
Publish a new version by pushing a docker container to
<code>registry.planetwars.dev/{bot["name"]}:latest</code>, or using the web editor.
</div>
<div class="versions">
<h3>Versions</h3>
<ul class="version-list">
{#each versions.slice(0, 10) as version}
<li class="bot-version">
{dayjs(version["created_at"]).format("YYYY-MM-DD HH:mm")}
{#if version["container_digest"]}
<span class="container-digest">{version["container_digest"]}</span>
{:else}
<a href={`/code/${version["id"]}`}>view code</a>
{/if}
</li>
{/each}
</ul>
{#if versions.length == 0}
This bot does not have any versions yet.
{/if}
</div>
{/if}
<div class="matches">
<h3>Recent matches</h3>
<MatchList {matches} />
{#if matches.length > 0}
<div class="btn-container">
<LinkButton href={`/matches?bot=${bot["name"]}`}>All matches</LinkButton>
</div>
{/if}
</div>
</div>
<style lang="scss">
.container {
width: 800px;
max-width: 80%;
margin: 50px auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 60px;
border-bottom: 1px solid black;
}
$header-space-above-line: 12px;
.bot-name {
font-size: 24pt;
margin-bottom: $header-space-above-line;
}
.owner-name {
font-size: 14pt;
text-decoration: none;
color: #333;
margin-bottom: $header-space-above-line;
}
.btn-container {
padding: 24px;
text-align: center;
}
.versions {
margin: 30px 0;
}
.version-list {
padding: 0;
}
.bot-version {
display: flex;
justify-content: space-between;
padding: 4px 24px;
}
</style>

View file

@ -1,99 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { get_session_token } from "$lib/auth";
import { currentUser } from "$lib/stores/current_user";
import { onMount } from "svelte";
let botName: string | undefined = undefined;
let saveErrors: string[] = [];
onMount(() => {
// ensure user is logged in
if (!$currentUser) {
goto("/login");
}
});
async function createBot() {
saveErrors = [];
// TODO: how can we handle this with the new ApiClient?
let response = await fetch("/api/bots", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${get_session_token()}`,
},
body: JSON.stringify({
name: botName,
}),
});
let responseData = await response.json();
if (response.ok) {
let bot = responseData;
goto(`/bots/${bot["name"]}`);
} else {
const error = responseData["error"];
if (error["type"] === "validation_failed") {
saveErrors = error["validation_errors"];
} else if (error["type"] === "bot_name_taken") {
saveErrors = ["Bot name is already taken"];
} else {
// unexpected error
throw responseData;
}
}
}
</script>
<div class="container">
<div class="create-bot-form">
<h4>Create new bot</h4>
<input type="text" class="bot-name-input" placeholder="bot name" bind:value={botName} />
{#if saveErrors.length > 0}
<ul>
{#each saveErrors as errorText}
<li class="error-text">{errorText}</li>
{/each}
</ul>
{/if}
<button class="submit-button save-button" on:click={createBot}>Save</button>
</div>
</div>
<style lang="scss">
.container {
width: 400px;
max-width: 80%;
margin: 50px auto;
}
.create-bot-form h4 {
margin-bottom: 0.3em;
}
.error-text {
color: red;
}
.submit-button {
padding: 6px 16px;
border-radius: 4px;
border: 0;
font-size: 18pt;
display: block;
margin: 10px auto;
background-color: lightgreen;
cursor: pointer;
}
.bot-name-input {
width: 100%;
font-size: 16px;
padding: 8px 16px;
box-sizing: border-box;
margin: 10px 0;
border: 1px solid rgb(216, 219, 223);
border-radius: 3px;
}
</style>

View file

@ -1,35 +0,0 @@
<script lang="ts" context="module">
import { ApiClient } from "$lib/api_client";
export async function load({ params, fetch }) {
const apiClient = new ApiClient(fetch);
try {
const code = await apiClient.getText(`/api/code/${params["bundle_id"]}`);
return {
props: {
code,
},
};
} catch (error) {
return {
status: error.status,
error: error,
};
}
}
</script>
<script lang="ts">
export let code;
</script>
<pre class="bot-code">
{code}
</pre>
<style lang="scss">
.bot-code {
margin: 24px 12px;
}
</style>

View file

@ -1,62 +0,0 @@
<script>
import TocEntry from "$lib/components/docs/TocEntry.svelte";
</script>
<div class="container">
<div class="sidebar">
<div class="sidebar-content">
<h2>Docs</h2>
<div class="sidebar-nav-group">
<TocEntry href="/docs/rules" text="Rules" />
<TocEntry href="/docs/local-development" text="Local development" />
</div>
</div>
</div>
<div class="content-container">
<div class="content">
<slot />
</div>
</div>
</div>
<style lang="scss">
@use "src/styles/variables";
.container {
display: flex;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Open Sans", "Helvetica Neue", sans-serif;
flex-grow: 1;
}
.sidebar {
width: 300px;
line-height: 20px;
border-right: 1px solid variables.$light-grey;
}
.sidebar-content {
padding: 32px 16px;
position: sticky;
align-self: flex-start;
top: 0px;
}
.sidebar-content h2 {
margin: 16px 0;
}
.sidebar-nav-group {
padding: 0 8px;
}
.content-container {
padding: 16px 0;
flex-grow: 1;
}
.content {
max-width: 75%;
margin: 0 auto;
}
</style>

View file

@ -1,10 +0,0 @@
<div class="container markdown-body">
<slot />
</div>
<style lang="scss">
@use "src/styles/variables";
.container {
max-width: 800px;
}
</style>

View file

@ -1,8 +0,0 @@
<script context="module">
export async function load() {
return {
status: 302,
redirect: "/docs/rules",
};
}
</script>

View file

@ -1,97 +0,0 @@
# Local development
Besides using the web editor, it is also possible to develop a bot in your own development environment.
Using the `planetwars-client` you can play matches remotely, with your bot running on your computer.
This is similar to using the "Play" button in the web editor.
You can then submit your bot to the server as a docker container.
This way, you can author bots in any language or tool you want - as long as you can dockerize it.
## Running your bot locally
You can use the `planetwars-client` to play matches locally.
Currently, no binaries are available, so you'll have to build the client from source.
### Building the binary
If you do not have a rust compiler installed already, obtain one through https://rustup.rs/.
1. Clone the repository:
`git clone https://github.com/iasoon/planetwars.dev.git`
2. Build and install the client:
`cargo install --path planetwars.dev/planetwars-client`
### Create a bot config
The bot config file specifies how to run your bot. Create a file `mybot.toml` with contents like so:
```toml
# Comand to run when starting the bot.
# Argv style also supported: ["python", "simplebot.py"]
command = "python simplebot.py"
# Directory in which to run the command.
# It is recommended to use an absolute path here.
working_directory = "/home/user/simplebot"
```
### Playing a match
Run `planetwars-client path/to/mybot.toml opponent_name`
Try `planetwars-client --help` for more options.
## Publishing your bot as a docker container
Once you are happy with your bot, you can push it to the planetwars server as a docker container.
First, we will containerize our bot.
### Containerizing your bot
Our project directory looks like this:
```
simplebot/
├── Dockerfile
└── simplebot.py
```
We used this basic dockerfile. You can reuse this for simple python-based bots.
```Dockerfile
FROM python:3.10.1-slim-buster
WORKDIR /app
COPY simplebot.py simplebot.py
CMD python simplebot.py
```
Refer to https://docs.docker.com for guides on how to write your own dockerfile.
In the directory that contains your `Dockerfile`, run the following command:
```bash
docker build -t my-bot-name .
```
If all went well, your docker daemon now holds a container tagged as `my-bot-name`.
### Publishing the bot
1. **Create a bot**:
Before you can publish your container, you will first need to create a bot on planetwars.dev.
You can create a new bot by clicking the "New bot" button on your user profile page.
If you have an existing bot that you wish to overwrite, you can use that instead.
2. **Log in to the planetwars docker registry**:
`docker login registry.planetwars.dev`
Authenticate using your planetwars.dev credentials.
3. **Tag your bot**:
`docker tag my-bot-name registry.planetwars.dev/my-bot-name`
4. **Push your bot**:
`docker push registry.planetwars.dev/my-bot-name`
This will upload the container to planetwars.dev, and automatically create a new bot version.
That was it! If all went well, you should be able to see the new version on your bot page.

View file

@ -1,114 +0,0 @@
# How to play
## Protocol
In every game turn, your bot will receive a json-encoded line on stdin, describing the current
state of the game. Each state will hold a set of planets, and a set of spaceship fleets
traveling between the planets (_expeditions_).
Example game state:
```json
{
"planets": [
{
"ship_count": 2,
"x": -2.0,
"y": 0.0,
"owner": 1,
"name": "your planet"
},
{
"ship_count": 4,
"x": 2.0,
"y": 0.0,
"owner": 2,
"name": "enemy planet"
},
{
"ship_count": 2,
"x": 0.0,
"y": 2.0,
"owner": null,
"name": "neutral planet"
}
],
"expeditions": [
{
"id": 169,
"ship_count": 8,
"origin": "your planet",
"destination": "enemy planet",
"owner": 1,
"turns_remaining": 2
}
]
}
```
The `owner` field holds a player number when the planet is held by a player, and is
`null` otherwise. Your bot is always referred to as player 1.
Planets will never move during the game.
Every turn, you may send out expeditions to conquer other planets. You can do this by writing
a json-encoded line to stdout:
Example command:
```json
{
"moves": [
{
"origin": "your planet",
"destination": "enemy planet",
"ship_count": 2
}
]
}
You can dispatch as many expeditions as you like.
```
## Rules
All players send out their commands simultaneously, so there is no player order.
The amount of turns an expedition will travel is equal to the ceiled euclidean distance
between its origin and destination planet.
Each turn, one additional ship will be constructed on each player-owned planet.
Neutral planets do not construct ships.
Ships will only battle on planets. Combat resolution is simple: every ship destroys one enemy
ship, last man standing gets to keep the planet. When no player has ships remaining, the planet will turn neutral.
A turn progresses as follows:
1. Construct ships
2. Dispatch expeditions
3. Arrivals & combat resolution
It is not allowed for players to abandon a planet - at least one ship should remain at all times.
Note that you are still allowed to dispatch the full ship count you observe in the game state,
as an additional ship will be constructed before the ships depart.
The game will end when no enemy player ships remain (neutral ships may survive), or when the
turn limit is reached. When the turn limit is hit, the game will end it a tie.
Currently, the limit is set at 500 turns.
## Writing your bot
You can code a bot in python 3.10 using the [web editor](/editor). A working example bot is provided.
If you'd like to use a different programming language, or prefer coding on your own editor,
you can try [local development](/docs/local-development).
As logging to stdout will be interpreted as commands by the game server, we suggest you log to stderr.
In python, you can do this using
```python
print("hello world", file=sys.stderr)
```
Output written to stderr will be displayed alongside the match replay.
Feel free to launch some test matches to get the hang of it!

View file

@ -1,255 +0,0 @@
<script lang="ts">
import Visualizer from "$lib/components/Visualizer.svelte";
import EditorView from "$lib/components/EditorView.svelte";
import { onMount } from "svelte";
import { DateTime } from "luxon";
import type { Ace } from "ace-builds";
import ace from "ace-builds/src-noconflict/ace?client";
import * as AcePythonMode from "ace-builds/src-noconflict/mode-python?client";
import { getBotCode, saveBotCode } from "$lib/bot_code";
import { matchHistory } from "$lib/stores/editor_state";
import { debounce } from "$lib/utils";
import SubmitPane from "$lib/components/SubmitPane.svelte";
import OutputPane from "$lib/components/OutputPane.svelte";
import BotName from "./bots/[bot_name].svelte";
enum ViewMode {
Editor,
MatchVisualizer,
}
let viewMode = ViewMode.Editor;
let selectedMatchId: string | undefined = undefined;
let selectedMatchLog: string | undefined = undefined;
let editSession: Ace.EditSession;
onMount(() => {
init_editor();
});
function init_editor() {
editSession = new ace.EditSession(getBotCode());
editSession.setMode(new AcePythonMode.Mode());
const saveCode = () => {
const code = editSession.getDocument().getValue();
saveBotCode(code);
};
// cast to any because the type annotations are wrong here
(editSession as any).on("change", debounce(saveCode, 2000));
}
async function onMatchCreated(e: CustomEvent) {
const matchData = e.detail["match"];
matchHistory.pushMatch(matchData);
await selectMatch(matchData["id"]);
}
async function selectMatch(matchId: string) {
selectedMatchId = matchId;
selectedMatchLog = null;
fetchSelectedMatchLog(matchId);
viewMode = ViewMode.MatchVisualizer;
}
async function fetchSelectedMatchLog(matchId: string) {
if (matchId !== selectedMatchId) {
return;
}
let matchLog = await getMatchLog(matchId);
if (matchLog) {
selectedMatchLog = matchLog;
} else {
// try again in 1 second
setTimeout(fetchSelectedMatchLog, 1000, matchId);
}
}
async function getMatchData(matchId: string) {
let response = await fetch(`/api/matches/${matchId}`, {
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw Error(response.statusText);
}
let matchData = await response.json();
return matchData;
}
async function getMatchLog(matchId: string) {
const matchData = await getMatchData(matchId);
console.log(matchData);
if (matchData["state"] !== "Finished") {
// log is not available yet
return null;
}
const res = await fetch(`/api/matches/${matchId}/log`, {
headers: {
"Content-Type": "application/json",
},
});
let log = await res.text();
return log;
}
function setViewMode(viewMode_: ViewMode) {
selectedMatchId = undefined;
selectedMatchLog = undefined;
viewMode = viewMode_;
}
function formatMatchTimestamp(timestampString: string): string {
let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal();
if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) {
return timestamp.toFormat("HH:mm");
} else {
return timestamp.toFormat("dd/MM");
}
}
$: selectedMatch = $matchHistory.find((m) => m["id"] === selectedMatchId);
</script>
<div class="container">
<div class="sidebar-left">
<div
class="editor-button sidebar-item"
class:selected={viewMode === ViewMode.Editor}
on:click={() => setViewMode(ViewMode.Editor)}
>
Code
</div>
<div class="sidebar-header">match history</div>
<ul class="match-list">
{#each $matchHistory as match}
<li
class="match-card sidebar-item"
on:click={() => selectMatch(match.id)}
class:selected={match.id === selectedMatchId}
>
<div class="match-timestamp">{formatMatchTimestamp(match.timestamp)}</div>
<div class="match-card-body">
<!-- ugly temporary hardcode -->
<div class="match-opponent">{match["players"][1]["bot_name"]}</div>
<div class="match-map">{match["map"]?.name}</div>
</div>
</li>
{/each}
</ul>
</div>
<div class="editor-container">
{#if viewMode === ViewMode.MatchVisualizer}
<Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} />
{:else if viewMode === ViewMode.Editor}
<EditorView {editSession} />
{/if}
</div>
<div class="sidebar-right">
{#if viewMode === ViewMode.MatchVisualizer}
<OutputPane matchLog={selectedMatchLog} />
{:else if viewMode === ViewMode.Editor}
<SubmitPane {editSession} on:matchCreated={onMatchCreated} />
{/if}
</div>
</div>
<style lang="scss">
@import "src/styles/variables.scss";
.container {
display: flex;
flex-grow: 1;
min-height: 0;
}
.sidebar-left {
width: 240px;
background-color: $bg-color;
display: flex;
flex-direction: column;
}
.sidebar-right {
width: 400px;
background-color: white;
border-left: 1px solid;
padding: 0;
display: flex;
overflow: hidden;
}
.editor-container {
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
background-color: white;
}
.editor-container {
height: 100%;
}
.sidebar-item {
color: #eee;
padding: 15px;
}
.sidebar-item:hover {
background-color: #333;
}
.sidebar-item.selected {
background-color: #333;
}
.match-list {
list-style: none;
color: #eee;
padding-top: 15px;
overflow-y: scroll;
padding-left: 0px;
}
.match-card {
padding: 10px 15px;
font-size: 11pt;
display: flex;
}
.match-timestamp {
color: #ccc;
}
.match-card-body {
margin: 0 8px;
}
.match-opponent {
font-weight: 600;
color: #eee;
}
.match-map {
color: #ccc;
}
.sidebar-header {
margin-top: 2em;
text-transform: uppercase;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-family: "Open Sans", sans-serif;
padding-left: 14px;
}
</style>

View file

@ -1,92 +1,276 @@
<script lang="ts" context="module"> <script lang="ts">
import { ApiClient } from "$lib/api_client"; import Visualizer from "$lib/components/Visualizer.svelte";
import EditorView from "$lib/components/EditorView.svelte";
import { onMount } from "svelte";
const NUM_MATCHES = "25"; import { DateTime } from "luxon";
export async function load({ fetch }) { import type { Ace } from "ace-builds";
try { import ace from "ace-builds/src-noconflict/ace?client";
const apiClient = new ApiClient(fetch); import * as AcePythonMode from "ace-builds/src-noconflict/mode-python?client";
import { getBotCode, saveBotCode, hasBotCode } from "$lib/bot_code";
import { debounce } from "$lib/utils";
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";
let { matches, has_next } = await apiClient.get("/api/matches", { enum ViewMode {
count: NUM_MATCHES, Editor,
MatchVisualizer,
Rules,
Leaderboard,
}
let matches = [];
let viewMode = ViewMode.Editor;
let selectedMatchId: string | undefined = undefined;
let selectedMatchLog: string | undefined = undefined;
let editSession: Ace.EditSession;
onMount(() => {
if (!hasBotCode()) {
viewMode = ViewMode.Rules;
}
init_editor();
}); });
return { function init_editor() {
props: { editSession = new ace.EditSession(getBotCode());
matches, editSession.setMode(new AcePythonMode.Mode());
hasNext: has_next,
const saveCode = () => {
const code = editSession.getDocument().getValue();
saveBotCode(code);
};
// cast to any because the type annotations are wrong here
(editSession as any).on("change", debounce(saveCode, 2000));
}
async function onMatchCreated(e: CustomEvent) {
const matchData = e.detail["match"];
matches.unshift(matchData);
matches = matches;
await selectMatch(matchData["id"]);
}
async function selectMatch(matchId: string) {
selectedMatchId = matchId;
selectedMatchLog = null;
fetchSelectedMatchLog(matchId);
viewMode = ViewMode.MatchVisualizer;
}
async function fetchSelectedMatchLog(matchId: string) {
if (matchId !== selectedMatchId) {
return;
}
let matchLog = await getMatchLog(matchId);
if (matchLog) {
selectedMatchLog = matchLog;
} else {
// try again in 1 second
setTimeout(fetchSelectedMatchLog, 1000, matchId);
}
}
async function getMatchData(matchId: string) {
let response = await fetch(`/api/matches/${matchId}`, {
headers: {
"Content-Type": "application/json",
}, },
}; });
} catch (error) {
return { if (!response.ok) {
status: error.status, throw Error(response.statusText);
error: new Error("failed to load matches"),
};
} }
let matchData = await response.json();
return matchData;
} }
</script>
<script lang="ts"> async function getMatchLog(matchId: string) {
import LinkButton from "$lib/components/LinkButton.svelte"; const matchData = await getMatchData(matchId);
import MatchList from "$lib/components/matches/MatchList.svelte"; console.log(matchData);
if (matchData["state"] !== "Finished") {
export let matches; // log is not available yet
export let hasNext;
$: viewMoreUrl = olderMatchesLink(matches);
// TODO: deduplicate.
// Maybe move to ApiClient logic?
function olderMatchesLink(matches: object[]): string {
if (matches.length == 0 || !hasNext) {
return null; return null;
} }
const lastTimestamp = matches[matches.length - 1]["timestamp"];
return `/matches?before=${lastTimestamp}`; const res = await fetch(`/api/matches/${matchId}/log`, {
headers: {
"Content-Type": "application/json",
},
});
let log = await res.text();
return log;
} }
function setViewMode(viewMode_: ViewMode) {
selectedMatchId = undefined;
selectedMatchLog = undefined;
viewMode = viewMode_;
}
function selectRules() {
selectedMatchId = undefined;
selectedMatchLog = undefined;
viewMode = ViewMode.Rules;
}
function formatMatchTimestamp(timestampString: string): string {
let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal();
if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) {
return timestamp.toFormat("HH:mm");
} else {
return timestamp.toFormat("dd/MM");
}
}
$: selectedMatch = matches.find((m) => m["id"] === selectedMatchId);
</script> </script>
<div class="container"> <div class="container">
<div class="introduction"> <div class="sidebar-left">
<h2>Welcome to PlanetWars!</h2> <div
<p> class="editor-button sidebar-item"
Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that class:selected={viewMode === ViewMode.Editor}
will conquer the galaxy for you, while you take care of more important stuff. on:click={() => setViewMode(ViewMode.Editor)}
</p> >
<p> Editor
Feel free to watch some games below to see what it's all about. When you are ready to try
writing your own bot, head over to
<a href="/docs">How to play</a> for instructions. You can program your bot in the browser
using the <a href="/editor">Editor</a>.
</p>
</div> </div>
<h2>Recent matches</h2> <div
<MatchList {matches} /> class="rules-button sidebar-item"
<div class="see-more-container"> class:selected={viewMode === ViewMode.Rules}
<LinkButton href={viewMoreUrl}>View more</LinkButton> 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}
<li
class="match-card sidebar-item"
on:click={() => selectMatch(match.id)}
class:selected={match.id === selectedMatchId}
>
<span class="match-timestamp">{formatMatchTimestamp(match.timestamp)}</span>
<!-- hex is hardcoded for now, don't show map name -->
<!-- <span class="match-mapname">hex</span> -->
<!-- ugly temporary hardcode -->
<span class="match-opponent">{match["players"][1]["bot_name"]}</span>
</li>
{/each}
</ul>
</div>
<div class="editor-container">
{#if viewMode === ViewMode.MatchVisualizer}
<Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} />
{:else if viewMode === ViewMode.Editor}
<EditorView {editSession} />
{:else if viewMode === ViewMode.Rules}
<RulesView />
{:else if viewMode === ViewMode.Leaderboard}
<Leaderboard />
{/if}
</div>
<div class="sidebar-right">
{#if viewMode === ViewMode.MatchVisualizer}
<OutputPane matchLog={selectedMatchLog} />
{:else if viewMode === ViewMode.Editor}
<SubmitPane {editSession} on:matchCreated={onMatchCreated} />
{/if}
</div> </div>
</div> </div>
<style scoped lang="scss"> <style lang="scss">
@import "src/styles/variables.scss";
.container { .container {
max-width: 800px; display: flex;
margin: 0 auto; flex-grow: 1;
min-height: 0;
} }
.introduction { .sidebar-left {
padding-top: 16px; width: 240px;
a { background-color: $bg-color;
color: rgb(9, 105, 218); display: flex;
text-decoration: none; flex-direction: column;
}
.sidebar-right {
width: 400px;
background-color: white;
border-left: 1px solid;
padding: 0;
display: flex;
overflow: hidden;
}
.editor-container {
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
background-color: white;
} }
a:hover { .editor-container {
text-decoration: underline; height: 100%;
}
} }
.see-more-container { .sidebar-item {
padding: 24px; color: #eee;
text-align: center; padding: 15px;
}
.sidebar-item:hover {
background-color: #333;
}
.sidebar-item.selected {
background-color: #333;
}
.match-list {
list-style: none;
color: #eee;
padding-top: 15px;
overflow-y: scroll;
}
.match-card {
padding: 10px 15px;
font-size: 11pt;
}
.match-timestamp {
color: #ccc;
}
.match-opponent {
padding: 0 0.5em;
}
.sidebar-header {
margin-top: 2em;
text-transform: uppercase;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-family: "Open Sans", sans-serif;
padding-left: 14px;
} }
</style> </style>

View file

@ -1,28 +0,0 @@
<script lang="ts" context="module">
import { ApiClient } from "$lib/api_client";
export async function load({ fetch }) {
try {
const apiClient = new ApiClient(fetch);
const leaderboard = await apiClient.get("/api/leaderboard");
return {
props: {
leaderboard,
},
};
} catch (error) {
return {
status: error.status,
error: error,
};
}
}
</script>
<script lang="ts">
import Leaderboard from "$lib/components/Leaderboard.svelte";
export let leaderboard: object[];
</script>
<Leaderboard {leaderboard} />

View file

@ -1,44 +1,31 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { ApiClient } from "$lib/api_client"; export async function load({ page }) {
export async function load({ params, fetch }) { const res = await fetch(`/api/matches/${page.params["match_id"]}`, {
try { headers: {
const matchId = params["match_id"]; "Content-Type": "application/json",
const apiClient = new ApiClient(fetch); },
const [matchData, matchLog] = await Promise.all([ });
apiClient.get(`/api/matches/${matchId}`),
apiClient.getText(`/api/matches/${matchId}/log`), if (res.ok) {
]);
return { return {
props: { props: {
matchData: matchData, matchLog: await res.text(),
matchLog: matchLog,
}, },
}; };
} catch (error) {
return {
status: error.status,
error: error,
};
} }
return {
status: res.status,
error: new Error("failed to load match"),
};
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import Visualizer from "$lib/components/Visualizer.svelte"; import Visualizer from "$lib/components/Visualizer.svelte";
export let matchLog: string; export let matchLog: string;
export let matchData: object;
</script> </script>
<div class="container"> <div>
<Visualizer {matchLog} {matchData} /> <Visualizer {matchLog} />
</div> </div>
<style lang="scss">
.container {
display: flex;
// these are needed for making the visualizer fill the screen.
min-height: 0;
flex-grow: 1;
overflow: hidden;
}
</style>

View file

@ -1,121 +1,36 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { ApiClient } from "$lib/api_client"; export async function load() {
const res = await fetch("/api/matches", {
const PAGE_SIZE = "50"; headers: {
"Content-Type": "application/json",
export async function load({ url, fetch }) { },
try { });
const apiClient = new ApiClient(fetch);
const botName = url.searchParams.get("bot");
let query = {
count: PAGE_SIZE,
before: url.searchParams.get("before"),
after: url.searchParams.get("after"),
bot: botName,
};
let { matches, has_next } = await apiClient.get("/api/matches", removeUndefined(query));
// TODO: should this be done client-side?
if (query["after"]) {
matches = matches.reverse();
}
if (res.ok) {
return { return {
props: { props: {
matches, matches: await res.json(),
botName,
hasNext: has_next,
query,
}, },
}; };
} catch (error) {
return {
status: error.status,
error: new Error("failed to load matches"),
};
}
} }
function removeUndefined(obj: Record<string, string>): Record<string, string> { return {
Object.keys(obj).forEach((key) => { status: res.status,
if (obj[key] === undefined || obj[key] === null) { error: new Error("failed to load matches"),
delete obj[key]; };
}
});
return obj;
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import LinkButton from "$lib/components/LinkButton.svelte"; import dayjs from "dayjs";
import MatchList from "$lib/components/matches/MatchList.svelte"; export let matches;
export let matches: object[];
export let botName: string | null;
// whether a next page exists in the current iteration direction (before/after)
export let hasNext: boolean;
export let query: object;
type Cursor = {
before?: string;
after?: string;
};
function pageLink(cursor: Cursor) {
let paramsObj = {
...cursor,
};
if (botName) {
paramsObj["bot"] = botName;
}
const params = new URLSearchParams(paramsObj);
return `?${params}`;
}
function olderMatchesLink(matches: object[]): string {
if (matches.length == 0 || (query["before"] && !hasNext)) {
return null;
}
const lastTimestamp = matches[matches.length - 1]["timestamp"];
return pageLink({ before: lastTimestamp });
}
function newerMatchesLink(matches: object[]): string {
if (
matches.length == 0 ||
(query["after"] && !hasNext) ||
// we are viewing the first page, so there should be no newer matches.
// alternatively, we could show a "refresh" here.
(!query["before"] && !query["after"])
) {
return null;
}
const firstTimestamp = matches[0]["timestamp"];
return pageLink({ after: firstTimestamp });
}
</script> </script>
<div class="container"> <a href="/matches/new">new match</a>
<MatchList {matches} /> <ul>
<div class="page-controls"> {#each matches as match}
<div class="btn-group"> <li>
<LinkButton href={newerMatchesLink(matches)}>Newer</LinkButton> <a href="/matches/{match['id']}">{dayjs(match["created_at"]).format("YYYY-MM-DD HH:mm")}</a>
<LinkButton href={olderMatchesLink(matches)}>Older</LinkButton> </li>
</div> {/each}
</div> </ul>
</div>
<style lang="scss">
.container {
width: 800px;
margin: 0 auto;
}
.page-controls {
display: flex;
justify-content: center;
margin: 24px 0;
}
</style>

View file

@ -1,5 +1,6 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { get_session_token } from "$lib/auth"; import { get_session_token } from "$lib/auth";
import { mount_component } from "svelte/internal";
export async function load({ page }) { export async function load({ page }) {
const token = get_session_token(); const token = get_session_token();

View file

@ -2,17 +2,3 @@ body {
margin: 0; margin: 0;
font-family: Roboto, Helvetica, sans-serif; font-family: Roboto, Helvetica, sans-serif;
} }
/* generic scrollbar styling for chrome & friends */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-thumb {
background: #bdbdbd;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #6e6e6e;
}

View file

@ -1,111 +0,0 @@
<script lang="ts" context="module">
export async function load({ params, fetch }) {
const userName = params["user_name"];
const userBotsResponse = await fetch(`/api/users/${userName}/bots`);
return {
props: {
userName,
bots: await userBotsResponse.json(),
},
};
// return {
// status: matchDataResponse.status,
// error: new Error("failed to load match"),
// };
}
</script>
<script lang="ts">
import { currentUser } from "$lib/stores/current_user";
export let userName: string;
export let bots: object[];
</script>
<div class="container">
<div class="header">
<h1 class="user-name">{userName}</h1>
</div>
<div class="bot-list-header">
<h2 class="bot-list-header-title">Bots</h2>
{#if $currentUser && $currentUser.username == userName}
<a href="/bots/new" class="btn-new-bot"> New bot </a>
{/if}
</div>
<ul class="bot-list">
{#each bots as bot}
<li class="bot">
<a class="bot-name" href="/bots/{bot['name']}">{bot["name"]}</a>
</li>
{/each}
</ul>
{#if bots.length == 0}
This user does not have any bots yet.
{/if}
</div>
<style lang="scss">
.container {
width: 800px;
max-width: 80%;
margin: 50px auto;
}
.header {
margin-bottom: 60px;
border-bottom: 1px solid black;
}
.user-name {
margin-bottom: 0.5em;
}
.bot-list-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.bot-list-header-title {
margin-bottom: 0px;
}
.btn-new-bot {
padding: 8px 12px;
border-radius: 4px;
border: 0;
display: block;
color: white;
background-color: rgb(40, 167, 69);
font-weight: 500;
text-decoration: none;
font-size: 11pt;
cursor: pointer;
}
.bot-list {
list-style: none;
padding: 0;
}
$border-color: #d0d7de;
.bot {
display: block;
padding: 24px 0;
border-bottom: 1px solid $border-color;
}
.bot-name {
font-size: 20px;
font-weight: 400;
text-decoration: none;
color: black;
}
.bot:first-child {
border-top: 1px solid $border-color;
}
</style>

View file

@ -1,33 +0,0 @@
@use "./variables";
$btn-text-color: variables.$blue-primary;
$btn-border-color: variables.$light-grey;
.btn {
color: $btn-text-color;
font-size: 14px;
text-decoration: none;
padding: 6px 16px;
border: 1px solid $btn-border-color;
border-radius: 5px;
}
.btn.btn-disabled {
color: $btn-border-color;
}
.btn-group {
display: flex;
}
.btn-group .btn:not(:last-child) {
border-right: none;
}
.btn-group .btn:first-child {
border-radius: 5px 0 0 5px;
}
.btn-group .btn:last-child {
border-radius: 0 5px 5px 0;
}

View file

@ -1,4 +0,0 @@
@forward "./variables.scss";
@forward "./buttons.scss";
@forward "./markdown.scss";
@forward "./prism.scss";

View file

@ -1,44 +0,0 @@
@use "./variables.scss";
.markdown-body {
color: rgb(36, 41, 47);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Open Sans", "Helvetica Neue", sans-serif;
h2 {
margin-top: 1.5em;
}
h3 {
margin-top: 1.2em;
}
a {
color: variables.$blue-primary;
}
code {
color: darken(#e3116c, 5%);
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
font-size: 0.9em;
line-height: 1.2em;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
li {
margin-bottom: 1em;
}
}

View file

@ -1,126 +0,0 @@
/**
* GHColors theme by Avi Aryan (http://aviaryan.in)
* Inspired by Github syntax coloring
*/
code[class*="language-"],
pre[class*="language-"] {
color: #393a34;
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
font-size: 0.9em;
line-height: 1.2em;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre > code[class*="language-"] {
font-size: 1em;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
background: #b3d4fc;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
background: #b3d4fc;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border: 1px solid #dddddd;
background-color: white;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.2em;
padding-top: 1px;
padding-bottom: 1px;
background: #f8f8f8;
border: 1px solid #dddddd;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #999988;
font-style: italic;
}
.token.namespace {
opacity: 0.7;
}
.token.string,
.token.attr-value {
color: #e3116c;
}
.token.punctuation,
.token.operator {
color: #393a34; /* no highlight */
}
.token.entity,
.token.url,
.token.symbol,
.token.number,
.token.boolean,
.token.variable,
.token.constant,
.token.property,
.token.regex,
.token.inserted {
color: #36acaa;
}
.token.atrule,
.token.keyword,
.token.attr-name,
.language-autohotkey .token.selector {
color: #00a4db;
}
.token.function,
.token.deleted,
.language-autohotkey .token.tag {
color: #9a050f;
}
.token.tag,
.token.selector,
.language-autohotkey .token.keyword {
color: #00009f;
}
.token.important,
.token.function,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}

Some files were not shown because too many files have changed in this diff Show more