Compare commits
136 commits
docker-reg
...
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 | ||
|
7a3b801f58 | ||
|
a376698073 | ||
|
5ee66c9c9b | ||
|
d1977b95c8 | ||
|
e3cf0df450 | ||
|
1b2472fbfc | ||
|
028d4a99e4 | ||
|
ff061f2a7a | ||
|
69421d7b25 | ||
|
2f915af919 | ||
|
d0faec7d1f | ||
|
90ecb13a17 | ||
|
c3d32e051c | ||
|
0f80b19614 | ||
|
af5cd69f7b |
117 changed files with 3977 additions and 2090 deletions
|
@ -3,6 +3,6 @@
|
||||||
members = [
|
members = [
|
||||||
"planetwars-rules",
|
"planetwars-rules",
|
||||||
"planetwars-matchrunner",
|
"planetwars-matchrunner",
|
||||||
"planetwars-cli",
|
|
||||||
"planetwars-server",
|
"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-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-server`: frontend
|
||||||
- `web/pw-visualizer`: code for the visualizer
|
- `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
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "testmatch"
|
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
futures-core = "0.3"
|
futures-core = "0.3"
|
||||||
|
|
|
@ -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::io;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{Arc, Mutex};
|
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::match_log::{MatchLogMessage, MatchLogger, StdErrMessage};
|
||||||
use crate::BotSpec;
|
use crate::BotSpec;
|
||||||
|
|
||||||
|
// TODO: this API needs a better design with respect to pulling
|
||||||
|
// and general container management
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DockerBotSpec {
|
pub struct DockerBotSpec {
|
||||||
pub image: String,
|
pub image: String,
|
||||||
pub code_path: PathBuf,
|
pub binds: Option<Vec<String>>,
|
||||||
pub argv: 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]
|
#[async_trait]
|
||||||
|
@ -42,14 +52,36 @@ async fn spawn_docker_process(
|
||||||
params: &DockerBotSpec,
|
params: &DockerBotSpec,
|
||||||
) -> Result<ContainerProcess, bollard::errors::Error> {
|
) -> Result<ContainerProcess, bollard::errors::Error> {
|
||||||
let docker = Docker::connect_with_socket_defaults()?;
|
let docker = Docker::connect_with_socket_defaults()?;
|
||||||
let bot_code_dir = std::fs::canonicalize(¶ms.code_path).unwrap();
|
|
||||||
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 memory_limit = 512 * 1024 * 1024; // 512MB
|
||||||
let config = container::Config {
|
let config = container::Config {
|
||||||
image: Some(params.image.clone()),
|
image: Some(params.image.clone()),
|
||||||
host_config: Some(bollard::models::HostConfig {
|
host_config: Some(bollard::models::HostConfig {
|
||||||
binds: Some(vec![format!("{}:{}", code_dir_str, "/workdir")]),
|
binds: params.binds.clone(),
|
||||||
network_mode: Some("none".to_string()),
|
network_mode: Some("none".to_string()),
|
||||||
memory: Some(memory_limit),
|
memory: Some(memory_limit),
|
||||||
memory_swap: Some(memory_limit),
|
memory_swap: Some(memory_limit),
|
||||||
|
@ -59,8 +91,8 @@ async fn spawn_docker_process(
|
||||||
// cpu_quota: Some(10_000),
|
// cpu_quota: Some(10_000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
working_dir: Some("/workdir".to_string()),
|
working_dir: params.working_dir.clone(),
|
||||||
cmd: Some(params.argv.clone()),
|
cmd: params.argv.clone(),
|
||||||
attach_stdin: Some(true),
|
attach_stdin: Some(true),
|
||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
|
|
|
@ -58,7 +58,7 @@ pub struct MatchOutcome {
|
||||||
pub async fn run_match(config: MatchConfig) -> MatchOutcome {
|
pub async fn run_match(config: MatchConfig) -> MatchOutcome {
|
||||||
let pw_config = PwConfig {
|
let pw_config = PwConfig {
|
||||||
map_file: config.map_path,
|
map_file: config.map_path,
|
||||||
max_turns: 100,
|
max_turns: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
let event_bus = Arc::new(Mutex::new(EventBus::new()));
|
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 planetwars_rules::protocol::State;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::pw_match::PlayerCommand;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum MatchLogMessage {
|
pub enum MatchLogMessage {
|
||||||
|
@ -13,6 +15,19 @@ pub enum MatchLogMessage {
|
||||||
GameState(State),
|
GameState(State),
|
||||||
#[serde(rename = "stderr")]
|
#[serde(rename = "stderr")]
|
||||||
StdErr(StdErrMessage),
|
StdErr(StdErrMessage),
|
||||||
|
#[serde(rename = "timeout")]
|
||||||
|
Timeout { player_id: u32 },
|
||||||
|
#[serde(rename = "bad_command")]
|
||||||
|
BadCommand {
|
||||||
|
player_id: u32,
|
||||||
|
command: String,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "dispatches")]
|
||||||
|
Dispatches {
|
||||||
|
player_id: u32,
|
||||||
|
dispatches: Vec<PlayerCommand>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
|
|
@ -12,7 +12,7 @@ use std::convert::TryInto;
|
||||||
|
|
||||||
pub use planetwars_rules::config::{Config, Map};
|
pub use planetwars_rules::config::{Config, Map};
|
||||||
|
|
||||||
use planetwars_rules::protocol::{self as proto, PlayerAction};
|
use planetwars_rules::protocol as proto;
|
||||||
use planetwars_rules::serializer as pw_serializer;
|
use planetwars_rules::serializer as pw_serializer;
|
||||||
use planetwars_rules::{PlanetWars, PwConfig};
|
use planetwars_rules::{PlanetWars, PwConfig};
|
||||||
|
|
||||||
|
@ -40,22 +40,18 @@ impl PwMatch {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(&mut self) {
|
pub async fn run(&mut self) {
|
||||||
|
// log initial state
|
||||||
|
self.log_game_state();
|
||||||
|
|
||||||
while !self.match_state.is_finished() {
|
while !self.match_state.is_finished() {
|
||||||
let player_messages = self.prompt_players().await;
|
let player_messages = self.prompt_players().await;
|
||||||
|
|
||||||
for (player_id, turn) in player_messages {
|
for (player_id, turn) in player_messages {
|
||||||
let res = self.execute_action(player_id, turn);
|
let player_action = self.execute_action(player_id, turn);
|
||||||
if let Some(err) = action_errors(res) {
|
self.log_player_action(player_id, player_action);
|
||||||
let _info_str = serde_json::to_string(&err).unwrap();
|
|
||||||
// TODO
|
|
||||||
// println!("player {}: {}", player_id, info_str);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.match_state.step();
|
self.match_state.step();
|
||||||
|
self.log_game_state();
|
||||||
// Log state
|
|
||||||
let state = self.match_state.serialize_state();
|
|
||||||
self.match_ctx.log(MatchLogMessage::GameState(state));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,18 +84,14 @@ impl PwMatch {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_action(
|
fn execute_action(&mut self, player_num: usize, turn: RequestResult<Vec<u8>>) -> PlayerAction {
|
||||||
&mut self,
|
let data = match turn {
|
||||||
player_num: usize,
|
Err(_timeout) => return PlayerAction::Timeout,
|
||||||
turn: RequestResult<Vec<u8>>,
|
|
||||||
) -> proto::PlayerAction {
|
|
||||||
let turn = match turn {
|
|
||||||
Err(_timeout) => return proto::PlayerAction::Timeout,
|
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let action: proto::Action = match serde_json::from_slice(&turn) {
|
let action: proto::Action = match serde_json::from_slice(&data) {
|
||||||
Err(err) => return proto::PlayerAction::ParseError(err.to_string()),
|
Err(error) => return PlayerAction::ParseError { data, error },
|
||||||
Ok(action) => action,
|
Ok(action) => action,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -108,15 +100,64 @@ impl PwMatch {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|command| {
|
.map(|command| {
|
||||||
let res = self.match_state.execute_command(player_num, &command);
|
let res = self.match_state.execute_command(player_num, &command);
|
||||||
proto::PlayerCommand {
|
PlayerCommand {
|
||||||
command,
|
command,
|
||||||
error: res.err(),
|
error: res.err(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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> {
|
fn action_errors(action: PlayerAction) -> Option<PlayerAction> {
|
||||||
|
|
|
@ -84,7 +84,6 @@ impl PlanetWars {
|
||||||
.ok_or(CommandError::DestinationDoesNotExist)?;
|
.ok_or(CommandError::DestinationDoesNotExist)?;
|
||||||
|
|
||||||
if self.state.planets[origin_id].owner() != Some(player_id - 1) {
|
if self.state.planets[origin_id].owner() != Some(player_id - 1) {
|
||||||
println!("owner was {:?}", self.state.planets[origin_id].owner());
|
|
||||||
return Err(CommandError::OriginNotOwned);
|
return Err(CommandError::OriginNotOwned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,31 +49,3 @@ pub enum CommandError {
|
||||||
OriginDoesNotExist,
|
OriginDoesNotExist,
|
||||||
DestinationDoesNotExist,
|
DestinationDoesNotExist,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PlayerCommand {
|
|
||||||
pub command: Command,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub error: Option<CommandError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[serde(tag = "type", content = "value")]
|
|
||||||
pub enum PlayerAction {
|
|
||||||
Timeout,
|
|
||||||
ParseError(String),
|
|
||||||
Commands(Vec<PlayerCommand>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[serde(tag = "type", content = "content")]
|
|
||||||
pub enum ServerMessage {
|
|
||||||
/// Game state in current turn
|
|
||||||
GameState(State),
|
|
||||||
/// The action that was performed
|
|
||||||
PlayerAction(PlayerAction),
|
|
||||||
/// The game is over, and this is the concluding state.
|
|
||||||
FinalState(State),
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,12 +2,21 @@
|
||||||
name = "planetwars-server"
|
name = "planetwars-server"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
default-run = "planetwars-server"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
[[bin]]
|
||||||
|
name = "planetwars-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "planetwars-server-cli"
|
||||||
|
path = "src/cli.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
tokio = { version = "1.15", features = ["full"] }
|
tokio = { version = "1.15", features = ["full"] }
|
||||||
|
tokio-stream = "0.1.9"
|
||||||
hyper = "0.14"
|
hyper = "0.14"
|
||||||
axum = { version = "0.5", features = ["json", "headers", "multipart"] }
|
axum = { version = "0.5", features = ["json", "headers", "multipart"] }
|
||||||
diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
|
diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
|
||||||
|
@ -29,9 +38,15 @@ config = { version = "0.12", features = ["toml"] }
|
||||||
thiserror = "1.0.31"
|
thiserror = "1.0.31"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
tokio-util = { version="0.7.3", features=["io"] }
|
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
|
# TODO: remove me
|
||||||
shlex = "1.1"
|
shlex = "1.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-build = "0.7.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
parking_lot = "0.11"
|
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"
|
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 @@
|
||||||
|
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 diesel::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::schema::{bots, code_bundles};
|
use crate::schema::{bot_versions, bots};
|
||||||
use chrono;
|
use chrono;
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
|
@ -16,6 +16,7 @@ pub struct Bot {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub owner_id: Option<i32>,
|
pub owner_id: Option<i32>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub active_version: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_bot(new_bot: &NewBot, conn: &PgConnection) -> QueryResult<Bot> {
|
pub fn create_bot(new_bot: &NewBot, conn: &PgConnection) -> QueryResult<Bot> {
|
||||||
|
@ -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)
|
bots::table.filter(bots::name.eq(name)).first(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_all_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> {
|
pub fn find_bot_with_version_by_name(
|
||||||
// TODO: filter out bots that cannot be run (have no valid code bundle associated with them)
|
bot_name: &str,
|
||||||
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,
|
|
||||||
conn: &PgConnection,
|
conn: &PgConnection,
|
||||||
) -> QueryResult<CodeBundle> {
|
) -> QueryResult<(Bot, BotVersion)> {
|
||||||
diesel::insert_into(code_bundles::table)
|
bots::table
|
||||||
.values(new_code_bundle)
|
.inner_join(bot_versions::table.on(bots::active_version.eq(bot_versions::id.nullable())))
|
||||||
.get_result(conn)
|
.filter(bots::name.eq(bot_name))
|
||||||
|
.first(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_bot_code_bundles(bot_id: i32, conn: &PgConnection) -> QueryResult<Vec<CodeBundle>> {
|
pub fn all_active_bots_with_version(conn: &PgConnection) -> QueryResult<Vec<(Bot, BotVersion)>> {
|
||||||
code_bundles::table
|
bots::table
|
||||||
.filter(code_bundles::bot_id.eq(bot_id))
|
.inner_join(bot_versions::table.on(bots::active_version.eq(bot_versions::id.nullable())))
|
||||||
.get_results(conn)
|
.get_results(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_code_bundle(bot_id: i32, conn: &PgConnection) -> QueryResult<CodeBundle> {
|
pub fn find_all_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> {
|
||||||
code_bundles::table
|
bots::table.get_results(conn)
|
||||||
.filter(code_bundles::bot_id.eq(bot_id))
|
}
|
||||||
.order(code_bundles::created_at.desc())
|
|
||||||
|
/// 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)
|
.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;
|
pub use crate::db_types::MatchState;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use diesel::associations::BelongsTo;
|
use diesel::associations::BelongsTo;
|
||||||
|
use diesel::pg::Pg;
|
||||||
|
use diesel::query_builder::BoxedSelectStatement;
|
||||||
|
use diesel::query_source::{AppearsInFromClause, Once};
|
||||||
use diesel::{
|
use diesel::{
|
||||||
BelongingToDsl, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl,
|
BelongingToDsl, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, RunQueryDsl,
|
||||||
};
|
};
|
||||||
use diesel::{Connection, GroupedBy, PgConnection, QueryResult};
|
use diesel::{Connection, GroupedBy, PgConnection, QueryResult};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::schema::{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)]
|
#[derive(Insertable)]
|
||||||
#[table_name = "matches"]
|
#[table_name = "matches"]
|
||||||
pub struct NewMatch<'a> {
|
pub struct NewMatch<'a> {
|
||||||
pub state: MatchState,
|
pub state: MatchState,
|
||||||
pub log_path: &'a str,
|
pub log_path: &'a str,
|
||||||
|
pub is_public: bool,
|
||||||
|
pub map_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
|
@ -25,7 +32,7 @@ pub struct NewMatchPlayer {
|
||||||
/// player id within the match
|
/// player id within the match
|
||||||
pub player_id: i32,
|
pub player_id: i32,
|
||||||
/// id of the bot behind this player
|
/// id of the bot behind this player
|
||||||
pub code_bundle_id: i32,
|
pub bot_version_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Queryable, Identifiable)]
|
#[derive(Queryable, Identifiable)]
|
||||||
|
@ -36,6 +43,8 @@ pub struct MatchBase {
|
||||||
pub log_path: String,
|
pub log_path: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub winner: Option<i32>,
|
pub winner: Option<i32>,
|
||||||
|
pub is_public: bool,
|
||||||
|
pub map_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Queryable, Identifiable, Associations, Clone)]
|
#[derive(Queryable, Identifiable, Associations, Clone)]
|
||||||
|
@ -44,11 +53,11 @@ pub struct MatchBase {
|
||||||
pub struct MatchPlayer {
|
pub struct MatchPlayer {
|
||||||
pub match_id: i32,
|
pub match_id: i32,
|
||||||
pub player_id: i32,
|
pub player_id: i32,
|
||||||
pub code_bundle_id: i32,
|
pub code_bundle_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MatchPlayerData {
|
pub struct MatchPlayerData {
|
||||||
pub code_bundle_id: i32,
|
pub code_bundle_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_match(
|
pub fn create_match(
|
||||||
|
@ -67,7 +76,7 @@ pub fn create_match(
|
||||||
.map(|(num, player_data)| NewMatchPlayer {
|
.map(|(num, player_data)| NewMatchPlayer {
|
||||||
match_id: match_base.id,
|
match_id: match_base.id,
|
||||||
player_id: num as i32,
|
player_id: num as i32,
|
||||||
code_bundle_id: player_data.code_bundle_id,
|
bot_version_id: player_data.code_bundle_id,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
@ -87,32 +96,133 @@ pub struct MatchData {
|
||||||
pub match_players: Vec<MatchPlayer>,
|
pub match_players: Vec<MatchPlayer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_matches(conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
|
/// 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)
|
||||||
|
.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);
|
||||||
|
|
||||||
|
let res = matches
|
||||||
|
.into_iter()
|
||||||
|
.zip(match_players.into_iter())
|
||||||
|
.map(|(base, players)| FullMatchData {
|
||||||
|
match_players: players.into_iter().collect(),
|
||||||
|
map: base
|
||||||
|
.map_id
|
||||||
|
.and_then(|map_id| maps_by_id.get(&map_id).cloned()),
|
||||||
|
base,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this method should disappear
|
||||||
|
pub fn list_matches(amount: i64, conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
|
||||||
conn.transaction(|| {
|
conn.transaction(|| {
|
||||||
let matches = matches::table.get_results::<MatchBase>(conn)?;
|
let matches = matches::table
|
||||||
|
.filter(matches::state.eq(MatchState::Finished))
|
||||||
|
.order_by(matches::created_at.desc())
|
||||||
|
.limit(amount)
|
||||||
|
.get_results::<MatchBase>(conn)?;
|
||||||
|
|
||||||
let match_players = MatchPlayer::belonging_to(&matches)
|
fetch_full_match_data(matches, conn)
|
||||||
.inner_join(code_bundles::table)
|
|
||||||
.left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
|
|
||||||
.load::<FullMatchPlayerData>(conn)?
|
|
||||||
.grouped_by(&matches);
|
|
||||||
|
|
||||||
let res = matches
|
|
||||||
.into_iter()
|
|
||||||
.zip(match_players.into_iter())
|
|
||||||
.map(|(base, players)| FullMatchData {
|
|
||||||
base,
|
|
||||||
match_players: players.into_iter().collect(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(res)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_public_matches(
|
||||||
|
amount: i64,
|
||||||
|
before: Option<NaiveDateTime>,
|
||||||
|
after: Option<NaiveDateTime>,
|
||||||
|
conn: &PgConnection,
|
||||||
|
) -> QueryResult<Vec<FullMatchData>> {
|
||||||
|
conn.transaction(|| {
|
||||||
|
// TODO: how can this common logic be abstracted?
|
||||||
|
let query = matches::table
|
||||||
|
.filter(matches::state.eq(MatchState::Finished))
|
||||||
|
.filter(matches::is_public.eq(true))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
let matches =
|
||||||
|
select_matches_page(query, amount, before, after).get_results::<MatchBase>(conn)?;
|
||||||
|
fetch_full_match_data(matches, conn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_bot_matches(
|
||||||
|
bot_id: i32,
|
||||||
|
amount: i64,
|
||||||
|
before: Option<NaiveDateTime>,
|
||||||
|
after: Option<NaiveDateTime>,
|
||||||
|
conn: &PgConnection,
|
||||||
|
) -> QueryResult<Vec<FullMatchData>> {
|
||||||
|
let query = matches::table
|
||||||
|
.filter(matches::state.eq(MatchState::Finished))
|
||||||
|
.filter(matches::is_public.eq(true))
|
||||||
|
.order_by(matches::created_at.desc())
|
||||||
|
.inner_join(match_players::table)
|
||||||
|
.inner_join(
|
||||||
|
bot_versions::table.on(match_players::bot_version_id.eq(bot_versions::id.nullable())),
|
||||||
|
)
|
||||||
|
.filter(bot_versions::bot_id.eq(bot_id))
|
||||||
|
.select(matches::all_columns)
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
let matches =
|
||||||
|
select_matches_page(query, amount, before, after).get_results::<MatchBase>(conn)?;
|
||||||
|
fetch_full_match_data(matches, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_matches_page<QS>(
|
||||||
|
query: BoxedSelectStatement<'static, matches::SqlType, QS, Pg>,
|
||||||
|
amount: i64,
|
||||||
|
before: Option<NaiveDateTime>,
|
||||||
|
after: Option<NaiveDateTime>,
|
||||||
|
) -> BoxedSelectStatement<'static, matches::SqlType, QS, Pg>
|
||||||
|
where
|
||||||
|
QS: AppearsInFromClause<matches::table, Count = Once>,
|
||||||
|
{
|
||||||
|
// TODO: this is not nice. Replace this with proper cursor logic.
|
||||||
|
match (before, after) {
|
||||||
|
(None, None) => query.order_by(matches::created_at.desc()),
|
||||||
|
(Some(before), None) => query
|
||||||
|
.filter(matches::created_at.lt(before))
|
||||||
|
.order_by(matches::created_at.desc()),
|
||||||
|
(None, Some(after)) => query
|
||||||
|
.filter(matches::created_at.gt(after))
|
||||||
|
.order_by(matches::created_at.asc()),
|
||||||
|
(Some(before), Some(after)) => query
|
||||||
|
.filter(matches::created_at.lt(before))
|
||||||
|
.filter(matches::created_at.gt(after))
|
||||||
|
.order_by(matches::created_at.desc()),
|
||||||
|
}
|
||||||
|
.limit(amount)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: maybe unify this with matchdata?
|
// TODO: maybe unify this with matchdata?
|
||||||
pub struct FullMatchData {
|
pub struct FullMatchData {
|
||||||
pub base: MatchBase,
|
pub base: MatchBase,
|
||||||
|
pub map: Option<Map>,
|
||||||
pub match_players: Vec<FullMatchPlayerData>,
|
pub match_players: Vec<FullMatchPlayerData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,7 +230,7 @@ pub struct FullMatchData {
|
||||||
// #[primary_key(base.match_id, base::player_id)]
|
// #[primary_key(base.match_id, base::player_id)]
|
||||||
pub struct FullMatchPlayerData {
|
pub struct FullMatchPlayerData {
|
||||||
pub base: MatchPlayer,
|
pub base: MatchPlayer,
|
||||||
pub code_bundle: CodeBundle,
|
pub bot_version: Option<BotVersion>,
|
||||||
pub bot: Option<Bot>,
|
pub bot: Option<Bot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,14 +251,24 @@ pub fn find_match(id: i32, conn: &PgConnection) -> QueryResult<FullMatchData> {
|
||||||
conn.transaction(|| {
|
conn.transaction(|| {
|
||||||
let match_base = matches::table.find(id).get_result::<MatchBase>(conn)?;
|
let match_base = matches::table.find(id).get_result::<MatchBase>(conn)?;
|
||||||
|
|
||||||
|
let map = match match_base.map_id {
|
||||||
|
None => None,
|
||||||
|
Some(map_id) => Some(super::maps::find_map(map_id, conn)?),
|
||||||
|
};
|
||||||
|
|
||||||
let match_players = MatchPlayer::belonging_to(&match_base)
|
let match_players = MatchPlayer::belonging_to(&match_base)
|
||||||
.inner_join(code_bundles::table)
|
.left_join(
|
||||||
.left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
|
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)?;
|
.load::<FullMatchPlayerData>(conn)?;
|
||||||
|
|
||||||
let res = FullMatchData {
|
let res = FullMatchData {
|
||||||
base: match_base,
|
base: match_base,
|
||||||
match_players,
|
match_players,
|
||||||
|
map,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(res)
|
Ok(res)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod bots;
|
pub mod bots;
|
||||||
|
pub mod maps;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
pub mod ratings;
|
pub mod ratings;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|
|
@ -42,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 argon_config = argon2_config();
|
||||||
|
|
||||||
let salt: [u8; 32] = rand::thread_rng().gen();
|
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 {
|
let new_user = NewUser {
|
||||||
username: credentials.username,
|
username: credentials.username,
|
||||||
password_salt: &salt,
|
password_salt: &salt,
|
||||||
|
@ -57,14 +63,36 @@ pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResul
|
||||||
.get_result::<User>(conn)
|
.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
|
users::table
|
||||||
.filter(users::username.eq(username))
|
.filter(users::username.eq(username))
|
||||||
.first::<User>(db_conn)
|
.first::<User>(db_conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_user_password(credentials: Credentials, db_conn: &PgConnection) -> QueryResult<()> {
|
||||||
|
let (hash, salt) = hash_password(&credentials.password);
|
||||||
|
|
||||||
|
let n_changes = diesel::update(users::table.filter(users::username.eq(&credentials.username)))
|
||||||
|
.set((
|
||||||
|
users::password_salt.eq(salt.as_slice()),
|
||||||
|
users::password_hash.eq(hash.as_slice()),
|
||||||
|
))
|
||||||
|
.execute(db_conn)?;
|
||||||
|
if n_changes == 0 {
|
||||||
|
Err(diesel::result::Error::NotFound)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> {
|
pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> {
|
||||||
find_user(credentials.username, db_conn)
|
find_user_by_name(credentials.username, db_conn)
|
||||||
.optional()
|
.optional()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.and_then(|user| {
|
.and_then(|user| {
|
||||||
|
|
|
@ -8,16 +8,19 @@ pub mod routes;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::{fs, net::SocketAddr};
|
||||||
|
|
||||||
use bb8::{Pool, PooledConnection};
|
use bb8::{Pool, PooledConnection};
|
||||||
use bb8_diesel::{self, DieselConnectionManager};
|
use bb8_diesel::{self, DieselConnectionManager};
|
||||||
use config::ConfigError;
|
use config::ConfigError;
|
||||||
use diesel::{Connection, PgConnection};
|
use diesel::{Connection, PgConnection};
|
||||||
|
use modules::client_api::run_client_api;
|
||||||
use modules::ranking::run_ranker;
|
use modules::ranking::run_ranker;
|
||||||
use modules::registry::registry_service;
|
use modules::registry::registry_service;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
async_trait,
|
async_trait,
|
||||||
|
@ -27,15 +30,45 @@ use axum::{
|
||||||
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>>;
|
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");
|
let conn = pool.get().await.expect("could not get database connection");
|
||||||
// This transaction is expected to fail when simplebot already exists.
|
// This transaction is expected to fail when simplebot already exists.
|
||||||
let _res = conn.transaction::<(), diesel::result::Error, _>(|| {
|
let _res = conn.transaction::<(), diesel::result::Error, _>(|| {
|
||||||
|
@ -51,7 +84,7 @@ pub async fn seed_simplebot(pool: &ConnectionPool) {
|
||||||
let simplebot_code =
|
let simplebot_code =
|
||||||
std::fs::read_to_string(SIMPLEBOT_PATH).expect("could not read simplebot code");
|
std::fs::read_to_string(SIMPLEBOT_PATH).expect("could not read simplebot code");
|
||||||
|
|
||||||
modules::bots::save_code_bundle(&simplebot_code, Some(simplebot.id), &conn)?;
|
modules::bots::save_code_string(&simplebot_code, Some(simplebot.id), &conn, config)?;
|
||||||
|
|
||||||
println!("initialized simplebot");
|
println!("initialized simplebot");
|
||||||
|
|
||||||
|
@ -61,11 +94,22 @@ pub async fn seed_simplebot(pool: &ConnectionPool) {
|
||||||
|
|
||||||
pub type DbPool = Pool<DieselConnectionManager<PgConnection>>;
|
pub type DbPool = Pool<DieselConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
pub async fn prepare_db(database_url: &str) -> DbPool {
|
pub async fn create_db_pool(config: &GlobalConfig) -> DbPool {
|
||||||
let manager = DieselConnectionManager::<PgConnection>::new(database_url);
|
let manager = DieselConnectionManager::<PgConnection>::new(&config.database_url);
|
||||||
let pool = bb8::Pool::builder().build(manager).await.unwrap();
|
bb8::Pool::builder().build(manager).await.unwrap()
|
||||||
seed_simplebot(&pool).await;
|
}
|
||||||
pool
|
|
||||||
|
// create all directories required for further operation
|
||||||
|
fn init_directories(config: &GlobalConfig) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(&config.bots_directory)?;
|
||||||
|
fs::create_dir_all(&config.maps_directory)?;
|
||||||
|
fs::create_dir_all(&config.match_logs_directory)?;
|
||||||
|
|
||||||
|
let registry_path = PathBuf::from(&config.registry_directory);
|
||||||
|
fs::create_dir_all(registry_path.join("sha256"))?;
|
||||||
|
fs::create_dir_all(registry_path.join("manifests"))?;
|
||||||
|
fs::create_dir_all(registry_path.join("uploads"))?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn api() -> Router {
|
pub fn api() -> Router {
|
||||||
|
@ -73,31 +117,30 @@ pub fn api() -> Router {
|
||||||
.route("/register", post(routes::users::register))
|
.route("/register", post(routes::users::register))
|
||||||
.route("/login", post(routes::users::login))
|
.route("/login", post(routes::users::login))
|
||||||
.route("/users/me", get(routes::users::current_user))
|
.route("/users/me", get(routes::users::current_user))
|
||||||
|
.route("/users/:user/bots", get(routes::bots::get_user_bots))
|
||||||
.route(
|
.route(
|
||||||
"/bots",
|
"/bots",
|
||||||
get(routes::bots::list_bots).post(routes::bots::create_bot),
|
get(routes::bots::list_bots).post(routes::bots::create_bot),
|
||||||
)
|
)
|
||||||
.route("/bots/my_bots", get(routes::bots::get_my_bots))
|
.route("/bots/:bot_name", get(routes::bots::get_bot))
|
||||||
.route("/bots/:bot_id", get(routes::bots::get_bot))
|
|
||||||
.route(
|
.route(
|
||||||
"/bots/:bot_id/upload",
|
"/bots/:bot_name/upload",
|
||||||
post(routes::bots::upload_code_multipart),
|
post(routes::bots::upload_code_multipart),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/code/:version_id", get(routes::bots::get_code))
|
||||||
"/matches",
|
.route("/matches", get(routes::matches::list_recent_matches))
|
||||||
get(routes::matches::list_matches).post(routes::matches::play_match),
|
|
||||||
)
|
|
||||||
.route("/matches/:match_id", get(routes::matches::get_match_data))
|
.route("/matches/:match_id", get(routes::matches::get_match_data))
|
||||||
.route(
|
.route(
|
||||||
"/matches/:match_id/log",
|
"/matches/:match_id/log",
|
||||||
get(routes::matches::get_match_log),
|
get(routes::matches::get_match_log),
|
||||||
)
|
)
|
||||||
|
.route("/maps", get(routes::maps::list_maps))
|
||||||
.route("/leaderboard", get(routes::bots::get_ranking))
|
.route("/leaderboard", get(routes::bots::get_ranking))
|
||||||
.route("/submit_bot", post(routes::demo::submit_bot))
|
.route("/submit_bot", post(routes::demo::submit_bot))
|
||||||
.route("/save_bot", post(routes::bots::save_bot))
|
.route("/save_bot", post(routes::bots::save_bot))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_config() -> Result<Configuration, ConfigError> {
|
pub fn get_config() -> Result<GlobalConfig, ConfigError> {
|
||||||
config::Config::builder()
|
config::Config::builder()
|
||||||
.add_source(config::File::with_name("configuration.toml"))
|
.add_source(config::File::with_name("configuration.toml"))
|
||||||
.add_source(config::Environment::with_prefix("PLANETWARS"))
|
.add_source(config::Environment::with_prefix("PLANETWARS"))
|
||||||
|
@ -105,7 +148,7 @@ pub fn get_config() -> Result<Configuration, ConfigError> {
|
||||||
.try_deserialize()
|
.try_deserialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_registry(db_pool: DbPool) {
|
async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) {
|
||||||
// TODO: put in config
|
// TODO: put in config
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 9001));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 9001));
|
||||||
|
|
||||||
|
@ -113,6 +156,7 @@ async fn run_registry(db_pool: DbPool) {
|
||||||
.serve(
|
.serve(
|
||||||
registry_service()
|
registry_service()
|
||||||
.layer(Extension(db_pool))
|
.layer(Extension(db_pool))
|
||||||
|
.layer(Extension(config))
|
||||||
.into_make_service(),
|
.into_make_service(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
@ -120,15 +164,21 @@ async fn run_registry(db_pool: DbPool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_app() {
|
pub async fn run_app() {
|
||||||
let configuration = get_config().unwrap();
|
let global_config = Arc::new(get_config().unwrap());
|
||||||
let db_pool = prepare_db(&configuration.database_url).await;
|
let db_pool = create_db_pool(&global_config).await;
|
||||||
|
seed_simplebot(&global_config, &db_pool).await;
|
||||||
|
init_directories(&global_config).unwrap();
|
||||||
|
|
||||||
tokio::spawn(run_ranker(db_pool.clone()));
|
if global_config.ranker_enabled {
|
||||||
tokio::spawn(run_registry(db_pool.clone()));
|
tokio::spawn(run_ranker(global_config.clone(), db_pool.clone()));
|
||||||
|
}
|
||||||
|
tokio::spawn(run_registry(global_config.clone(), db_pool.clone()));
|
||||||
|
tokio::spawn(run_client_api(global_config.clone(), db_pool.clone()));
|
||||||
|
|
||||||
let api_service = Router::new()
|
let api_service = Router::new()
|
||||||
.nest("/api", api())
|
.nest("/api", api())
|
||||||
.layer(Extension(db_pool))
|
.layer(Extension(db_pool))
|
||||||
|
.layer(Extension(global_config))
|
||||||
.into_make_service();
|
.into_make_service();
|
||||||
|
|
||||||
// TODO: put in config
|
// TODO: put in config
|
||||||
|
@ -137,11 +187,6 @@ pub async fn run_app() {
|
||||||
axum::Server::bind(&addr).serve(api_service).await.unwrap();
|
axum::Server::bind(&addr).serve(api_service).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct Configuration {
|
|
||||||
pub database_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// we can also write a custom extractor that grabs a connection from the pool
|
// we can also write a custom extractor that grabs a connection from the pool
|
||||||
// which setup is appropriate depends on your application
|
// which setup is appropriate depends on your application
|
||||||
pub struct DatabaseConnection(PooledConnection<'static, DieselConnectionManager<PgConnection>>);
|
pub struct DatabaseConnection(PooledConnection<'static, DieselConnectionManager<PgConnection>>);
|
||||||
|
|
|
@ -2,22 +2,32 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
use diesel::{PgConnection, QueryResult};
|
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_code: &str,
|
||||||
bot_id: Option<i32>,
|
bot_id: Option<i32>,
|
||||||
conn: &PgConnection,
|
conn: &PgConnection,
|
||||||
) -> QueryResult<db::bots::CodeBundle> {
|
config: &GlobalConfig,
|
||||||
|
) -> QueryResult<db::bots::BotVersion> {
|
||||||
let bundle_name = gen_alphanumeric(16);
|
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::create_dir(&code_bundle_dir).unwrap();
|
||||||
std::fs::write(code_bundle_dir.join("bot.py"), bot_code).unwrap();
|
std::fs::write(code_bundle_dir.join("bot.py"), bot_code).unwrap();
|
||||||
|
|
||||||
let new_code_bundle = db::bots::NewCodeBundle {
|
let new_code_bundle = db::bots::NewBotVersion {
|
||||||
bot_id,
|
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,87 +1,161 @@
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use diesel::{PgConnection, QueryResult};
|
use diesel::{PgConnection, QueryResult};
|
||||||
use planetwars_matchrunner::{self as runner, docker_runner::DockerBotSpec, BotSpec, MatchConfig};
|
use planetwars_matchrunner::{self as runner, docker_runner::DockerBotSpec, BotSpec, MatchConfig};
|
||||||
use runner::MatchOutcome;
|
use runner::MatchOutcome;
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
|
maps::Map,
|
||||||
matches::{MatchData, MatchResult},
|
matches::{MatchData, MatchResult},
|
||||||
},
|
},
|
||||||
util::gen_alphanumeric,
|
util::gen_alphanumeric,
|
||||||
ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
|
ConnectionPool, GlobalConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
|
pub struct RunMatch {
|
||||||
|
|
||||||
pub struct RunMatch<'a> {
|
|
||||||
log_file_name: String,
|
log_file_name: String,
|
||||||
player_code_bundles: Vec<&'a db::bots::CodeBundle>,
|
players: Vec<MatchPlayer>,
|
||||||
match_id: Option<i32>,
|
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 enum MatchPlayer {
|
||||||
pub fn from_players(player_code_bundles: Vec<&'a db::bots::CodeBundle>) -> Self {
|
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));
|
let log_file_name = format!("{}.log", gen_alphanumeric(16));
|
||||||
RunMatch {
|
RunMatch {
|
||||||
|
config,
|
||||||
log_file_name,
|
log_file_name,
|
||||||
player_code_bundles,
|
players,
|
||||||
match_id: None,
|
is_public,
|
||||||
|
map,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn runner_config(&self) -> runner::MatchConfig {
|
fn into_runner_config(self) -> runner::MatchConfig {
|
||||||
runner::MatchConfig {
|
runner::MatchConfig {
|
||||||
map_path: PathBuf::from(MAPS_DIR).join("hex.json"),
|
map_path: PathBuf::from(&self.config.maps_directory).join(self.map.file_path),
|
||||||
map_name: "hex".to_string(),
|
map_name: self.map.name,
|
||||||
log_path: PathBuf::from(MATCHES_DIR).join(&self.log_file_name),
|
log_path: PathBuf::from(&self.config.match_logs_directory).join(&self.log_file_name),
|
||||||
players: self
|
players: self
|
||||||
.player_code_bundles
|
.players
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|b| runner::MatchPlayer {
|
.map(|player| runner::MatchPlayer {
|
||||||
bot_spec: code_bundle_to_botspec(b),
|
bot_spec: match player {
|
||||||
|
MatchPlayer::BotVersion { bot, version } => {
|
||||||
|
bot_version_to_botspec(&self.config, bot.as_ref(), &version)
|
||||||
|
}
|
||||||
|
MatchPlayer::BotSpec { spec } => spec,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn store_in_database(&mut self, db_conn: &PgConnection) -> QueryResult<MatchData> {
|
pub async fn run(
|
||||||
// don't store the same match twice
|
self,
|
||||||
assert!(self.match_id.is_none());
|
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 {
|
let new_match_data = db::matches::NewMatch {
|
||||||
state: db::matches::MatchState::Playing,
|
state: db::matches::MatchState::Playing,
|
||||||
log_path: &self.log_file_name,
|
log_path: &self.log_file_name,
|
||||||
|
is_public: self.is_public,
|
||||||
|
map_id: Some(self.map.id),
|
||||||
};
|
};
|
||||||
let new_match_players = self
|
let new_match_players = self
|
||||||
.player_code_bundles
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| db::matches::MatchPlayerData {
|
.map(|p| db::matches::MatchPlayerData {
|
||||||
code_bundle_id: b.id,
|
code_bundle_id: match p {
|
||||||
|
MatchPlayer::BotVersion { version, .. } => Some(version.id),
|
||||||
|
MatchPlayer::BotSpec { .. } => None,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let match_data = db::matches::create_match(&new_match_data, &new_match_players, &db_conn)?;
|
db::matches::create_match(&new_match_data, &new_match_players, db_conn)
|
||||||
self.match_id = Some(match_data.base.id);
|
|
||||||
Ok(match_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn(self, pool: ConnectionPool) -> JoinHandle<MatchOutcome> {
|
|
||||||
let match_id = self.match_id.expect("match must be saved before running");
|
|
||||||
let runner_config = self.runner_config();
|
|
||||||
tokio::spawn(run_match_task(pool, runner_config, match_id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn code_bundle_to_botspec(code_bundle: &db::bots::CodeBundle) -> Box<dyn BotSpec> {
|
pub fn bot_version_to_botspec(
|
||||||
let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
|
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 {
|
||||||
|
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 {
|
Box::new(DockerBotSpec {
|
||||||
code_path: bundle_path,
|
image: config.python_runner_image.clone(),
|
||||||
image: PYTHON_IMAGE.to_string(),
|
binds: Some(vec![format!("{}:{}", code_bundle_path_str, "/workdir")]),
|
||||||
argv: vec!["python".to_string(), "bot.py".to_string()],
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,5 +178,5 @@ async fn run_match_task(
|
||||||
|
|
||||||
db::matches::save_match_result(match_id, result, &conn).expect("could not save match result");
|
db::matches::save_match_result(match_id, result, &conn).expect("could not save match result");
|
||||||
|
|
||||||
return outcome;
|
outcome
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// This module implements general domain logic, not directly
|
// This module implements general domain logic, not directly
|
||||||
// tied to the database or API layers.
|
// tied to the database or API layers.
|
||||||
pub mod bots;
|
pub mod bots;
|
||||||
|
pub mod client_api;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
pub mod ranking;
|
pub mod ranking;
|
||||||
pub mod registry;
|
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::db;
|
||||||
use crate::modules::matches::RunMatch;
|
use crate::modules::matches::{MatchPlayer, RunMatch};
|
||||||
use diesel::{PgConnection, QueryResult};
|
use diesel::{PgConnection, QueryResult};
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::mem;
|
use std::mem;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio;
|
use tokio;
|
||||||
|
|
||||||
|
// TODO: put these in a config
|
||||||
const RANKER_INTERVAL: u64 = 60;
|
const RANKER_INTERVAL: u64 = 60;
|
||||||
|
const RANKER_NUM_MATCHES: i64 = 10_000;
|
||||||
|
|
||||||
pub async fn run_ranker(db_pool: DbPool) {
|
pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) {
|
||||||
// TODO: make this configurable
|
// TODO: make this configurable
|
||||||
// play at most one match every n seconds
|
// play at most one match every n seconds
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL));
|
let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL));
|
||||||
|
@ -21,39 +26,49 @@ pub async fn run_ranker(db_pool: DbPool) {
|
||||||
.expect("could not get database connection");
|
.expect("could not get database connection");
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let bots = db::bots::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 {
|
if bots.len() < 2 {
|
||||||
// not enough bots to play a match
|
// not enough bots to play a match
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let selected_bots: Vec<Bot> = {
|
|
||||||
let mut rng = &mut rand::thread_rng();
|
let selected_bots: Vec<(Bot, BotVersion)> = bots
|
||||||
bots.choose_multiple(&mut rng, 2).cloned().collect()
|
.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");
|
recalculate_ratings(&db_conn).expect("could not recalculate ratings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn play_ranking_match(selected_bots: Vec<Bot>, db_pool: DbPool) {
|
async fn play_ranking_match(
|
||||||
let db_conn = db_pool.get().await.expect("could not get db pool");
|
config: Arc<GlobalConfig>,
|
||||||
let mut code_bundles = Vec::new();
|
map: Map,
|
||||||
for bot in &selected_bots {
|
selected_bots: Vec<(Bot, BotVersion)>,
|
||||||
let code_bundle = db::bots::active_code_bundle(bot.id, &db_conn)
|
db_pool: DbPool,
|
||||||
.expect("could not get active code bundle");
|
) {
|
||||||
code_bundles.push(code_bundle);
|
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().collect::<Vec<_>>();
|
let (_, handle) = RunMatch::new(config, true, map, players)
|
||||||
|
.run(db_pool.clone())
|
||||||
let mut run_match = RunMatch::from_players(code_bundle_refs);
|
|
||||||
run_match
|
|
||||||
.store_in_database(&db_conn)
|
|
||||||
.expect("could not store match in db");
|
|
||||||
run_match
|
|
||||||
.spawn(db_pool.clone())
|
|
||||||
.await
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recalculate_ratings(db_conn: &PgConnection) -> QueryResult<()> {
|
fn recalculate_ratings(db_conn: &PgConnection) -> QueryResult<()> {
|
||||||
|
@ -77,7 +92,7 @@ struct MatchStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_match_stats(db_conn: &PgConnection) -> QueryResult<HashMap<(i32, i32), MatchStats>> {
|
fn fetch_match_stats(db_conn: &PgConnection) -> QueryResult<HashMap<(i32, i32), MatchStats>> {
|
||||||
let matches = db::matches::list_matches(db_conn)?;
|
let matches = db::matches::list_matches(RANKER_NUM_MATCHES, db_conn)?;
|
||||||
|
|
||||||
let mut match_stats = HashMap::<(i32, i32), MatchStats>::new();
|
let mut match_stats = HashMap::<(i32, i32), MatchStats>::new();
|
||||||
for m in matches {
|
for m in matches {
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
|
// TODO: this module is functional, but it needs a good refactor for proper error handling.
|
||||||
|
|
||||||
use axum::body::{Body, StreamBody};
|
use axum::body::{Body, StreamBody};
|
||||||
use axum::extract::{BodyStream, FromRequest, Path, Query, RequestParts, TypedHeader};
|
use axum::extract::{BodyStream, FromRequest, Path, Query, RequestParts, TypedHeader};
|
||||||
use axum::handler::Handler;
|
|
||||||
use axum::headers::authorization::Basic;
|
use axum::headers::authorization::Basic;
|
||||||
use axum::headers::Authorization;
|
use axum::headers::Authorization;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{get, head, post, put};
|
use axum::routing::{get, head, post, put};
|
||||||
use axum::{async_trait, Router};
|
use axum::{async_trait, Extension, Router};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
use crate::db::bots::NewBotVersion;
|
||||||
use crate::util::gen_alphanumeric;
|
use crate::util::gen_alphanumeric;
|
||||||
use crate::{db, DatabaseConnection};
|
use crate::{db, DatabaseConnection, GlobalConfig};
|
||||||
|
|
||||||
use crate::db::users::{authenticate_user, Credentials, User};
|
use crate::db::users::{authenticate_user, Credentials, User};
|
||||||
|
|
||||||
const REGISTRY_PATH: &str = "./data/registry";
|
|
||||||
|
|
||||||
pub fn registry_service() -> Router {
|
pub fn registry_service() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// The docker API requires this trailing slash
|
// The docker API requires this trailing slash
|
||||||
.nest("/v2/", registry_api_v2())
|
.nest("/v2/", registry_api_v2())
|
||||||
.fallback(fallback.into_service())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_api_v2() -> Router {
|
fn registry_api_v2() -> Router {
|
||||||
|
@ -46,15 +46,7 @@ fn registry_api_v2() -> Router {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fallback(request: axum::http::Request<Body>) -> impl IntoResponse {
|
|
||||||
// for debugging
|
|
||||||
println!("no route for {} {}", request.method(), request.uri());
|
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADMIN_USERNAME: &str = "admin";
|
const ADMIN_USERNAME: &str = "admin";
|
||||||
// TODO: put this in some configuration
|
|
||||||
const ADMIN_PASSWORD: &str = "supersecretpassword";
|
|
||||||
|
|
||||||
type AuthorizationHeader = TypedHeader<Authorization<Basic>>;
|
type AuthorizationHeader = TypedHeader<Authorization<Basic>>;
|
||||||
|
|
||||||
|
@ -109,8 +101,12 @@ where
|
||||||
password: basic.password(),
|
password: basic.password(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Extension(config) = Extension::<Arc<GlobalConfig>>::from_request(req)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if credentials.username == ADMIN_USERNAME {
|
if credentials.username == ADMIN_USERNAME {
|
||||||
if credentials.password == ADMIN_PASSWORD {
|
if credentials.password == config.registry_admin_password {
|
||||||
Ok(RegistryAuth::Admin)
|
Ok(RegistryAuth::Admin)
|
||||||
} else {
|
} else {
|
||||||
Err(RegistryAuthError::InvalidCredentials)
|
Err(RegistryAuthError::InvalidCredentials)
|
||||||
|
@ -166,11 +162,14 @@ async fn check_blob_exists(
|
||||||
db_conn: DatabaseConnection,
|
db_conn: DatabaseConnection,
|
||||||
auth: RegistryAuth,
|
auth: RegistryAuth,
|
||||||
Path((repository_name, raw_digest)): Path<(String, String)>,
|
Path((repository_name, raw_digest)): Path<(String, String)>,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
let digest = raw_digest.strip_prefix("sha256:").unwrap();
|
let digest = raw_digest.strip_prefix("sha256:").unwrap();
|
||||||
let blob_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
|
let blob_path = PathBuf::from(&config.registry_directory)
|
||||||
|
.join("sha256")
|
||||||
|
.join(&digest);
|
||||||
if blob_path.exists() {
|
if blob_path.exists() {
|
||||||
let metadata = std::fs::metadata(&blob_path).unwrap();
|
let metadata = std::fs::metadata(&blob_path).unwrap();
|
||||||
Ok((StatusCode::OK, [("Content-Length", metadata.len())]))
|
Ok((StatusCode::OK, [("Content-Length", metadata.len())]))
|
||||||
|
@ -183,11 +182,14 @@ async fn get_blob(
|
||||||
db_conn: DatabaseConnection,
|
db_conn: DatabaseConnection,
|
||||||
auth: RegistryAuth,
|
auth: RegistryAuth,
|
||||||
Path((repository_name, raw_digest)): Path<(String, String)>,
|
Path((repository_name, raw_digest)): Path<(String, String)>,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
let digest = raw_digest.strip_prefix("sha256:").unwrap();
|
let digest = raw_digest.strip_prefix("sha256:").unwrap();
|
||||||
let blob_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
|
let blob_path = PathBuf::from(&config.registry_directory)
|
||||||
|
.join("sha256")
|
||||||
|
.join(&digest);
|
||||||
if !blob_path.exists() {
|
if !blob_path.exists() {
|
||||||
return Err(StatusCode::NOT_FOUND);
|
return Err(StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
@ -201,13 +203,18 @@ async fn create_upload(
|
||||||
db_conn: DatabaseConnection,
|
db_conn: DatabaseConnection,
|
||||||
auth: RegistryAuth,
|
auth: RegistryAuth,
|
||||||
Path(repository_name): Path<String>,
|
Path(repository_name): Path<String>,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
let uuid = gen_alphanumeric(16);
|
let uuid = gen_alphanumeric(16);
|
||||||
tokio::fs::File::create(PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid))
|
tokio::fs::File::create(
|
||||||
.await
|
PathBuf::from(&config.registry_directory)
|
||||||
.unwrap();
|
.join("uploads")
|
||||||
|
.join(&uuid),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::ACCEPTED)
|
.status(StatusCode::ACCEPTED)
|
||||||
|
@ -226,11 +233,14 @@ async fn patch_upload(
|
||||||
auth: RegistryAuth,
|
auth: RegistryAuth,
|
||||||
Path((repository_name, uuid)): Path<(String, String)>,
|
Path((repository_name, uuid)): Path<(String, String)>,
|
||||||
mut stream: BodyStream,
|
mut stream: BodyStream,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
// TODO: support content range header in request
|
// TODO: support content range header in request
|
||||||
let upload_path = PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid);
|
let upload_path = PathBuf::from(&config.registry_directory)
|
||||||
|
.join("uploads")
|
||||||
|
.join(&uuid);
|
||||||
let mut file = tokio::fs::OpenOptions::new()
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
.read(false)
|
.read(false)
|
||||||
.write(true)
|
.write(true)
|
||||||
|
@ -270,10 +280,13 @@ async fn put_upload(
|
||||||
Path((repository_name, uuid)): Path<(String, String)>,
|
Path((repository_name, uuid)): Path<(String, String)>,
|
||||||
Query(params): Query<UploadParams>,
|
Query(params): Query<UploadParams>,
|
||||||
mut stream: BodyStream,
|
mut stream: BodyStream,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
let upload_path = PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid);
|
let upload_path = PathBuf::from(&config.registry_directory)
|
||||||
|
.join("uploads")
|
||||||
|
.join(&uuid);
|
||||||
let mut file = tokio::fs::OpenOptions::new()
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
.read(false)
|
.read(false)
|
||||||
.write(true)
|
.write(true)
|
||||||
|
@ -287,8 +300,10 @@ async fn put_upload(
|
||||||
while let Some(Ok(chunk)) = stream.next().await {
|
while let Some(Ok(chunk)) = stream.next().await {
|
||||||
file.write_all(&chunk).await.unwrap();
|
file.write_all(&chunk).await.unwrap();
|
||||||
}
|
}
|
||||||
file.flush().await.unwrap();
|
|
||||||
let range_end = last_byte_pos(&file).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 expected_digest = params.digest.strip_prefix("sha256:").unwrap();
|
||||||
let digest = file_sha256_digest(&upload_path).unwrap();
|
let digest = file_sha256_digest(&upload_path).unwrap();
|
||||||
|
@ -297,7 +312,9 @@ async fn put_upload(
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
|
let target_path = PathBuf::from(&config.registry_directory)
|
||||||
|
.join("sha256")
|
||||||
|
.join(&digest);
|
||||||
tokio::fs::rename(&upload_path, &target_path).await.unwrap();
|
tokio::fs::rename(&upload_path, &target_path).await.unwrap();
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
|
@ -318,10 +335,11 @@ async fn get_manifest(
|
||||||
db_conn: DatabaseConnection,
|
db_conn: DatabaseConnection,
|
||||||
auth: RegistryAuth,
|
auth: RegistryAuth,
|
||||||
Path((repository_name, reference)): Path<(String, String)>,
|
Path((repository_name, reference)): Path<(String, String)>,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
let manifest_path = PathBuf::from(REGISTRY_PATH)
|
let manifest_path = PathBuf::from(&config.registry_directory)
|
||||||
.join("manifests")
|
.join("manifests")
|
||||||
.join(&repository_name)
|
.join(&repository_name)
|
||||||
.join(&reference)
|
.join(&reference)
|
||||||
|
@ -343,10 +361,11 @@ async fn put_manifest(
|
||||||
auth: RegistryAuth,
|
auth: RegistryAuth,
|
||||||
Path((repository_name, reference)): Path<(String, String)>,
|
Path((repository_name, reference)): Path<(String, String)>,
|
||||||
mut stream: BodyStream,
|
mut stream: BodyStream,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
check_access(&repository_name, &auth, &db_conn)?;
|
let bot = check_access(&repository_name, &auth, &db_conn)?;
|
||||||
|
|
||||||
let repository_dir = PathBuf::from(REGISTRY_PATH)
|
let repository_dir = PathBuf::from(&config.registry_directory)
|
||||||
.join("manifests")
|
.join("manifests")
|
||||||
.join(&repository_name);
|
.join(&repository_name);
|
||||||
|
|
||||||
|
@ -373,6 +392,18 @@ async fn put_manifest(
|
||||||
let digest_path = repository_dir.join(&content_digest).with_extension("json");
|
let digest_path = repository_dir.join(&content_digest).with_extension("json");
|
||||||
tokio::fs::copy(manifest_path, digest_path).await.unwrap();
|
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()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::CREATED)
|
.status(StatusCode::CREATED)
|
||||||
.header(
|
.header(
|
||||||
|
@ -385,12 +416,13 @@ async fn put_manifest(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure that the accessed repository exists
|
/// Ensure that the accessed repository exists
|
||||||
/// and the user is allowed to access ti
|
/// and the user is allowed to access it.
|
||||||
|
/// Returns the associated bot.
|
||||||
fn check_access(
|
fn check_access(
|
||||||
repository_name: &str,
|
repository_name: &str,
|
||||||
auth: &RegistryAuth,
|
auth: &RegistryAuth,
|
||||||
db_conn: &DatabaseConnection,
|
db_conn: &DatabaseConnection,
|
||||||
) -> Result<(), StatusCode> {
|
) -> Result<db::bots::Bot, StatusCode> {
|
||||||
use diesel::OptionalExtension;
|
use diesel::OptionalExtension;
|
||||||
|
|
||||||
// TODO: it would be nice to provide the found repository
|
// TODO: it would be nice to provide the found repository
|
||||||
|
@ -401,10 +433,10 @@ fn check_access(
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
match &auth {
|
match &auth {
|
||||||
RegistryAuth::Admin => Ok(()),
|
RegistryAuth::Admin => Ok(bot),
|
||||||
RegistryAuth::User(user) => {
|
RegistryAuth::User(user) => {
|
||||||
if bot.owner_id == Some(user.id) {
|
if bot.owner_id == Some(user.id) {
|
||||||
Ok(())
|
Ok(bot)
|
||||||
} else {
|
} else {
|
||||||
Err(StatusCode::FORBIDDEN)
|
Err(StatusCode::FORBIDDEN)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::extract::{Multipart, Path};
|
use axum::extract::{Multipart, Path};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{body, Json};
|
use axum::{body, Extension, Json};
|
||||||
use diesel::OptionalExtension;
|
use diesel::OptionalExtension;
|
||||||
use rand::distributions::Alphanumeric;
|
use rand::distributions::Alphanumeric;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
@ -9,15 +9,19 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{self, json, value::Value as JsonValue};
|
use serde_json::{self, json, value::Value as JsonValue};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use thiserror;
|
use thiserror;
|
||||||
|
|
||||||
use crate::db::bots::{self, CodeBundle};
|
use crate::db;
|
||||||
|
use crate::db::bots::{self, BotVersion};
|
||||||
use crate::db::ratings::{self, RankedBot};
|
use crate::db::ratings::{self, RankedBot};
|
||||||
use crate::db::users::User;
|
use crate::db::users::User;
|
||||||
use crate::modules::bots::save_code_bundle;
|
use crate::modules::bots::save_code_string;
|
||||||
use crate::{DatabaseConnection, BOTS_DIR};
|
use crate::{DatabaseConnection, GlobalConfig};
|
||||||
use bots::Bot;
|
use bots::Bot;
|
||||||
|
|
||||||
|
use super::users::UserData;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct SaveBotParams {
|
pub struct SaveBotParams {
|
||||||
pub bot_name: String,
|
pub bot_name: String,
|
||||||
|
@ -96,6 +100,7 @@ pub async fn save_bot(
|
||||||
Json(params): Json<SaveBotParams>,
|
Json(params): Json<SaveBotParams>,
|
||||||
user: User,
|
user: User,
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<Json<Bot>, SaveBotError> {
|
) -> Result<Json<Bot>, SaveBotError> {
|
||||||
let res = bots::find_bot_by_name(¶ms.bot_name, &conn)
|
let res = bots::find_bot_by_name(¶ms.bot_name, &conn)
|
||||||
.optional()
|
.optional()
|
||||||
|
@ -119,8 +124,8 @@ pub async fn save_bot(
|
||||||
bots::create_bot(&new_bot, &conn).expect("could not create bot")
|
bots::create_bot(&new_bot, &conn).expect("could not create bot")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _code_bundle =
|
let _code_bundle = save_code_string(¶ms.code, Some(bot.id), &conn, &config)
|
||||||
save_code_bundle(¶ms.code, Some(bot.id), &conn).expect("failed to save code bundle");
|
.expect("failed to save code bundle");
|
||||||
Ok(Json(bot))
|
Ok(Json(bot))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,44 +134,64 @@ pub struct BotParams {
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: can we unify this with save_bot?
|
||||||
pub async fn create_bot(
|
pub async fn create_bot(
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
user: User,
|
user: User,
|
||||||
params: Json<BotParams>,
|
params: Json<BotParams>,
|
||||||
) -> (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 {
|
let bot_params = bots::NewBot {
|
||||||
owner_id: Some(user.id),
|
owner_id: Some(user.id),
|
||||||
name: ¶ms.name,
|
name: ¶ms.name,
|
||||||
};
|
};
|
||||||
let bot = bots::create_bot(&bot_params, &conn).unwrap();
|
let bot = bots::create_bot(&bot_params, &conn).unwrap();
|
||||||
(StatusCode::CREATED, Json(bot))
|
Ok((StatusCode::CREATED, Json(bot)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: handle errors
|
// TODO: handle errors
|
||||||
pub async fn get_bot(
|
pub async fn get_bot(
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
Path(bot_id): Path<i32>,
|
Path(bot_name): Path<String>,
|
||||||
) -> Result<Json<JsonValue>, StatusCode> {
|
) -> Result<Json<JsonValue>, StatusCode> {
|
||||||
let bot = bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
let bot = db::bots::find_bot_by_name(&bot_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
let bundles = bots::find_bot_code_bundles(bot.id, &conn)
|
let owner: Option<UserData> = match bot.owner_id {
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
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!({
|
Ok(Json(json!({
|
||||||
"bot": bot,
|
"bot": bot,
|
||||||
"bundles": bundles,
|
"owner": owner,
|
||||||
|
"versions": versions,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_my_bots(
|
pub async fn get_user_bots(
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
user: User,
|
Path(user_name): Path<String>,
|
||||||
) -> Result<Json<Vec<Bot>>, StatusCode> {
|
) -> 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(Json)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all active bots
|
||||||
pub async fn list_bots(conn: DatabaseConnection) -> Result<Json<Vec<Bot>>, StatusCode> {
|
pub async fn list_bots(conn: DatabaseConnection) -> Result<Json<Vec<Bot>>, StatusCode> {
|
||||||
bots::find_all_bots(&conn)
|
bots::find_active_bots(&conn)
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
.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(
|
pub async fn upload_code_multipart(
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
user: User,
|
user: User,
|
||||||
Path(bot_id): Path<i32>,
|
Path(bot_name): Path<String>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<CodeBundle>, StatusCode> {
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
let bots_dir = PathBuf::from(BOTS_DIR);
|
) -> 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 {
|
if Some(user.id) != bot.owner_id {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
@ -213,12 +239,39 @@ pub async fn upload_code_multipart(
|
||||||
.extract(bots_dir.join(&folder_name))
|
.extract(bots_dir.join(&folder_name))
|
||||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
let bundle = bots::NewCodeBundle {
|
let bot_version = bots::NewBotVersion {
|
||||||
bot_id: Some(bot.id),
|
bot_id: Some(bot.id),
|
||||||
path: &folder_name,
|
code_bundle_path: Some(&folder_name),
|
||||||
|
container_digest: None,
|
||||||
};
|
};
|
||||||
let code_bundle =
|
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))
|
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;
|
||||||
use crate::db::matches::{FullMatchData, FullMatchPlayerData};
|
use crate::db::matches::{FullMatchData, FullMatchPlayerData};
|
||||||
use crate::modules::bots::save_code_bundle;
|
use crate::modules::bots::save_code_string;
|
||||||
use crate::modules::matches::RunMatch;
|
use crate::modules::matches::{MatchPlayer, RunMatch};
|
||||||
use crate::ConnectionPool;
|
use crate::ConnectionPool;
|
||||||
|
use crate::GlobalConfig;
|
||||||
use axum::extract::Extension;
|
use axum::extract::Extension;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
|
@ -11,12 +14,13 @@ use serde::{Deserialize, Serialize};
|
||||||
use super::matches::ApiMatch;
|
use super::matches::ApiMatch;
|
||||||
|
|
||||||
const DEFAULT_OPPONENT_NAME: &str = "simplebot";
|
const DEFAULT_OPPONENT_NAME: &str = "simplebot";
|
||||||
|
const DEFAULT_MAP_NAME: &str = "hex";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct SubmitBotParams {
|
pub struct SubmitBotParams {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
// TODO: would it be better to pass an ID here?
|
|
||||||
pub opponent_name: Option<String>,
|
pub opponent_name: Option<String>,
|
||||||
|
pub map_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
@ -25,11 +29,11 @@ pub struct SubmitBotResponse {
|
||||||
pub match_data: ApiMatch,
|
pub match_data: ApiMatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// submit python code for a bot, which will face off
|
/// Submit bot code and opponent name to play a match
|
||||||
/// with a demo bot. Return a played match.
|
|
||||||
pub async fn submit_bot(
|
pub async fn submit_bot(
|
||||||
Json(params): Json<SubmitBotParams>,
|
Json(params): Json<SubmitBotParams>,
|
||||||
Extension(pool): Extension<ConnectionPool>,
|
Extension(pool): Extension<ConnectionPool>,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<Json<SubmitBotResponse>, StatusCode> {
|
) -> Result<Json<SubmitBotResponse>, StatusCode> {
|
||||||
let conn = pool.get().await.expect("could not get database connection");
|
let conn = pool.get().await.expect("could not get database connection");
|
||||||
|
|
||||||
|
@ -37,20 +41,39 @@ pub async fn submit_bot(
|
||||||
.opponent_name
|
.opponent_name
|
||||||
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
|
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
|
||||||
|
|
||||||
let opponent =
|
let map_name = params
|
||||||
db::bots::find_bot_by_name(&opponent_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
|
.map_name
|
||||||
let opponent_code_bundle =
|
.unwrap_or_else(|| DEFAULT_MAP_NAME.to_string());
|
||||||
db::bots::active_code_bundle(opponent.id, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
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?
|
// TODO: can we recover from this?
|
||||||
.expect("could not save bot code");
|
.expect("could not save bot code");
|
||||||
|
|
||||||
let mut run_match = RunMatch::from_players(vec![&player_code_bundle, &opponent_code_bundle]);
|
let run_match = RunMatch::new(
|
||||||
let match_data = run_match
|
config,
|
||||||
.store_in_database(&conn)
|
false,
|
||||||
.expect("failed to save match");
|
map.clone(),
|
||||||
run_match.spawn(pool.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
|
// TODO: avoid clones
|
||||||
let full_match_data = FullMatchData {
|
let full_match_data = FullMatchData {
|
||||||
|
@ -58,15 +81,16 @@ pub async fn submit_bot(
|
||||||
match_players: vec![
|
match_players: vec![
|
||||||
FullMatchPlayerData {
|
FullMatchPlayerData {
|
||||||
base: match_data.match_players[0].clone(),
|
base: match_data.match_players[0].clone(),
|
||||||
code_bundle: player_code_bundle,
|
bot_version: Some(player_bot_version),
|
||||||
bot: None,
|
bot: None,
|
||||||
},
|
},
|
||||||
FullMatchPlayerData {
|
FullMatchPlayerData {
|
||||||
base: match_data.match_players[1].clone(),
|
base: match_data.match_players[1].clone(),
|
||||||
code_bundle: opponent_code_bundle,
|
bot_version: Some(opponent_bot_version),
|
||||||
bot: Some(opponent),
|
bot: Some(opponent_bot),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
map: Some(map),
|
||||||
};
|
};
|
||||||
|
|
||||||
let api_match = super::matches::match_data_to_api(full_match_data);
|
let api_match = super::matches::match_data_to_api(full_match_data);
|
||||||
|
|
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::{
|
use axum::{
|
||||||
extract::{Extension, Path},
|
extract::{Path, Query},
|
||||||
Json,
|
Extension, Json,
|
||||||
};
|
};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use planetwars_matchrunner::{docker_runner::DockerBotSpec, run_match, MatchConfig, MatchPlayer};
|
|
||||||
use rand::{distributions::Alphanumeric, Rng};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
bots,
|
self,
|
||||||
matches::{self, MatchState},
|
matches::{self, MatchState},
|
||||||
users::User,
|
|
||||||
},
|
},
|
||||||
ConnectionPool, DatabaseConnection, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
|
DatabaseConnection, GlobalConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
use super::maps::ApiMap;
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ApiMatch {
|
pub struct ApiMatch {
|
||||||
|
@ -103,19 +23,71 @@ pub struct ApiMatch {
|
||||||
timestamp: chrono::NaiveDateTime,
|
timestamp: chrono::NaiveDateTime,
|
||||||
state: MatchState,
|
state: MatchState,
|
||||||
players: Vec<ApiMatchPlayer>,
|
players: Vec<ApiMatchPlayer>,
|
||||||
|
winner: Option<i32>,
|
||||||
|
map: Option<ApiMap>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ApiMatchPlayer {
|
pub struct ApiMatchPlayer {
|
||||||
code_bundle_id: i32,
|
bot_version_id: Option<i32>,
|
||||||
bot_id: Option<i32>,
|
bot_id: Option<i32>,
|
||||||
bot_name: Option<String>,
|
bot_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_matches(conn: DatabaseConnection) -> Result<Json<Vec<ApiMatch>>, StatusCode> {
|
#[derive(Serialize, Deserialize)]
|
||||||
matches::list_matches(&conn)
|
pub struct ListRecentMatchesParams {
|
||||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
count: Option<usize>,
|
||||||
.map(|matches| Json(matches.into_iter().map(match_data_to_api).collect()))
|
// 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 {
|
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
|
.match_players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|_p| ApiMatchPlayer {
|
.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_id: _p.bot.as_ref().map(|b| b.id),
|
||||||
bot_name: _p.bot.as_ref().map(|b| b.name.clone()),
|
bot_name: _p.bot.as_ref().map(|b| b.name.clone()),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
winner: data.base.winner,
|
||||||
|
map: data.map.map(|m| ApiMap { name: m.name }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this is duplicated from planetwars-cli
|
|
||||||
// clean this up and move to matchrunner crate
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct BotConfig {
|
|
||||||
pub name: String,
|
|
||||||
pub run_command: String,
|
|
||||||
pub build_command: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_match_data(
|
pub async fn get_match_data(
|
||||||
Path(match_id): Path<i32>,
|
Path(match_id): Path<i32>,
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
|
@ -157,10 +122,11 @@ pub async fn get_match_data(
|
||||||
pub async fn get_match_log(
|
pub async fn get_match_log(
|
||||||
Path(match_id): Path<i32>,
|
Path(match_id): Path<i32>,
|
||||||
conn: DatabaseConnection,
|
conn: DatabaseConnection,
|
||||||
|
Extension(config): Extension<Arc<GlobalConfig>>,
|
||||||
) -> Result<Vec<u8>, StatusCode> {
|
) -> Result<Vec<u8>, StatusCode> {
|
||||||
let match_base =
|
let match_base =
|
||||||
matches::find_match_base(match_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
matches::find_match_base(match_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
let log_path = PathBuf::from(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)?;
|
let log_contents = std::fs::read(log_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(log_contents)
|
Ok(log_contents)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod bots;
|
pub mod bots;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
|
pub mod maps;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
|
@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const RESERVED_USERNAMES: &[&str] = &["admin", "system"];
|
||||||
|
|
||||||
type AuthorizationHeader = TypedHeader<Authorization<Bearer>>;
|
type AuthorizationHeader = TypedHeader<Authorization<Bearer>>;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
@ -89,7 +91,11 @@ impl RegistrationParams {
|
||||||
errors.push("password must be at least 8 characters".to_string());
|
errors.push("password must be at least 8 characters".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if 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());
|
errors.push("username is already taken".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,19 @@
|
||||||
// This file is autogenerated by diesel
|
// This file is autogenerated by diesel
|
||||||
#![allow(unused_imports)]
|
#![allow(unused_imports)]
|
||||||
|
|
||||||
|
table! {
|
||||||
|
use diesel::sql_types::*;
|
||||||
|
use crate::db_types::*;
|
||||||
|
|
||||||
|
bot_versions (id) {
|
||||||
|
id -> Int4,
|
||||||
|
bot_id -> Nullable<Int4>,
|
||||||
|
code_bundle_path -> Nullable<Text>,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
container_digest -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
use diesel::sql_types::*;
|
use diesel::sql_types::*;
|
||||||
use crate::db_types::*;
|
use crate::db_types::*;
|
||||||
|
@ -9,6 +22,7 @@ table! {
|
||||||
id -> Int4,
|
id -> Int4,
|
||||||
owner_id -> Nullable<Int4>,
|
owner_id -> Nullable<Int4>,
|
||||||
name -> Text,
|
name -> Text,
|
||||||
|
active_version -> Nullable<Int4>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,11 +30,10 @@ table! {
|
||||||
use diesel::sql_types::*;
|
use diesel::sql_types::*;
|
||||||
use crate::db_types::*;
|
use crate::db_types::*;
|
||||||
|
|
||||||
code_bundles (id) {
|
maps (id) {
|
||||||
id -> Int4,
|
id -> Int4,
|
||||||
bot_id -> Nullable<Int4>,
|
name -> Text,
|
||||||
path -> Text,
|
file_path -> Text,
|
||||||
created_at -> Timestamp,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +44,7 @@ table! {
|
||||||
match_players (match_id, player_id) {
|
match_players (match_id, player_id) {
|
||||||
match_id -> Int4,
|
match_id -> Int4,
|
||||||
player_id -> Int4,
|
player_id -> Int4,
|
||||||
code_bundle_id -> Int4,
|
bot_version_id -> Nullable<Int4>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +58,8 @@ table! {
|
||||||
log_path -> Text,
|
log_path -> Text,
|
||||||
created_at -> Timestamp,
|
created_at -> Timestamp,
|
||||||
winner -> Nullable<Int4>,
|
winner -> Nullable<Int4>,
|
||||||
|
is_public -> Bool,
|
||||||
|
map_id -> Nullable<Int4>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,15 +97,16 @@ table! {
|
||||||
}
|
}
|
||||||
|
|
||||||
joinable!(bots -> users (owner_id));
|
joinable!(bots -> users (owner_id));
|
||||||
joinable!(code_bundles -> bots (bot_id));
|
joinable!(match_players -> bot_versions (bot_version_id));
|
||||||
joinable!(match_players -> code_bundles (code_bundle_id));
|
|
||||||
joinable!(match_players -> matches (match_id));
|
joinable!(match_players -> matches (match_id));
|
||||||
|
joinable!(matches -> maps (map_id));
|
||||||
joinable!(ratings -> bots (bot_id));
|
joinable!(ratings -> bots (bot_id));
|
||||||
joinable!(sessions -> users (user_id));
|
joinable!(sessions -> users (user_id));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
|
bot_versions,
|
||||||
bots,
|
bots,
|
||||||
code_bundles,
|
maps,
|
||||||
match_players,
|
match_players,
|
||||||
matches,
|
matches,
|
||||||
ratings,
|
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-config-prettier": "^8.3.0",
|
||||||
"eslint-plugin-svelte3": "^3.2.1",
|
"eslint-plugin-svelte3": "^3.2.1",
|
||||||
"luxon": "^2.3.0",
|
"luxon": "^2.3.0",
|
||||||
|
"mdsvex": "^0.10.6",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.4.1",
|
||||||
"prettier-plugin-svelte": "^2.4.0",
|
"prettier-plugin-svelte": "^2.4.0",
|
||||||
"sass": "^1.49.7",
|
"sass": "^1.49.7",
|
||||||
|
|
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">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
export let leaderboard;
|
||||||
|
|
||||||
let leaderboard = [];
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const res = await fetch("/api/leaderboard", {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
leaderboard = await res.json();
|
|
||||||
console.log(leaderboard);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatRating(entry: object): any {
|
function formatRating(entry: object): any {
|
||||||
const rating = entry["rating"];
|
const rating = entry["rating"];
|
||||||
|
@ -41,10 +26,17 @@
|
||||||
<td class="leaderboard-rating">
|
<td class="leaderboard-rating">
|
||||||
{formatRating(entry)}
|
{formatRating(entry)}
|
||||||
</td>
|
</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">
|
<td class="leaderboard-author">
|
||||||
{#if entry["author"]}
|
{#if entry["author"]}
|
||||||
{entry["author"]["username"]}
|
<!-- TODO: remove duplication -->
|
||||||
|
<a class="leaderboard-href" href="/users/{entry['author']['username']}"
|
||||||
|
>{entry["author"]["username"]}</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -69,4 +61,9 @@
|
||||||
.leaderboard-rank {
|
.leaderboard-rank {
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leaderboard-href {
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
</style>
|
</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">
|
<script lang="ts">
|
||||||
|
import { parsePlayerLog, PlayerLog } from "$lib/log_parser";
|
||||||
|
|
||||||
export let matchLog: string;
|
export let matchLog: string;
|
||||||
|
let playerLog: PlayerLog;
|
||||||
|
|
||||||
function getStdErr(botId: number, log?: string): string {
|
let showRawStderr = false;
|
||||||
if (!log) {
|
|
||||||
return "";
|
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"]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return output.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: botStdErr = getStdErr(1, matchLog);
|
$: if (matchLog) {
|
||||||
|
playerLog = parsePlayerLog(1, matchLog);
|
||||||
|
} else {
|
||||||
|
playerLog = [];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="output">
|
<div class="output">
|
||||||
{#if botStdErr.length > 0}
|
<h3 class="output-header">Player log</h3>
|
||||||
<h3 class="output-header">stderr:</h3>
|
{#if showRawStderr}
|
||||||
|
<div class="output-text stderr-text">
|
||||||
|
{playerLog.flatMap((turn) => turn.stderr).join("\n")}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="output-text">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -39,12 +87,71 @@
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.turn {
|
||||||
|
margin: 16px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.output-text {
|
.output-text {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
font-family: monospace;
|
}
|
||||||
|
|
||||||
|
.turn-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-header-text {
|
||||||
|
color: #eee;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad-command-container {
|
||||||
|
border-left: 1px solid red;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad-command-text {
|
||||||
|
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad-command-error {
|
||||||
|
color: whitesmoke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stderr-text {
|
||||||
|
// font-family: monospace;
|
||||||
|
font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stderr-header {
|
||||||
|
color: #eee;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stderr-text-box {
|
||||||
|
border-left: 1px solid #ccc;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.output-header {
|
.output-header {
|
||||||
color: #eee;
|
color: #eee;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
|
|
|
@ -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",
|
|
||||||
"destination": "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 destination 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,15 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ApiClient } from "$lib/api_client";
|
||||||
|
|
||||||
import { get_session_token } from "$lib/auth";
|
import { get_session_token } from "$lib/auth";
|
||||||
import { getBotName, saveBotName } from "$lib/bot_code";
|
import { getBotName, saveBotName } from "$lib/bot_code";
|
||||||
|
|
||||||
import { currentUser } from "$lib/stores/current_user";
|
import { currentUser } from "$lib/stores/current_user";
|
||||||
|
import { selectedOpponent, selectedMap } from "$lib/stores/editor_state";
|
||||||
|
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
import Select from "svelte-select";
|
import Select from "svelte-select";
|
||||||
|
|
||||||
export let editSession;
|
export let editSession;
|
||||||
|
|
||||||
let availableBots: object[] = [];
|
let availableBots: object[] = [];
|
||||||
let selectedOpponent = undefined;
|
let maps: object[] = [];
|
||||||
|
|
||||||
let botName: string | undefined = undefined;
|
let botName: string | undefined = undefined;
|
||||||
// whether to show the "save succesful" message
|
// whether to show the "save succesful" message
|
||||||
let saveSuccesful = false;
|
let saveSuccesful = false;
|
||||||
|
@ -18,24 +23,28 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
botName = getBotName();
|
botName = getBotName();
|
||||||
|
const apiClient = new ApiClient();
|
||||||
|
|
||||||
const res = await fetch("/api/bots", {
|
const [_bots, _maps] = await Promise.all([
|
||||||
headers: {
|
apiClient.get("/api/bots"),
|
||||||
"Content-Type": "application/json",
|
apiClient.get("/api/maps"),
|
||||||
},
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
availableBots = _bots;
|
||||||
availableBots = await res.json();
|
maps = _maps;
|
||||||
selectedOpponent = availableBots.find((b) => b["name"] === "simplebot");
|
|
||||||
|
if (!$selectedOpponent) {
|
||||||
|
selectedOpponent.set(availableBots.find((b) => b["name"] === "simplebot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$selectedMap) {
|
||||||
|
selectedMap.set(maps.find((m) => m["name"] === "hex"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
async function submitBot() {
|
async function submitBot() {
|
||||||
const opponentName = selectedOpponent["name"];
|
|
||||||
|
|
||||||
let response = await fetch("/api/submit_bot", {
|
let response = await fetch("/api/submit_bot", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -43,7 +52,8 @@
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
code: editSession.getDocument().getValue(),
|
code: editSession.getDocument().getValue(),
|
||||||
opponent_name: opponentName,
|
opponent_name: $selectedOpponent["name"],
|
||||||
|
map_name: $selectedMap["name"],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -100,13 +110,23 @@
|
||||||
<div class="submit-pane">
|
<div class="submit-pane">
|
||||||
<div class="match-form">
|
<div class="match-form">
|
||||||
<h4>Play a match</h4>
|
<h4>Play a match</h4>
|
||||||
<div class="play-text">Select an opponent to test your bot</div>
|
<div class="play-text">Opponent</div>
|
||||||
<div class="opponentSelect">
|
<div class="opponent-select">
|
||||||
<Select
|
<Select
|
||||||
optionIdentifier="name"
|
optionIdentifier="name"
|
||||||
labelIdentifier="name"
|
labelIdentifier="name"
|
||||||
items={availableBots}
|
items={availableBots}
|
||||||
bind:value={selectedOpponent}
|
bind:value={$selectedOpponent}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>Map</span>
|
||||||
|
<div class="map-select">
|
||||||
|
<Select
|
||||||
|
optionIdentifier="name"
|
||||||
|
labelIdentifier="name"
|
||||||
|
items={maps}
|
||||||
|
bind:value={$selectedMap}
|
||||||
isClearable={false}
|
isClearable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -145,8 +165,9 @@
|
||||||
margin-bottom: 0.3em;
|
margin-bottom: 0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponentSelect {
|
.opponent-select,
|
||||||
margin: 20px 0;
|
.map-select {
|
||||||
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-form {
|
.save-form {
|
||||||
|
|
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">
|
<div class="user-controls">
|
||||||
{#if $currentUser}
|
{#if $currentUser}
|
||||||
<div class="current-user-name">
|
<a class="current-user-name" href="/users/{$currentUser['username']}">
|
||||||
{$currentUser["username"]}
|
{$currentUser["username"]}
|
||||||
</div>
|
</a>
|
||||||
<div class="sign-out" on:click={signOut}>Sign out</div>
|
<div class="sign-out" on:click={signOut}>Sign out</div>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="account-href" href="login">Sign in</a>
|
<a class="account-href" href="/login">Sign in</a>
|
||||||
<a class="account-href" href="register">Sign up</a>
|
<a class="account-href" href="/register">Sign up</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@
|
||||||
|
|
||||||
.current-user-name {
|
.current-user-name {
|
||||||
@include navbar-item;
|
@include navbar-item;
|
||||||
|
text-decoration: none;
|
||||||
color: #fff;
|
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) {
|
export function debounce(func: Function, timeout: number = 300) {
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
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) {
|
export async function get(url: string, params?: Record<string, string>, fetch_fn: FetchFn = fetch) {
|
||||||
const headers = { "Content-Type": "application/json" };
|
const client = new ApiClient(fetch_fn);
|
||||||
|
return await client.get(url, params);
|
||||||
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 post(url: string, data: any, fetch_fn: Function = fetch) {
|
export async function post(url: string, data: any, fetch_fn: FetchFn = fetch) {
|
||||||
const headers = { "Content-Type": "application/json" };
|
const client = new ApiClient(fetch_fn);
|
||||||
|
return await client.post(url, data);
|
||||||
const token = get_session_token();
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch_fn(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.parse(response);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,16 +6,29 @@
|
||||||
|
|
||||||
<div class="outer-container">
|
<div class="outer-container">
|
||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="navbar-main">
|
<div class="navbar-left">
|
||||||
<a href="/">PlanetWars</a>
|
<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>
|
||||||
<UserControls />
|
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
@import "src/styles/variables.scss";
|
@import "src/styles/global.scss";
|
||||||
|
|
||||||
.outer-container {
|
.outer-container {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
@ -34,13 +47,33 @@
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-main {
|
.navbar-left {
|
||||||
margin: auto 0;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-main a {
|
.navbar-right {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-header {
|
||||||
|
margin: auto 0;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-header a {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #eee;
|
color: #fff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.navbar-item {
|
||||||
|
margin: auto 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item a {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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,276 +1,92 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
import { ApiClient } from "$lib/api_client";
|
||||||
|
|
||||||
|
const NUM_MATCHES = "25";
|
||||||
|
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
try {
|
||||||
|
const apiClient = new ApiClient(fetch);
|
||||||
|
|
||||||
|
let { matches, has_next } = await apiClient.get("/api/matches", {
|
||||||
|
count: NUM_MATCHES,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
matches,
|
||||||
|
hasNext: has_next,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: error.status,
|
||||||
|
error: new Error("failed to load matches"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Visualizer from "$lib/components/Visualizer.svelte";
|
import LinkButton from "$lib/components/LinkButton.svelte";
|
||||||
import EditorView from "$lib/components/EditorView.svelte";
|
import MatchList from "$lib/components/matches/MatchList.svelte";
|
||||||
import { onMount } from "svelte";
|
|
||||||
|
|
||||||
import { DateTime } from "luxon";
|
export let matches;
|
||||||
|
export let hasNext;
|
||||||
|
|
||||||
import type { Ace } from "ace-builds";
|
$: viewMoreUrl = olderMatchesLink(matches);
|
||||||
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";
|
|
||||||
|
|
||||||
enum ViewMode {
|
// TODO: deduplicate.
|
||||||
Editor,
|
// Maybe move to ApiClient logic?
|
||||||
MatchVisualizer,
|
function olderMatchesLink(matches: object[]): string {
|
||||||
Rules,
|
if (matches.length == 0 || !hasNext) {
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
const lastTimestamp = matches[matches.length - 1]["timestamp"];
|
||||||
const res = await fetch(`/api/matches/${matchId}/log`, {
|
return `/matches?before=${lastTimestamp}`;
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let log = await res.text();
|
|
||||||
return log;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setViewMode(viewMode_: ViewMode) {
|
|
||||||
selectedMatchId = undefined;
|
|
||||||
selectedMatchLog = undefined;
|
|
||||||
viewMode = viewMode_;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRules() {
|
|
||||||
selectedMatchId = undefined;
|
|
||||||
selectedMatchLog = undefined;
|
|
||||||
viewMode = ViewMode.Rules;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMatchTimestamp(timestampString: string): string {
|
|
||||||
let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal();
|
|
||||||
if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) {
|
|
||||||
return timestamp.toFormat("HH:mm");
|
|
||||||
} else {
|
|
||||||
return timestamp.toFormat("dd/MM");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: selectedMatch = matches.find((m) => m["id"] === selectedMatchId);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="sidebar-left">
|
<div class="introduction">
|
||||||
<div
|
<h2>Welcome to PlanetWars!</h2>
|
||||||
class="editor-button sidebar-item"
|
<p>
|
||||||
class:selected={viewMode === ViewMode.Editor}
|
Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that
|
||||||
on:click={() => setViewMode(ViewMode.Editor)}
|
will conquer the galaxy for you, while you take care of more important stuff.
|
||||||
>
|
</p>
|
||||||
Editor
|
<p>
|
||||||
</div>
|
Feel free to watch some games below to see what it's all about. When you are ready to try
|
||||||
<div
|
writing your own bot, head over to
|
||||||
class="rules-button sidebar-item"
|
<a href="/docs">How to play</a> for instructions. You can program your bot in the browser
|
||||||
class:selected={viewMode === ViewMode.Rules}
|
using the <a href="/editor">Editor</a>.
|
||||||
on:click={() => setViewMode(ViewMode.Rules)}
|
</p>
|
||||||
>
|
|
||||||
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>
|
||||||
<div class="editor-container">
|
<h2>Recent matches</h2>
|
||||||
{#if viewMode === ViewMode.MatchVisualizer}
|
<MatchList {matches} />
|
||||||
<Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} />
|
<div class="see-more-container">
|
||||||
{:else if viewMode === ViewMode.Editor}
|
<LinkButton href={viewMoreUrl}>View more</LinkButton>
|
||||||
<EditorView {editSession} />
|
|
||||||
{:else if viewMode === ViewMode.Rules}
|
|
||||||
<RulesView />
|
|
||||||
{:else if viewMode === ViewMode.Leaderboard}
|
|
||||||
<Leaderboard />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-right">
|
|
||||||
{#if viewMode === ViewMode.MatchVisualizer}
|
|
||||||
<OutputPane matchLog={selectedMatchLog} />
|
|
||||||
{:else if viewMode === ViewMode.Editor}
|
|
||||||
<SubmitPane {editSession} on:matchCreated={onMatchCreated} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style scoped lang="scss">
|
||||||
@import "src/styles/variables.scss";
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
max-width: 800px;
|
||||||
flex-grow: 1;
|
margin: 0 auto;
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-left {
|
.introduction {
|
||||||
width: 240px;
|
padding-top: 16px;
|
||||||
background-color: $bg-color;
|
a {
|
||||||
display: flex;
|
color: rgb(9, 105, 218);
|
||||||
flex-direction: column;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.sidebar-right {
|
|
||||||
width: 400px;
|
a:hover {
|
||||||
background-color: white;
|
text-decoration: underline;
|
||||||
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 {
|
.see-more-container {
|
||||||
height: 100%;
|
padding: 24px;
|
||||||
}
|
text-align: center;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-card {
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-size: 11pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-timestamp {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-opponent {
|
|
||||||
padding: 0 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
margin-top: 2em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
padding-left: 14px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
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">
|
<script lang="ts" context="module">
|
||||||
export async function load({ page }) {
|
import { ApiClient } from "$lib/api_client";
|
||||||
const res = await fetch(`/api/matches/${page.params["match_id"]}`, {
|
export async function load({ params, fetch }) {
|
||||||
headers: {
|
try {
|
||||||
"Content-Type": "application/json",
|
const matchId = params["match_id"];
|
||||||
},
|
const apiClient = new ApiClient(fetch);
|
||||||
});
|
const [matchData, matchLog] = await Promise.all([
|
||||||
|
apiClient.get(`/api/matches/${matchId}`),
|
||||||
if (res.ok) {
|
apiClient.getText(`/api/matches/${matchId}/log`),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
matchLog: await res.text(),
|
matchData: matchData,
|
||||||
|
matchLog: matchLog,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: error.status,
|
||||||
|
error: error,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
status: res.status,
|
|
||||||
error: new Error("failed to load match"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Visualizer from "$lib/components/Visualizer.svelte";
|
import Visualizer from "$lib/components/Visualizer.svelte";
|
||||||
export let matchLog: string;
|
export let matchLog: string;
|
||||||
|
export let matchData: object;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div class="container">
|
||||||
<Visualizer {matchLog} />
|
<Visualizer {matchLog} {matchData} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
// these are needed for making the visualizer fill the screen.
|
||||||
|
min-height: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,36 +1,121 @@
|
||||||
<script lang="ts" context="module">
|
<script lang="ts" context="module">
|
||||||
export async function load() {
|
import { ApiClient } from "$lib/api_client";
|
||||||
const res = await fetch("/api/matches", {
|
|
||||||
headers: {
|
const PAGE_SIZE = "50";
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
export async function load({ url, fetch }) {
|
||||||
});
|
try {
|
||||||
|
const apiClient = new ApiClient(fetch);
|
||||||
|
const botName = url.searchParams.get("bot");
|
||||||
|
|
||||||
|
let query = {
|
||||||
|
count: PAGE_SIZE,
|
||||||
|
before: url.searchParams.get("before"),
|
||||||
|
after: url.searchParams.get("after"),
|
||||||
|
bot: botName,
|
||||||
|
};
|
||||||
|
|
||||||
|
let { matches, has_next } = await apiClient.get("/api/matches", removeUndefined(query));
|
||||||
|
|
||||||
|
// TODO: should this be done client-side?
|
||||||
|
if (query["after"]) {
|
||||||
|
matches = matches.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
matches: await res.json(),
|
matches,
|
||||||
|
botName,
|
||||||
|
hasNext: has_next,
|
||||||
|
query,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: error.status,
|
||||||
|
error: new Error("failed to load matches"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
function removeUndefined(obj: Record<string, string>): Record<string, string> {
|
||||||
status: res.status,
|
Object.keys(obj).forEach((key) => {
|
||||||
error: new Error("failed to load matches"),
|
if (obj[key] === undefined || obj[key] === null) {
|
||||||
};
|
delete obj[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import dayjs from "dayjs";
|
import LinkButton from "$lib/components/LinkButton.svelte";
|
||||||
export let matches;
|
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>
|
</script>
|
||||||
|
|
||||||
<a href="/matches/new">new match</a>
|
<div class="container">
|
||||||
<ul>
|
<MatchList {matches} />
|
||||||
{#each matches as match}
|
<div class="page-controls">
|
||||||
<li>
|
<div class="btn-group">
|
||||||
<a href="/matches/{match['id']}">{dayjs(match["created_at"]).format("YYYY-MM-DD HH:mm")}</a>
|
<LinkButton href={newerMatchesLink(matches)}>Newer</LinkButton>
|
||||||
</li>
|
<LinkButton href={olderMatchesLink(matches)}>Older</LinkButton>
|
||||||
{/each}
|
</div>
|
||||||
</ul>
|
</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">
|
<script lang="ts" context="module">
|
||||||
import { get_session_token } from "$lib/auth";
|
import { get_session_token } from "$lib/auth";
|
||||||
import { mount_component } from "svelte/internal";
|
|
||||||
|
|
||||||
export async function load({ page }) {
|
export async function load({ page }) {
|
||||||
const token = get_session_token();
|
const token = get_session_token();
|
||||||
|
|
|
@ -2,3 +2,17 @@ body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Roboto, Helvetica, sans-serif;
|
font-family: Roboto, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* generic scrollbar styling for chrome & friends */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #bdbdbd;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
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>
|
33
web/pw-server/src/styles/buttons.scss
Normal file
33
web/pw-server/src/styles/buttons.scss
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
@use "./variables";
|
||||||
|
|
||||||
|
$btn-text-color: variables.$blue-primary;
|
||||||
|
$btn-border-color: variables.$light-grey;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
color: $btn-text-color;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid $btn-border-color;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-disabled {
|
||||||
|
color: $btn-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn:not(:last-child) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn:first-child {
|
||||||
|
border-radius: 5px 0 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn:last-child {
|
||||||
|
border-radius: 0 5px 5px 0;
|
||||||
|
}
|
4
web/pw-server/src/styles/global.scss
Normal file
4
web/pw-server/src/styles/global.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
@forward "./variables.scss";
|
||||||
|
@forward "./buttons.scss";
|
||||||
|
@forward "./markdown.scss";
|
||||||
|
@forward "./prism.scss";
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue