Compare commits

...

136 commits

Author SHA1 Message Date
Ilion Beyst
9e05a9bdd5 update docs
- divide rules doc in sections
- clarify turn order
- document logging to stderr
- bump turn limit to 500
2022-09-19 22:28:27 +02:00
Ilion Beyst
6887c8ee0e remove unneccesary print in game rules 2022-09-19 21:57:09 +02:00
Ilion Beyst
3138eca6d0 show dispatches and timeouts in outputpane 2022-09-18 13:16:57 +02:00
Ilion Beyst
3be0cfa0ea log dispatches and timeouts 2022-09-18 13:00:32 +02:00
Ilion Beyst
f5fe1c4f29 initial version of structured log display 2022-09-17 11:55:17 +02:00
Ilion Beyst
518ad1d811 log initial state 2022-09-17 11:30:52 +02:00
Ilion Beyst
a70689faa9 introduce turn-based log parser 2022-09-16 23:28:32 +02:00
Ilion Beyst
36c16aa8c7 log bad commands 2022-09-15 21:59:03 +02:00
Ilion Beyst
ecd378f0d9 remove obsolete test code 2022-09-15 18:07:28 +02:00
Ilion Beyst
46efe3c5fa docs: extend docker instructions 2022-09-14 21:46:26 +02:00
Ilion Beyst
1a5755f0a3 add basic markdown document styling 2022-09-14 20:50:47 +02:00
Ilion Beyst
3eedbc0b85 add prism syntax highlighting theme 2022-09-13 21:24:11 +02:00
Ilion Beyst
380a1d8f4e add local development doc page 2022-09-13 20:40:35 +02:00
Ilion Beyst
7f1b6c06b6 add toc sidebar to docs site 2022-09-12 21:55:16 +02:00
Ilion Beyst
66162ea43c redirect /docs to /docs/rules 2022-09-11 10:56:36 +02:00
Ilion Beyst
9e574f08ee show bot versions on bot page 2022-09-10 18:57:38 +02:00
Ilion Beyst
3058028edc allow retrieving bot code 2022-09-10 18:56:03 +02:00
Ilion Beyst
78a6032b60 refactor: introduce promise helper type 2022-09-10 16:13:43 +02:00
Ilion Beyst
8eeb81bd5a client_api: allow player to connect before match has started 2022-09-09 21:56:11 +02:00
Ilion Beyst
d68731a114 translate rules view to markdown 2022-09-05 19:21:37 +02:00
Ilion Beyst
dcb0a2bde8 Merge branch 'site-docs' 2022-09-04 13:14:43 +02:00
Ilion Beyst
2fec5e4509 implement map selection in cli 2022-09-02 21:58:32 +02:00
Ilion Beyst
d95eedcc83 registry: ensure that files have been closed before calculating checksums 2022-08-30 19:35:29 +02:00
Ilion Beyst
e8e353192c show only completed matches in match log 2022-08-29 22:06:21 +02:00
Ilion Beyst
c6c484daf3 add missing maps routes 2022-08-28 17:15:56 +02:00
Ilion Beyst
2aab23c40f run ranker on all maps 2022-08-28 17:12:07 +02:00
Ilion Beyst
09ce75d5bf hotfix: make sure turn slider does not obstruct view 2022-08-28 17:11:42 +02:00
Ilion Beyst
0dbaf29d2f fix mouseenter detection 2022-08-28 16:28:15 +02:00
Ilion Beyst
0ce40a9f31 raise turn limit to 500 2022-08-28 15:47:31 +02:00
Ilion Beyst
49a5735e07 show map info for matches 2022-08-27 17:41:32 +02:00
Ilion Beyst
c80ce33279 allow selecting a map in editor view 2022-08-27 17:05:11 +02:00
Ilion Beyst
e26f13c8bb add maps to matches api 2022-08-26 19:21:30 +02:00
Ilion Beyst
624fa99fad add map info to matches 2022-08-25 20:47:00 +02:00
Ilion Beyst
f727613efb create db::maps module 2022-08-25 20:14:50 +02:00
Ilion Beyst
aa066ef5bb create maps table 2022-08-23 20:00:21 +02:00
Ilion Beyst
fa4c684475 create editor store 2022-08-22 21:58:13 +02:00
Ilion Beyst
82ab9cef78 populate new index page 2022-08-22 21:18:20 +02:00
Ilion Beyst
947ced152e move rules to their own route 2022-08-22 19:52:44 +02:00
Ilion Beyst
a5399728c1 make scroll bars less ugly in chrome 2022-08-21 21:36:47 +02:00
Ilion Beyst
8eec57f560 extract leaderboard from editor 2022-08-21 21:18:06 +02:00
Ilion Beyst
64d24c9e3d move bot editor to /editor 2022-08-21 21:06:10 +02:00
Ilion Beyst
71d37e758f implement LinkButton component 2022-08-20 19:55:21 +02:00
Ilion Beyst
329fc73b94 create global scss styles 2022-08-19 19:57:57 +02:00
Ilion Beyst
87f6a9c455 consume matches pagination api 2022-08-16 20:00:49 +02:00
Ilion Beyst
03ca884c40 return pagination object from list matches API 2022-08-16 19:23:53 +02:00
Ilion Beyst
a1c7866f87 styling of next/prev buttons 2022-08-15 20:46:13 +02:00
Ilion Beyst
4dcccb589d link to all matches on bot page 2022-08-15 20:05:00 +02:00
Ilion Beyst
7ba9fcee64 allow filtering for bot matches 2022-08-12 20:54:34 +02:00
Ilion Beyst
84748fd60e abstract matches pagination logic 2022-08-12 20:21:39 +02:00
Ilion Beyst
7c4314ae23 save matches pagination cursor in url 2022-08-11 23:06:53 +02:00
Ilion Beyst
406c726601 create password reset utility
Co-authored-by: Wout Schellaert <wout.schellaert@gmail.com>
2022-08-09 23:27:22 +02:00
Ilion Beyst
58c1c5f9fb badly paginated matches 2022-08-09 20:43:02 +02:00
Ilion Beyst
00356d669c properly initialize fetch in ApiClient 2022-08-08 19:12:29 +02:00
Ilion Beyst
cf52ab6f7f implement before and after filters for matches 2022-08-08 18:31:11 +02:00
Ilion Beyst
db7980504f move match index to ApiClient 2022-08-07 10:57:15 +02:00
Ilion Beyst
6f0c1093ac extend api_client for loading match data 2022-08-06 16:12:00 +02:00
Ilion Beyst
4672a08462 introduce ApiClient 2022-08-06 15:23:02 +02:00
Ilion Beyst
70c79646ae show recent matches on bots page 2022-08-05 20:28:51 +02:00
Ilion Beyst
6e75cac7cc extract MatchList component 2022-08-05 19:21:32 +02:00
Ilion Beyst
3113f762d8 list matches for a specific bot 2022-08-04 21:48:25 +02:00
Ilion Beyst
3c2f4977e4 add parameters to recent_matches api endpoint 2022-08-02 20:12:34 +02:00
Ilion Beyst
aafb785645 make name field optional 2022-08-01 00:16:24 +02:00
Ilion Beyst
8f65a7d3e2 create a basic match table 2022-07-31 21:04:48 +02:00
Ilion Beyst
15c1aa9d59 add winner to match api responses 2022-07-31 21:04:13 +02:00
Ilion Beyst
e8dbb01933 list only public matches in API 2022-07-30 19:49:08 +02:00
Ilion Beyst
ee5c67c092 add is_public to matches 2022-07-30 17:03:32 +02:00
Ilion Beyst
fd6664b8e7 set up docs route 2022-07-28 20:32:10 +02:00
Ilion Beyst
18aede91be setup mdsvex 2022-07-27 21:04:51 +02:00
Ilion Beyst
1d280c62e2 don't allow registering reserved usernames 2022-07-25 22:51:31 +02:00
Ilion Beyst
4099e3ab6e add local development instructions to README 2022-07-25 22:46:13 +02:00
Ilion Beyst
c30222cf9a limit amount of matches used by ranker 2022-07-25 22:26:58 +02:00
Ilion Beyst
67276bd0bb rename bot_api to client_api 2022-07-25 22:16:50 +02:00
Ilion Beyst
93c4306b10 pull docker bots before running them 2022-07-24 23:08:51 +02:00
Ilion Beyst
14b51033fc add placeholders for empty lists 2022-07-24 18:09:04 +02:00
Ilion Beyst
90dfc3dec4 create new bot flow 2022-07-24 18:05:20 +02:00
Ilion Beyst
99987f8444 use absolute paths for login and register 2022-07-24 17:49:31 +02:00
Ilion Beyst
109aaf2758 remove global style overrides from visualizer 2022-07-24 17:01:22 +02:00
Ilion Beyst
ccfe86729e add bot detail page 2022-07-24 16:45:29 +02:00
Ilion Beyst
33664eff2c basic user profile pages 2022-07-24 15:15:42 +02:00
Ilion Beyst
4a582e8079 store active version id in bots table 2022-07-23 23:40:25 +02:00
Ilion Beyst
f19a70e710 sort match players to ensure correct ordering 2022-07-23 14:47:24 +02:00
Ilion Beyst
5e560b23f8 update README 2022-07-23 00:38:44 +02:00
Ilion Beyst
500061375c support working_directory and command string 2022-07-23 00:38:34 +02:00
Ilion Beyst
1cf20810c5 enable ssl for grpc client 2022-07-22 23:50:52 +02:00
Ilion Beyst
fe2f382e04 allow configuring grpc server url 2022-07-22 23:06:59 +02:00
Ilion Beyst
b0725c21df planetwars-client: configure bot config path and opponent name 2022-07-22 22:27:12 +02:00
Ilion Beyst
1011015b29 show match url in planetwars_client 2022-07-21 21:42:47 +02:00
Ilion Beyst
b84e9be9d6 re-enable bot_api server 2022-07-21 20:29:01 +02:00
Ilion Beyst
c6293d8e32 delete old planetwars-cli code 2022-07-21 19:19:40 +02:00
Ilion Beyst
31f8271db6 update client for oneof messages 2022-07-20 23:43:19 +02:00
Ilion Beyst
73c536b4a6 wrap bot api in oneof for extendability 2022-07-20 23:21:06 +02:00
Ilion Beyst
f058000072 fix match detail view 2022-07-19 22:38:52 +02:00
Ilion Beyst
d4c52c3e99 We're planetwars.dev now! 2022-07-19 21:57:38 +02:00
Ilion Beyst
d6acb6e071 remove obsolete dependencies 2022-07-19 21:22:51 +02:00
Ilion Beyst
338dc6ac63 bugfix: don't cache textures bound to a specific GLContext 2022-07-18 21:55:35 +02:00
Ilion Beyst
7daf8f6437 Merge branch 'next' 2022-07-18 21:03:34 +02:00
Ilion Beyst
d092f5d89c use texture for rendering ships 2022-07-18 21:02:27 +02:00
Ilion Beyst
6e494aca46 add half-pixel correction to text 2022-07-18 07:54:18 +02:00
Ilion Beyst
270476e038 use texture for rendering planets 2022-07-18 07:40:01 +02:00
Ilion Beyst
e5cb04208f allow disabling ranker in develpoment 2022-07-17 18:23:24 +02:00
Ilion Beyst
09c543eee3 create all required directories on startup 2022-07-17 17:07:53 +02:00
Ilion Beyst
c16b068f8b cleanup: remove old configuration code 2022-07-17 15:10:17 +02:00
Ilion Beyst
dad19548d1 read GlobalConfig from configuration.toml 2022-07-16 21:57:12 +02:00
Ilion Beyst
0cf7b5299d integrate registry with GlobalConfig 2022-07-16 21:47:34 +02:00
Ilion Beyst
d13d131130 move storage paths to GlobalConfig 2022-07-16 21:22:03 +02:00
Ilion Beyst
ec5c91d37b change runnerconfig to globalconfig 2022-07-14 21:50:42 +02:00
Ilion Beyst
00459f9e3d create a configuration to hold docker registry url 2022-07-14 20:53:08 +02:00
Ilion Beyst
668409e76d refactor: unify match save and spawn 2022-07-13 19:36:07 +02:00
Ilion Beyst
e69bd14f1d refactor: delay BotSpec construction in RunMatch 2022-07-12 20:54:00 +02:00
Ilion Beyst
0b9a9f0eaa tying it together: execute docker bots 2022-07-11 20:43:10 +02:00
Ilion Beyst
ec1d50f655 refactor: pass on both Bot and BotVersion to MatchPlayer 2022-07-09 20:01:05 +02:00
Ilion Beyst
7eb02a2efc create a new bot verison on docker push 2022-07-08 20:40:20 +02:00
Ilion Beyst
0f14dee499 refactor: rename save_code_bundle to save_code_string 2022-07-07 19:13:55 +02:00
Ilion Beyst
6ec792e3bd NewBotVersion 2022-07-07 18:57:46 +02:00
Ilion Beyst
d7b7585dd7 rename code_bundle to bot_version 2022-07-06 22:41:27 +02:00
Ilion Beyst
b3df5c6f8c migrate code_bundles to bot_versions 2022-07-05 20:34:20 +02:00
Ilion Beyst
8a47b948eb migrate code_bundles to bot_versions 2022-07-05 20:33:39 +02:00
Ilion Beyst
ea05674b44 remove obsolete create match route 2022-07-04 22:33:35 +02:00
Ilion Beyst
268e080ec1 Merge branch 'bot-api' into next 2022-07-04 20:16:42 +02:00
Ilion Beyst
bbed877554 cleanup and comments 2022-07-04 20:11:29 +02:00
Ilion Beyst
608d05bc16 update README 2022-07-03 22:01:00 +02:00
Ilion Beyst
7a3b801f58 use RunMatch in bot_api service 2022-06-11 17:50:44 +02:00
Ilion Beyst
a376698073 update RunMatch helper to allow for remote bots 2022-06-10 21:49:32 +02:00
Ilion Beyst
5ee66c9c9b allow match_player code_bundle_id to be null 2022-06-10 21:09:33 +02:00
Ilion Beyst
d1977b95c8 consistently use player_key and player_id 2022-06-09 20:57:45 +02:00
Ilion Beyst
e3cf0df450 update client to request matches 2022-06-08 23:24:32 +02:00
Ilion Beyst
1b2472fbfc implement grpc match creation PoC 2022-06-08 22:37:38 +02:00
Ilion Beyst
028d4a99e4 run bot process in client 2022-06-07 20:16:42 +02:00
Ilion Beyst
ff061f2a7a timeout when player never connects 2022-06-07 19:12:49 +02:00
Ilion Beyst
69421d7b25 bot api: handle timeouts and disconnects 2022-06-06 20:23:01 +02:00
Ilion Beyst
2f915af919 send player_id through request metadata 2022-06-06 14:25:56 +02:00
Ilion Beyst
d0faec7d1f implement PlayerRouter 2022-06-06 13:08:43 +02:00
Ilion Beyst
90ecb13a17 baby steps towards a working bot api 2022-06-05 21:22:38 +02:00
Ilion Beyst
c3d32e051c basic bot api proto definition 2022-06-03 21:24:18 +02:00
Ilion Beyst
0f80b19614 set up stub grpc client 2022-06-01 20:19:13 +02:00
Ilion Beyst
af5cd69f7b set up gprc server 2022-05-31 21:08:56 +02:00
117 changed files with 3977 additions and 2090 deletions

View file

@ -3,6 +3,6 @@
members = [
"planetwars-rules",
"planetwars-matchrunner",
"planetwars-cli",
"planetwars-server",
"planetwars-client",
]

View file

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

View file

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

View file

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

View file

@ -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
}
]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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, &timestamp));
let mut players = Vec::new();
for bot_name in &self.bots {
let bot = workspace.get_bot(&bot_name)?;
players.push(MatchPlayer {
name: bot_name.clone(),
path: bot.path.clone(),
argv: bot.config.get_run_argv(),
});
}
let match_config = MatchConfig {
map_name: self.map,
map_path,
log_path: log_path.clone(),
players,
};
run_match(match_config).await;
println!("match completed successfully");
// TODO: maybe print the match result as well?
let relative_path = match log_path.strip_prefix(&workspace.root_path) {
Ok(path) => path.to_str().unwrap(),
Err(_) => log_path.to_str().unwrap(),
};
println!("wrote match log to {}", relative_path);
Ok(())
}
}

View file

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

View file

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

View file

@ -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(),
}
}
}

View file

@ -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.")
})
}
}

View file

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

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

View 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

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

View file

@ -0,0 +1,2 @@
name = "simplebot"
command = ["python", "../simplebot/simplebot.py"]

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@ pub struct MatchOutcome {
pub async fn run_match(config: MatchConfig) -> MatchOutcome {
let pw_config = PwConfig {
map_file: config.map_path,
max_turns: 100,
max_turns: 500,
};
let event_bus = Arc::new(Mutex::new(EventBus::new()));

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE match_players ALTER COLUMN code_bundle_id SET NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE match_players ALTER COLUMN code_bundle_id DROP NOT NULL;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -1,17 +1,22 @@
use crate::{db::bots::Bot, DbPool};
use crate::db::bots::BotVersion;
use crate::db::maps::Map;
use crate::{db::bots::Bot, DbPool, GlobalConfig};
use crate::db;
use crate::modules::matches::RunMatch;
use crate::modules::matches::{MatchPlayer, RunMatch};
use diesel::{PgConnection, QueryResult};
use rand::seq::SliceRandom;
use std::collections::HashMap;
use std::mem;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio;
// TODO: put these in a config
const RANKER_INTERVAL: u64 = 60;
const RANKER_NUM_MATCHES: i64 = 10_000;
pub async fn run_ranker(db_pool: DbPool) {
pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) {
// TODO: make this configurable
// play at most one match every n seconds
let mut interval = tokio::time::interval(Duration::from_secs(RANKER_INTERVAL));
@ -21,39 +26,49 @@ pub async fn run_ranker(db_pool: DbPool) {
.expect("could not get database connection");
loop {
interval.tick().await;
let bots = db::bots::find_all_bots(&db_conn).unwrap();
let bots = db::bots::all_active_bots_with_version(&db_conn).expect("could not load bots");
if bots.len() < 2 {
// not enough bots to play a match
continue;
}
let selected_bots: Vec<Bot> = {
let mut rng = &mut rand::thread_rng();
bots.choose_multiple(&mut rng, 2).cloned().collect()
let selected_bots: Vec<(Bot, BotVersion)> = bots
.choose_multiple(&mut rand::thread_rng(), 2)
.cloned()
.collect();
let maps = db::maps::list_maps(&db_conn).expect("could not load map");
let map = match maps.choose(&mut rand::thread_rng()).cloned() {
None => continue, // no maps available
Some(map) => map,
};
play_ranking_match(selected_bots, db_pool.clone()).await;
play_ranking_match(config.clone(), map, selected_bots, db_pool.clone()).await;
recalculate_ratings(&db_conn).expect("could not recalculate ratings");
}
}
async fn play_ranking_match(selected_bots: Vec<Bot>, db_pool: DbPool) {
let db_conn = db_pool.get().await.expect("could not get db pool");
let mut code_bundles = Vec::new();
for bot in &selected_bots {
let code_bundle = db::bots::active_code_bundle(bot.id, &db_conn)
.expect("could not get active code bundle");
code_bundles.push(code_bundle);
async fn play_ranking_match(
config: Arc<GlobalConfig>,
map: Map,
selected_bots: Vec<(Bot, BotVersion)>,
db_pool: DbPool,
) {
let mut players = Vec::new();
for (bot, bot_version) in selected_bots {
let player = MatchPlayer::BotVersion {
bot: Some(bot),
version: bot_version,
};
players.push(player);
}
let code_bundle_refs = code_bundles.iter().collect::<Vec<_>>();
let mut run_match = RunMatch::from_players(code_bundle_refs);
run_match
.store_in_database(&db_conn)
.expect("could not store match in db");
run_match
.spawn(db_pool.clone())
let (_, handle) = RunMatch::new(config, true, map, players)
.run(db_pool.clone())
.await
.expect("running match failed");
.expect("failed to run match");
// wait for match to complete, so that only one ranking match can be running
let _outcome = handle.await;
}
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>> {
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();
for m in matches {

View file

@ -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::extract::{BodyStream, FromRequest, Path, Query, RequestParts, TypedHeader};
use axum::handler::Handler;
use axum::headers::authorization::Basic;
use axum::headers::Authorization;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, head, post, put};
use axum::{async_trait, Router};
use axum::{async_trait, Extension, Router};
use futures::StreamExt;
use hyper::StatusCode;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use crate::db::bots::NewBotVersion;
use crate::util::gen_alphanumeric;
use crate::{db, DatabaseConnection};
use crate::{db, DatabaseConnection, GlobalConfig};
use crate::db::users::{authenticate_user, Credentials, User};
const REGISTRY_PATH: &str = "./data/registry";
pub fn registry_service() -> Router {
Router::new()
// The docker API requires this trailing slash
.nest("/v2/", registry_api_v2())
.fallback(fallback.into_service())
}
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";
// TODO: put this in some configuration
const ADMIN_PASSWORD: &str = "supersecretpassword";
type AuthorizationHeader = TypedHeader<Authorization<Basic>>;
@ -109,8 +101,12 @@ where
password: basic.password(),
};
let Extension(config) = Extension::<Arc<GlobalConfig>>::from_request(req)
.await
.unwrap();
if credentials.username == ADMIN_USERNAME {
if credentials.password == ADMIN_PASSWORD {
if credentials.password == config.registry_admin_password {
Ok(RegistryAuth::Admin)
} else {
Err(RegistryAuthError::InvalidCredentials)
@ -166,11 +162,14 @@ async fn check_blob_exists(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, raw_digest)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let digest = raw_digest.strip_prefix("sha256:").unwrap();
let blob_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
let blob_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
if blob_path.exists() {
let metadata = std::fs::metadata(&blob_path).unwrap();
Ok((StatusCode::OK, [("Content-Length", metadata.len())]))
@ -183,11 +182,14 @@ async fn get_blob(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, raw_digest)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let digest = raw_digest.strip_prefix("sha256:").unwrap();
let blob_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
let blob_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
if !blob_path.exists() {
return Err(StatusCode::NOT_FOUND);
}
@ -201,13 +203,18 @@ async fn create_upload(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path(repository_name): Path<String>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let uuid = gen_alphanumeric(16);
tokio::fs::File::create(PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid))
.await
.unwrap();
tokio::fs::File::create(
PathBuf::from(&config.registry_directory)
.join("uploads")
.join(&uuid),
)
.await
.unwrap();
Ok(Response::builder()
.status(StatusCode::ACCEPTED)
@ -226,11 +233,14 @@ async fn patch_upload(
auth: RegistryAuth,
Path((repository_name, uuid)): Path<(String, String)>,
mut stream: BodyStream,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
// TODO: support content range header in request
let upload_path = PathBuf::from(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()
.read(false)
.write(true)
@ -270,10 +280,13 @@ async fn put_upload(
Path((repository_name, uuid)): Path<(String, String)>,
Query(params): Query<UploadParams>,
mut stream: BodyStream,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let upload_path = PathBuf::from(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()
.read(false)
.write(true)
@ -287,8 +300,10 @@ async fn put_upload(
while let Some(Ok(chunk)) = stream.next().await {
file.write_all(&chunk).await.unwrap();
}
file.flush().await.unwrap();
let range_end = last_byte_pos(&file).await.unwrap();
// Close the file to ensure all data has been flushed to the kernel.
// If we don't do this, calculating the checksum can fail.
std::mem::drop(file);
let expected_digest = params.digest.strip_prefix("sha256:").unwrap();
let digest = file_sha256_digest(&upload_path).unwrap();
@ -297,7 +312,9 @@ async fn put_upload(
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();
Ok(Response::builder()
@ -318,10 +335,11 @@ async fn get_manifest(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, reference)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let manifest_path = PathBuf::from(REGISTRY_PATH)
let manifest_path = PathBuf::from(&config.registry_directory)
.join("manifests")
.join(&repository_name)
.join(&reference)
@ -343,10 +361,11 @@ async fn put_manifest(
auth: RegistryAuth,
Path((repository_name, reference)): Path<(String, String)>,
mut stream: BodyStream,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> 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(&repository_name);
@ -373,6 +392,18 @@ async fn put_manifest(
let digest_path = repository_dir.join(&content_digest).with_extension("json");
tokio::fs::copy(manifest_path, digest_path).await.unwrap();
// Register the new image as a bot version
// TODO: how should tags be handled?
let new_version = NewBotVersion {
bot_id: Some(bot.id),
code_bundle_path: None,
container_digest: Some(&content_digest),
};
let version =
db::bots::create_bot_version(&new_version, &db_conn).expect("could not save bot version");
db::bots::set_active_version(bot.id, Some(version.id), &db_conn)
.expect("could not update bot version");
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(
@ -385,12 +416,13 @@ async fn put_manifest(
}
/// 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(
repository_name: &str,
auth: &RegistryAuth,
db_conn: &DatabaseConnection,
) -> Result<(), StatusCode> {
) -> Result<db::bots::Bot, StatusCode> {
use diesel::OptionalExtension;
// TODO: it would be nice to provide the found repository
@ -401,10 +433,10 @@ fn check_access(
.ok_or(StatusCode::NOT_FOUND)?;
match &auth {
RegistryAuth::Admin => Ok(()),
RegistryAuth::Admin => Ok(bot),
RegistryAuth::User(user) => {
if bot.owner_id == Some(user.id) {
Ok(())
Ok(bot)
} else {
Err(StatusCode::FORBIDDEN)
}

View file

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

View file

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

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

View file

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

View file

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

View file

@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
const RESERVED_USERNAMES: &[&str] = &["admin", "system"];
type AuthorizationHeader = TypedHeader<Authorization<Bearer>>;
#[async_trait]
@ -89,7 +91,11 @@ impl RegistrationParams {
errors.push("password must be at least 8 characters".to_string());
}
if users::find_user(&self.username, &conn).is_ok() {
if RESERVED_USERNAMES.contains(&self.username.as_str()) {
errors.push("that username is not allowed".to_string());
}
if users::find_user_by_name(&self.username, &conn).is_ok() {
errors.push("username is already taken".to_string());
}

View file

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

46
proto/client_api.proto Normal file
View 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;
}

View file

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

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

View file

@ -1,20 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
let leaderboard = [];
onMount(async () => {
const res = await fetch("/api/leaderboard", {
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
leaderboard = await res.json();
console.log(leaderboard);
}
});
export let leaderboard;
function formatRating(entry: object): any {
const rating = entry["rating"];
@ -41,10 +26,17 @@
<td class="leaderboard-rating">
{formatRating(entry)}
</td>
<td class="leaderboard-bot">{entry["bot"]["name"]}</td>
<td class="leaderboard-bot">
<a class="leaderboard-href" href="/bots/{entry['bot']['name']}"
>{entry["bot"]["name"]}
</a></td
>
<td class="leaderboard-author">
{#if entry["author"]}
{entry["author"]["username"]}
<!-- TODO: remove duplication -->
<a class="leaderboard-href" href="/users/{entry['author']['username']}"
>{entry["author"]["username"]}</a
>
{/if}
</td>
</tr>
@ -69,4 +61,9 @@
.leaderboard-rank {
color: #333;
}
.leaderboard-href {
text-decoration: none;
color: black;
}
</style>

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View 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