From c04d4a449bd147c632c0b6ceae04f0514803b66f Mon Sep 17 00:00:00 2001 From: Ilion Beyst Date: Sun, 26 Dec 2021 21:06:52 +0100 Subject: rename to planetwars-cli --- Cargo.toml | 2 +- planetwars-cli/Cargo.toml | 24 +++ planetwars-cli/README.md | 10 ++ planetwars-cli/assets/hex.json | 43 ++++++ planetwars-cli/assets/pw_project.toml | 10 ++ planetwars-cli/assets/simplebot.py | 33 +++++ planetwars-cli/src/bin/pwcli.rs | 6 + planetwars-cli/src/lib.rs | 156 ++++++++++++++++++++ planetwars-cli/src/match_runner/bot_runner.rs | 120 +++++++++++++++ planetwars-cli/src/match_runner/match_context.rs | 161 +++++++++++++++++++++ planetwars-cli/src/match_runner/mod.rs | 91 ++++++++++++ planetwars-cli/src/match_runner/pw_match.rs | 136 +++++++++++++++++ planetwars-cli/src/web/mod.rs | 148 +++++++++++++++++++ planetwars-localdev/Cargo.toml | 24 --- planetwars-localdev/README.md | 10 -- planetwars-localdev/assets/hex.json | 43 ------ planetwars-localdev/assets/pw_project.toml | 10 -- planetwars-localdev/assets/simplebot.py | 33 ----- planetwars-localdev/src/bin/pwcli.rs | 6 - planetwars-localdev/src/lib.rs | 156 -------------------- planetwars-localdev/src/match_runner/bot_runner.rs | 120 --------------- .../src/match_runner/match_context.rs | 161 --------------------- planetwars-localdev/src/match_runner/mod.rs | 91 ------------ planetwars-localdev/src/match_runner/pw_match.rs | 136 ----------------- planetwars-localdev/src/web/mod.rs | 148 ------------------- 25 files changed, 939 insertions(+), 939 deletions(-) create mode 100644 planetwars-cli/Cargo.toml create mode 100644 planetwars-cli/README.md create mode 100644 planetwars-cli/assets/hex.json create mode 100644 planetwars-cli/assets/pw_project.toml create mode 100644 planetwars-cli/assets/simplebot.py create mode 100644 planetwars-cli/src/bin/pwcli.rs create mode 100644 planetwars-cli/src/lib.rs create mode 100644 planetwars-cli/src/match_runner/bot_runner.rs create mode 100644 planetwars-cli/src/match_runner/match_context.rs create mode 100644 planetwars-cli/src/match_runner/mod.rs create mode 100644 planetwars-cli/src/match_runner/pw_match.rs create mode 100644 planetwars-cli/src/web/mod.rs delete mode 100644 planetwars-localdev/Cargo.toml delete mode 100644 planetwars-localdev/README.md delete mode 100644 planetwars-localdev/assets/hex.json delete mode 100644 planetwars-localdev/assets/pw_project.toml delete mode 100644 planetwars-localdev/assets/simplebot.py delete mode 100644 planetwars-localdev/src/bin/pwcli.rs delete mode 100644 planetwars-localdev/src/lib.rs delete mode 100644 planetwars-localdev/src/match_runner/bot_runner.rs delete mode 100644 planetwars-localdev/src/match_runner/match_context.rs delete mode 100644 planetwars-localdev/src/match_runner/mod.rs delete mode 100644 planetwars-localdev/src/match_runner/pw_match.rs delete mode 100644 planetwars-localdev/src/web/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 474a843..8af2433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,5 @@ members = [ "planetwars-rules", - "planetwars-localdev", + "planetwars-cli", ] diff --git a/planetwars-cli/Cargo.toml b/planetwars-cli/Cargo.toml new file mode 100644 index 0000000..977ce18 --- /dev/null +++ b/planetwars-cli/Cargo.toml @@ -0,0 +1,24 @@ +[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"] } + +rust-embed = "6.3.0" +axum = "0.4" +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..72572d2 --- /dev/null +++ b/planetwars-cli/README.md @@ -0,0 +1,10 @@ +# planetwars-localdev + +Tools for developping planetwars bots locally. + +## Getting started + +1. Initialize your project directory: `pwcli init-project my_project` +2. Enter your fresh project: `cd my_project` +3. Run an example match: `pwcli run-match hex simplebot simplebot` +4. View your match in the web UI: `pwcli serve` 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_project.toml b/planetwars-cli/assets/pw_project.toml new file mode 100644 index 0000000..85a4ab6 --- /dev/null +++ b/planetwars-cli/assets/pw_project.toml @@ -0,0 +1,10 @@ +[bots] + +# define a bot called simplebot +[bots.simplebot] + +# The working directory for the bot. +path = "./bots/simplebot" + +# What command to use for running the bot +argv = ["python", "simplebot.py"] \ No newline at end of file diff --git a/planetwars-cli/assets/simplebot.py b/planetwars-cli/assets/simplebot.py new file mode 100644 index 0000000..b2a6b8f --- /dev/null +++ b/planetwars-cli/assets/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/lib.rs b/planetwars-cli/src/lib.rs new file mode 100644 index 0000000..7eea75a --- /dev/null +++ b/planetwars-cli/src/lib.rs @@ -0,0 +1,156 @@ +use match_runner::{MatchBot, MatchConfig}; +use serde::Deserialize; + +mod match_runner; + +use serde::Serialize; +use std::collections::HashMap; +use std::env; +use std::io; +use std::path::{Path, PathBuf}; +use toml; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[clap(name = "pwcli")] +#[clap(author, version, about)] +struct Cli { + #[clap(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new project + InitProject(InitProjectCommand), + /// Run a match + RunMatch(RunMatchCommand), + /// Host local webserver + Serve(ServeCommand), +} + +#[derive(Parser)] +struct RunMatchCommand { + /// map name + map: String, + /// bot names + bots: Vec, +} + +#[derive(Parser)] +struct InitProjectCommand { + /// project root directory + path: String, +} + +#[derive(Parser)] +struct ServeCommand; + +#[derive(Serialize, Deserialize, Debug)] +struct ProjectConfig { + bots: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BotConfig { + path: String, + argv: Vec, +} + +pub async fn run() { + let matches = Cli::parse(); + let res = match matches.command { + Commands::RunMatch(command) => run_match(command).await, + Commands::InitProject(command) => init_project(command), + Commands::Serve(_) => run_webserver().await, + }; + if let Err(err) = res { + eprintln!("{}", err); + std::process::exit(1); + } +} + +async fn run_match(command: RunMatchCommand) -> io::Result<()> { + let project_dir = env::current_dir().unwrap(); + + let config_path = project_dir.join("pw_project.toml"); + + let map_path = project_dir.join(format!("maps/{}.json", command.map)); + + let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"); + let log_path = project_dir.join(format!("matches/{}.log", timestamp)); + + let config_str = std::fs::read_to_string(config_path).unwrap(); + let project_config: ProjectConfig = toml::from_str(&config_str).unwrap(); + + let players = command + .bots + .into_iter() + .map(|bot_name| { + let bot_config = project_config.bots.get(&bot_name).unwrap().clone(); + let resolved_config = resolve_bot_config(&project_dir, bot_config); + MatchBot { + name: bot_name, + bot_config: resolved_config, + } + }) + .collect(); + + let match_config = MatchConfig { + map_name: command.map, + map_path, + log_path, + players, + }; + + match_runner::run_match(match_config).await; + println!("match completed successfully"); + // TODO: don't hardcode match path. + // maybe print the match result as well? + println!("wrote match log to matches/{}.log", timestamp); + Ok(()) +} + +fn resolve_bot_config(project_dir: &Path, config: BotConfig) -> BotConfig { + let mut path = PathBuf::from(project_dir); + path.push(&config.path); + BotConfig { + path: path.to_str().unwrap().to_string(), + argv: config.argv, + } +} + +macro_rules! copy_asset { + ($path:expr, $file_name:literal) => { + ::std::fs::write( + $path.join($file_name), + include_bytes!(concat!("../assets/", $file_name)), + )?; + }; +} + +fn init_project(command: InitProjectCommand) -> io::Result<()> { + let path = PathBuf::from(&command.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_project.toml"); + copy_asset!(path.join("maps"), "hex.json"); + copy_asset!(path.join("bots/simplebot"), "simplebot.py"); + + Ok(()) +} + +mod web; +async fn run_webserver() -> io::Result<()> { + let project_dir = env::current_dir().unwrap(); + + web::run(project_dir).await; + Ok(()) +} 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..290df07 --- /dev/null +++ b/planetwars-cli/src/match_runner/bot_runner.rs @@ -0,0 +1,120 @@ +use std::io; +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, +} + +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>, 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>, + rx: mpsc::UnboundedReceiver, + 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: String, + pub argv: Vec, +} + +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>, +} + +impl BotProcess { + // TODO: gracefully handle errors + pub async fn communicate(&mut self, input: &[u8]) -> io::Result { + 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, +} + +pub struct MatchCtx { + event_bus: Arc>, + players: HashMap, + // output: MsgStreamHandle, + log_sink: File, +} + +impl MatchCtx { + pub fn new( + event_bus: Arc>, + players: HashMap>, + log_file: File, + // log: MsgStreamHandle, + ) -> 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, 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 { + 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, +} + +type RequestId = (u32, u32); +pub struct EventBus { + request_responses: HashMap>>, + wakers: HashMap, +} + +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>) { + 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>, +} + +impl Request { + #[allow(dead_code)] + pub fn player_id(&self) -> u32 { + self.player_id + } +} + +impl Future for Request { + type Output = RequestResult>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + 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 = Result; diff --git a/planetwars-cli/src/match_runner/mod.rs b/planetwars-cli/src/match_runner/mod.rs new file mode 100644 index 0000000..50b7a3b --- /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::BotConfig; + +use self::match_context::{EventBus, PlayerHandle}; + +pub struct MatchConfig { + pub map_name: String, + pub map_path: PathBuf, + pub log_path: PathBuf, + pub players: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct MatchMeta { + pub map_name: String, + pub timestamp: chrono::DateTime, + pub players: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct PlayerInfo { + pub name: String, +} + +pub struct MatchBot { + pub name: String, + pub bot_config: BotConfig, +} + +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, bot)| { + let player_id = (player_id + 1) as u32; + let bot = bot_runner::Bot { + working_dir: bot.bot_config.path.clone(), + argv: bot.bot_config.argv.clone(), + }; + let handle = bot_runner::run_local_bot(player_id, event_bus.clone(), bot); + (player_id, Box::new(handle) as Box) + }) + .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>)> { + // 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::>() + .collect::>() + .await + } + + fn execute_action( + &mut self, + player_num: usize, + turn: RequestResult>, + ) -> 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 { + match action { + PlayerAction::Commands(commands) => { + let failed = commands + .into_iter() + .filter(|cmd| cmd.error.is_some()) + .collect::>(); + + 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..cd94f5e --- /dev/null +++ b/planetwars-cli/src/web/mod.rs @@ -0,0 +1,148 @@ +use axum::{ + body::{boxed, Full}, + extract::{Extension, Path}, + 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::{self, PathBuf}, + sync::Arc, +}; + +use crate::match_runner::MatchMeta; + +struct State { + project_root: PathBuf, +} + +impl State { + fn new(project_root: PathBuf) -> Self { + Self { project_root } + } +} + +pub async fn run(project_root: PathBuf) { + let shared_state = Arc::new(State::new(project_root)); + + // build our application with a route + let app = Router::new() + .route("/", get(index_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(); +} + +#[derive(Serialize, Deserialize)] +struct MatchData { + name: String, + #[serde(flatten)] + meta: MatchMeta, +} + +async fn list_matches(Extension(state): Extension>) -> Json> { + let matches = state + .project_root + .join("matches") + .read_dir() + .unwrap() + .filter_map(|entry| { + let entry = entry.unwrap(); + get_match_data(&entry).ok() + }) + .collect::>(); + Json(matches) +} + +// extracts 'filename' if the entry matches'$filename.log'. +fn get_match_data(entry: &fs::DirEntry) -> io::Result { + 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 { + 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 { + 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>, Path(id): Path) -> String { + let mut match_path = state.project_root.join("matches").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::().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(pub T); + +impl IntoResponse for StaticFile +where + T: Into, +{ + 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-localdev/Cargo.toml b/planetwars-localdev/Cargo.toml deleted file mode 100644 index 8595d14..0000000 --- a/planetwars-localdev/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "planetwars-localdev" -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"] } - -rust-embed = "6.3.0" -axum = "0.4" -mime_guess = "2" \ No newline at end of file diff --git a/planetwars-localdev/README.md b/planetwars-localdev/README.md deleted file mode 100644 index 72572d2..0000000 --- a/planetwars-localdev/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# planetwars-localdev - -Tools for developping planetwars bots locally. - -## Getting started - -1. Initialize your project directory: `pwcli init-project my_project` -2. Enter your fresh project: `cd my_project` -3. Run an example match: `pwcli run-match hex simplebot simplebot` -4. View your match in the web UI: `pwcli serve` diff --git a/planetwars-localdev/assets/hex.json b/planetwars-localdev/assets/hex.json deleted file mode 100644 index 5ef4f31..0000000 --- a/planetwars-localdev/assets/hex.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "planets": [ - { - "name": "protos", - "x": -6, - "y": 0, - "owner": 1, - "ship_count": 6 - }, - { - "name": "duteros", - "x": -3, - "y": 5, - "ship_count": 6 - }, - { - "name": "tritos", - "x": 3, - "y": 5, - "ship_count": 6 - }, - { - "name": "tetartos", - "x": 6, - "y": 0, - "owner": 2, - "ship_count": 6 - }, - { - "name": "pemptos", - "x": 3, - "y": -5, - "ship_count": 6 - }, - { - "name": "extos", - "x": -3, - "y": -5, - "ship_count": 6 - } - ] -} - diff --git a/planetwars-localdev/assets/pw_project.toml b/planetwars-localdev/assets/pw_project.toml deleted file mode 100644 index 85a4ab6..0000000 --- a/planetwars-localdev/assets/pw_project.toml +++ /dev/null @@ -1,10 +0,0 @@ -[bots] - -# define a bot called simplebot -[bots.simplebot] - -# The working directory for the bot. -path = "./bots/simplebot" - -# What command to use for running the bot -argv = ["python", "simplebot.py"] \ No newline at end of file diff --git a/planetwars-localdev/assets/simplebot.py b/planetwars-localdev/assets/simplebot.py deleted file mode 100644 index b2a6b8f..0000000 --- a/planetwars-localdev/assets/simplebot.py +++ /dev/null @@ -1,33 +0,0 @@ -import sys, json - -def move(command): - """ print a command record to stdout """ - moves = [] - if command is not None: - moves.append(command) - - print(json.dumps({ 'moves': moves })) - # flush the buffer, so that the gameserver can receive the json-encoded line. - sys.stdout.flush() - - -for line in sys.stdin: - state = json.loads(line) - # you are always player 1. - my_planets = [p for p in state['planets'] if p['owner'] == 1] - other_planets = [p for p in state['planets'] if p['owner'] != 1] - - if not my_planets or not other_planets: - # we don't own any planets, so we can't make any moves. - move(None) - else: - # find my planet that has the most ships - planet = max(my_planets, key=lambda p: p['ship_count']) - # find enemy planet that has the least ships - destination = min(other_planets, key=lambda p: p['ship_count']) - # attack! - move({ - 'origin': planet['name'], - 'destination': destination['name'], - 'ship_count': planet['ship_count'] - 1 - }) diff --git a/planetwars-localdev/src/bin/pwcli.rs b/planetwars-localdev/src/bin/pwcli.rs deleted file mode 100644 index c45bbfa..0000000 --- a/planetwars-localdev/src/bin/pwcli.rs +++ /dev/null @@ -1,6 +0,0 @@ -use planetwars_localdev; - -#[tokio::main] -async fn main() { - planetwars_localdev::run().await -} diff --git a/planetwars-localdev/src/lib.rs b/planetwars-localdev/src/lib.rs deleted file mode 100644 index 7eea75a..0000000 --- a/planetwars-localdev/src/lib.rs +++ /dev/null @@ -1,156 +0,0 @@ -use match_runner::{MatchBot, MatchConfig}; -use serde::Deserialize; - -mod match_runner; - -use serde::Serialize; -use std::collections::HashMap; -use std::env; -use std::io; -use std::path::{Path, PathBuf}; -use toml; - -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[clap(name = "pwcli")] -#[clap(author, version, about)] -struct Cli { - #[clap(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Initialize a new project - InitProject(InitProjectCommand), - /// Run a match - RunMatch(RunMatchCommand), - /// Host local webserver - Serve(ServeCommand), -} - -#[derive(Parser)] -struct RunMatchCommand { - /// map name - map: String, - /// bot names - bots: Vec, -} - -#[derive(Parser)] -struct InitProjectCommand { - /// project root directory - path: String, -} - -#[derive(Parser)] -struct ServeCommand; - -#[derive(Serialize, Deserialize, Debug)] -struct ProjectConfig { - bots: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct BotConfig { - path: String, - argv: Vec, -} - -pub async fn run() { - let matches = Cli::parse(); - let res = match matches.command { - Commands::RunMatch(command) => run_match(command).await, - Commands::InitProject(command) => init_project(command), - Commands::Serve(_) => run_webserver().await, - }; - if let Err(err) = res { - eprintln!("{}", err); - std::process::exit(1); - } -} - -async fn run_match(command: RunMatchCommand) -> io::Result<()> { - let project_dir = env::current_dir().unwrap(); - - let config_path = project_dir.join("pw_project.toml"); - - let map_path = project_dir.join(format!("maps/{}.json", command.map)); - - let timestamp = chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"); - let log_path = project_dir.join(format!("matches/{}.log", timestamp)); - - let config_str = std::fs::read_to_string(config_path).unwrap(); - let project_config: ProjectConfig = toml::from_str(&config_str).unwrap(); - - let players = command - .bots - .into_iter() - .map(|bot_name| { - let bot_config = project_config.bots.get(&bot_name).unwrap().clone(); - let resolved_config = resolve_bot_config(&project_dir, bot_config); - MatchBot { - name: bot_name, - bot_config: resolved_config, - } - }) - .collect(); - - let match_config = MatchConfig { - map_name: command.map, - map_path, - log_path, - players, - }; - - match_runner::run_match(match_config).await; - println!("match completed successfully"); - // TODO: don't hardcode match path. - // maybe print the match result as well? - println!("wrote match log to matches/{}.log", timestamp); - Ok(()) -} - -fn resolve_bot_config(project_dir: &Path, config: BotConfig) -> BotConfig { - let mut path = PathBuf::from(project_dir); - path.push(&config.path); - BotConfig { - path: path.to_str().unwrap().to_string(), - argv: config.argv, - } -} - -macro_rules! copy_asset { - ($path:expr, $file_name:literal) => { - ::std::fs::write( - $path.join($file_name), - include_bytes!(concat!("../assets/", $file_name)), - )?; - }; -} - -fn init_project(command: InitProjectCommand) -> io::Result<()> { - let path = PathBuf::from(&command.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_project.toml"); - copy_asset!(path.join("maps"), "hex.json"); - copy_asset!(path.join("bots/simplebot"), "simplebot.py"); - - Ok(()) -} - -mod web; -async fn run_webserver() -> io::Result<()> { - let project_dir = env::current_dir().unwrap(); - - web::run(project_dir).await; - Ok(()) -} diff --git a/planetwars-localdev/src/match_runner/bot_runner.rs b/planetwars-localdev/src/match_runner/bot_runner.rs deleted file mode 100644 index 290df07..0000000 --- a/planetwars-localdev/src/match_runner/bot_runner.rs +++ /dev/null @@ -1,120 +0,0 @@ -use std::io; -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, -} - -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>, 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>, - rx: mpsc::UnboundedReceiver, - 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: String, - pub argv: Vec, -} - -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>, -} - -impl BotProcess { - // TODO: gracefully handle errors - pub async fn communicate(&mut self, input: &[u8]) -> io::Result { - 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-localdev/src/match_runner/match_context.rs b/planetwars-localdev/src/match_runner/match_context.rs deleted file mode 100644 index 466da13..0000000 --- a/planetwars-localdev/src/match_runner/match_context.rs +++ /dev/null @@ -1,161 +0,0 @@ -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, -} - -pub struct MatchCtx { - event_bus: Arc>, - players: HashMap, - // output: MsgStreamHandle, - log_sink: File, -} - -impl MatchCtx { - pub fn new( - event_bus: Arc>, - players: HashMap>, - log_file: File, - // log: MsgStreamHandle, - ) -> 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, 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 { - 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, -} - -type RequestId = (u32, u32); -pub struct EventBus { - request_responses: HashMap>>, - wakers: HashMap, -} - -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>) { - 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>, -} - -impl Request { - #[allow(dead_code)] - pub fn player_id(&self) -> u32 { - self.player_id - } -} - -impl Future for Request { - type Output = RequestResult>; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - 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 = Result; diff --git a/planetwars-localdev/src/match_runner/mod.rs b/planetwars-localdev/src/match_runner/mod.rs deleted file mode 100644 index 50b7a3b..0000000 --- a/planetwars-localdev/src/match_runner/mod.rs +++ /dev/null @@ -1,91 +0,0 @@ -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::BotConfig; - -use self::match_context::{EventBus, PlayerHandle}; - -pub struct MatchConfig { - pub map_name: String, - pub map_path: PathBuf, - pub log_path: PathBuf, - pub players: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct MatchMeta { - pub map_name: String, - pub timestamp: chrono::DateTime, - pub players: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct PlayerInfo { - pub name: String, -} - -pub struct MatchBot { - pub name: String, - pub bot_config: BotConfig, -} - -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, bot)| { - let player_id = (player_id + 1) as u32; - let bot = bot_runner::Bot { - working_dir: bot.bot_config.path.clone(), - argv: bot.bot_config.argv.clone(), - }; - let handle = bot_runner::run_local_bot(player_id, event_bus.clone(), bot); - (player_id, Box::new(handle) as Box) - }) - .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-localdev/src/match_runner/pw_match.rs b/planetwars-localdev/src/match_runner/pw_match.rs deleted file mode 100644 index 42bc9d2..0000000 --- a/planetwars-localdev/src/match_runner/pw_match.rs +++ /dev/null @@ -1,136 +0,0 @@ -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>)> { - // 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::>() - .collect::>() - .await - } - - fn execute_action( - &mut self, - player_num: usize, - turn: RequestResult>, - ) -> 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 { - match action { - PlayerAction::Commands(commands) => { - let failed = commands - .into_iter() - .filter(|cmd| cmd.error.is_some()) - .collect::>(); - - if failed.is_empty() { - None - } else { - Some(PlayerAction::Commands(failed)) - } - } - e => Some(e), - } -} diff --git a/planetwars-localdev/src/web/mod.rs b/planetwars-localdev/src/web/mod.rs deleted file mode 100644 index cd94f5e..0000000 --- a/planetwars-localdev/src/web/mod.rs +++ /dev/null @@ -1,148 +0,0 @@ -use axum::{ - body::{boxed, Full}, - extract::{Extension, Path}, - 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::{self, PathBuf}, - sync::Arc, -}; - -use crate::match_runner::MatchMeta; - -struct State { - project_root: PathBuf, -} - -impl State { - fn new(project_root: PathBuf) -> Self { - Self { project_root } - } -} - -pub async fn run(project_root: PathBuf) { - let shared_state = Arc::new(State::new(project_root)); - - // build our application with a route - let app = Router::new() - .route("/", get(index_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(); -} - -#[derive(Serialize, Deserialize)] -struct MatchData { - name: String, - #[serde(flatten)] - meta: MatchMeta, -} - -async fn list_matches(Extension(state): Extension>) -> Json> { - let matches = state - .project_root - .join("matches") - .read_dir() - .unwrap() - .filter_map(|entry| { - let entry = entry.unwrap(); - get_match_data(&entry).ok() - }) - .collect::>(); - Json(matches) -} - -// extracts 'filename' if the entry matches'$filename.log'. -fn get_match_data(entry: &fs::DirEntry) -> io::Result { - 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 { - 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 { - 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>, Path(id): Path) -> String { - let mut match_path = state.project_root.join("matches").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::().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(pub T); - -impl IntoResponse for StaticFile -where - T: Into, -{ - 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(), - } - } -} -- cgit v1.2.3