diff --git a/planetwars-localdev/README.md b/planetwars-localdev/README.md index 3263508..72572d2 100644 --- a/planetwars-localdev/README.md +++ b/planetwars-localdev/README.md @@ -7,3 +7,4 @@ Tools for developping planetwars bots locally. 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/src/lib.rs b/planetwars-localdev/src/lib.rs index 562e9a6..c64fb55 100644 --- a/planetwars-localdev/src/lib.rs +++ b/planetwars-localdev/src/lib.rs @@ -26,6 +26,8 @@ enum Commands { InitProject(InitProjectCommand), /// Run a match RunMatch(RunMatchCommand), + /// Host local webserver + Serve(ServeCommand), } #[derive(Parser)] @@ -42,6 +44,9 @@ struct InitProjectCommand { path: String, } +#[derive(Parser)] +struct ServeCommand; + #[derive(Serialize, Deserialize, Debug)] struct ProjectConfig { bots: HashMap, @@ -58,6 +63,7 @@ pub async fn run() { 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); @@ -139,3 +145,11 @@ fn init_project(command: InitProjectCommand) -> io::Result<()> { 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/web/mod.rs b/planetwars-localdev/src/web/mod.rs new file mode 100644 index 0000000..a5d0989 --- /dev/null +++ b/planetwars-localdev/src/web/mod.rs @@ -0,0 +1,123 @@ +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::{ + net::SocketAddr, + path::{self, PathBuf}, + sync::Arc, +}; + +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 Match { + name: String, +} + +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(); + extract_match_name(entry).map(|name| Match { name }) + }) + .collect::>(); + Json(matches) +} + +// extracts 'filename' if the entry matches'$filename.log'. +fn extract_match_name(entry: std::fs::DirEntry) -> Option { + let file_name = entry.file_name(); + let path = path::Path::new(&file_name); + if path.extension() == Some("log".as_ref()) { + path.file_stem() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + } else { + None + } +} + +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"); + std::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(), + } + } +}