aboutsummaryrefslogtreecommitdiff
path: root/planetwars-cli
diff options
context:
space:
mode:
Diffstat (limited to 'planetwars-cli')
-rw-r--r--planetwars-cli/Cargo.toml25
-rw-r--r--planetwars-cli/README.md1
-rw-r--r--planetwars-cli/assets/hex.json43
-rw-r--r--planetwars-cli/assets/pw_workspace.toml6
-rw-r--r--planetwars-cli/assets/simplebot/botconfig.toml2
-rw-r--r--planetwars-cli/assets/simplebot/simplebot.py33
-rw-r--r--planetwars-cli/src/bin/pwcli.rs6
-rw-r--r--planetwars-cli/src/commands/build.rs27
-rw-r--r--planetwars-cli/src/commands/init.rs38
-rw-r--r--planetwars-cli/src/commands/mod.rs40
-rw-r--r--planetwars-cli/src/commands/run_match.rs51
-rw-r--r--planetwars-cli/src/commands/serve.rs17
-rw-r--r--planetwars-cli/src/lib.rs12
-rw-r--r--planetwars-cli/src/match_runner/bot_runner.rs121
-rw-r--r--planetwars-cli/src/match_runner/match_context.rs161
-rw-r--r--planetwars-cli/src/match_runner/mod.rs91
-rw-r--r--planetwars-cli/src/match_runner/pw_match.rs136
-rw-r--r--planetwars-cli/src/web/mod.rs174
-rw-r--r--planetwars-cli/src/workspace/bot.rs50
-rw-r--r--planetwars-cli/src/workspace/mod.rs77
20 files changed, 1111 insertions, 0 deletions
diff --git a/planetwars-cli/Cargo.toml b/planetwars-cli/Cargo.toml
new file mode 100644
index 0000000..e1f0a8e
--- /dev/null
+++ b/planetwars-cli/Cargo.toml
@@ -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"
+planetwars-rules = { path = "../planetwars-rules" }
+clap = { version = "3.0.0-rc.8", features = ["derive"] }
+chrono = { version = "0.4", features = ["serde"] }
+shlex = "1.1"
+
+rust-embed = "6.3.0"
+axum = { version = "0.4", features = ["ws"] }
+mime_guess = "2" \ No newline at end of file
diff --git a/planetwars-cli/README.md b/planetwars-cli/README.md
new file mode 100644
index 0000000..db2a67b
--- /dev/null
+++ b/planetwars-cli/README.md
@@ -0,0 +1 @@
+# planetwars-cli \ No newline at end of file
diff --git a/planetwars-cli/assets/hex.json b/planetwars-cli/assets/hex.json
new file mode 100644
index 0000000..5ef4f31
--- /dev/null
+++ b/planetwars-cli/assets/hex.json
@@ -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
+ }
+ ]
+}
+
diff --git a/planetwars-cli/assets/pw_workspace.toml b/planetwars-cli/assets/pw_workspace.toml
new file mode 100644
index 0000000..d82840f
--- /dev/null
+++ b/planetwars-cli/assets/pw_workspace.toml
@@ -0,0 +1,6 @@
+[paths]
+maps_dir = "maps"
+matches_dir = "matches"
+
+[bots.simplebot]
+path = "bots/simplebot"
diff --git a/planetwars-cli/assets/simplebot/botconfig.toml b/planetwars-cli/assets/simplebot/botconfig.toml
new file mode 100644
index 0000000..b3a4163
--- /dev/null
+++ b/planetwars-cli/assets/simplebot/botconfig.toml
@@ -0,0 +1,2 @@
+name = "simplebot"
+run_command = "python3 simplebot.py" \ No newline at end of file
diff --git a/planetwars-cli/assets/simplebot/simplebot.py b/planetwars-cli/assets/simplebot/simplebot.py
new file mode 100644
index 0000000..b2a6b8f
--- /dev/null
+++ b/planetwars-cli/assets/simplebot/simplebot.py
@@ -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
+ })
diff --git a/planetwars-cli/src/bin/pwcli.rs b/planetwars-cli/src/bin/pwcli.rs
new file mode 100644
index 0000000..438d3bc
--- /dev/null
+++ b/planetwars-cli/src/bin/pwcli.rs
@@ -0,0 +1,6 @@
+use planetwars_cli;
+
+#[tokio::main]
+async fn main() {
+ planetwars_cli::run().await
+}
diff --git a/planetwars-cli/src/commands/build.rs b/planetwars-cli/src/commands/build.rs
new file mode 100644
index 0000000..1df0bb6
--- /dev/null
+++ b/planetwars-cli/src/commands/build.rs
@@ -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(())
+ }
+}
diff --git a/planetwars-cli/src/commands/init.rs b/planetwars-cli/src/commands/init.rs
new file mode 100644
index 0000000..c95626b
--- /dev/null
+++ b/planetwars-cli/src/commands/init.rs
@@ -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(())
+ }
+}
diff --git a/planetwars-cli/src/commands/mod.rs b/planetwars-cli/src/commands/mod.rs
new file mode 100644
index 0000000..52fed5c
--- /dev/null
+++ b/planetwars-cli/src/commands/mod.rs
@@ -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),
+}
diff --git a/planetwars-cli/src/commands/run_match.rs b/planetwars-cli/src/commands/run_match.rs
new file mode 100644
index 0000000..868e87c
--- /dev/null
+++ b/planetwars-cli/src/commands/run_match.rs
@@ -0,0 +1,51 @@
+use std::io;
+
+use clap::Parser;
+
+use crate::match_runner::MatchConfig;
+use crate::match_runner::{self, 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(),
+ bot,
+ });
+ }
+
+ let match_config = MatchConfig {
+ map_name: self.map,
+ map_path,
+ log_path: log_path.clone(),
+ players,
+ };
+
+ match_runner::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(())
+ }
+}
diff --git a/planetwars-cli/src/commands/serve.rs b/planetwars-cli/src/commands/serve.rs
new file mode 100644
index 0000000..aa8d149
--- /dev/null
+++ b/planetwars-cli/src/commands/serve.rs
@@ -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(())
+ }
+}
diff --git a/planetwars-cli/src/lib.rs b/planetwars-cli/src/lib.rs
new file mode 100644
index 0000000..e5566b0
--- /dev/null
+++ b/planetwars-cli/src/lib.rs
@@ -0,0 +1,12 @@
+mod commands;
+mod match_runner;
+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);
+ }
+}
diff --git a/planetwars-cli/src/match_runner/bot_runner.rs b/planetwars-cli/src/match_runner/bot_runner.rs
new file mode 100644
index 0000000..70fc060
--- /dev/null
+++ b/planetwars-cli/src/match_runner/bot_runner.rs
@@ -0,0 +1,121 @@
+use std::io;
+use std::path::PathBuf;
+use std::process::Stdio;
+use std::sync::Arc;
+use std::sync::Mutex;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
+use tokio::process;
+use tokio::sync::mpsc;
+use tokio::time::timeout;
+
+use super::match_context::EventBus;
+use super::match_context::PlayerHandle;
+use super::match_context::RequestError;
+use super::match_context::RequestMessage;
+pub struct LocalBotHandle {
+ tx: mpsc::UnboundedSender<RequestMessage>,
+}
+
+impl PlayerHandle for LocalBotHandle {
+ fn send_request(&mut self, r: RequestMessage) {
+ self.tx
+ .send(r)
+ .expect("failed to send message to local bot");
+ }
+
+ fn send_info(&mut self, _msg: String) {
+ // TODO: log this somewhere
+ // drop info message
+ }
+}
+
+pub fn run_local_bot(player_id: u32, event_bus: Arc<Mutex<EventBus>>, bot: Bot) -> LocalBotHandle {
+ let (tx, rx) = mpsc::unbounded_channel();
+
+ let runner = LocalBotRunner {
+ event_bus,
+ rx,
+ player_id,
+ bot,
+ };
+ tokio::spawn(runner.run());
+
+ return LocalBotHandle { tx };
+}
+
+pub struct LocalBotRunner {
+ event_bus: Arc<Mutex<EventBus>>,
+ rx: mpsc::UnboundedReceiver<RequestMessage>,
+ player_id: u32,
+ bot: Bot,
+}
+
+impl LocalBotRunner {
+ pub async fn run(mut self) {
+ let mut process = self.bot.spawn_process();
+
+ while let Some(request) = self.rx.recv().await {
+ let resp_fut = process.communicate(&request.content);
+ let result = timeout(request.timeout, resp_fut)
+ .await
+ // TODO: how can this failure be handled cleanly?
+ .expect("process read failed");
+ let result = match result {
+ Ok(line) => Ok(line.into_bytes()),
+ Err(_elapsed) => Err(RequestError::Timeout),
+ };
+ let request_id = (self.player_id, request.request_id);
+
+ self.event_bus
+ .lock()
+ .unwrap()
+ .resolve_request(request_id, result);
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Bot {
+ pub working_dir: PathBuf,
+ pub argv: Vec<String>,
+}
+
+impl Bot {
+ pub fn spawn_process(&self) -> BotProcess {
+ let mut child = process::Command::new(&self.argv[0])
+ .args(&self.argv[1..])
+ .current_dir(self.working_dir.clone())
+ .kill_on_drop(true)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .expect("spawning failed");
+
+ let stdout = child.stdout.take().unwrap();
+ let reader = BufReader::new(stdout).lines();
+
+ return BotProcess {
+ stdin: child.stdin.take().unwrap(),
+ stdout: reader,
+ child,
+ };
+ }
+}
+
+pub struct BotProcess {
+ #[allow(dead_code)]
+ child: process::Child,
+ stdin: process::ChildStdin,
+ stdout: Lines<BufReader<process::ChildStdout>>,
+}
+
+impl BotProcess {
+ // TODO: gracefully handle errors
+ pub async fn communicate(&mut self, input: &[u8]) -> io::Result<String> {
+ self.stdin.write_all(input).await?;
+ self.stdin.write_u8(b'\n').await?;
+ let line = self.stdout.next_line().await?;
+ line.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "no response received"))
+ }
+}
diff --git a/planetwars-cli/src/match_runner/match_context.rs b/planetwars-cli/src/match_runner/match_context.rs
new file mode 100644
index 0000000..466da13
--- /dev/null
+++ b/planetwars-cli/src/match_runner/match_context.rs
@@ -0,0 +1,161 @@
+use futures::task::{Context, Poll};
+use futures::{future::Future, task::AtomicWaker};
+use serde::{Deserialize, Serialize};
+use std::fs::File;
+use std::io::Write;
+use std::pin::Pin;
+use std::time::Duration;
+use std::{
+ collections::HashMap,
+ sync::{Arc, Mutex},
+};
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct RequestMessage {
+ pub request_id: u32,
+ pub timeout: Duration,
+ pub content: Vec<u8>,
+}
+
+pub struct MatchCtx {
+ event_bus: Arc<Mutex<EventBus>>,
+ players: HashMap<u32, PlayerData>,
+ // output: MsgStreamHandle<String>,
+ log_sink: File,
+}
+
+impl MatchCtx {
+ pub fn new(
+ event_bus: Arc<Mutex<EventBus>>,
+ players: HashMap<u32, Box<dyn PlayerHandle>>,
+ log_file: File,
+ // log: MsgStreamHandle<String>,
+ ) -> Self {
+ MatchCtx {
+ event_bus,
+ players: players
+ .into_iter()
+ .map(|(id, handle)| {
+ let player_handle = PlayerData {
+ request_ctr: 0,
+ handle,
+ };
+ (id, player_handle)
+ })
+ .collect(),
+ log_sink: log_file,
+ }
+ }
+
+ // TODO: implement a clean way to handle the player not existing
+ pub fn request(&mut self, player_id: u32, content: Vec<u8>, timeout: Duration) -> Request {
+ let player = self.players.get_mut(&player_id).unwrap();
+ let request_id = player.request_ctr;
+ player.request_ctr += 1;
+
+ player.handle.send_request(RequestMessage {
+ request_id,
+ content,
+ timeout,
+ });
+
+ return Request {
+ player_id,
+ request_id,
+ event_bus: self.event_bus.clone(),
+ };
+ }
+
+ pub fn send_info(&mut self, player_id: u32, msg: String) {
+ let player = self.players.get_mut(&player_id).unwrap();
+ player.handle.send_info(msg);
+ }
+
+ pub fn players(&self) -> Vec<u32> {
+ self.players.keys().cloned().collect()
+ }
+
+ // this method should be used to emit log states etc.
+ pub fn log_string(&mut self, message: String) {
+ write!(self.log_sink, "{}\n", message).expect("failed to write to log file");
+ }
+}
+
+pub trait PlayerHandle: Send {
+ fn send_request(&mut self, r: RequestMessage);
+ fn send_info(&mut self, msg: String);
+}
+
+struct PlayerData {
+ request_ctr: u32,
+ handle: Box<dyn PlayerHandle>,
+}
+
+type RequestId = (u32, u32);
+pub struct EventBus {
+ request_responses: HashMap<RequestId, RequestResult<Vec<u8>>>,
+ wakers: HashMap<RequestId, AtomicWaker>,
+}
+
+impl EventBus {
+ pub fn new() -> Self {
+ EventBus {
+ request_responses: HashMap::new(),
+ wakers: HashMap::new(),
+ }
+ }
+}
+
+impl EventBus {
+ pub fn resolve_request(&mut self, id: RequestId, result: RequestResult<Vec<u8>>) {
+ if self.request_responses.contains_key(&id) {
+ // request already resolved
+ // TODO: maybe report this?
+ return;
+ }
+ self.request_responses.insert(id, result);
+ if let Some(waker) = self.wakers.remove(&id) {
+ waker.wake();
+ }
+ }
+}
+
+pub struct Request {
+ player_id: u32,
+ request_id: u32,
+ event_bus: Arc<Mutex<EventBus>>,
+}
+
+impl Request {
+ #[allow(dead_code)]
+ pub fn player_id(&self) -> u32 {
+ self.player_id
+ }
+}
+
+impl Future for Request {
+ type Output = RequestResult<Vec<u8>>;
+
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+ let mut event_bus = self.event_bus.lock().unwrap();
+ let request_id = (self.player_id, self.request_id);
+
+ if let Some(result) = event_bus.request_responses.get(&request_id) {
+ return Poll::Ready(result.clone());
+ }
+
+ event_bus
+ .wakers
+ .entry(request_id)
+ .or_insert_with(|| AtomicWaker::new())
+ .register(cx.waker());
+ return Poll::Pending;
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum RequestError {
+ Timeout,
+}
+
+pub type RequestResult<T> = Result<T, RequestError>;
diff --git a/planetwars-cli/src/match_runner/mod.rs b/planetwars-cli/src/match_runner/mod.rs
new file mode 100644
index 0000000..fdd02d5
--- /dev/null
+++ b/planetwars-cli/src/match_runner/mod.rs
@@ -0,0 +1,91 @@
+mod bot_runner;
+mod match_context;
+mod pw_match;
+
+use std::{
+ io::Write,
+ path::PathBuf,
+ sync::{Arc, Mutex},
+};
+
+use match_context::MatchCtx;
+use planetwars_rules::PwConfig;
+use serde::{Deserialize, Serialize};
+
+use crate::workspace::bot::WorkspaceBot;
+
+use self::match_context::{EventBus, PlayerHandle};
+
+pub struct MatchConfig {
+ pub map_name: String,
+ pub map_path: PathBuf,
+ pub log_path: PathBuf,
+ pub players: Vec<MatchPlayer>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct MatchMeta {
+ pub map_name: String,
+ pub timestamp: chrono::DateTime<chrono::Local>,
+ pub players: Vec<PlayerInfo>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct PlayerInfo {
+ pub name: String,
+}
+
+pub struct MatchPlayer {
+ pub name: String,
+ pub bot: WorkspaceBot,
+}
+
+pub async fn run_match(config: MatchConfig) {
+ let pw_config = PwConfig {
+ map_file: config.map_path,
+ max_turns: 100,
+ };
+
+ let event_bus = Arc::new(Mutex::new(EventBus::new()));
+
+ // start bots
+ let players = config
+ .players
+ .iter()
+ .enumerate()
+ .map(|(player_id, player)| {
+ let player_id = (player_id + 1) as u32;
+ let bot = bot_runner::Bot {
+ working_dir: player.bot.path.clone(),
+ argv: player.bot.config.get_run_argv(),
+ };
+ let handle = bot_runner::run_local_bot(player_id, event_bus.clone(), bot);
+ (player_id, Box::new(handle) as Box<dyn PlayerHandle>)
+ })
+ .collect();
+ let mut log_file = std::fs::File::create(config.log_path).expect("could not create log file");
+
+ // assemble the math meta struct
+ let match_meta = MatchMeta {
+ map_name: config.map_name.clone(),
+ timestamp: chrono::Local::now(),
+ players: config
+ .players
+ .iter()
+ .map(|bot| PlayerInfo {
+ name: bot.name.clone(),
+ })
+ .collect(),
+ };
+ write!(
+ log_file,
+ "{}\n",
+ serde_json::to_string(&match_meta).unwrap()
+ )
+ .unwrap();
+
+ let match_ctx = MatchCtx::new(event_bus, players, log_file);
+
+ let match_state = pw_match::PwMatch::create(match_ctx, pw_config);
+ match_state.run().await;
+}
diff --git a/planetwars-cli/src/match_runner/pw_match.rs b/planetwars-cli/src/match_runner/pw_match.rs
new file mode 100644
index 0000000..42bc9d2
--- /dev/null
+++ b/planetwars-cli/src/match_runner/pw_match.rs
@@ -0,0 +1,136 @@
+use super::match_context::{MatchCtx, RequestResult};
+use futures::stream::futures_unordered::FuturesUnordered;
+use futures::{FutureExt, StreamExt};
+use serde::{Deserialize, Serialize};
+use tokio::time::Duration;
+
+use serde_json;
+
+use std::convert::TryInto;
+
+pub use planetwars_rules::config::{Config, Map};
+
+use planetwars_rules::protocol::{self as proto, PlayerAction};
+use planetwars_rules::serializer as pw_serializer;
+use planetwars_rules::{PlanetWars, PwConfig};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MatchConfig {
+ pub map_name: String,
+ pub max_turns: usize,
+}
+
+pub struct PwMatch {
+ match_ctx: MatchCtx,
+ match_state: PlanetWars,
+}
+
+impl PwMatch {
+ pub fn create(match_ctx: MatchCtx, config: PwConfig) -> Self {
+ // TODO: this is kind of hacked together at the moment
+ let match_state = PlanetWars::create(config, match_ctx.players().len());
+
+ PwMatch {
+ match_state,
+ match_ctx,
+ }
+ }
+
+ pub async fn run(mut self) {
+ while !self.match_state.is_finished() {
+ let player_messages = self.prompt_players().await;
+
+ for (player_id, turn) in player_messages {
+ let res = self.execute_action(player_id, turn);
+ if let Some(err) = action_errors(res) {
+ let info_str = serde_json::to_string(&err).unwrap();
+ self.match_ctx.send_info(player_id as u32, info_str);
+ }
+ }
+ self.match_state.step();
+
+ // Log state
+ let state = self.match_state.serialize_state();
+ self.match_ctx
+ .log_string(serde_json::to_string(&state).unwrap());
+ }
+ }
+
+ async fn prompt_players(&mut self) -> Vec<(usize, RequestResult<Vec<u8>>)> {
+ // borrow these outside closure to make the borrow checker happy
+ let state = self.match_state.state();
+ let match_ctx = &mut self.match_ctx;
+
+ // TODO: this numbering is really messy.
+ // Get rid of the distinction between player_num
+ // and player_id.
+
+ self.match_state
+ .state()
+ .players
+ .iter()
+ .filter(|p| p.alive)
+ .map(move |player| {
+ let state_for_player = pw_serializer::serialize_rotated(&state, player.id - 1);
+ match_ctx
+ .request(
+ player.id.try_into().unwrap(),
+ serde_json::to_vec(&state_for_player).unwrap(),
+ Duration::from_millis(1000),
+ )
+ .map(move |resp| (player.id, resp))
+ })
+ .collect::<FuturesUnordered<_>>()
+ .collect::<Vec<_>>()
+ .await
+ }
+
+ fn execute_action(
+ &mut self,
+ player_num: usize,
+ turn: RequestResult<Vec<u8>>,
+ ) -> proto::PlayerAction {
+ let turn = match turn {
+ Err(_timeout) => return proto::PlayerAction::Timeout,
+ Ok(data) => data,
+ };
+
+ let action: proto::Action = match serde_json::from_slice(&turn) {
+ Err(err) => return proto::PlayerAction::ParseError(err.to_string()),
+ Ok(action) => action,
+ };
+
+ let commands = action
+ .commands
+ .into_iter()
+ .map(|command| {
+ let res = self.match_state.execute_command(player_num, &command);
+ proto::PlayerCommand {
+ command,
+ error: res.err(),
+ }
+ })
+ .collect();
+
+ return proto::PlayerAction::Commands(commands);
+ }
+}
+
+fn action_errors(action: PlayerAction) -> Option<PlayerAction> {
+ match action {
+ PlayerAction::Commands(commands) => {
+ let failed = commands
+ .into_iter()
+ .filter(|cmd| cmd.error.is_some())
+ .collect::<Vec<_>>();
+
+ if failed.is_empty() {
+ None
+ } else {
+ Some(PlayerAction::Commands(failed))
+ }
+ }
+ e => Some(e),
+ }
+}
diff --git a/planetwars-cli/src/web/mod.rs b/planetwars-cli/src/web/mod.rs
new file mode 100644
index 0000000..a0e452e
--- /dev/null
+++ b/planetwars-cli/src/web/mod.rs
@@ -0,0 +1,174 @@
+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 rust_embed::RustEmbed;
+use serde::{Deserialize, Serialize};
+use std::{
+ fs,
+ io::{self, BufRead},
+ net::SocketAddr,
+ path,
+ sync::Arc,
+};
+
+use crate::{match_runner::MatchMeta, 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(),
+ }
+ }
+}
diff --git a/planetwars-cli/src/workspace/bot.rs b/planetwars-cli/src/workspace/bot.rs
new file mode 100644
index 0000000..a0ecb90
--- /dev/null
+++ b/planetwars-cli/src/workspace/bot.rs
@@ -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.")
+ })
+ }
+}
diff --git a/planetwars-cli/src/workspace/mod.rs b/planetwars-cli/src/workspace/mod.rs
new file mode 100644
index 0000000..5a1a4ae
--- /dev/null
+++ b/planetwars-cli/src/workspace/mod.rs
@@ -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))
+ }
+}