Compare commits
No commits in common. "main" and "bot-api" have entirely different histories.
115 changed files with 2433 additions and 4530 deletions
30
README.md
30
README.md
|
@ -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
25
planetwars-cli/Cargo.toml
Normal 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
57
planetwars-cli/README.md
Normal 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!
|
43
planetwars-cli/assets/hex.json
Normal file
43
planetwars-cli/assets/hex.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
6
planetwars-cli/assets/pw_workspace.toml
Normal file
6
planetwars-cli/assets/pw_workspace.toml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[paths]
|
||||||
|
maps_dir = "maps"
|
||||||
|
matches_dir = "matches"
|
||||||
|
|
||||||
|
[bots.simplebot]
|
||||||
|
path = "bots/simplebot"
|
2
planetwars-cli/assets/simplebot/botconfig.toml
Normal file
2
planetwars-cli/assets/simplebot/botconfig.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
name = "simplebot"
|
||||||
|
run_command = "python3 simplebot.py"
|
33
planetwars-cli/assets/simplebot/simplebot.py
Normal file
33
planetwars-cli/assets/simplebot/simplebot.py
Normal 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
|
||||||
|
})
|
6
planetwars-cli/src/bin/pwcli.rs
Normal file
6
planetwars-cli/src/bin/pwcli.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
use planetwars_cli;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
planetwars_cli::run().await
|
||||||
|
}
|
27
planetwars-cli/src/commands/build.rs
Normal file
27
planetwars-cli/src/commands/build.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
38
planetwars-cli/src/commands/init.rs
Normal file
38
planetwars-cli/src/commands/init.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
40
planetwars-cli/src/commands/mod.rs
Normal file
40
planetwars-cli/src/commands/mod.rs
Normal 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),
|
||||||
|
}
|
51
planetwars-cli/src/commands/run_match.rs
Normal file
51
planetwars-cli/src/commands/run_match.rs
Normal 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, ×tamp));
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
17
planetwars-cli/src/commands/serve.rs
Normal file
17
planetwars-cli/src/commands/serve.rs
Normal 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
11
planetwars-cli/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
175
planetwars-cli/src/web/mod.rs
Normal file
175
planetwars-cli/src/web/mod.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
planetwars-cli/src/workspace/bot.rs
Normal file
50
planetwars-cli/src/workspace/bot.rs
Normal 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.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
77
planetwars-cli/src/workspace/mod.rs
Normal file
77
planetwars-cli/src/workspace/mod.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
44
planetwars-matchrunner/src/bin/testmatch.rs
Normal file
44
planetwars-matchrunner/src/bin/testmatch.rs
Normal 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;
|
||||||
|
}
|
|
@ -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(¶ms.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),
|
||||||
|
|
|
@ -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()));
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- This file should undo anything in `up.sql`
|
|
||||||
ALTER TABLE bots DROP COLUMN active_version;
|
|
|
@ -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;
|
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE matches DROP COLUMN is_public;
|
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE matches ADD COLUMN is_public boolean NOT NULL DEFAULT false;
|
|
|
@ -1,3 +0,0 @@
|
||||||
ALTER TABLE matches DROP COLUMN map_id;
|
|
||||||
|
|
||||||
DROP TABLE maps;
|
|
|
@ -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);
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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| {
|
||||||
|
|
|
@ -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>>);
|
||||||
|
|
272
planetwars-server/src/modules/bot_api.rs
Normal file
272
planetwars-server/src/modules/bot_api.rs
Normal 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()
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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, ¶ms);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(¶ms.bot_name, &conn)
|
let res = bots::find_bot_by_name(¶ms.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(¶ms.code, Some(bot.id), &conn, &config)
|
let _code_bundle =
|
||||||
.expect("failed to save code bundle");
|
save_code_bundle(¶ms.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(¶ms.name)?;
|
|
||||||
let existing_bot = bots::find_bot_by_name(¶ms.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: ¶ms.name,
|
name: ¶ms.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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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(¶ms.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(¶ms.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);
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
36
proto/bot_api.proto
Normal 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);
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
|
@ -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;
|
||||||
|
|
129
web/pw-server/src/lib/components/RulesView.svelte
Normal file
129
web/pw-server/src/lib/components/RulesView.svelte
Normal 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>
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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);
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
74
web/pw-server/src/routes/bots/[bot_id].svelte
Normal file
74
web/pw-server/src/routes/bots/[bot_id].svelte
Normal 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>
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -1,10 +0,0 @@
|
||||||
<div class="container markdown-body">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "src/styles/variables";
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,8 +0,0 @@
|
||||||
<script context="module">
|
|
||||||
export async function load() {
|
|
||||||
return {
|
|
||||||
status: 302,
|
|
||||||
redirect: "/docs/rules",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -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.
|
|
|
@ -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!
|
|
|
@ -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>
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
@forward "./variables.scss";
|
|
||||||
@forward "./buttons.scss";
|
|
||||||
@forward "./markdown.scss";
|
|
||||||
@forward "./prism.scss";
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
Loading…
Reference in a new issue