Compare commits
161 commits
feature/le
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
9e05a9bdd5 | ||
|
6887c8ee0e | ||
|
3138eca6d0 | ||
|
3be0cfa0ea | ||
|
f5fe1c4f29 | ||
|
518ad1d811 | ||
|
a70689faa9 | ||
|
36c16aa8c7 | ||
|
ecd378f0d9 | ||
|
46efe3c5fa | ||
|
1a5755f0a3 | ||
|
3eedbc0b85 | ||
|
380a1d8f4e | ||
|
7f1b6c06b6 | ||
|
66162ea43c | ||
|
9e574f08ee | ||
|
3058028edc | ||
|
78a6032b60 | ||
|
8eeb81bd5a | ||
|
d68731a114 | ||
|
dcb0a2bde8 | ||
|
2fec5e4509 | ||
|
d95eedcc83 | ||
|
e8e353192c | ||
|
c6c484daf3 | ||
|
2aab23c40f | ||
|
09ce75d5bf | ||
|
0dbaf29d2f | ||
|
0ce40a9f31 | ||
|
49a5735e07 | ||
|
c80ce33279 | ||
|
e26f13c8bb | ||
|
624fa99fad | ||
|
f727613efb | ||
|
aa066ef5bb | ||
|
fa4c684475 | ||
|
82ab9cef78 | ||
|
947ced152e | ||
|
a5399728c1 | ||
|
8eec57f560 | ||
|
64d24c9e3d | ||
|
71d37e758f | ||
|
329fc73b94 | ||
|
87f6a9c455 | ||
|
03ca884c40 | ||
|
a1c7866f87 | ||
|
4dcccb589d | ||
|
7ba9fcee64 | ||
|
84748fd60e | ||
|
7c4314ae23 | ||
|
406c726601 | ||
|
58c1c5f9fb | ||
|
00356d669c | ||
|
cf52ab6f7f | ||
|
db7980504f | ||
|
6f0c1093ac | ||
|
4672a08462 | ||
|
70c79646ae | ||
|
6e75cac7cc | ||
|
3113f762d8 | ||
|
3c2f4977e4 | ||
|
aafb785645 | ||
|
8f65a7d3e2 | ||
|
15c1aa9d59 | ||
|
e8dbb01933 | ||
|
ee5c67c092 | ||
|
fd6664b8e7 | ||
|
18aede91be | ||
|
1d280c62e2 | ||
|
4099e3ab6e | ||
|
c30222cf9a | ||
|
67276bd0bb | ||
|
93c4306b10 | ||
|
14b51033fc | ||
|
90dfc3dec4 | ||
|
99987f8444 | ||
|
109aaf2758 | ||
|
ccfe86729e | ||
|
33664eff2c | ||
|
4a582e8079 | ||
|
f19a70e710 | ||
|
5e560b23f8 | ||
|
500061375c | ||
|
1cf20810c5 | ||
|
fe2f382e04 | ||
|
b0725c21df | ||
|
1011015b29 | ||
|
b84e9be9d6 | ||
|
c6293d8e32 | ||
|
31f8271db6 | ||
|
73c536b4a6 | ||
|
f058000072 | ||
|
d4c52c3e99 | ||
|
d6acb6e071 | ||
|
338dc6ac63 | ||
|
7daf8f6437 | ||
|
d092f5d89c | ||
|
6e494aca46 | ||
|
270476e038 | ||
|
e5cb04208f | ||
|
09c543eee3 | ||
|
c16b068f8b | ||
|
dad19548d1 | ||
|
0cf7b5299d | ||
|
d13d131130 | ||
|
ec5c91d37b | ||
|
00459f9e3d | ||
|
668409e76d | ||
|
e69bd14f1d | ||
|
0b9a9f0eaa | ||
|
ec1d50f655 | ||
|
7eb02a2efc | ||
|
0f14dee499 | ||
|
6ec792e3bd | ||
|
d7b7585dd7 | ||
|
b3df5c6f8c | ||
|
8a47b948eb | ||
|
ea05674b44 | ||
|
268e080ec1 | ||
|
bbed877554 | ||
|
608d05bc16 | ||
|
7b88bb0502 | ||
|
419029738d | ||
|
4d1c0a3289 | ||
|
d7e4a1fd5c | ||
|
f6fca3818a | ||
|
381ce040fd | ||
|
059cd4fa0e | ||
|
951cb29311 | ||
|
a2a8a41689 | ||
|
478094abcf | ||
|
2cde7ec673 | ||
|
b90b3d3635 | ||
|
dde0bc820e | ||
|
7a3b801f58 | ||
|
cf248ff41a | ||
|
a376698073 | ||
|
5ee66c9c9b | ||
|
d1977b95c8 | ||
|
e3cf0df450 | ||
|
1b2472fbfc | ||
|
028d4a99e4 | ||
|
ff061f2a7a | ||
|
69421d7b25 | ||
|
2f915af919 | ||
|
d0faec7d1f | ||
|
90ecb13a17 | ||
|
b2bfe988b4 | ||
|
9087daa205 | ||
|
f899fba8ad | ||
|
c3d32e051c | ||
|
0f80b19614 | ||
|
af5cd69f7b | ||
|
ef19e3a9e7 | ||
|
6e1167ee9e | ||
|
80c60ac69c | ||
|
643c0e7706 | ||
|
6885361204 | ||
|
4d6a9591d8 | ||
|
b1151f6ac7 | ||
|
c873f3a1cb |
119 changed files with 4727 additions and 2097 deletions
|
@ -3,6 +3,6 @@
|
|||
members = [
|
||||
"planetwars-rules",
|
||||
"planetwars-matchrunner",
|
||||
"planetwars-cli",
|
||||
"planetwars-server",
|
||||
"planetwars-client",
|
||||
]
|
||||
|
|
30
README.md
30
README.md
|
@ -1,9 +1,31 @@
|
|||
# mozaic4
|
||||
# planetwars
|
||||
|
||||
Because third time's the charm!
|
||||
Planetwars is a competitive programming game. You implement a bot that will be pitted against all other bots.
|
||||
|
||||
Project layout:
|
||||
Try it out at https://planetwars.dev !
|
||||
|
||||
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-matchrunner`: implements the game
|
||||
- `planetwars-matchrunner`: code for running matches
|
||||
- `planetwars-rules`: implements the game rules
|
||||
- `planetwars-client`: for running your bot locally
|
||||
- `web/pw-server`: frontend
|
||||
- `web/pw-visualizer`: code for the visualizer
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
[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"
|
|
@ -1,57 +0,0 @@
|
|||
# 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!
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
[paths]
|
||||
maps_dir = "maps"
|
||||
matches_dir = "matches"
|
||||
|
||||
[bots.simplebot]
|
||||
path = "bots/simplebot"
|
|
@ -1,2 +0,0 @@
|
|||
name = "simplebot"
|
||||
run_command = "python3 simplebot.py"
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
})
|
|
@ -1,6 +0,0 @@
|
|||
use planetwars_cli;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
planetwars_cli::run().await
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
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),
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -1,175 +0,0 @@
|
|||
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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
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.")
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
20
planetwars-client/Cargo.toml
Normal file
20
planetwars-client/Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "planetwars-client"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.15", features = ["full"] }
|
||||
tokio-stream = "0.1.9"
|
||||
prost = "0.10"
|
||||
tonic = { version = "0.7.2", features = ["tls", "tls-roots"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.5"
|
||||
planetwars-matchrunner = { path = "../planetwars-matchrunner" }
|
||||
clap = { version = "3.2", features = ["derive", "env"]}
|
||||
shlex = "1.1"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.7.2"
|
24
planetwars-client/README.md
Normal file
24
planetwars-client/README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
# 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
|
9
planetwars-client/build.rs
Normal file
9
planetwars-client/build.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
extern crate tonic_build;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_build::configure()
|
||||
.build_server(false)
|
||||
.build_client(true)
|
||||
.compile(&["../proto/client_api.proto"], &["../proto"])?;
|
||||
Ok(())
|
||||
}
|
2
planetwars-client/simplebot.toml
Normal file
2
planetwars-client/simplebot.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
name = "simplebot"
|
||||
command = ["python", "../simplebot/simplebot.py"]
|
144
planetwars-client/src/main.rs
Normal file
144
planetwars-client/src/main.rs
Normal file
|
@ -0,0 +1,144 @@
|
|||
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 clap::Parser;
|
||||
use pb::client_api_service_client::ClientApiServiceClient;
|
||||
use planetwars_matchrunner::bot_runner::Bot;
|
||||
use serde::Deserialize;
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
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)]
|
||||
struct BotConfig {
|
||||
#[allow(dead_code)]
|
||||
name: Option<String>,
|
||||
command: Command,
|
||||
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]
|
||||
async fn main() {
|
||||
let play_match = PlayMatch::parse();
|
||||
|
||||
let content = std::fs::read_to_string(play_match.bot_config_path).unwrap();
|
||||
let bot_config: BotConfig = toml::from_str(&content).unwrap();
|
||||
|
||||
let uri = play_match
|
||||
.grpc_server_url
|
||||
.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
|
||||
.unwrap();
|
||||
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;
|
||||
}
|
||||
|
||||
async fn create_match(
|
||||
channel: Channel,
|
||||
opponent_name: String,
|
||||
map_name: Option<String>,
|
||||
) -> Result<pb::CreateMatchResponse, Status> {
|
||||
let mut client = ClientApiServiceClient::new(channel);
|
||||
let res = client
|
||||
.create_match(Request::new(pb::CreateMatchRequest {
|
||||
opponent_name,
|
||||
map_name: map_name.unwrap_or_default(),
|
||||
}))
|
||||
.await;
|
||||
res.map(|response| response.into_inner())
|
||||
}
|
||||
|
||||
async fn run_player(bot_config: BotConfig, player_key: String, channel: Channel) {
|
||||
let mut client = ClientApiServiceClient::with_interceptor(channel, |mut req: Request<()>| {
|
||||
let player_key: MetadataValue<_> = player_key.parse().unwrap();
|
||||
req.metadata_mut().insert("player_key", player_key);
|
||||
Ok(req)
|
||||
});
|
||||
|
||||
let mut bot_process = Bot {
|
||||
working_dir: PathBuf::from(
|
||||
bot_config
|
||||
.working_directory
|
||||
.unwrap_or_else(|| ".".to_string()),
|
||||
),
|
||||
argv: bot_config.command.to_argv(),
|
||||
}
|
||||
.spawn_process();
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let mut stream = client
|
||||
.connect_player(UnboundedReceiverStream::new(rx))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
while let Some(message) = stream.message().await.unwrap() {
|
||||
match message.server_message {
|
||||
Some(pb::PlayerApiServerMessageType::ActionRequest(req)) => {
|
||||
let moves = bot_process.communicate(&req.content).await.unwrap();
|
||||
let action = pb::PlayerAction {
|
||||
action_request_id: req.action_request_id,
|
||||
content: moves.as_bytes().to_vec(),
|
||||
};
|
||||
let msg = pb::PlayerApiClientMessage {
|
||||
client_message: Some(pb::PlayerApiClientMessageType::Action(action)),
|
||||
};
|
||||
tx.send(msg).unwrap();
|
||||
}
|
||||
_ => {} // pass
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,9 +5,6 @@ edition = "2021"
|
|||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[[bin]]
|
||||
name = "testmatch"
|
||||
|
||||
|
||||
[dependencies]
|
||||
futures-core = "0.3"
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
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,5 +1,4 @@
|
|||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
@ -16,11 +15,22 @@ use crate::match_context::{EventBus, PlayerHandle, RequestError, RequestMessage}
|
|||
use crate::match_log::{MatchLogMessage, MatchLogger, StdErrMessage};
|
||||
use crate::BotSpec;
|
||||
|
||||
// TODO: this API needs a better design with respect to pulling
|
||||
// and general container management
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DockerBotSpec {
|
||||
pub image: String,
|
||||
pub code_path: PathBuf,
|
||||
pub argv: Vec<String>,
|
||||
pub binds: Option<Vec<String>>,
|
||||
pub argv: Option<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]
|
||||
|
@ -42,21 +52,52 @@ async fn spawn_docker_process(
|
|||
params: &DockerBotSpec,
|
||||
) -> Result<ContainerProcess, bollard::errors::Error> {
|
||||
let docker = Docker::connect_with_socket_defaults()?;
|
||||
let bot_code_dir = std::fs::canonicalize(¶ms.code_path).unwrap();
|
||||
let code_dir_str = bot_code_dir.as_os_str().to_str().unwrap();
|
||||
|
||||
if params.pull {
|
||||
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 config = container::Config {
|
||||
image: Some(params.image.clone()),
|
||||
host_config: Some(bollard::models::HostConfig {
|
||||
binds: Some(vec![format!("{}:{}", code_dir_str, "/workdir")]),
|
||||
binds: params.binds.clone(),
|
||||
network_mode: Some("none".to_string()),
|
||||
memory: Some(memory_limit),
|
||||
memory_swap: Some(memory_limit),
|
||||
// TODO: this seems to have caused weird delays when executing bots
|
||||
// on the production server. A solution should still be found, though.
|
||||
// cpu_period: Some(100_000),
|
||||
// cpu_quota: Some(10_000),
|
||||
..Default::default()
|
||||
}),
|
||||
working_dir: Some("/workdir".to_string()),
|
||||
cmd: Some(params.argv.clone()),
|
||||
working_dir: params.working_dir.clone(),
|
||||
cmd: params.argv.clone(),
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
open_stdin: Some(true),
|
||||
network_disabled: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
@ -85,16 +126,35 @@ async fn spawn_docker_process(
|
|||
.await?;
|
||||
|
||||
Ok(ContainerProcess {
|
||||
docker,
|
||||
container_id,
|
||||
stdin: input,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
struct ContainerProcess {
|
||||
docker: Docker,
|
||||
container_id: String,
|
||||
stdin: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||
}
|
||||
|
||||
impl ContainerProcess {
|
||||
// &mut is required here to make terminate().await Sync
|
||||
async fn terminate(&mut self) -> Result<(), bollard::errors::Error> {
|
||||
self.docker
|
||||
.remove_container(
|
||||
&self.container_id,
|
||||
Some(bollard::container::RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn create_docker_bot(
|
||||
process: ContainerProcess,
|
||||
player_id: u32,
|
||||
|
@ -151,6 +211,11 @@ impl DockerBotRunner {
|
|||
.unwrap()
|
||||
.resolve_request(request_id, request_response);
|
||||
}
|
||||
|
||||
self.process
|
||||
.terminate()
|
||||
.await
|
||||
.expect("could not terminate process");
|
||||
}
|
||||
|
||||
pub async fn communicate(&mut self, input: &[u8]) -> io::Result<Bytes> {
|
||||
|
|
|
@ -58,7 +58,7 @@ pub struct MatchOutcome {
|
|||
pub async fn run_match(config: MatchConfig) -> MatchOutcome {
|
||||
let pw_config = PwConfig {
|
||||
map_file: config.map_path,
|
||||
max_turns: 100,
|
||||
max_turns: 500,
|
||||
};
|
||||
|
||||
let event_bus = Arc::new(Mutex::new(EventBus::new()));
|
||||
|
|
|
@ -6,6 +6,8 @@ use tokio::{fs::File, io::AsyncWriteExt};
|
|||
use planetwars_rules::protocol::State;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::pw_match::PlayerCommand;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MatchLogMessage {
|
||||
|
@ -13,6 +15,19 @@ pub enum MatchLogMessage {
|
|||
GameState(State),
|
||||
#[serde(rename = "stderr")]
|
||||
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)]
|
||||
|
|
|
@ -12,7 +12,7 @@ use std::convert::TryInto;
|
|||
|
||||
pub use planetwars_rules::config::{Config, Map};
|
||||
|
||||
use planetwars_rules::protocol::{self as proto, PlayerAction};
|
||||
use planetwars_rules::protocol as proto;
|
||||
use planetwars_rules::serializer as pw_serializer;
|
||||
use planetwars_rules::{PlanetWars, PwConfig};
|
||||
|
||||
|
@ -40,22 +40,18 @@ impl PwMatch {
|
|||
}
|
||||
|
||||
pub async fn run(&mut self) {
|
||||
// log initial state
|
||||
self.log_game_state();
|
||||
|
||||
while !self.match_state.is_finished() {
|
||||
let player_messages = self.prompt_players().await;
|
||||
|
||||
for (player_id, turn) in player_messages {
|
||||
let res = self.execute_action(player_id, turn);
|
||||
if let Some(err) = action_errors(res) {
|
||||
let _info_str = serde_json::to_string(&err).unwrap();
|
||||
// TODO
|
||||
// println!("player {}: {}", player_id, info_str);
|
||||
}
|
||||
let player_action = self.execute_action(player_id, turn);
|
||||
self.log_player_action(player_id, player_action);
|
||||
}
|
||||
self.match_state.step();
|
||||
|
||||
// Log state
|
||||
let state = self.match_state.serialize_state();
|
||||
self.match_ctx.log(MatchLogMessage::GameState(state));
|
||||
self.log_game_state();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,18 +84,14 @@ impl PwMatch {
|
|||
.await
|
||||
}
|
||||
|
||||
fn execute_action(
|
||||
&mut self,
|
||||
player_num: usize,
|
||||
turn: RequestResult<Vec<u8>>,
|
||||
) -> proto::PlayerAction {
|
||||
let turn = match turn {
|
||||
Err(_timeout) => return proto::PlayerAction::Timeout,
|
||||
fn execute_action(&mut self, player_num: usize, turn: RequestResult<Vec<u8>>) -> PlayerAction {
|
||||
let data = match turn {
|
||||
Err(_timeout) => return PlayerAction::Timeout,
|
||||
Ok(data) => data,
|
||||
};
|
||||
|
||||
let action: proto::Action = match serde_json::from_slice(&turn) {
|
||||
Err(err) => return proto::PlayerAction::ParseError(err.to_string()),
|
||||
let action: proto::Action = match serde_json::from_slice(&data) {
|
||||
Err(error) => return PlayerAction::ParseError { data, error },
|
||||
Ok(action) => action,
|
||||
};
|
||||
|
||||
|
@ -108,15 +100,64 @@ impl PwMatch {
|
|||
.into_iter()
|
||||
.map(|command| {
|
||||
let res = self.match_state.execute_command(player_num, &command);
|
||||
proto::PlayerCommand {
|
||||
PlayerCommand {
|
||||
command,
|
||||
error: res.err(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
proto::PlayerAction::Commands(commands)
|
||||
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> {
|
||||
|
|
|
@ -84,7 +84,6 @@ impl PlanetWars {
|
|||
.ok_or(CommandError::DestinationDoesNotExist)?;
|
||||
|
||||
if self.state.planets[origin_id].owner() != Some(player_id - 1) {
|
||||
println!("owner was {:?}", self.state.planets[origin_id].owner());
|
||||
return Err(CommandError::OriginNotOwned);
|
||||
}
|
||||
|
||||
|
|
|
@ -49,31 +49,3 @@ pub enum CommandError {
|
|||
OriginDoesNotExist,
|
||||
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,13 +2,23 @@
|
|||
name = "planetwars-server"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
default-run = "planetwars-server"
|
||||
|
||||
# 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]
|
||||
futures = "0.3"
|
||||
tokio = { version = "1.15", features = ["full"] }
|
||||
tokio-stream = "0.1.9"
|
||||
hyper = "0.14"
|
||||
axum = { version = "0.4", features = ["json", "headers", "multipart"] }
|
||||
axum = { version = "0.5", features = ["json", "headers", "multipart"] }
|
||||
diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
|
||||
diesel-derive-enum = { version = "1.1", features = ["postgres"] }
|
||||
bb8 = "0.7"
|
||||
|
@ -26,9 +36,17 @@ toml = "0.5"
|
|||
planetwars-matchrunner = { path = "../planetwars-matchrunner" }
|
||||
config = { version = "0.12", features = ["toml"] }
|
||||
thiserror = "1.0.31"
|
||||
sha2 = "0.10"
|
||||
tokio-util = { version="0.7.3", features=["io"] }
|
||||
prost = "0.10"
|
||||
tonic = "0.7.2"
|
||||
clap = { version = "3.2", features = ["derive", "env"]}
|
||||
|
||||
# TODO: remove me
|
||||
shlex = "1.1"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.7.2"
|
||||
|
||||
[dev-dependencies]
|
||||
parking_lot = "0.11"
|
||||
|
|
9
planetwars-server/build.rs
Normal file
9
planetwars-server/build.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
extern crate tonic_build;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_build::configure()
|
||||
.build_server(true)
|
||||
.build_client(false)
|
||||
.compile(&["../proto/client_api.proto"], &["../proto"])?;
|
||||
Ok(())
|
||||
}
|
|
@ -1 +1,16 @@
|
|||
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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE matches
|
||||
DROP column winner;
|
|
@ -0,0 +1,3 @@
|
|||
-- Your SQL goes here
|
||||
ALTER TABLE matches
|
||||
ADD COLUMN winner integer;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE match_players ALTER COLUMN code_bundle_id SET NOT NULL;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE match_players ALTER COLUMN code_bundle_id DROP NOT NULL;
|
|
@ -0,0 +1,6 @@
|
|||
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;
|
|
@ -0,0 +1,6 @@
|
|||
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;
|
|
@ -0,0 +1,2 @@
|
|||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE bots DROP COLUMN active_version;
|
|
@ -0,0 +1,12 @@
|
|||
-- 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;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE matches DROP COLUMN is_public;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE matches ADD COLUMN is_public boolean NOT NULL DEFAULT false;
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE matches DROP COLUMN map_id;
|
||||
|
||||
DROP TABLE maps;
|
|
@ -0,0 +1,7 @@
|
|||
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);
|
54
planetwars-server/src/cli.rs
Normal file
54
planetwars-server/src/cli.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
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 serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::{bots, code_bundles};
|
||||
use crate::schema::{bot_versions, bots};
|
||||
use chrono;
|
||||
|
||||
#[derive(Insertable)]
|
||||
|
@ -16,6 +16,7 @@ pub struct Bot {
|
|||
pub id: i32,
|
||||
pub owner_id: Option<i32>,
|
||||
pub name: String,
|
||||
pub active_version: Option<i32>,
|
||||
}
|
||||
|
||||
pub fn create_bot(new_bot: &NewBot, conn: &PgConnection) -> QueryResult<Bot> {
|
||||
|
@ -38,44 +39,79 @@ pub fn find_bot_by_name(name: &str, conn: &PgConnection) -> QueryResult<Bot> {
|
|||
bots::table.filter(bots::name.eq(name)).first(conn)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "code_bundles"]
|
||||
pub struct NewCodeBundle<'a> {
|
||||
pub bot_id: Option<i32>,
|
||||
pub path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Serialize, Deserialize, Debug)]
|
||||
pub struct CodeBundle {
|
||||
pub id: i32,
|
||||
pub bot_id: Option<i32>,
|
||||
pub path: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
pub fn create_code_bundle(
|
||||
new_code_bundle: &NewCodeBundle,
|
||||
pub fn find_bot_with_version_by_name(
|
||||
bot_name: &str,
|
||||
conn: &PgConnection,
|
||||
) -> QueryResult<CodeBundle> {
|
||||
diesel::insert_into(code_bundles::table)
|
||||
.values(new_code_bundle)
|
||||
.get_result(conn)
|
||||
) -> 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 find_bot_code_bundles(bot_id: i32, conn: &PgConnection) -> QueryResult<Vec<CodeBundle>> {
|
||||
code_bundles::table
|
||||
.filter(code_bundles::bot_id.eq(bot_id))
|
||||
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 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())
|
||||
pub fn find_all_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> {
|
||||
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)]
|
||||
#[table_name = "bot_versions"]
|
||||
pub struct NewBotVersion<'a> {
|
||||
pub bot_id: Option<i32>,
|
||||
pub code_bundle_path: Option<&'a str>,
|
||||
pub container_digest: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct BotVersion {
|
||||
pub id: i32,
|
||||
pub bot_id: Option<i32>,
|
||||
pub code_bundle_path: Option<String>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub container_digest: Option<String>,
|
||||
}
|
||||
|
||||
pub fn create_bot_version(
|
||||
new_bot_version: &NewBotVersion,
|
||||
conn: &PgConnection,
|
||||
) -> QueryResult<BotVersion> {
|
||||
diesel::insert_into(bot_versions::table)
|
||||
.values(new_bot_version)
|
||||
.get_result(conn)
|
||||
}
|
||||
|
||||
pub fn set_active_version(
|
||||
bot_id: i32,
|
||||
version_id: Option<i32>,
|
||||
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)
|
||||
}
|
||||
|
|
35
planetwars-server/src/db/maps.rs
Normal file
35
planetwars-server/src/db/maps.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
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,20 +1,27 @@
|
|||
pub use crate::db_types::MatchState;
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::associations::BelongsTo;
|
||||
use diesel::pg::Pg;
|
||||
use diesel::query_builder::BoxedSelectStatement;
|
||||
use diesel::query_source::{AppearsInFromClause, Once};
|
||||
use diesel::{
|
||||
BelongingToDsl, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl,
|
||||
};
|
||||
use diesel::{Connection, GroupedBy, PgConnection, QueryResult};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::schema::{bots, code_bundles, match_players, matches};
|
||||
use crate::schema::{bot_versions, bots, maps, match_players, matches};
|
||||
|
||||
use super::bots::{Bot, CodeBundle};
|
||||
use super::bots::{Bot, BotVersion};
|
||||
use super::maps::Map;
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[table_name = "matches"]
|
||||
pub struct NewMatch<'a> {
|
||||
pub state: MatchState,
|
||||
pub log_path: &'a str,
|
||||
pub is_public: bool,
|
||||
pub map_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
|
@ -25,7 +32,7 @@ pub struct NewMatchPlayer {
|
|||
/// player id within the match
|
||||
pub player_id: i32,
|
||||
/// id of the bot behind this player
|
||||
pub code_bundle_id: i32,
|
||||
pub bot_version_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Identifiable)]
|
||||
|
@ -35,6 +42,9 @@ pub struct MatchBase {
|
|||
pub state: MatchState,
|
||||
pub log_path: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub winner: Option<i32>,
|
||||
pub is_public: bool,
|
||||
pub map_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Identifiable, Associations, Clone)]
|
||||
|
@ -43,11 +53,11 @@ pub struct MatchBase {
|
|||
pub struct MatchPlayer {
|
||||
pub match_id: i32,
|
||||
pub player_id: i32,
|
||||
pub code_bundle_id: i32,
|
||||
pub code_bundle_id: Option<i32>,
|
||||
}
|
||||
|
||||
pub struct MatchPlayerData {
|
||||
pub code_bundle_id: i32,
|
||||
pub code_bundle_id: Option<i32>,
|
||||
}
|
||||
|
||||
pub fn create_match(
|
||||
|
@ -66,7 +76,7 @@ pub fn create_match(
|
|||
.map(|(num, player_data)| NewMatchPlayer {
|
||||
match_id: match_base.id,
|
||||
player_id: num as i32,
|
||||
code_bundle_id: player_data.code_bundle_id,
|
||||
bot_version_id: player_data.code_bundle_id,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
|
@ -86,13 +96,29 @@ pub struct MatchData {
|
|||
pub match_players: Vec<MatchPlayer>,
|
||||
}
|
||||
|
||||
pub fn list_matches(conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
|
||||
conn.transaction(|| {
|
||||
let matches = matches::table.get_results::<MatchBase>(conn)?;
|
||||
/// Add player information to MatchBase instances
|
||||
fn fetch_full_match_data(
|
||||
matches: Vec<MatchBase>,
|
||||
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)
|
||||
.inner_join(code_bundles::table)
|
||||
.left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
|
||||
.left_join(
|
||||
bot_versions::table.on(match_players::bot_version_id.eq(bot_versions::id.nullable())),
|
||||
)
|
||||
.left_join(bots::table.on(bot_versions::bot_id.eq(bots::id.nullable())))
|
||||
.order_by((
|
||||
match_players::match_id.asc(),
|
||||
match_players::player_id.asc(),
|
||||
))
|
||||
.load::<FullMatchPlayerData>(conn)?
|
||||
.grouped_by(&matches);
|
||||
|
||||
|
@ -100,18 +126,103 @@ pub fn list_matches(conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
|
|||
.into_iter()
|
||||
.zip(match_players.into_iter())
|
||||
.map(|(base, players)| FullMatchData {
|
||||
base,
|
||||
match_players: players.into_iter().collect(),
|
||||
map: base
|
||||
.map_id
|
||||
.and_then(|map_id| maps_by_id.get(&map_id).cloned()),
|
||||
base,
|
||||
})
|
||||
.collect();
|
||||
|
||||
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?
|
||||
pub struct FullMatchData {
|
||||
pub base: MatchBase,
|
||||
pub map: Option<Map>,
|
||||
pub match_players: Vec<FullMatchPlayerData>,
|
||||
}
|
||||
|
||||
|
@ -119,7 +230,7 @@ pub struct FullMatchData {
|
|||
// #[primary_key(base.match_id, base::player_id)]
|
||||
pub struct FullMatchPlayerData {
|
||||
pub base: MatchPlayer,
|
||||
pub code_bundle: CodeBundle,
|
||||
pub bot_version: Option<BotVersion>,
|
||||
pub bot: Option<Bot>,
|
||||
}
|
||||
|
||||
|
@ -140,14 +251,24 @@ pub fn find_match(id: i32, conn: &PgConnection) -> QueryResult<FullMatchData> {
|
|||
conn.transaction(|| {
|
||||
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)
|
||||
.inner_join(code_bundles::table)
|
||||
.left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
|
||||
.left_join(
|
||||
bot_versions::table
|
||||
.on(match_players::bot_version_id.eq(bot_versions::id.nullable())),
|
||||
)
|
||||
.left_join(bots::table.on(bot_versions::bot_id.eq(bots::id.nullable())))
|
||||
.order_by(match_players::player_id.asc())
|
||||
.load::<FullMatchPlayerData>(conn)?;
|
||||
|
||||
let res = FullMatchData {
|
||||
base: match_base,
|
||||
match_players,
|
||||
map,
|
||||
};
|
||||
|
||||
Ok(res)
|
||||
|
@ -158,9 +279,18 @@ pub fn find_match_base(id: i32, conn: &PgConnection) -> QueryResult<MatchBase> {
|
|||
matches::table.find(id).get_result::<MatchBase>(conn)
|
||||
}
|
||||
|
||||
pub fn set_match_state(id: i32, match_state: MatchState, conn: &PgConnection) -> QueryResult<()> {
|
||||
pub enum MatchResult {
|
||||
Finished { winner: Option<i32> },
|
||||
}
|
||||
|
||||
pub fn save_match_result(id: i32, result: MatchResult, conn: &PgConnection) -> QueryResult<()> {
|
||||
let MatchResult::Finished { winner } = result;
|
||||
|
||||
diesel::update(matches::table.find(id))
|
||||
.set(matches::state.eq(match_state))
|
||||
.set((
|
||||
matches::winner.eq(winner),
|
||||
matches::state.eq(MatchState::Finished),
|
||||
))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
pub mod bots;
|
||||
pub mod maps;
|
||||
pub mod matches;
|
||||
pub mod ratings;
|
||||
pub mod sessions;
|
||||
|
|
|
@ -42,11 +42,17 @@ fn argon2_config() -> argon2::Config<'static> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> {
|
||||
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(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
|
||||
let hash = argon2::hash_raw(password.as_bytes(), &salt, &argon_config).unwrap();
|
||||
|
||||
(hash, salt)
|
||||
}
|
||||
|
||||
pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> {
|
||||
let (hash, salt) = hash_password(&credentials.password);
|
||||
|
||||
let new_user = NewUser {
|
||||
username: credentials.username,
|
||||
password_salt: &salt,
|
||||
|
@ -57,14 +63,36 @@ pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResul
|
|||
.get_result::<User>(conn)
|
||||
}
|
||||
|
||||
pub fn find_user(username: &str, db_conn: &PgConnection) -> QueryResult<User> {
|
||||
pub fn find_user(user_id: i32, 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
|
||||
.filter(users::username.eq(username))
|
||||
.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> {
|
||||
find_user(credentials.username, db_conn)
|
||||
find_user_by_name(credentials.username, db_conn)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.and_then(|user| {
|
||||
|
|
|
@ -8,33 +8,67 @@ pub mod routes;
|
|||
pub mod schema;
|
||||
pub mod util;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::Deref;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::{fs, net::SocketAddr};
|
||||
|
||||
use bb8::{Pool, PooledConnection};
|
||||
use bb8_diesel::{self, DieselConnectionManager};
|
||||
use config::ConfigError;
|
||||
use diesel::{Connection, PgConnection};
|
||||
use modules::client_api::run_client_api;
|
||||
use modules::ranking::run_ranker;
|
||||
use serde::Deserialize;
|
||||
use modules::registry::registry_service;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{Extension, FromRequest, RequestParts},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
AddExtensionLayer, Router,
|
||||
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>>;
|
||||
|
||||
pub async fn seed_simplebot(pool: &ConnectionPool) {
|
||||
// this should probably be modularized a bit as the config grows
|
||||
#[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");
|
||||
// This transaction is expected to fail when simplebot already exists.
|
||||
let _res = conn.transaction::<(), diesel::result::Error, _>(|| {
|
||||
|
@ -50,7 +84,7 @@ pub async fn seed_simplebot(pool: &ConnectionPool) {
|
|||
let simplebot_code =
|
||||
std::fs::read_to_string(SIMPLEBOT_PATH).expect("could not read simplebot code");
|
||||
|
||||
modules::bots::save_code_bundle(&simplebot_code, Some(simplebot.id), &conn)?;
|
||||
modules::bots::save_code_string(&simplebot_code, Some(simplebot.id), &conn, config)?;
|
||||
|
||||
println!("initialized simplebot");
|
||||
|
||||
|
@ -60,11 +94,22 @@ pub async fn seed_simplebot(pool: &ConnectionPool) {
|
|||
|
||||
pub type DbPool = Pool<DieselConnectionManager<PgConnection>>;
|
||||
|
||||
pub async fn prepare_db(database_url: &str) -> DbPool {
|
||||
let manager = DieselConnectionManager::<PgConnection>::new(database_url);
|
||||
let pool = bb8::Pool::builder().build(manager).await.unwrap();
|
||||
seed_simplebot(&pool).await;
|
||||
pool
|
||||
pub async fn create_db_pool(config: &GlobalConfig) -> DbPool {
|
||||
let manager = DieselConnectionManager::<PgConnection>::new(&config.database_url);
|
||||
bb8::Pool::builder().build(manager).await.unwrap()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -72,31 +117,30 @@ pub fn api() -> Router {
|
|||
.route("/register", post(routes::users::register))
|
||||
.route("/login", post(routes::users::login))
|
||||
.route("/users/me", get(routes::users::current_user))
|
||||
.route("/users/:user/bots", get(routes::bots::get_user_bots))
|
||||
.route(
|
||||
"/bots",
|
||||
get(routes::bots::list_bots).post(routes::bots::create_bot),
|
||||
)
|
||||
.route("/bots/my_bots", get(routes::bots::get_my_bots))
|
||||
.route("/bots/:bot_id", get(routes::bots::get_bot))
|
||||
.route("/bots/:bot_name", get(routes::bots::get_bot))
|
||||
.route(
|
||||
"/bots/:bot_id/upload",
|
||||
"/bots/:bot_name/upload",
|
||||
post(routes::bots::upload_code_multipart),
|
||||
)
|
||||
.route(
|
||||
"/matches",
|
||||
get(routes::matches::list_matches).post(routes::matches::play_match),
|
||||
)
|
||||
.route("/code/:version_id", get(routes::bots::get_code))
|
||||
.route("/matches", get(routes::matches::list_recent_matches))
|
||||
.route("/matches/:match_id", get(routes::matches::get_match_data))
|
||||
.route(
|
||||
"/matches/:match_id/log",
|
||||
get(routes::matches::get_match_log),
|
||||
)
|
||||
.route("/maps", get(routes::maps::list_maps))
|
||||
.route("/leaderboard", get(routes::bots::get_ranking))
|
||||
.route("/submit_bot", post(routes::demo::submit_bot))
|
||||
.route("/save_bot", post(routes::bots::save_bot))
|
||||
}
|
||||
|
||||
pub fn get_config() -> Result<Configuration, ConfigError> {
|
||||
pub fn get_config() -> Result<GlobalConfig, ConfigError> {
|
||||
config::Config::builder()
|
||||
.add_source(config::File::with_name("configuration.toml"))
|
||||
.add_source(config::Environment::with_prefix("PLANETWARS"))
|
||||
|
@ -104,15 +148,37 @@ pub fn get_config() -> Result<Configuration, ConfigError> {
|
|||
.try_deserialize()
|
||||
}
|
||||
|
||||
pub async fn run_app() {
|
||||
let configuration = get_config().unwrap();
|
||||
let db_pool = prepare_db(&configuration.database_url).await;
|
||||
async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) {
|
||||
// TODO: put in config
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 9001));
|
||||
|
||||
tokio::spawn(run_ranker(db_pool.clone()));
|
||||
axum::Server::bind(&addr)
|
||||
.serve(
|
||||
registry_service()
|
||||
.layer(Extension(db_pool))
|
||||
.layer(Extension(config))
|
||||
.into_make_service(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub async fn run_app() {
|
||||
let global_config = Arc::new(get_config().unwrap());
|
||||
let db_pool = create_db_pool(&global_config).await;
|
||||
seed_simplebot(&global_config, &db_pool).await;
|
||||
init_directories(&global_config).unwrap();
|
||||
|
||||
if global_config.ranker_enabled {
|
||||
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()
|
||||
.nest("/api", api())
|
||||
.layer(AddExtensionLayer::new(db_pool))
|
||||
.layer(Extension(db_pool))
|
||||
.layer(Extension(global_config))
|
||||
.into_make_service();
|
||||
|
||||
// TODO: put in config
|
||||
|
@ -121,11 +187,6 @@ pub async fn run_app() {
|
|||
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
|
||||
// which setup is appropriate depends on your application
|
||||
pub struct DatabaseConnection(PooledConnection<'static, DieselConnectionManager<PgConnection>>);
|
||||
|
|
|
@ -2,22 +2,32 @@ use std::path::PathBuf;
|
|||
|
||||
use diesel::{PgConnection, QueryResult};
|
||||
|
||||
use crate::{db, util::gen_alphanumeric, BOTS_DIR};
|
||||
use crate::{db, util::gen_alphanumeric, GlobalConfig};
|
||||
|
||||
pub fn save_code_bundle(
|
||||
/// Save a string containing bot code as a code bundle.
|
||||
/// If a bot was provided, set the saved bundle as its active version.
|
||||
pub fn save_code_string(
|
||||
bot_code: &str,
|
||||
bot_id: Option<i32>,
|
||||
conn: &PgConnection,
|
||||
) -> QueryResult<db::bots::CodeBundle> {
|
||||
config: &GlobalConfig,
|
||||
) -> QueryResult<db::bots::BotVersion> {
|
||||
let bundle_name = gen_alphanumeric(16);
|
||||
|
||||
let code_bundle_dir = PathBuf::from(BOTS_DIR).join(&bundle_name);
|
||||
let code_bundle_dir = PathBuf::from(&config.bots_directory).join(&bundle_name);
|
||||
std::fs::create_dir(&code_bundle_dir).unwrap();
|
||||
std::fs::write(code_bundle_dir.join("bot.py"), bot_code).unwrap();
|
||||
|
||||
let new_code_bundle = db::bots::NewCodeBundle {
|
||||
let new_code_bundle = db::bots::NewBotVersion {
|
||||
bot_id,
|
||||
path: &bundle_name,
|
||||
code_bundle_path: Some(&bundle_name),
|
||||
container_digest: None,
|
||||
};
|
||||
db::bots::create_code_bundle(&new_code_bundle, conn)
|
||||
let version = db::bots::create_bot_version(&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)
|
||||
}
|
||||
|
|
390
planetwars-server/src/modules/client_api.rs
Normal file
390
planetwars-server/src/modules/client_api.rs
Normal file
|
@ -0,0 +1,390 @@
|
|||
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,84 +1,161 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use diesel::{PgConnection, QueryResult};
|
||||
use planetwars_matchrunner::{self as runner, docker_runner::DockerBotSpec, BotSpec, MatchConfig};
|
||||
use runner::MatchOutcome;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
db::{self, matches::MatchData},
|
||||
db::{
|
||||
self,
|
||||
maps::Map,
|
||||
matches::{MatchData, MatchResult},
|
||||
},
|
||||
util::gen_alphanumeric,
|
||||
ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
|
||||
ConnectionPool, GlobalConfig,
|
||||
};
|
||||
|
||||
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
|
||||
|
||||
pub struct RunMatch<'a> {
|
||||
pub struct RunMatch {
|
||||
log_file_name: String,
|
||||
player_code_bundles: Vec<&'a db::bots::CodeBundle>,
|
||||
match_id: Option<i32>,
|
||||
players: Vec<MatchPlayer>,
|
||||
config: Arc<GlobalConfig>,
|
||||
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,
|
||||
}
|
||||
|
||||
impl<'a> RunMatch<'a> {
|
||||
pub fn from_players(player_code_bundles: Vec<&'a db::bots::CodeBundle>) -> Self {
|
||||
pub enum MatchPlayer {
|
||||
BotVersion {
|
||||
bot: Option<db::bots::Bot>,
|
||||
version: db::bots::BotVersion,
|
||||
},
|
||||
BotSpec {
|
||||
spec: Box<dyn BotSpec>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RunMatch {
|
||||
// TODO: create a MatchParams struct
|
||||
pub fn new(
|
||||
config: Arc<GlobalConfig>,
|
||||
is_public: bool,
|
||||
map: Map,
|
||||
players: Vec<MatchPlayer>,
|
||||
) -> Self {
|
||||
let log_file_name = format!("{}.log", gen_alphanumeric(16));
|
||||
RunMatch {
|
||||
config,
|
||||
log_file_name,
|
||||
player_code_bundles,
|
||||
match_id: None,
|
||||
players,
|
||||
is_public,
|
||||
map,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runner_config(&self) -> runner::MatchConfig {
|
||||
fn into_runner_config(self) -> runner::MatchConfig {
|
||||
runner::MatchConfig {
|
||||
map_path: PathBuf::from(MAPS_DIR).join("hex.json"),
|
||||
map_name: "hex".to_string(),
|
||||
log_path: PathBuf::from(MATCHES_DIR).join(&self.log_file_name),
|
||||
map_path: PathBuf::from(&self.config.maps_directory).join(self.map.file_path),
|
||||
map_name: self.map.name,
|
||||
log_path: PathBuf::from(&self.config.match_logs_directory).join(&self.log_file_name),
|
||||
players: self
|
||||
.player_code_bundles
|
||||
.iter()
|
||||
.map(|b| runner::MatchPlayer {
|
||||
bot_spec: code_bundle_to_botspec(b),
|
||||
.players
|
||||
.into_iter()
|
||||
.map(|player| runner::MatchPlayer {
|
||||
bot_spec: match player {
|
||||
MatchPlayer::BotVersion { bot, version } => {
|
||||
bot_version_to_botspec(&self.config, bot.as_ref(), &version)
|
||||
}
|
||||
MatchPlayer::BotSpec { spec } => spec,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store_in_database(&mut self, db_conn: &PgConnection) -> QueryResult<MatchData> {
|
||||
// don't store the same match twice
|
||||
assert!(self.match_id.is_none());
|
||||
pub async fn run(
|
||||
self,
|
||||
conn_pool: ConnectionPool,
|
||||
) -> 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 {
|
||||
state: db::matches::MatchState::Playing,
|
||||
log_path: &self.log_file_name,
|
||||
is_public: self.is_public,
|
||||
map_id: Some(self.map.id),
|
||||
};
|
||||
let new_match_players = self
|
||||
.player_code_bundles
|
||||
.players
|
||||
.iter()
|
||||
.map(|b| db::matches::MatchPlayerData {
|
||||
code_bundle_id: b.id,
|
||||
.map(|p| db::matches::MatchPlayerData {
|
||||
code_bundle_id: match p {
|
||||
MatchPlayer::BotVersion { version, .. } => Some(version.id),
|
||||
MatchPlayer::BotSpec { .. } => None,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let match_data = db::matches::create_match(&new_match_data, &new_match_players, &db_conn)?;
|
||||
self.match_id = Some(match_data.base.id);
|
||||
Ok(match_data)
|
||||
}
|
||||
|
||||
pub fn spawn(self, pool: ConnectionPool) -> JoinHandle<MatchOutcome> {
|
||||
let match_id = self.match_id.expect("match must be saved before running");
|
||||
let runner_config = self.runner_config();
|
||||
tokio::spawn(run_match_task(pool, runner_config, match_id))
|
||||
db::matches::create_match(&new_match_data, &new_match_players, db_conn)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code_bundle_to_botspec(code_bundle: &db::bots::CodeBundle) -> Box<dyn BotSpec> {
|
||||
let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
|
||||
|
||||
pub fn bot_version_to_botspec(
|
||||
runner_config: &GlobalConfig,
|
||||
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 {
|
||||
code_path: bundle_path,
|
||||
image: PYTHON_IMAGE.to_string(),
|
||||
argv: vec!["python".to_string(), "bot.py".to_string()],
|
||||
image: format!(
|
||||
"{}/{}@{}",
|
||||
runner_config.container_registry_url, bot.name, container_digest
|
||||
),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -95,8 +172,11 @@ async fn run_match_task(
|
|||
.await
|
||||
.expect("could not get database connection");
|
||||
|
||||
db::matches::set_match_state(match_id, db::matches::MatchState::Finished, &conn)
|
||||
.expect("could not update match state");
|
||||
let result = MatchResult::Finished {
|
||||
winner: outcome.winner.map(|w| (w - 1) as i32), // player numbers in matchrunner start at 1
|
||||
};
|
||||
|
||||
return outcome;
|
||||
db::matches::save_match_result(match_id, result, &conn).expect("could not save match result");
|
||||
|
||||
outcome
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// This module implements general domain logic, not directly
|
||||
// tied to the database or API layers.
|
||||
pub mod bots;
|
||||
pub mod client_api;
|
||||
pub mod matches;
|
||||
pub mod ranking;
|
||||
pub mod registry;
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
use crate::{db::bots::Bot, DbPool};
|
||||
use crate::db::bots::BotVersion;
|
||||
use crate::db::maps::Map;
|
||||
use crate::{db::bots::Bot, DbPool, GlobalConfig};
|
||||
|
||||
use crate::db;
|
||||
use crate::modules::matches::RunMatch;
|
||||
use crate::modules::matches::{MatchPlayer, RunMatch};
|
||||
use diesel::{PgConnection, QueryResult};
|
||||
use rand::seq::SliceRandom;
|
||||
use std::time::Duration;
|
||||
use std::collections::HashMap;
|
||||
use std::mem;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio;
|
||||
|
||||
// TODO: put these in a config
|
||||
const RANKER_INTERVAL: u64 = 60;
|
||||
const START_RATING: f64 = 0.0;
|
||||
const SCALE: f64 = 100.0;
|
||||
const MAX_UPDATE: f64 = 10.0;
|
||||
const RANKER_NUM_MATCHES: i64 = 10_000;
|
||||
|
||||
pub async fn run_ranker(db_pool: DbPool) {
|
||||
pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) {
|
||||
// TODO: make this configurable
|
||||
// play at most one match every n seconds
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL));
|
||||
|
@ -21,67 +26,316 @@ pub async fn run_ranker(db_pool: DbPool) {
|
|||
.expect("could not get database connection");
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let bots = db::bots::find_all_bots(&db_conn).unwrap();
|
||||
let bots = db::bots::all_active_bots_with_version(&db_conn).expect("could not load bots");
|
||||
if bots.len() < 2 {
|
||||
// not enough bots to play a match
|
||||
continue;
|
||||
}
|
||||
let selected_bots: Vec<Bot> = {
|
||||
let mut rng = &mut rand::thread_rng();
|
||||
bots.choose_multiple(&mut rng, 2).cloned().collect()
|
||||
|
||||
let selected_bots: Vec<(Bot, BotVersion)> = bots
|
||||
.choose_multiple(&mut rand::thread_rng(), 2)
|
||||
.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(selected_bots: Vec<Bot>, db_pool: DbPool) {
|
||||
let db_conn = db_pool.get().await.expect("could not get db pool");
|
||||
let mut code_bundles = Vec::new();
|
||||
for bot in &selected_bots {
|
||||
let code_bundle = db::bots::active_code_bundle(bot.id, &db_conn)
|
||||
.expect("could not get active code bundle");
|
||||
code_bundles.push(code_bundle);
|
||||
async fn play_ranking_match(
|
||||
config: Arc<GlobalConfig>,
|
||||
map: Map,
|
||||
selected_bots: Vec<(Bot, BotVersion)>,
|
||||
db_pool: DbPool,
|
||||
) {
|
||||
let mut players = Vec::new();
|
||||
for (bot, bot_version) in selected_bots {
|
||||
let player = MatchPlayer::BotVersion {
|
||||
bot: Some(bot),
|
||||
version: bot_version,
|
||||
};
|
||||
players.push(player);
|
||||
}
|
||||
|
||||
let code_bundle_refs = code_bundles.iter().map(|b| b).collect::<Vec<_>>();
|
||||
|
||||
let mut run_match = RunMatch::from_players(code_bundle_refs);
|
||||
run_match
|
||||
.store_in_database(&db_conn)
|
||||
.expect("could not store match in db");
|
||||
let outcome = run_match
|
||||
.spawn(db_pool.clone())
|
||||
let (_, handle) = RunMatch::new(config, true, map, players)
|
||||
.run(db_pool.clone())
|
||||
.await
|
||||
.expect("running match failed");
|
||||
.expect("failed to run match");
|
||||
// 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<()> {
|
||||
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 {
|
||||
db::ratings::set_rating(bot_id, rating, db_conn).expect("could not update bot rating");
|
||||
}
|
||||
let elapsed = Instant::now() - start;
|
||||
// TODO: set up proper logging infrastructure
|
||||
println!("computed ratings in {} ms", elapsed.subsec_millis());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// simple elo rating
|
||||
#[derive(Default)]
|
||||
struct MatchStats {
|
||||
total_score: f64,
|
||||
num_matches: usize,
|
||||
}
|
||||
|
||||
let scores = match outcome.winner {
|
||||
None => vec![0.5; 2],
|
||||
Some(player_num) => {
|
||||
// TODO: please get rid of this offset
|
||||
let player_ix = player_num - 1;
|
||||
let mut scores = vec![0.0; 2];
|
||||
scores[player_ix] = 1.0;
|
||||
scores
|
||||
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"),
|
||||
};
|
||||
|
||||
for i in 0..2 {
|
||||
let j = 1 - i;
|
||||
// put players in canonical order: smallest id first
|
||||
if b_id < a_id {
|
||||
mem::swap(&mut a_id, &mut b_id);
|
||||
score = 1.0 - score;
|
||||
}
|
||||
|
||||
let scaled_difference = (ratings[j] - ratings[i]) / SCALE;
|
||||
let expected = 1.0 / (1.0 + 10f64.powf(scaled_difference));
|
||||
let new_rating = ratings[i] + MAX_UPDATE * (scores[i] - expected);
|
||||
db::ratings::set_rating(selected_bots[i].id, new_rating, &db_conn)
|
||||
.expect("could not update bot rating");
|
||||
let entry = match_stats.entry((a_id, b_id)).or_default();
|
||||
entry.num_matches += 1;
|
||||
entry.total_score += score;
|
||||
}
|
||||
Ok(match_stats)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
445
planetwars-server/src/modules/registry.rs
Normal file
445
planetwars-server/src/modules/registry.rs
Normal file
|
@ -0,0 +1,445 @@
|
|||
// 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::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{body, Json};
|
||||
use axum::{body, Extension, Json};
|
||||
use diesel::OptionalExtension;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::Rng;
|
||||
|
@ -9,15 +9,19 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{self, json, value::Value as JsonValue};
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use thiserror;
|
||||
|
||||
use crate::db::bots::{self, CodeBundle};
|
||||
use crate::db::ratings::{RankedBot, self};
|
||||
use crate::db;
|
||||
use crate::db::bots::{self, BotVersion};
|
||||
use crate::db::ratings::{self, RankedBot};
|
||||
use crate::db::users::User;
|
||||
use crate::modules::bots::save_code_bundle;
|
||||
use crate::{DatabaseConnection, BOTS_DIR};
|
||||
use crate::modules::bots::save_code_string;
|
||||
use crate::{DatabaseConnection, GlobalConfig};
|
||||
use bots::Bot;
|
||||
|
||||
use super::users::UserData;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SaveBotParams {
|
||||
pub bot_name: String,
|
||||
|
@ -96,6 +100,7 @@ pub async fn save_bot(
|
|||
Json(params): Json<SaveBotParams>,
|
||||
user: User,
|
||||
conn: DatabaseConnection,
|
||||
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||
) -> Result<Json<Bot>, SaveBotError> {
|
||||
let res = bots::find_bot_by_name(¶ms.bot_name, &conn)
|
||||
.optional()
|
||||
|
@ -119,8 +124,8 @@ pub async fn save_bot(
|
|||
bots::create_bot(&new_bot, &conn).expect("could not create bot")
|
||||
}
|
||||
};
|
||||
let _code_bundle =
|
||||
save_code_bundle(¶ms.code, Some(bot.id), &conn).expect("failed to save code bundle");
|
||||
let _code_bundle = save_code_string(¶ms.code, Some(bot.id), &conn, &config)
|
||||
.expect("failed to save code bundle");
|
||||
Ok(Json(bot))
|
||||
}
|
||||
|
||||
|
@ -129,44 +134,64 @@ pub struct BotParams {
|
|||
name: String,
|
||||
}
|
||||
|
||||
// TODO: can we unify this with save_bot?
|
||||
pub async fn create_bot(
|
||||
conn: DatabaseConnection,
|
||||
user: User,
|
||||
params: Json<BotParams>,
|
||||
) -> (StatusCode, Json<Bot>) {
|
||||
) -> Result<(StatusCode, Json<Bot>), SaveBotError> {
|
||||
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 {
|
||||
owner_id: Some(user.id),
|
||||
name: ¶ms.name,
|
||||
};
|
||||
let bot = bots::create_bot(&bot_params, &conn).unwrap();
|
||||
(StatusCode::CREATED, Json(bot))
|
||||
Ok((StatusCode::CREATED, Json(bot)))
|
||||
}
|
||||
|
||||
// TODO: handle errors
|
||||
pub async fn get_bot(
|
||||
conn: DatabaseConnection,
|
||||
Path(bot_id): Path<i32>,
|
||||
Path(bot_name): Path<String>,
|
||||
) -> Result<Json<JsonValue>, StatusCode> {
|
||||
let bot = bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let bundles = bots::find_bot_code_bundles(bot.id, &conn)
|
||||
let bot = db::bots::find_bot_by_name(&bot_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let owner: Option<UserData> = match bot.owner_id {
|
||||
Some(user_id) => {
|
||||
let user = db::users::find_user(user_id, &conn)
|
||||
.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!({
|
||||
"bot": bot,
|
||||
"bundles": bundles,
|
||||
"owner": owner,
|
||||
"versions": versions,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_my_bots(
|
||||
pub async fn get_user_bots(
|
||||
conn: DatabaseConnection,
|
||||
user: User,
|
||||
Path(user_name): Path<String>,
|
||||
) -> Result<Json<Vec<Bot>>, StatusCode> {
|
||||
bots::find_bots_by_owner(user.id, &conn)
|
||||
let user =
|
||||
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_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
/// List all active bots
|
||||
pub async fn list_bots(conn: DatabaseConnection) -> Result<Json<Vec<Bot>>, StatusCode> {
|
||||
bots::find_all_bots(&conn)
|
||||
bots::find_active_bots(&conn)
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
@ -181,12 +206,13 @@ pub async fn get_ranking(conn: DatabaseConnection) -> Result<Json<Vec<RankedBot>
|
|||
pub async fn upload_code_multipart(
|
||||
conn: DatabaseConnection,
|
||||
user: User,
|
||||
Path(bot_id): Path<i32>,
|
||||
Path(bot_name): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<CodeBundle>, StatusCode> {
|
||||
let bots_dir = PathBuf::from(BOTS_DIR);
|
||||
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||
) -> Result<Json<BotVersion>, StatusCode> {
|
||||
let bots_dir = PathBuf::from(&config.bots_directory);
|
||||
|
||||
let bot = bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let bot = bots::find_bot_by_name(&bot_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
if Some(user.id) != bot.owner_id {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
|
@ -213,12 +239,39 @@ pub async fn upload_code_multipart(
|
|||
.extract(bots_dir.join(&folder_name))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let bundle = bots::NewCodeBundle {
|
||||
let bot_version = bots::NewBotVersion {
|
||||
bot_id: Some(bot.id),
|
||||
path: &folder_name,
|
||||
code_bundle_path: Some(&folder_name),
|
||||
container_digest: None,
|
||||
};
|
||||
let code_bundle =
|
||||
bots::create_code_bundle(&bundle, &conn).expect("Failed to create code bundle");
|
||||
bots::create_bot_version(&bot_version, &conn).expect("Failed to create 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,8 +1,11 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::matches::{FullMatchData, FullMatchPlayerData};
|
||||
use crate::modules::bots::save_code_bundle;
|
||||
use crate::modules::matches::RunMatch;
|
||||
use crate::modules::bots::save_code_string;
|
||||
use crate::modules::matches::{MatchPlayer, RunMatch};
|
||||
use crate::ConnectionPool;
|
||||
use crate::GlobalConfig;
|
||||
use axum::extract::Extension;
|
||||
use axum::Json;
|
||||
use hyper::StatusCode;
|
||||
|
@ -11,12 +14,13 @@ use serde::{Deserialize, Serialize};
|
|||
use super::matches::ApiMatch;
|
||||
|
||||
const DEFAULT_OPPONENT_NAME: &str = "simplebot";
|
||||
const DEFAULT_MAP_NAME: &str = "hex";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SubmitBotParams {
|
||||
pub code: String,
|
||||
// TODO: would it be better to pass an ID here?
|
||||
pub opponent_name: Option<String>,
|
||||
pub map_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
@ -25,11 +29,11 @@ pub struct SubmitBotResponse {
|
|||
pub match_data: ApiMatch,
|
||||
}
|
||||
|
||||
/// submit python code for a bot, which will face off
|
||||
/// with a demo bot. Return a played match.
|
||||
/// Submit bot code and opponent name to play a match
|
||||
pub async fn submit_bot(
|
||||
Json(params): Json<SubmitBotParams>,
|
||||
Extension(pool): Extension<ConnectionPool>,
|
||||
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||
) -> Result<Json<SubmitBotResponse>, StatusCode> {
|
||||
let conn = pool.get().await.expect("could not get database connection");
|
||||
|
||||
|
@ -37,20 +41,39 @@ pub async fn submit_bot(
|
|||
.opponent_name
|
||||
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
|
||||
|
||||
let opponent =
|
||||
db::bots::find_bot_by_name(&opponent_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let opponent_code_bundle =
|
||||
db::bots::active_code_bundle(opponent.id, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let map_name = params
|
||||
.map_name
|
||||
.unwrap_or_else(|| DEFAULT_MAP_NAME.to_string());
|
||||
|
||||
let player_code_bundle = save_code_bundle(¶ms.code, None, &conn)
|
||||
let (opponent_bot, opponent_bot_version) =
|
||||
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?
|
||||
.expect("could not save bot code");
|
||||
|
||||
let mut run_match = RunMatch::from_players(vec![&player_code_bundle, &opponent_code_bundle]);
|
||||
let match_data = run_match
|
||||
.store_in_database(&conn)
|
||||
.expect("failed to save match");
|
||||
run_match.spawn(pool.clone());
|
||||
let run_match = RunMatch::new(
|
||||
config,
|
||||
false,
|
||||
map.clone(),
|
||||
vec![
|
||||
MatchPlayer::BotVersion {
|
||||
bot: None,
|
||||
version: player_bot_version.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
|
||||
let full_match_data = FullMatchData {
|
||||
|
@ -58,15 +81,16 @@ pub async fn submit_bot(
|
|||
match_players: vec![
|
||||
FullMatchPlayerData {
|
||||
base: match_data.match_players[0].clone(),
|
||||
code_bundle: player_code_bundle,
|
||||
bot_version: Some(player_bot_version),
|
||||
bot: None,
|
||||
},
|
||||
FullMatchPlayerData {
|
||||
base: match_data.match_players[1].clone(),
|
||||
code_bundle: opponent_code_bundle,
|
||||
bot: Some(opponent),
|
||||
bot_version: Some(opponent_bot_version),
|
||||
bot: Some(opponent_bot),
|
||||
},
|
||||
],
|
||||
map: Some(map),
|
||||
};
|
||||
|
||||
let api_match = super::matches::match_data_to_api(full_match_data);
|
||||
|
|
19
planetwars-server/src/routes/maps.rs
Normal file
19
planetwars-server/src/routes/maps.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
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,101 +1,21 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
Json,
|
||||
extract::{Path, Query},
|
||||
Extension, Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use hyper::StatusCode;
|
||||
use planetwars_matchrunner::{docker_runner::DockerBotSpec, run_match, MatchConfig, MatchPlayer};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
db::{
|
||||
bots,
|
||||
self,
|
||||
matches::{self, MatchState},
|
||||
users::User,
|
||||
},
|
||||
ConnectionPool, DatabaseConnection, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
|
||||
DatabaseConnection, GlobalConfig,
|
||||
};
|
||||
|
||||
#[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: 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");
|
||||
}
|
||||
use super::maps::ApiMap;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ApiMatch {
|
||||
|
@ -103,19 +23,71 @@ pub struct ApiMatch {
|
|||
timestamp: chrono::NaiveDateTime,
|
||||
state: MatchState,
|
||||
players: Vec<ApiMatchPlayer>,
|
||||
winner: Option<i32>,
|
||||
map: Option<ApiMap>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ApiMatchPlayer {
|
||||
code_bundle_id: i32,
|
||||
bot_version_id: Option<i32>,
|
||||
bot_id: Option<i32>,
|
||||
bot_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_matches(conn: DatabaseConnection) -> Result<Json<Vec<ApiMatch>>, StatusCode> {
|
||||
matches::list_matches(&conn)
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
.map(|matches| Json(matches.into_iter().map(match_data_to_api).collect()))
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ListRecentMatchesParams {
|
||||
count: Option<usize>,
|
||||
// TODO: should timezone be specified here?
|
||||
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 {
|
||||
|
@ -127,23 +99,16 @@ pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch {
|
|||
.match_players
|
||||
.iter()
|
||||
.map(|_p| ApiMatchPlayer {
|
||||
code_bundle_id: _p.code_bundle.id,
|
||||
bot_version_id: _p.bot_version.as_ref().map(|cb| cb.id),
|
||||
bot_id: _p.bot.as_ref().map(|b| b.id),
|
||||
bot_name: _p.bot.as_ref().map(|b| b.name.clone()),
|
||||
})
|
||||
.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(
|
||||
Path(match_id): Path<i32>,
|
||||
conn: DatabaseConnection,
|
||||
|
@ -157,10 +122,11 @@ pub async fn get_match_data(
|
|||
pub async fn get_match_log(
|
||||
Path(match_id): Path<i32>,
|
||||
conn: DatabaseConnection,
|
||||
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||
) -> Result<Vec<u8>, StatusCode> {
|
||||
let match_base =
|
||||
matches::find_match_base(match_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let log_path = PathBuf::from(MATCHES_DIR).join(&match_base.log_path);
|
||||
let log_path = PathBuf::from(&config.match_logs_directory).join(&match_base.log_path);
|
||||
let log_contents = std::fs::read(log_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(log_contents)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
pub mod bots;
|
||||
pub mod demo;
|
||||
pub mod maps;
|
||||
pub mod matches;
|
||||
pub mod users;
|
||||
|
|
|
@ -5,12 +5,14 @@ use axum::extract::{FromRequest, RequestParts, TypedHeader};
|
|||
use axum::headers::authorization::Bearer;
|
||||
use axum::headers::Authorization;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Headers, IntoResponse, Response};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{async_trait, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
const RESERVED_USERNAMES: &[&str] = &["admin", "system"];
|
||||
|
||||
type AuthorizationHeader = TypedHeader<Authorization<Bearer>>;
|
||||
|
||||
#[async_trait]
|
||||
|
@ -89,7 +91,11 @@ impl RegistrationParams {
|
|||
errors.push("password must be at least 8 characters".to_string());
|
||||
}
|
||||
|
||||
if users::find_user(&self.username, &conn).is_ok() {
|
||||
if RESERVED_USERNAMES.contains(&self.username.as_str()) {
|
||||
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());
|
||||
}
|
||||
|
||||
|
@ -163,9 +169,9 @@ pub async fn login(conn: DatabaseConnection, params: Json<LoginParams>) -> Respo
|
|||
Some(user) => {
|
||||
let session = sessions::create_session(&user, &conn);
|
||||
let user_data: UserData = user.into();
|
||||
let headers = Headers(vec![("Token", &session.token)]);
|
||||
let headers = [("Token", &session.token)];
|
||||
|
||||
(headers, Json(user_data)).into_response()
|
||||
(StatusCode::OK, headers, Json(user_data)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,19 @@
|
|||
// This file is autogenerated by diesel
|
||||
#![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! {
|
||||
use diesel::sql_types::*;
|
||||
use crate::db_types::*;
|
||||
|
@ -9,6 +22,7 @@ table! {
|
|||
id -> Int4,
|
||||
owner_id -> Nullable<Int4>,
|
||||
name -> Text,
|
||||
active_version -> Nullable<Int4>,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -16,11 +30,10 @@ table! {
|
|||
use diesel::sql_types::*;
|
||||
use crate::db_types::*;
|
||||
|
||||
code_bundles (id) {
|
||||
maps (id) {
|
||||
id -> Int4,
|
||||
bot_id -> Nullable<Int4>,
|
||||
path -> Text,
|
||||
created_at -> Timestamp,
|
||||
name -> Text,
|
||||
file_path -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,7 +44,7 @@ table! {
|
|||
match_players (match_id, player_id) {
|
||||
match_id -> Int4,
|
||||
player_id -> Int4,
|
||||
code_bundle_id -> Int4,
|
||||
bot_version_id -> Nullable<Int4>,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,6 +57,9 @@ table! {
|
|||
state -> Match_state,
|
||||
log_path -> Text,
|
||||
created_at -> Timestamp,
|
||||
winner -> Nullable<Int4>,
|
||||
is_public -> Bool,
|
||||
map_id -> Nullable<Int4>,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,15 +97,16 @@ table! {
|
|||
}
|
||||
|
||||
joinable!(bots -> users (owner_id));
|
||||
joinable!(code_bundles -> bots (bot_id));
|
||||
joinable!(match_players -> code_bundles (code_bundle_id));
|
||||
joinable!(match_players -> bot_versions (bot_version_id));
|
||||
joinable!(match_players -> matches (match_id));
|
||||
joinable!(matches -> maps (map_id));
|
||||
joinable!(ratings -> bots (bot_id));
|
||||
joinable!(sessions -> users (user_id));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
bot_versions,
|
||||
bots,
|
||||
code_bundles,
|
||||
maps,
|
||||
match_players,
|
||||
matches,
|
||||
ratings,
|
||||
|
|
46
proto/client_api.proto
Normal file
46
proto/client_api.proto
Normal file
|
@ -0,0 +1,46 @@
|
|||
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,6 +22,7 @@
|
|||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-svelte3": "^3.2.1",
|
||||
"luxon": "^2.3.0",
|
||||
"mdsvex": "^0.10.6",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier-plugin-svelte": "^2.4.0",
|
||||
"sass": "^1.49.7",
|
||||
|
|
80
web/pw-server/src/lib/api_client.ts
Normal file
80
web/pw-server/src/lib/api_client.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
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,20 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let leaderboard = [];
|
||||
|
||||
onMount(async () => {
|
||||
const res = await fetch("/api/leaderboard", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
leaderboard = await res.json();
|
||||
console.log(leaderboard);
|
||||
}
|
||||
});
|
||||
export let leaderboard;
|
||||
|
||||
function formatRating(entry: object): any {
|
||||
const rating = entry["rating"];
|
||||
|
@ -41,10 +26,17 @@
|
|||
<td class="leaderboard-rating">
|
||||
{formatRating(entry)}
|
||||
</td>
|
||||
<td class="leaderboard-bot">{entry["bot"]["name"]}</td>
|
||||
<td class="leaderboard-bot">
|
||||
<a class="leaderboard-href" href="/bots/{entry['bot']['name']}"
|
||||
>{entry["bot"]["name"]}
|
||||
</a></td
|
||||
>
|
||||
<td class="leaderboard-author">
|
||||
{#if entry["author"]}
|
||||
{entry["author"]["username"]}
|
||||
<!-- TODO: remove duplication -->
|
||||
<a class="leaderboard-href" href="/users/{entry['author']['username']}"
|
||||
>{entry["author"]["username"]}</a
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -69,4 +61,9 @@
|
|||
.leaderboard-rank {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.leaderboard-href {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
|
|
7
web/pw-server/src/lib/components/LinkButton.svelte
Normal file
7
web/pw-server/src/lib/components/LinkButton.svelte
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
export let href: string | null;
|
||||
|
||||
$: isDisabled = !href;
|
||||
</script>
|
||||
|
||||
<a class="btn" class:btn-disabled={isDisabled} {href}><slot /></a>
|
|
@ -1,32 +1,80 @@
|
|||
<script lang="ts">
|
||||
import { parsePlayerLog, PlayerLog } from "$lib/log_parser";
|
||||
|
||||
export let matchLog: string;
|
||||
let playerLog: PlayerLog;
|
||||
|
||||
function getStdErr(botId: number, log?: string): string {
|
||||
if (!log) {
|
||||
return "";
|
||||
let showRawStderr = false;
|
||||
|
||||
const PLURAL_MAP = {
|
||||
dispatch: "dispatches",
|
||||
ship: "ships",
|
||||
};
|
||||
|
||||
function pluralize(num: number, word: string): string {
|
||||
if (num == 1) {
|
||||
return `1 ${word}`;
|
||||
} else {
|
||||
return `${num} ${PLURAL_MAP[word]}`;
|
||||
}
|
||||
}
|
||||
|
||||
let output = [];
|
||||
log
|
||||
.split("\n")
|
||||
.slice(0, -1)
|
||||
.forEach((line) => {
|
||||
let message = JSON.parse(line);
|
||||
if (message["type"] === "stderr" && message["player_id"] === botId) {
|
||||
output.push(message["message"]);
|
||||
$: if (matchLog) {
|
||||
playerLog = parsePlayerLog(1, matchLog);
|
||||
} else {
|
||||
playerLog = [];
|
||||
}
|
||||
});
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
$: botStdErr = getStdErr(1, matchLog);
|
||||
</script>
|
||||
|
||||
<div class="output">
|
||||
{#if botStdErr.length > 0}
|
||||
<h3 class="output-header">stderr:</h3>
|
||||
<h3 class="output-header">Player log</h3>
|
||||
{#if showRawStderr}
|
||||
<div class="output-text stderr-text">
|
||||
{playerLog.flatMap((turn) => turn.stderr).join("\n")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="output-text">
|
||||
{botStdErr}
|
||||
{#each playerLog as logTurn, i}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -39,12 +87,71 @@
|
|||
padding: 15px;
|
||||
}
|
||||
|
||||
.turn {
|
||||
margin: 16px 4px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
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;
|
||||
}
|
||||
|
||||
.stderr-header {
|
||||
color: #eee;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.stderr-text-box {
|
||||
border-left: 1px solid #ccc;
|
||||
margin-left: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.output-header {
|
||||
color: #eee;
|
||||
padding-bottom: 20px;
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
<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,39 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { ApiClient } from "$lib/api_client";
|
||||
|
||||
import { get_session_token } from "$lib/auth";
|
||||
import { getBotName, saveBotName } from "$lib/bot_code";
|
||||
|
||||
import { currentUser } from "$lib/stores/current_user";
|
||||
import { selectedOpponent, selectedMap } from "$lib/stores/editor_state";
|
||||
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import Select from "svelte-select";
|
||||
|
||||
export let editSession;
|
||||
|
||||
let availableBots: object[] = [];
|
||||
let selectedOpponent = undefined;
|
||||
let maps: object[] = [];
|
||||
|
||||
let botName: string | undefined = undefined;
|
||||
// whether to show the "save succesful" message
|
||||
let saveSuccesful = false;
|
||||
|
||||
let saveErrors: string[] = [];
|
||||
|
||||
onMount(async () => {
|
||||
botName = getBotName();
|
||||
const apiClient = new ApiClient();
|
||||
|
||||
const res = await fetch("/api/bots", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const [_bots, _maps] = await Promise.all([
|
||||
apiClient.get("/api/bots"),
|
||||
apiClient.get("/api/maps"),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
availableBots = await res.json();
|
||||
selectedOpponent = availableBots.find((b) => b["name"] === "simplebot");
|
||||
availableBots = _bots;
|
||||
maps = _maps;
|
||||
|
||||
if (!$selectedOpponent) {
|
||||
selectedOpponent.set(availableBots.find((b) => b["name"] === "simplebot"));
|
||||
}
|
||||
|
||||
if (!$selectedMap) {
|
||||
selectedMap.set(maps.find((m) => m["name"] === "hex"));
|
||||
}
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function submitBot() {
|
||||
const opponentName = selectedOpponent["name"];
|
||||
|
||||
let response = await fetch("/api/submit_bot", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -41,7 +52,8 @@
|
|||
},
|
||||
body: JSON.stringify({
|
||||
code: editSession.getDocument().getValue(),
|
||||
opponent_name: opponentName,
|
||||
opponent_name: $selectedOpponent["name"],
|
||||
map_name: $selectedMap["name"],
|
||||
}),
|
||||
});
|
||||
|
||||
|
@ -56,6 +68,9 @@
|
|||
}
|
||||
|
||||
async function saveBot() {
|
||||
saveSuccesful = false;
|
||||
saveErrors = [];
|
||||
|
||||
let response = await fetch("/api/save_bot", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -77,8 +92,7 @@
|
|||
if (!availableBots.find((bot) => bot["id"] == responseData["id"])) {
|
||||
availableBots = [...availableBots, responseData];
|
||||
}
|
||||
// clear errors
|
||||
saveErrors = [];
|
||||
saveSuccesful = true;
|
||||
} else {
|
||||
const error = responseData["error"];
|
||||
if (error["type"] === "validation_failed") {
|
||||
|
@ -96,13 +110,23 @@
|
|||
<div class="submit-pane">
|
||||
<div class="match-form">
|
||||
<h4>Play a match</h4>
|
||||
<div class="play-text">Select an opponent to test your bot</div>
|
||||
<div class="opponentSelect">
|
||||
<div class="play-text">Opponent</div>
|
||||
<div class="opponent-select">
|
||||
<Select
|
||||
optionIdentifier="name"
|
||||
labelIdentifier="name"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
@ -113,7 +137,9 @@
|
|||
{#if $currentUser}
|
||||
<div>Add your bot to the opponents list</div>
|
||||
<input type="text" class="bot-name-input" placeholder="bot name" bind:value={botName} />
|
||||
{#if saveErrors.length > 0}
|
||||
{#if saveSuccesful}
|
||||
<div class="success-text">Bot saved succesfully</div>
|
||||
{:else if saveErrors.length > 0}
|
||||
<ul>
|
||||
{#each saveErrors as errorText}
|
||||
<li class="error-text">{errorText}</li>
|
||||
|
@ -139,8 +165,9 @@
|
|||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.opponentSelect {
|
||||
margin: 20px 0;
|
||||
.opponent-select,
|
||||
.map-select {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.save-form {
|
||||
|
@ -151,6 +178,11 @@
|
|||
color: red;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: green;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
|
|
21
web/pw-server/src/lib/components/docs/TocEntry.svelte
Normal file
21
web/pw-server/src/lib/components/docs/TocEntry.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<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>
|
104
web/pw-server/src/lib/components/matches/MatchList.svelte
Normal file
104
web/pw-server/src/lib/components/matches/MatchList.svelte
Normal file
|
@ -0,0 +1,104 @@
|
|||
<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">
|
||||
{#if $currentUser}
|
||||
<div class="current-user-name">
|
||||
<a class="current-user-name" href="/users/{$currentUser['username']}">
|
||||
{$currentUser["username"]}
|
||||
</div>
|
||||
</a>
|
||||
<div class="sign-out" on:click={signOut}>Sign out</div>
|
||||
{:else}
|
||||
<a class="account-href" href="login">Sign in</a>
|
||||
<a class="account-href" href="register">Sign up</a>
|
||||
<a class="account-href" href="/login">Sign in</a>
|
||||
<a class="account-href" href="/register">Sign up</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
@ -61,6 +61,7 @@
|
|||
|
||||
.current-user-name {
|
||||
@include navbar-item;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
|
75
web/pw-server/src/lib/log_parser.ts
Normal file
75
web/pw-server/src/lib/log_parser.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
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;
|
||||
}
|
27
web/pw-server/src/lib/stores/editor_state.ts
Normal file
27
web/pw-server/src/lib/stores/editor_state.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
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);
|
0
web/pw-server/src/lib/urls.ts
Normal file
0
web/pw-server/src/lib/urls.ts
Normal file
|
@ -1,4 +1,4 @@
|
|||
import { get_session_token } from "./auth";
|
||||
import { ApiClient, FetchFn } from "./api_client";
|
||||
|
||||
export function debounce(func: Function, timeout: number = 300) {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
@ -10,35 +10,12 @@ export function debounce(func: Function, timeout: number = 300) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function get(url: string, 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: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
return JSON.parse(response);
|
||||
export async function get(url: string, params?: Record<string, string>, fetch_fn: FetchFn = fetch) {
|
||||
const client = new ApiClient(fetch_fn);
|
||||
return await client.get(url, params);
|
||||
}
|
||||
|
||||
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);
|
||||
export async function post(url: string, data: any, fetch_fn: FetchFn = fetch) {
|
||||
const client = new ApiClient(fetch_fn);
|
||||
return await client.post(url, data);
|
||||
}
|
||||
|
|
|
@ -6,16 +6,29 @@
|
|||
|
||||
<div class="outer-container">
|
||||
<div class="navbar">
|
||||
<div class="navbar-main">
|
||||
<div class="navbar-left">
|
||||
<div class="navbar-header">
|
||||
<a href="/">PlanetWars</a>
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "src/styles/variables.scss";
|
||||
<style lang="scss" global>
|
||||
@import "src/styles/global.scss";
|
||||
|
||||
.outer-container {
|
||||
width: 100vw;
|
||||
|
@ -34,13 +47,33 @@
|
|||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.navbar-main {
|
||||
margin: auto 0;
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-main a {
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-header {
|
||||
margin: auto 0;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.navbar-header a {
|
||||
font-size: 20px;
|
||||
color: #eee;
|
||||
color: #fff;
|
||||
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>
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
<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>
|
173
web/pw-server/src/routes/bots/[bot_name].svelte
Normal file
173
web/pw-server/src/routes/bots/[bot_name].svelte
Normal file
|
@ -0,0 +1,173 @@
|
|||
<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>
|
99
web/pw-server/src/routes/bots/new.svelte
Normal file
99
web/pw-server/src/routes/bots/new.svelte
Normal file
|
@ -0,0 +1,99 @@
|
|||
<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>
|
35
web/pw-server/src/routes/code/[bundle_id].svelte
Normal file
35
web/pw-server/src/routes/code/[bundle_id].svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<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>
|
62
web/pw-server/src/routes/docs/__layout.svelte
Normal file
62
web/pw-server/src/routes/docs/__layout.svelte
Normal file
|
@ -0,0 +1,62 @@
|
|||
<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>
|
10
web/pw-server/src/routes/docs/doc.svelte
Normal file
10
web/pw-server/src/routes/docs/doc.svelte
Normal file
|
@ -0,0 +1,10 @@
|
|||
<div class="container markdown-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use "src/styles/variables";
|
||||
.container {
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
8
web/pw-server/src/routes/docs/index.svelte
Normal file
8
web/pw-server/src/routes/docs/index.svelte
Normal file
|
@ -0,0 +1,8 @@
|
|||
<script context="module">
|
||||
export async function load() {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: "/docs/rules",
|
||||
};
|
||||
}
|
||||
</script>
|
97
web/pw-server/src/routes/docs/local-development.md
Normal file
97
web/pw-server/src/routes/docs/local-development.md
Normal file
|
@ -0,0 +1,97 @@
|
|||
# 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.
|
114
web/pw-server/src/routes/docs/rules.md
Normal file
114
web/pw-server/src/routes/docs/rules.md
Normal file
|
@ -0,0 +1,114 @@
|
|||
# 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!
|
255
web/pw-server/src/routes/editor.svelte
Normal file
255
web/pw-server/src/routes/editor.svelte
Normal file
|
@ -0,0 +1,255 @@
|
|||
<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,273 +1,92 @@
|
|||
<script lang="ts">
|
||||
import Visualizer from "$lib/components/Visualizer.svelte";
|
||||
import EditorView from "$lib/components/EditorView.svelte";
|
||||
import { onMount } from "svelte";
|
||||
<script lang="ts" context="module">
|
||||
import { ApiClient } from "$lib/api_client";
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
const NUM_MATCHES = "25";
|
||||
|
||||
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, 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";
|
||||
export async function load({ fetch }) {
|
||||
try {
|
||||
const apiClient = new ApiClient(fetch);
|
||||
|
||||
enum ViewMode {
|
||||
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();
|
||||
let { matches, has_next } = await apiClient.get("/api/matches", {
|
||||
count: NUM_MATCHES,
|
||||
});
|
||||
|
||||
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"];
|
||||
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",
|
||||
return {
|
||||
props: {
|
||||
matches,
|
||||
hasNext: has_next,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw Error(response.statusText);
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: error.status,
|
||||
error: new Error("failed to load matches"),
|
||||
};
|
||||
}
|
||||
|
||||
let matchData = await response.json();
|
||||
return matchData;
|
||||
}
|
||||
</script>
|
||||
|
||||
async function getMatchLog(matchId: string) {
|
||||
const matchData = await getMatchData(matchId);
|
||||
console.log(matchData);
|
||||
if (matchData["state"] !== "Finished") {
|
||||
// log is not available yet
|
||||
<script lang="ts">
|
||||
import LinkButton from "$lib/components/LinkButton.svelte";
|
||||
import MatchList from "$lib/components/matches/MatchList.svelte";
|
||||
|
||||
export let matches;
|
||||
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;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/matches/${matchId}/log`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
let log = await res.text();
|
||||
return log;
|
||||
const lastTimestamp = matches[matches.length - 1]["timestamp"];
|
||||
return `/matches?before=${lastTimestamp}`;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="container">
|
||||
<div class="sidebar-left">
|
||||
<div
|
||||
class="editor-button sidebar-item"
|
||||
class:selected={viewMode === ViewMode.Editor}
|
||||
on:click={() => setViewMode(ViewMode.Editor)}
|
||||
>
|
||||
Editor
|
||||
<div class="introduction">
|
||||
<h2>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>
|
||||
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
|
||||
class="rules-button sidebar-item"
|
||||
class:selected={viewMode === ViewMode.Rules}
|
||||
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}
|
||||
<h2>Recent matches</h2>
|
||||
<MatchList {matches} />
|
||||
<div class="see-more-container">
|
||||
<LinkButton href={viewMoreUrl}>View more</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "src/styles/variables.scss";
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sidebar-left {
|
||||
width: 240px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
.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;
|
||||
.introduction {
|
||||
padding-top: 16px;
|
||||
a {
|
||||
color: rgb(9, 105, 218);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 100%;
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
.see-more-container {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
28
web/pw-server/src/routes/leaderboard.svelte
Normal file
28
web/pw-server/src/routes/leaderboard.svelte
Normal file
|
@ -0,0 +1,28 @@
|
|||
<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,31 +1,44 @@
|
|||
<script lang="ts" context="module">
|
||||
export async function load({ page }) {
|
||||
const res = await fetch(`/api/matches/${page.params["match_id"]}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
import { ApiClient } from "$lib/api_client";
|
||||
export async function load({ params, fetch }) {
|
||||
try {
|
||||
const matchId = params["match_id"];
|
||||
const apiClient = new ApiClient(fetch);
|
||||
const [matchData, matchLog] = await Promise.all([
|
||||
apiClient.get(`/api/matches/${matchId}`),
|
||||
apiClient.getText(`/api/matches/${matchId}/log`),
|
||||
]);
|
||||
return {
|
||||
props: {
|
||||
matchLog: await res.text(),
|
||||
matchData: matchData,
|
||||
matchLog: matchLog,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
status: res.status,
|
||||
error: new Error("failed to load match"),
|
||||
status: error.status,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Visualizer from "$lib/components/Visualizer.svelte";
|
||||
export let matchLog: string;
|
||||
export let matchData: object;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Visualizer {matchLog} />
|
||||
<div class="container">
|
||||
<Visualizer {matchLog} {matchData} />
|
||||
</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,36 +1,121 @@
|
|||
<script lang="ts" context="module">
|
||||
export async function load() {
|
||||
const res = await fetch("/api/matches", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
import { ApiClient } from "$lib/api_client";
|
||||
|
||||
if (res.ok) {
|
||||
return {
|
||||
props: {
|
||||
matches: await res.json(),
|
||||
},
|
||||
const PAGE_SIZE = "50";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
props: {
|
||||
matches,
|
||||
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> {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] === undefined || obj[key] === null) {
|
||||
delete obj[key];
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
export let matches;
|
||||
import LinkButton from "$lib/components/LinkButton.svelte";
|
||||
import MatchList from "$lib/components/matches/MatchList.svelte";
|
||||
|
||||
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>
|
||||
|
||||
<a href="/matches/new">new match</a>
|
||||
<ul>
|
||||
{#each matches as match}
|
||||
<li>
|
||||
<a href="/matches/{match['id']}">{dayjs(match["created_at"]).format("YYYY-MM-DD HH:mm")}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="container">
|
||||
<MatchList {matches} />
|
||||
<div class="page-controls">
|
||||
<div class="btn-group">
|
||||
<LinkButton href={newerMatchesLink(matches)}>Newer</LinkButton>
|
||||
<LinkButton href={olderMatchesLink(matches)}>Older</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts" context="module">
|
||||
import { get_session_token } from "$lib/auth";
|
||||
import { mount_component } from "svelte/internal";
|
||||
|
||||
export async function load({ page }) {
|
||||
const token = get_session_token();
|
||||
|
|
|
@ -1,3 +1,18 @@
|
|||
body {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
|
111
web/pw-server/src/routes/users/[user_name].svelte
Normal file
111
web/pw-server/src/routes/users/[user_name].svelte
Normal file
|
@ -0,0 +1,111 @@
|
|||
<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>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue