diff options
Diffstat (limited to 'planetwars-cli/src')
-rw-r--r-- | planetwars-cli/src/bin/pwcli.rs | 6 | ||||
-rw-r--r-- | planetwars-cli/src/commands/build.rs | 27 | ||||
-rw-r--r-- | planetwars-cli/src/commands/init.rs | 38 | ||||
-rw-r--r-- | planetwars-cli/src/commands/mod.rs | 40 | ||||
-rw-r--r-- | planetwars-cli/src/commands/run_match.rs | 51 | ||||
-rw-r--r-- | planetwars-cli/src/commands/serve.rs | 17 | ||||
-rw-r--r-- | planetwars-cli/src/lib.rs | 12 | ||||
-rw-r--r-- | planetwars-cli/src/match_runner/bot_runner.rs | 121 | ||||
-rw-r--r-- | planetwars-cli/src/match_runner/match_context.rs | 161 | ||||
-rw-r--r-- | planetwars-cli/src/match_runner/mod.rs | 91 | ||||
-rw-r--r-- | planetwars-cli/src/match_runner/pw_match.rs | 136 | ||||
-rw-r--r-- | planetwars-cli/src/web/mod.rs | 174 | ||||
-rw-r--r-- | planetwars-cli/src/workspace/bot.rs | 50 | ||||
-rw-r--r-- | planetwars-cli/src/workspace/mod.rs | 77 |
14 files changed, 1001 insertions, 0 deletions
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, ×tamp)); + + let mut players = Vec::new(); + for bot_name in &self.bots { + let bot = workspace.get_bot(&bot_name)?; + players.push(MatchPlayer { + name: bot_name.clone(), + 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)) + } +} |