Compare commits

..

No commits in common. "main" and "docker-registry" have entirely different histories.

117 changed files with 2072 additions and 3959 deletions

View file

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

View file

@ -1,31 +1,9 @@
# planetwars
# mozaic4
Planetwars is a competitive programming game. You implement a bot that will be pitted against all other bots.
Because third time's the charm!
Try it out at https://planetwars.dev !
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:
Project layout:
- `planetwars-server`: rust webserver
- `planetwars-matchrunner`: code for running matches
- `planetwars-rules`: implements the game rules
- `planetwars-client`: for running your bot locally
- `planetwars-matchrunner`: implements the game
- `web/pw-server`: frontend
- `web/pw-visualizer`: code for the visualizer

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -1,20 +0,0 @@
[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

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

View file

@ -1,9 +0,0 @@
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

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

View file

@ -1,144 +0,0 @@
pub mod pb {
tonic::include_proto!("grpc.planetwars.client_api");
pub use player_api_client_message::ClientMessage as PlayerApiClientMessageType;
pub use player_api_server_message::ServerMessage as PlayerApiServerMessageType;
}
use 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,6 +5,9 @@ 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

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

View file

@ -1,4 +1,5 @@
use std::io;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
@ -15,22 +16,11 @@ use crate::match_context::{EventBus, PlayerHandle, RequestError, RequestMessage}
use crate::match_log::{MatchLogMessage, MatchLogger, StdErrMessage};
use crate::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 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,
pub code_path: PathBuf,
pub argv: Vec<String>,
}
#[async_trait]
@ -52,36 +42,14 @@ async fn spawn_docker_process(
params: &DockerBotSpec,
) -> Result<ContainerProcess, bollard::errors::Error> {
let docker = Docker::connect_with_socket_defaults()?;
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 bot_code_dir = std::fs::canonicalize(&params.code_path).unwrap();
let code_dir_str = bot_code_dir.as_os_str().to_str().unwrap();
let memory_limit = 512 * 1024 * 1024; // 512MB
let config = container::Config {
image: Some(params.image.clone()),
host_config: Some(bollard::models::HostConfig {
binds: params.binds.clone(),
binds: Some(vec![format!("{}:{}", code_dir_str, "/workdir")]),
network_mode: Some("none".to_string()),
memory: Some(memory_limit),
memory_swap: Some(memory_limit),
@ -91,8 +59,8 @@ async fn spawn_docker_process(
// cpu_quota: Some(10_000),
..Default::default()
}),
working_dir: params.working_dir.clone(),
cmd: params.argv.clone(),
working_dir: Some("/workdir".to_string()),
cmd: Some(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: 500,
max_turns: 100,
};
let event_bus = Arc::new(Mutex::new(EventBus::new()));

View file

@ -6,8 +6,6 @@ use tokio::{fs::File, io::AsyncWriteExt};
use planetwars_rules::protocol::State;
use tokio::sync::mpsc;
use crate::pw_match::PlayerCommand;
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum MatchLogMessage {
@ -15,19 +13,6 @@ 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 as proto;
use planetwars_rules::protocol::{self as proto, PlayerAction};
use planetwars_rules::serializer as pw_serializer;
use planetwars_rules::{PlanetWars, PwConfig};
@ -40,18 +40,22 @@ 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 player_action = self.execute_action(player_id, turn);
self.log_player_action(player_id, player_action);
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);
}
}
self.match_state.step();
self.log_game_state();
// Log state
let state = self.match_state.serialize_state();
self.match_ctx.log(MatchLogMessage::GameState(state));
}
}
@ -84,14 +88,18 @@ impl PwMatch {
.await
}
fn execute_action(&mut self, player_num: usize, turn: RequestResult<Vec<u8>>) -> PlayerAction {
let data = match turn {
Err(_timeout) => return PlayerAction::Timeout,
fn execute_action(
&mut self,
player_num: usize,
turn: RequestResult<Vec<u8>>,
) -> proto::PlayerAction {
let turn = match turn {
Err(_timeout) => return proto::PlayerAction::Timeout,
Ok(data) => data,
};
let action: proto::Action = match serde_json::from_slice(&data) {
Err(error) => return PlayerAction::ParseError { data, error },
let action: proto::Action = match serde_json::from_slice(&turn) {
Err(err) => return proto::PlayerAction::ParseError(err.to_string()),
Ok(action) => action,
};
@ -100,64 +108,15 @@ impl PwMatch {
.into_iter()
.map(|command| {
let res = self.match_state.execute_command(player_num, &command);
PlayerCommand {
proto::PlayerCommand {
command,
error: res.err(),
}
})
.collect();
PlayerAction::Commands(commands)
proto::PlayerAction::Commands(commands)
}
fn log_game_state(&mut self) {
let state = self.match_state.serialize_state();
self.match_ctx.log(MatchLogMessage::GameState(state));
}
fn log_player_action(&mut self, player_id: usize, player_action: PlayerAction) {
match player_action {
PlayerAction::Timeout => self.match_ctx.log(MatchLogMessage::Timeout {
player_id: player_id as u32,
}),
PlayerAction::ParseError { data, error } => {
// TODO: can this be handled better?
let command =
String::from_utf8(data).unwrap_or_else(|_| "<invalid utf-8>".to_string());
self.match_ctx.log(MatchLogMessage::BadCommand {
player_id: player_id as u32,
command,
error: error.to_string(),
});
}
PlayerAction::Commands(dispatches) => {
self.match_ctx.log(MatchLogMessage::Dispatches {
player_id: player_id as u32,
dispatches,
});
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerCommand {
#[serde(flatten)]
pub command: proto::Command,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<proto::CommandError>,
}
/// the action a player performed.
// TODO: can we name this better? Is this a "play"?
pub enum PlayerAction {
Timeout,
ParseError {
data: Vec<u8>,
error: serde_json::Error,
},
Commands(Vec<PlayerCommand>),
}
fn action_errors(action: PlayerAction) -> Option<PlayerAction> {

View file

@ -84,6 +84,7 @@ 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,3 +49,31 @@ 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,21 +2,12 @@
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"] }
@ -38,15 +29,9 @@ 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::schema::{bot_versions, bots};
use crate::schema::{bots, code_bundles};
use chrono;
#[derive(Insertable)]
@ -16,7 +16,6 @@ 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> {
@ -39,79 +38,44 @@ pub fn find_bot_by_name(name: &str, conn: &PgConnection) -> QueryResult<Bot> {
bots::table.filter(bots::name.eq(name)).first(conn)
}
pub fn find_bot_with_version_by_name(
bot_name: &str,
conn: &PgConnection,
) -> QueryResult<(Bot, BotVersion)> {
bots::table
.inner_join(bot_versions::table.on(bots::active_version.eq(bot_versions::id.nullable())))
.filter(bots::name.eq(bot_name))
.first(conn)
}
pub fn all_active_bots_with_version(conn: &PgConnection) -> QueryResult<Vec<(Bot, BotVersion)>> {
bots::table
.inner_join(bot_versions::table.on(bots::active_version.eq(bot_versions::id.nullable())))
.get_results(conn)
}
pub fn find_all_bots(conn: &PgConnection) -> QueryResult<Vec<Bot>> {
// TODO: filter out bots that cannot be run (have no valid code bundle associated with them)
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> {
#[table_name = "code_bundles"]
pub struct NewCodeBundle<'a> {
pub bot_id: Option<i32>,
pub code_bundle_path: Option<&'a str>,
pub container_digest: Option<&'a str>,
pub path: &'a str,
}
#[derive(Queryable, Serialize, Deserialize, Clone, Debug)]
pub struct BotVersion {
#[derive(Queryable, Serialize, Deserialize, Debug)]
pub struct CodeBundle {
pub id: i32,
pub bot_id: Option<i32>,
pub code_bundle_path: Option<String>,
pub path: String,
pub created_at: chrono::NaiveDateTime,
pub container_digest: Option<String>,
}
pub fn create_bot_version(
new_bot_version: &NewBotVersion,
pub fn create_code_bundle(
new_code_bundle: &NewCodeBundle,
conn: &PgConnection,
) -> QueryResult<BotVersion> {
diesel::insert_into(bot_versions::table)
.values(new_bot_version)
) -> QueryResult<CodeBundle> {
diesel::insert_into(code_bundles::table)
.values(new_code_bundle)
.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))
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))
.get_results(conn)
}
pub fn active_code_bundle(bot_id: i32, conn: &PgConnection) -> QueryResult<CodeBundle> {
code_bundles::table
.filter(code_bundles::bot_id.eq(bot_id))
.order(code_bundles::created_at.desc())
.first(conn)
}

View file

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

View file

@ -1,27 +1,20 @@
pub use crate::db_types::MatchState;
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::{bot_versions, bots, maps, match_players, matches};
use crate::schema::{bots, code_bundles, match_players, matches};
use super::bots::{Bot, BotVersion};
use super::maps::Map;
use super::bots::{Bot, CodeBundle};
#[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)]
@ -32,7 +25,7 @@ pub struct NewMatchPlayer {
/// player id within the match
pub player_id: i32,
/// id of the bot behind this player
pub bot_version_id: Option<i32>,
pub code_bundle_id: i32,
}
#[derive(Queryable, Identifiable)]
@ -43,8 +36,6 @@ 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)]
@ -53,11 +44,11 @@ pub struct MatchBase {
pub struct MatchPlayer {
pub match_id: i32,
pub player_id: i32,
pub code_bundle_id: Option<i32>,
pub code_bundle_id: i32,
}
pub struct MatchPlayerData {
pub code_bundle_id: Option<i32>,
pub code_bundle_id: i32,
}
pub fn create_match(
@ -76,7 +67,7 @@ pub fn create_match(
.map(|(num, player_data)| NewMatchPlayer {
match_id: match_base.id,
player_id: num as i32,
bot_version_id: player_data.code_bundle_id,
code_bundle_id: player_data.code_bundle_id,
})
.collect::<Vec<_>>();
@ -96,133 +87,32 @@ pub struct MatchData {
pub match_players: Vec<MatchPlayer>,
}
/// 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>> {
pub fn list_matches(conn: &PgConnection) -> QueryResult<Vec<FullMatchData>> {
conn.transaction(|| {
let matches = matches::table
.filter(matches::state.eq(MatchState::Finished))
.order_by(matches::created_at.desc())
.limit(amount)
.get_results::<MatchBase>(conn)?;
let matches = matches::table.get_results::<MatchBase>(conn)?;
fetch_full_match_data(matches, 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)
})
}
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>,
}
@ -230,7 +120,7 @@ pub struct FullMatchData {
// #[primary_key(base.match_id, base::player_id)]
pub struct FullMatchPlayerData {
pub base: MatchPlayer,
pub bot_version: Option<BotVersion>,
pub code_bundle: CodeBundle,
pub bot: Option<Bot>,
}
@ -251,24 +141,14 @@ 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)
.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())
.inner_join(code_bundles::table)
.left_join(bots::table.on(code_bundles::bot_id.eq(bots::id.nullable())))
.load::<FullMatchPlayerData>(conn)?;
let res = FullMatchData {
base: match_base,
match_players,
map,
};
Ok(res)

View file

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

View file

@ -42,17 +42,11 @@ fn argon2_config() -> argon2::Config<'static> {
}
}
pub fn hash_password(password: &str) -> (Vec<u8>, [u8; 32]) {
let argon_config = argon2_config();
let salt: [u8; 32] = rand::thread_rng().gen();
let hash = argon2::hash_raw(password.as_bytes(), &salt, &argon_config).unwrap();
(hash, salt)
}
pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> {
let (hash, salt) = hash_password(&credentials.password);
let argon_config = argon2_config();
let salt: [u8; 32] = rand::thread_rng().gen();
let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
let new_user = NewUser {
username: credentials.username,
password_salt: &salt,
@ -63,36 +57,14 @@ pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResul
.get_result::<User>(conn)
}
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> {
pub fn find_user(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_by_name(credentials.username, db_conn)
find_user(credentials.username, db_conn)
.optional()
.unwrap()
.and_then(|user| {

View file

@ -8,19 +8,16 @@ 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, Serialize};
use serde::Deserialize;
use axum::{
async_trait,
@ -30,45 +27,15 @@ use axum::{
Router,
};
type ConnectionPool = bb8::Pool<DieselConnectionManager<PgConnection>>;
// 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?
// 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";
pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) {
type ConnectionPool = bb8::Pool<DieselConnectionManager<PgConnection>>;
pub async fn seed_simplebot(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, _>(|| {
@ -84,7 +51,7 @@ pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) {
let simplebot_code =
std::fs::read_to_string(SIMPLEBOT_PATH).expect("could not read simplebot code");
modules::bots::save_code_string(&simplebot_code, Some(simplebot.id), &conn, config)?;
modules::bots::save_code_bundle(&simplebot_code, Some(simplebot.id), &conn)?;
println!("initialized simplebot");
@ -94,22 +61,11 @@ pub async fn seed_simplebot(config: &GlobalConfig, pool: &ConnectionPool) {
pub type DbPool = Pool<DieselConnectionManager<PgConnection>>;
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 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 fn api() -> Router {
@ -117,30 +73,31 @@ 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/:bot_name", get(routes::bots::get_bot))
.route("/bots/my_bots", get(routes::bots::get_my_bots))
.route("/bots/:bot_id", get(routes::bots::get_bot))
.route(
"/bots/:bot_name/upload",
"/bots/:bot_id/upload",
post(routes::bots::upload_code_multipart),
)
.route("/code/:version_id", get(routes::bots::get_code))
.route("/matches", get(routes::matches::list_recent_matches))
.route(
"/matches",
get(routes::matches::list_matches).post(routes::matches::play_match),
)
.route("/matches/:match_id", get(routes::matches::get_match_data))
.route(
"/matches/:match_id/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<GlobalConfig, ConfigError> {
pub fn get_config() -> Result<Configuration, ConfigError> {
config::Config::builder()
.add_source(config::File::with_name("configuration.toml"))
.add_source(config::Environment::with_prefix("PLANETWARS"))
@ -148,7 +105,7 @@ pub fn get_config() -> Result<GlobalConfig, ConfigError> {
.try_deserialize()
}
async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) {
async fn run_registry(db_pool: DbPool) {
// TODO: put in config
let addr = SocketAddr::from(([127, 0, 0, 1], 9001));
@ -156,7 +113,6 @@ async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) {
.serve(
registry_service()
.layer(Extension(db_pool))
.layer(Extension(config))
.into_make_service(),
)
.await
@ -164,21 +120,15 @@ async fn run_registry(config: Arc<GlobalConfig>, db_pool: DbPool) {
}
pub async fn run_app() {
let global_config = Arc::new(get_config().unwrap());
let db_pool = create_db_pool(&global_config).await;
seed_simplebot(&global_config, &db_pool).await;
init_directories(&global_config).unwrap();
let configuration = get_config().unwrap();
let db_pool = prepare_db(&configuration.database_url).await;
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()));
tokio::spawn(run_ranker(db_pool.clone()));
tokio::spawn(run_registry(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
@ -187,6 +137,11 @@ 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,32 +2,22 @@ use std::path::PathBuf;
use diesel::{PgConnection, QueryResult};
use crate::{db, util::gen_alphanumeric, GlobalConfig};
use crate::{db, util::gen_alphanumeric, BOTS_DIR};
/// Save a string containing bot code as a code bundle.
/// If a bot was provided, set the saved bundle as its active version.
pub fn save_code_string(
pub fn save_code_bundle(
bot_code: &str,
bot_id: Option<i32>,
conn: &PgConnection,
config: &GlobalConfig,
) -> QueryResult<db::bots::BotVersion> {
) -> QueryResult<db::bots::CodeBundle> {
let bundle_name = gen_alphanumeric(16);
let code_bundle_dir = PathBuf::from(&config.bots_directory).join(&bundle_name);
let code_bundle_dir = PathBuf::from(BOTS_DIR).join(&bundle_name);
std::fs::create_dir(&code_bundle_dir).unwrap();
std::fs::write(code_bundle_dir.join("bot.py"), bot_code).unwrap();
let new_code_bundle = db::bots::NewBotVersion {
let new_code_bundle = db::bots::NewCodeBundle {
bot_id,
code_bundle_path: Some(&bundle_name),
container_digest: None,
path: &bundle_name,
};
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)
db::bots::create_code_bundle(&new_code_bundle, conn)
}

View file

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

View file

@ -1,161 +1,87 @@
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, GlobalConfig,
ConnectionPool, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
};
pub struct RunMatch {
const PYTHON_IMAGE: &str = "python:3.10-slim-buster";
pub struct RunMatch<'a> {
log_file_name: String,
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,
player_code_bundles: Vec<&'a db::bots::CodeBundle>,
match_id: Option<i32>,
}
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 {
impl<'a> RunMatch<'a> {
pub fn from_players(player_code_bundles: Vec<&'a db::bots::CodeBundle>) -> Self {
let log_file_name = format!("{}.log", gen_alphanumeric(16));
RunMatch {
config,
log_file_name,
players,
is_public,
map,
player_code_bundles,
match_id: None,
}
}
fn into_runner_config(self) -> runner::MatchConfig {
pub fn runner_config(&self) -> runner::MatchConfig {
runner::MatchConfig {
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),
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),
players: self
.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,
},
.player_code_bundles
.iter()
.map(|b| runner::MatchPlayer {
bot_spec: code_bundle_to_botspec(b),
})
.collect(),
}
}
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)?
};
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());
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
.players
.player_code_bundles
.iter()
.map(|p| db::matches::MatchPlayerData {
code_bundle_id: match p {
MatchPlayer::BotVersion { version, .. } => Some(version.id),
MatchPlayer::BotSpec { .. } => None,
},
.map(|b| db::matches::MatchPlayerData {
code_bundle_id: b.id,
})
.collect::<Vec<_>>();
db::matches::create_match(&new_match_data, &new_match_players, db_conn)
let match_data = db::matches::create_match(&new_match_data, &new_match_players, &db_conn)?;
self.match_id = Some(match_data.base.id);
Ok(match_data)
}
pub fn spawn(self, pool: ConnectionPool) -> JoinHandle<MatchOutcome> {
let match_id = self.match_id.expect("match must be saved before running");
let runner_config = self.runner_config();
tokio::spawn(run_match_task(pool, runner_config, match_id))
}
}
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")
}
}
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);
fn python_docker_bot_spec(config: &GlobalConfig, code_bundle_path: &str) -> Box<dyn BotSpec> {
let code_bundle_rel_path = PathBuf::from(&config.bots_directory).join(code_bundle_path);
let code_bundle_abs_path = std::fs::canonicalize(&code_bundle_rel_path).unwrap();
let code_bundle_path_str = code_bundle_abs_path.as_os_str().to_str().unwrap();
// TODO: it would be good to simplify this configuration
Box::new(DockerBotSpec {
image: config.python_runner_image.clone(),
binds: Some(vec![format!("{}:{}", code_bundle_path_str, "/workdir")]),
argv: Some(vec!["python".to_string(), "bot.py".to_string()]),
working_dir: Some("/workdir".to_string()),
// This would be a pull from dockerhub at the moment, let's avoid that for now.
// Maybe the best course of action would be to replicate all images in the dedicated
// registry, so that we only have to provide credentials to that one.
pull: false,
credentials: None,
code_path: bundle_path,
image: PYTHON_IMAGE.to_string(),
argv: vec!["python".to_string(), "bot.py".to_string()],
})
}
@ -178,5 +104,5 @@ async fn run_match_task(
db::matches::save_match_result(match_id, result, &conn).expect("could not save match result");
outcome
return outcome;
}

View file

@ -1,7 +1,6 @@
// This module implements general domain logic, not directly
// 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,22 +1,17 @@
use crate::db::bots::BotVersion;
use crate::db::maps::Map;
use crate::{db::bots::Bot, DbPool, GlobalConfig};
use crate::{db::bots::Bot, DbPool};
use crate::db;
use crate::modules::matches::{MatchPlayer, RunMatch};
use crate::modules::matches::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(config: Arc<GlobalConfig>, db_pool: DbPool) {
pub async fn run_ranker(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));
@ -26,49 +21,39 @@ pub async fn run_ranker(config: Arc<GlobalConfig>, db_pool: DbPool) {
.expect("could not get database connection");
loop {
interval.tick().await;
let bots = db::bots::all_active_bots_with_version(&db_conn).expect("could not load bots");
let bots = db::bots::find_all_bots(&db_conn).unwrap();
if bots.len() < 2 {
// not enough bots to play a match
continue;
}
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,
let selected_bots: Vec<Bot> = {
let mut rng = &mut rand::thread_rng();
bots.choose_multiple(&mut rng, 2).cloned().collect()
};
play_ranking_match(config.clone(), map, selected_bots, db_pool.clone()).await;
play_ranking_match(selected_bots, db_pool.clone()).await;
recalculate_ratings(&db_conn).expect("could not recalculate ratings");
}
}
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);
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);
}
let (_, handle) = RunMatch::new(config, true, map, players)
.run(db_pool.clone())
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())
.await
.expect("failed to run match");
// wait for match to complete, so that only one ranking match can be running
let _outcome = handle.await;
.expect("running match failed");
}
fn recalculate_ratings(db_conn: &PgConnection) -> QueryResult<()> {
@ -92,7 +77,7 @@ struct MatchStats {
}
fn fetch_match_stats(db_conn: &PgConnection) -> QueryResult<HashMap<(i32, i32), MatchStats>> {
let matches = db::matches::list_matches(RANKER_NUM_MATCHES, db_conn)?;
let matches = db::matches::list_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, Extension, Router};
use axum::{async_trait, Router};
use futures::StreamExt;
use hyper::StatusCode;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use crate::db::bots::NewBotVersion;
use crate::util::gen_alphanumeric;
use crate::{db, DatabaseConnection, GlobalConfig};
use crate::{db, DatabaseConnection};
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,7 +46,15 @@ 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>>;
@ -101,12 +109,8 @@ where
password: basic.password(),
};
let Extension(config) = Extension::<Arc<GlobalConfig>>::from_request(req)
.await
.unwrap();
if credentials.username == ADMIN_USERNAME {
if credentials.password == config.registry_admin_password {
if credentials.password == ADMIN_PASSWORD {
Ok(RegistryAuth::Admin)
} else {
Err(RegistryAuthError::InvalidCredentials)
@ -162,14 +166,11 @@ async fn check_blob_exists(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, raw_digest)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let digest = raw_digest.strip_prefix("sha256:").unwrap();
let blob_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
let blob_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
if blob_path.exists() {
let metadata = std::fs::metadata(&blob_path).unwrap();
Ok((StatusCode::OK, [("Content-Length", metadata.len())]))
@ -182,14 +183,11 @@ async fn get_blob(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, raw_digest)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let digest = raw_digest.strip_prefix("sha256:").unwrap();
let blob_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
let blob_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
if !blob_path.exists() {
return Err(StatusCode::NOT_FOUND);
}
@ -203,18 +201,13 @@ async fn create_upload(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path(repository_name): Path<String>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let uuid = gen_alphanumeric(16);
tokio::fs::File::create(
PathBuf::from(&config.registry_directory)
.join("uploads")
.join(&uuid),
)
.await
.unwrap();
tokio::fs::File::create(PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid))
.await
.unwrap();
Ok(Response::builder()
.status(StatusCode::ACCEPTED)
@ -233,14 +226,11 @@ 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(&config.registry_directory)
.join("uploads")
.join(&uuid);
let upload_path = PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid);
let mut file = tokio::fs::OpenOptions::new()
.read(false)
.write(true)
@ -280,13 +270,10 @@ 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(&config.registry_directory)
.join("uploads")
.join(&uuid);
let upload_path = PathBuf::from(REGISTRY_PATH).join("uploads").join(&uuid);
let mut file = tokio::fs::OpenOptions::new()
.read(false)
.write(true)
@ -300,10 +287,8 @@ 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();
@ -312,9 +297,7 @@ async fn put_upload(
return Err(StatusCode::BAD_REQUEST);
}
let target_path = PathBuf::from(&config.registry_directory)
.join("sha256")
.join(&digest);
let target_path = PathBuf::from(REGISTRY_PATH).join("sha256").join(&digest);
tokio::fs::rename(&upload_path, &target_path).await.unwrap();
Ok(Response::builder()
@ -335,11 +318,10 @@ async fn get_manifest(
db_conn: DatabaseConnection,
auth: RegistryAuth,
Path((repository_name, reference)): Path<(String, String)>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<impl IntoResponse, StatusCode> {
check_access(&repository_name, &auth, &db_conn)?;
let manifest_path = PathBuf::from(&config.registry_directory)
let manifest_path = PathBuf::from(REGISTRY_PATH)
.join("manifests")
.join(&repository_name)
.join(&reference)
@ -361,11 +343,10 @@ 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> {
let bot = check_access(&repository_name, &auth, &db_conn)?;
check_access(&repository_name, &auth, &db_conn)?;
let repository_dir = PathBuf::from(&config.registry_directory)
let repository_dir = PathBuf::from(REGISTRY_PATH)
.join("manifests")
.join(&repository_name);
@ -392,18 +373,6 @@ 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(
@ -416,13 +385,12 @@ async fn put_manifest(
}
/// Ensure that the accessed repository exists
/// and the user is allowed to access it.
/// Returns the associated bot.
/// and the user is allowed to access ti
fn check_access(
repository_name: &str,
auth: &RegistryAuth,
db_conn: &DatabaseConnection,
) -> Result<db::bots::Bot, StatusCode> {
) -> Result<(), StatusCode> {
use diesel::OptionalExtension;
// TODO: it would be nice to provide the found repository
@ -433,10 +401,10 @@ fn check_access(
.ok_or(StatusCode::NOT_FOUND)?;
match &auth {
RegistryAuth::Admin => Ok(bot),
RegistryAuth::Admin => Ok(()),
RegistryAuth::User(user) => {
if bot.owner_id == Some(user.id) {
Ok(bot)
Ok(())
} 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, Extension, Json};
use axum::{body, Json};
use diesel::OptionalExtension;
use rand::distributions::Alphanumeric;
use rand::Rng;
@ -9,19 +9,15 @@ 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;
use crate::db::bots::{self, BotVersion};
use crate::db::bots::{self, CodeBundle};
use crate::db::ratings::{self, RankedBot};
use crate::db::users::User;
use crate::modules::bots::save_code_string;
use crate::{DatabaseConnection, GlobalConfig};
use crate::modules::bots::save_code_bundle;
use crate::{DatabaseConnection, BOTS_DIR};
use bots::Bot;
use super::users::UserData;
#[derive(Serialize, Deserialize, Debug)]
pub struct SaveBotParams {
pub bot_name: String,
@ -100,7 +96,6 @@ 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()
@ -124,8 +119,8 @@ pub async fn save_bot(
bots::create_bot(&new_bot, &conn).expect("could not create bot")
}
};
let _code_bundle = save_code_string(&params.code, Some(bot.id), &conn, &config)
.expect("failed to save code bundle");
let _code_bundle =
save_code_bundle(&params.code, Some(bot.id), &conn).expect("failed to save code bundle");
Ok(Json(bot))
}
@ -134,64 +129,44 @@ 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>,
) -> 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);
}
) -> (StatusCode, Json<Bot>) {
let bot_params = bots::NewBot {
owner_id: Some(user.id),
name: &params.name,
};
let bot = bots::create_bot(&bot_params, &conn).unwrap();
Ok((StatusCode::CREATED, Json(bot)))
(StatusCode::CREATED, Json(bot))
}
// TODO: handle errors
pub async fn get_bot(
conn: DatabaseConnection,
Path(bot_name): Path<String>,
Path(bot_id): Path<i32>,
) -> Result<Json<JsonValue>, StatusCode> {
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)?;
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)?;
Ok(Json(json!({
"bot": bot,
"owner": owner,
"versions": versions,
"bundles": bundles,
})))
}
pub async fn get_user_bots(
pub async fn get_my_bots(
conn: DatabaseConnection,
Path(user_name): Path<String>,
user: User,
) -> Result<Json<Vec<Bot>>, StatusCode> {
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)
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_active_bots(&conn)
bots::find_all_bots(&conn)
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
@ -206,13 +181,12 @@ pub async fn get_ranking(conn: DatabaseConnection) -> Result<Json<Vec<RankedBot>
pub async fn upload_code_multipart(
conn: DatabaseConnection,
user: User,
Path(bot_name): Path<String>,
Path(bot_id): Path<i32>,
mut multipart: Multipart,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<Json<BotVersion>, StatusCode> {
let bots_dir = PathBuf::from(&config.bots_directory);
) -> Result<Json<CodeBundle>, StatusCode> {
let bots_dir = PathBuf::from(BOTS_DIR);
let bot = bots::find_bot_by_name(&bot_name, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
let bot = bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
if Some(user.id) != bot.owner_id {
return Err(StatusCode::FORBIDDEN);
@ -239,39 +213,12 @@ pub async fn upload_code_multipart(
.extract(bots_dir.join(&folder_name))
.map_err(|_| StatusCode::BAD_REQUEST)?;
let bot_version = bots::NewBotVersion {
let bundle = bots::NewCodeBundle {
bot_id: Some(bot.id),
code_bundle_path: Some(&folder_name),
container_digest: None,
path: &folder_name,
};
let code_bundle =
bots::create_bot_version(&bot_version, &conn).expect("Failed to create code bundle");
bots::create_code_bundle(&bundle, &conn).expect("Failed to create code bundle");
Ok(Json(code_bundle))
}
pub async fn get_code(
conn: DatabaseConnection,
user: User,
Path(bundle_id): Path<i32>,
Extension(config): Extension<Arc<GlobalConfig>>,
) -> Result<Vec<u8>, StatusCode> {
let version =
db::bots::find_bot_version(bundle_id, &conn).map_err(|_| StatusCode::NOT_FOUND)?;
let bot_id = version.bot_id.ok_or(StatusCode::FORBIDDEN)?;
let bot = db::bots::find_bot(bot_id, &conn).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if bot.owner_id != Some(user.id) {
return Err(StatusCode::FORBIDDEN);
}
let bundle_path = version.code_bundle_path.ok_or(StatusCode::NOT_FOUND)?;
// TODO: avoid hardcoding paths
let full_bundle_path = PathBuf::from(&config.bots_directory)
.join(&bundle_path)
.join("bot.py");
let bot_code =
std::fs::read(full_bundle_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(bot_code)
}

View file

@ -1,11 +1,8 @@
use std::sync::Arc;
use crate::db;
use crate::db::matches::{FullMatchData, FullMatchPlayerData};
use crate::modules::bots::save_code_string;
use crate::modules::matches::{MatchPlayer, RunMatch};
use crate::modules::bots::save_code_bundle;
use crate::modules::matches::RunMatch;
use crate::ConnectionPool;
use crate::GlobalConfig;
use axum::extract::Extension;
use axum::Json;
use hyper::StatusCode;
@ -14,13 +11,12 @@ 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)]
@ -29,11 +25,11 @@ pub struct SubmitBotResponse {
pub match_data: ApiMatch,
}
/// Submit bot code and opponent name to play a match
/// submit python code for a bot, which will face off
/// with a demo bot. Return a played match.
pub async fn submit_bot(
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");
@ -41,39 +37,20 @@ pub async fn submit_bot(
.opponent_name
.unwrap_or_else(|| DEFAULT_OPPONENT_NAME.to_string());
let map_name = params
.map_name
.unwrap_or_else(|| DEFAULT_MAP_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 (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)
let player_code_bundle = save_code_bundle(&params.code, None, &conn)
// TODO: can we recover from this?
.expect("could not save bot code");
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");
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());
// TODO: avoid clones
let full_match_data = FullMatchData {
@ -81,16 +58,15 @@ pub async fn submit_bot(
match_players: vec![
FullMatchPlayerData {
base: match_data.match_players[0].clone(),
bot_version: Some(player_bot_version),
code_bundle: player_code_bundle,
bot: None,
},
FullMatchPlayerData {
base: match_data.match_players[1].clone(),
bot_version: Some(opponent_bot_version),
bot: Some(opponent_bot),
code_bundle: opponent_code_bundle,
bot: Some(opponent),
},
],
map: Some(map),
};
let api_match = super::matches::match_data_to_api(full_match_data);

View file

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

View file

@ -1,21 +1,101 @@
use std::path::PathBuf;
use axum::{
extract::{Path, Query},
Extension, Json,
extract::{Extension, Path},
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::{
self,
bots,
matches::{self, MatchState},
users::User,
},
DatabaseConnection, GlobalConfig,
ConnectionPool, DatabaseConnection, BOTS_DIR, MAPS_DIR, MATCHES_DIR,
};
use super::maps::ApiMap;
#[derive(Serialize, Deserialize, Debug)]
pub struct MatchParams {
// Just bot ids for now
players: Vec<i32>,
}
pub async fn play_match(
_user: User,
Extension(pool): Extension<ConnectionPool>,
Json(params): Json<MatchParams>,
) -> Result<(), StatusCode> {
let conn = pool.get().await.expect("could not get database connection");
let map_path = PathBuf::from(MAPS_DIR).join("hex.json");
let slug: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let log_file_name = format!("{}.log", slug);
let mut players = Vec::new();
let mut bot_ids = Vec::new();
for bot_name in params.players {
let bot = bots::find_bot(bot_name, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
let code_bundle =
bots::active_code_bundle(bot.id, &conn).map_err(|_| StatusCode::BAD_REQUEST)?;
let bundle_path = PathBuf::from(BOTS_DIR).join(&code_bundle.path);
let bot_config: BotConfig = std::fs::read_to_string(bundle_path.join("botconfig.toml"))
.and_then(|config_str| toml::from_str(&config_str).map_err(|e| e.into()))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
players.push(MatchPlayer {
bot_spec: Box::new(DockerBotSpec {
code_path: PathBuf::from(BOTS_DIR).join(code_bundle.path),
image: "python:3.10-slim-buster".to_string(),
argv: shlex::split(&bot_config.run_command)
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?,
}),
});
bot_ids.push(matches::MatchPlayerData {
code_bundle_id: code_bundle.id,
});
}
let match_config = MatchConfig {
map_name: "hex".to_string(),
map_path,
log_path: PathBuf::from(MATCHES_DIR).join(&log_file_name),
players,
};
tokio::spawn(run_match_task(
match_config,
log_file_name,
bot_ids,
pool.clone(),
));
Ok(())
}
async fn run_match_task(
config: MatchConfig,
log_file_name: String,
match_players: Vec<matches::MatchPlayerData>,
pool: ConnectionPool,
) {
let match_data = matches::NewMatch {
state: MatchState::Finished,
log_path: &log_file_name,
};
run_match(config).await;
let conn = pool.get().await.expect("could not get database connection");
matches::create_match(&match_data, &match_players, &conn).expect("could not create match");
}
#[derive(Serialize, Deserialize)]
pub struct ApiMatch {
@ -23,71 +103,19 @@ pub struct ApiMatch {
timestamp: chrono::NaiveDateTime,
state: MatchState,
players: Vec<ApiMatchPlayer>,
winner: Option<i32>,
map: Option<ApiMap>,
}
#[derive(Serialize, Deserialize)]
pub struct ApiMatchPlayer {
bot_version_id: Option<i32>,
code_bundle_id: i32,
bot_id: Option<i32>,
bot_name: Option<String>,
}
#[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 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()))
}
pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch {
@ -99,16 +127,23 @@ pub fn match_data_to_api(data: matches::FullMatchData) -> ApiMatch {
.match_players
.iter()
.map(|_p| ApiMatchPlayer {
bot_version_id: _p.bot_version.as_ref().map(|cb| cb.id),
code_bundle_id: _p.code_bundle.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,
@ -122,11 +157,10 @@ 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(&config.match_logs_directory).join(&match_base.log_path);
let log_path = PathBuf::from(MATCHES_DIR).join(&match_base.log_path);
let log_contents = std::fs::read(log_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(log_contents)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,6 @@
"eslint-config-prettier": "^8.3.0",
"eslint-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

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

View file

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

View file

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

View file

@ -1,80 +1,32 @@
<script lang="ts">
import { parsePlayerLog, PlayerLog } from "$lib/log_parser";
export let matchLog: string;
let playerLog: PlayerLog;
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]}`;
function getStdErr(botId: number, log?: string): string {
if (!log) {
return "";
}
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");
}
$: if (matchLog) {
playerLog = parsePlayerLog(1, matchLog);
} else {
playerLog = [];
}
$: botStdErr = getStdErr(1, matchLog);
</script>
<div class="output">
<h3 class="output-header">Player log</h3>
{#if showRawStderr}
<div class="output-text stderr-text">
{playerLog.flatMap((turn) => turn.stderr).join("\n")}
</div>
{:else}
{#if botStdErr.length > 0}
<h3 class="output-header">stderr:</h3>
<div class="output-text">
{#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}
{botStdErr}
</div>
{/if}
</div>
@ -87,71 +39,12 @@
padding: 15px;
}
.turn {
margin: 16px 4px;
}
.output-text {
color: #ccc;
}
.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;
font-family: 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

@ -0,0 +1,129 @@
<div class="container">
<div class="game-rules">
<h2 class="title">Welcome to planetwars!</h2>
<p>
Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that
will conquer the galaxy for you, while you take care of more important stuff.
</p>
<p>
In every game turn, your bot will receive a json-encoded line on stdin, describing the current
state of the game. Each state will hold a set of planets, and a set of spaceship fleets
traveling between the planets (<em>expeditions</em>).
</p>
<p>Example game state:</p>
<pre>{`
{
"planets": [
{
"ship_count": 2,
"x": -2.0,
"y": 0.0,
"owner": 1,
"name": "your planet"
},
{
"ship_count": 4,
"x": 2.0,
"y": 0.0,
"owner": 2,
"name": "enemy planet"
},
{
"ship_count": 2,
"x": 0.0,
"y": 2.0,
"owner": null,
"name": "neutral planet"
}
],
"expeditions": [
{
"id": 169,
"ship_count": 8,
"origin": "your planet",
"destination": "enemy planet",
"owner": 1,
"turns_remaining": 2
}
]
}
`}</pre>
<p>
The <code>owner</code> field holds a player number when the planet is held by a player, and is
<code>null</code> otherwise. Your bot is always referred to as player 1.<br />
Each turn, every player-owned planet will gain one additional ship. <br />
Planets will never move during the game.
</p>
<p>
Every turn, you may send out expeditions to conquer other planets. You can do this by writing
a json-encoded line to stdout:
</p>
<p>Example command:</p>
<pre>{`
{
"moves": [
{
"origin": "your planet",
"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,20 +1,15 @@
<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 maps: object[] = [];
let selectedOpponent = undefined;
let botName: string | undefined = undefined;
// whether to show the "save succesful" message
let saveSuccesful = false;
@ -23,28 +18,24 @@
onMount(async () => {
botName = getBotName();
const apiClient = new ApiClient();
const [_bots, _maps] = await Promise.all([
apiClient.get("/api/bots"),
apiClient.get("/api/maps"),
]);
const res = await fetch("/api/bots", {
headers: {
"Content-Type": "application/json",
},
});
availableBots = _bots;
maps = _maps;
if (!$selectedOpponent) {
selectedOpponent.set(availableBots.find((b) => b["name"] === "simplebot"));
}
if (!$selectedMap) {
selectedMap.set(maps.find((m) => m["name"] === "hex"));
if (res.ok) {
availableBots = await res.json();
selectedOpponent = availableBots.find((b) => b["name"] === "simplebot");
}
});
const dispatch = createEventDispatcher();
async function submitBot() {
const opponentName = selectedOpponent["name"];
let response = await fetch("/api/submit_bot", {
method: "POST",
headers: {
@ -52,8 +43,7 @@
},
body: JSON.stringify({
code: editSession.getDocument().getValue(),
opponent_name: $selectedOpponent["name"],
map_name: $selectedMap["name"],
opponent_name: opponentName,
}),
});
@ -110,23 +100,13 @@
<div class="submit-pane">
<div class="match-form">
<h4>Play a match</h4>
<div class="play-text">Opponent</div>
<div class="opponent-select">
<div class="play-text">Select an opponent to test your bot</div>
<div class="opponentSelect">
<Select
optionIdentifier="name"
labelIdentifier="name"
items={availableBots}
bind:value={$selectedOpponent}
isClearable={false}
/>
</div>
<span>Map</span>
<div class="map-select">
<Select
optionIdentifier="name"
labelIdentifier="name"
items={maps}
bind:value={$selectedMap}
bind:value={selectedOpponent}
isClearable={false}
/>
</div>
@ -165,9 +145,8 @@
margin-bottom: 0.3em;
}
.opponent-select,
.map-select {
margin: 8px 0;
.opponentSelect {
margin: 20px 0;
}
.save-form {

View file

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

View file

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

View file

@ -36,13 +36,13 @@
<div class="user-controls">
{#if $currentUser}
<a class="current-user-name" href="/users/{$currentUser['username']}">
<div class="current-user-name">
{$currentUser["username"]}
</a>
</div>
<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,7 +61,6 @@
.current-user-name {
@include navbar-item;
text-decoration: none;
color: #fff;
}

View file

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

View file

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

View file

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

View file

@ -6,29 +6,16 @@
<div class="outer-container">
<div class="navbar">
<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 class="navbar-main">
<a href="/">PlanetWars</a>
</div>
<UserControls />
</div>
<slot />
</div>
<style lang="scss" global>
@import "src/styles/global.scss";
<style lang="scss">
@import "src/styles/variables.scss";
.outer-container {
width: 100vw;
@ -47,33 +34,13 @@
padding: 0 15px;
}
.navbar-left {
display: flex;
}
.navbar-right {
display: flex;
}
.navbar-header {
.navbar-main {
margin: auto 0;
padding-right: 24px;
}
.navbar-header a {
.navbar-main a {
font-size: 20px;
color: #fff;
color: #eee;
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,92 +1,276 @@
<script lang="ts" context="module">
import { ApiClient } from "$lib/api_client";
<script lang="ts">
import Visualizer from "$lib/components/Visualizer.svelte";
import EditorView from "$lib/components/EditorView.svelte";
import { onMount } from "svelte";
const NUM_MATCHES = "25";
import { DateTime } from "luxon";
export async function load({ fetch }) {
try {
const apiClient = new ApiClient(fetch);
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";
let { matches, has_next } = await apiClient.get("/api/matches", {
count: NUM_MATCHES,
});
enum ViewMode {
Editor,
MatchVisualizer,
Rules,
Leaderboard,
}
return {
props: {
matches,
hasNext: has_next,
},
};
} catch (error) {
return {
status: error.status,
error: new Error("failed to load matches"),
};
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);
}
}
</script>
<script lang="ts">
import LinkButton from "$lib/components/LinkButton.svelte";
import MatchList from "$lib/components/matches/MatchList.svelte";
async function getMatchData(matchId: string) {
let response = await fetch(`/api/matches/${matchId}`, {
headers: {
"Content-Type": "application/json",
},
});
export let matches;
export let hasNext;
if (!response.ok) {
throw Error(response.statusText);
}
$: viewMoreUrl = olderMatchesLink(matches);
let matchData = await response.json();
return matchData;
}
// TODO: deduplicate.
// Maybe move to ApiClient logic?
function olderMatchesLink(matches: object[]): string {
if (matches.length == 0 || !hasNext) {
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 lastTimestamp = matches[matches.length - 1]["timestamp"];
return `/matches?before=${lastTimestamp}`;
const res = await fetch(`/api/matches/${matchId}/log`, {
headers: {
"Content-Type": "application/json",
},
});
let log = await res.text();
return log;
}
function setViewMode(viewMode_: ViewMode) {
selectedMatchId = undefined;
selectedMatchLog = undefined;
viewMode = viewMode_;
}
function selectRules() {
selectedMatchId = undefined;
selectedMatchLog = undefined;
viewMode = ViewMode.Rules;
}
function formatMatchTimestamp(timestampString: string): string {
let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal();
if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) {
return timestamp.toFormat("HH:mm");
} else {
return timestamp.toFormat("dd/MM");
}
}
$: selectedMatch = matches.find((m) => m["id"] === selectedMatchId);
</script>
<div class="container">
<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 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>
<h2>Recent matches</h2>
<MatchList {matches} />
<div class="see-more-container">
<LinkButton href={viewMoreUrl}>View more</LinkButton>
<div class="editor-container">
{#if viewMode === ViewMode.MatchVisualizer}
<Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} />
{:else if viewMode === ViewMode.Editor}
<EditorView {editSession} />
{:else if viewMode === ViewMode.Rules}
<RulesView />
{:else if viewMode === ViewMode.Leaderboard}
<Leaderboard />
{/if}
</div>
<div class="sidebar-right">
{#if viewMode === ViewMode.MatchVisualizer}
<OutputPane matchLog={selectedMatchLog} />
{:else if viewMode === ViewMode.Editor}
<SubmitPane {editSession} on:matchCreated={onMatchCreated} />
{/if}
</div>
</div>
<style scoped lang="scss">
<style lang="scss">
@import "src/styles/variables.scss";
.container {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-grow: 1;
min-height: 0;
}
.introduction {
padding-top: 16px;
a {
color: rgb(9, 105, 218);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.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;
}
.see-more-container {
padding: 24px;
text-align: center;
.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;
}
</style>

View file

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

View file

@ -1,44 +1,31 @@
<script lang="ts" context="module">
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`),
]);
export async function load({ page }) {
const res = await fetch(`/api/matches/${page.params["match_id"]}`, {
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
return {
props: {
matchData: matchData,
matchLog: matchLog,
matchLog: await res.text(),
},
};
} 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 class="container">
<Visualizer {matchLog} {matchData} />
<div>
<Visualizer {matchLog} />
</div>
<style lang="scss">
.container {
display: flex;
// these are needed for making the visualizer fill the screen.
min-height: 0;
flex-grow: 1;
overflow: hidden;
}
</style>

View file

@ -1,121 +1,36 @@
<script lang="ts" context="module">
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();
}
export async function load() {
const res = await fetch("/api/matches", {
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
return {
props: {
matches,
botName,
hasNext: has_next,
query,
matches: await res.json(),
},
};
} catch (error) {
return {
status: error.status,
error: new Error("failed to load matches"),
};
}
}
function removeUndefined(obj: Record<string, string>): Record<string, string> {
Object.keys(obj).forEach((key) => {
if (obj[key] === undefined || obj[key] === null) {
delete obj[key];
}
});
return obj;
return {
status: res.status,
error: new Error("failed to load matches"),
};
}
</script>
<script lang="ts">
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 });
}
import dayjs from "dayjs";
export let matches;
</script>
<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>
<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>

View file

@ -1,5 +1,6 @@
<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,17 +2,3 @@ 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

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

View file

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

View file

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

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