diff options
| -rw-r--r-- | Cargo.lock | 39 | ||||
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | src/main.rs | 116 |
3 files changed, 145 insertions, 14 deletions
@@ -24,6 +24,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" [[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -35,7 +46,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ - "axum-core", + "axum-core 0.5.0", "base64", "bytes", "form_urlencoded", @@ -68,6 +79,26 @@ dependencies = [ [[package]] name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" @@ -93,7 +124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" dependencies = [ "axum", - "axum-core", + "axum-core 0.5.0", "bytes", "futures-util", "http", @@ -433,6 +464,8 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa" dependencies = [ + "axum-core 0.4.5", + "http", "itoa", "maud_macros", ] @@ -503,6 +536,8 @@ dependencies = [ "cookie", "maud", "rand", + "serde", + "serde_json", "tokio", "tokio-tungstenite", "tower-http", @@ -10,8 +10,10 @@ axum-extra = "0.10.0" axum-macros = "0.5.0" bytes = "1.9.0" cookie = { version = "0.18.1", features = ["percent-encode"] } -maud = "0.26.0" +maud = { version = "0.26.0", features = ["axum"] } rand = "0.8.5" +serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.134" tokio = { version = "1.42.0", features = [ "macros", "rt-multi-thread", diff --git a/src/main.rs b/src/main.rs index 0cc864f..e164981 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, net::SocketAddr, sync::Arc}; +use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; use anyhow::{Context, Result}; use axum::{ @@ -10,10 +10,11 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, - Router, + Form, Router, }; use maud::{html, Markup, PreEscaped, DOCTYPE}; use rand::random; +use serde::Deserialize; use tokio::sync::{ broadcast::{self, Receiver, Sender}, Mutex, @@ -23,7 +24,7 @@ use tower_http::{ services::ServeDir, trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}, }; -use tracing::{error, info, trace, Level}; +use tracing::{error, info, trace, warn, Level}; use tracing_subscriber::FmtSubscriber; /// If a handler panics we run this function and return the [Response]. @@ -40,11 +41,31 @@ fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response { (StatusCode::INTERNAL_SERVER_ERROR).into_response() } +#[derive(Deserialize)] +struct NoteDatum { + // Epoch time in ms + time: u64, + // Fraction up/down + y: f64, +} + +#[derive(Deserialize)] +struct Notes { + notes: Vec<NoteDatum>, +} + +#[derive(Deserialize)] +struct NotesForm { + notes: String, +} + struct Game { id: u64, is_started: bool, + submitted: Option<Notes>, + /// Sender to broadcast things broadcast_tx: Sender<String>, /// Broadcast receiver to send to WebSocket @@ -52,24 +73,66 @@ struct Game { } impl Game { + /// Return the appropriate HTML for the game state. fn get_html(&self) -> Markup { let gid = self.id; - if !self.is_started { + + if self.submitted.is_some() { return html! { - h1 { "Game" } - p { "Current game id is " (gid)} - div #messages {}; - button hx-post=(format!("/game/{gid}/start")) { "Start" } + h1 { "Submitted!" } }; - } else { + } + if self.is_started { return html! { + script { ({PreEscaped("document.notes = []; ")})} h1 { "Playing Game" } p { "Were playing here" } + + canvas + #canvas + height=(256) width=(256) + style="position:relative; background: cyan;" + {}; + script { (PreEscaped(r#"document.getElementById("canvas").onclick = (e) => { + const notes = JSON.parse(e.target.dataset.notes ?? "[]"); + notes.push({ time: Date.now(), y: e.layerY / e.target.height}); + e.target.dataset.notes = JSON.stringify(notes); + }"#))} + + button hx-post=(format!("/game/{gid}/submit")) + hx-vals=r#"js:{ "notes": document.getElementById("canvas").dataset.notes }"# + { "Submit" } }; } + + html! { + h1 { "Game" } + p { "Current game id is " (gid)} + div #messages {}; + button hx-post=(format!("/game/{gid}/start")) { "Start" } + } } } +async fn submit_game_answer(lock: Arc<Mutex<Game>>, notes: Notes) { + lock.lock().await.submitted = Some(notes); + + tokio::spawn(async move { + let _ = tokio::time::sleep(Duration::from_secs(2)).await; + let mut g = lock.lock().await; + g.is_started = false; + g.submitted = None; + g.broadcast_tx.send( + html! { + section #content { + (g.get_html()) + } + } + .into_string(), + ) + }); +} + struct ServerState { games: HashMap<u64, Arc<Mutex<Game>>>, } @@ -80,6 +143,7 @@ impl ServerState { let g = Game { id: gid, is_started: false, + submitted: None, broadcast_rx: rx, broadcast_tx: tx, }; @@ -134,7 +198,7 @@ fn html_response(html: PreEscaped<String>) -> Response { .expect("Failed to set body") } -async fn post_game(State(st): State<Server>) -> Response { +async fn create_game(State(st): State<Server>) -> Response { let gid = random::<u64>(); let _ = { @@ -201,6 +265,35 @@ async fn get_game(Path(gid): Path<u64>, State(st): State<Server>) -> Response { }) } +async fn submit_game( + Path(gid): Path<u64>, + State(st): State<Server>, + Form(form): Form<NotesForm>, +) -> Response { + let glock = { + if let Some(g) = st.lock().await.games.get(&gid) { + g.clone() + } else { + return (StatusCode::NOT_FOUND).into_response(); + } + }; + + let Ok(notes) = serde_json::from_str(&form.notes) else { + warn!(notes = form.notes); + return (StatusCode::UNPROCESSABLE_ENTITY).into_response(); + }; + + submit_game_answer(glock.clone(), Notes { notes }).await; + + let g = glock.lock().await; + let html = g.get_html(); + g.broadcast_tx + .send(html! { section #content { (html) } }.into_string()) + .expect("failed to send"); + + StatusCode::OK.into_response() +} + async fn game_ws(ws: WebSocketUpgrade, Path(gid): Path<u64>, State(st): State<Server>) -> Response { let game = { let s = st.lock().await; @@ -262,9 +355,10 @@ async fn main() -> Result<()> { let app = Router::new() .route("/", get(route_index)) - .route("/game", post(post_game)) + .route("/game", post(create_game)) .route("/game/{gid}", get(get_game)) .route("/game/{gid}/start", post(start_game)) + .route("/game/{gid}/submit", post(submit_game)) .route("/game/{gid}/ws", get(game_ws)) .nest_service("/static", ServeDir::new("static")) .layer(CatchPanicLayer::custom(handle_panic)) |
