diff --git a/VERSION b/VERSION index abae0a3..869c71b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00005 +v202611-00006 diff --git a/nginx.conf b/nginx.conf index 7de6c98..547a5be 100644 --- a/nginx.conf +++ b/nginx.conf @@ -19,6 +19,18 @@ server { } # savegame service (server/). Resolved at request time, so the game still starts without it; # the game then falls back to savegame files. + # match relay WebSocket (server/src/netplay.rs) + location /api/netplay/ws { + resolver 127.0.0.11 valid=10s ipv6=off; + set $saves http://saves:8080; + proxy_pass $saves; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } location /api/ { resolver 127.0.0.11 valid=10s ipv6=off; set $saves http://saves:8080; diff --git a/server/Cargo.lock b/server/Cargo.lock index ab1e1b5..7f41b2d 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -27,6 +27,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -45,8 +46,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -72,6 +75,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -127,6 +136,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "digest" version = "0.10.7" @@ -144,7 +159,8 @@ version = "0.1.0" dependencies = [ "argon2", "axum", - "getrandom", + "futures-util", + "getrandom 0.2.17", "serde", "serde_json", "tokio", @@ -165,6 +181,23 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -178,6 +211,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -204,6 +239,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "http" version = "1.5.0" @@ -343,7 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -359,6 +406,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -377,12 +433,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ryu" version = "1.0.23" @@ -416,7 +507,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -455,6 +546,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "slab" version = "0.4.12" @@ -483,6 +585,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -500,12 +613,33 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tokio" version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -522,7 +656,19 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", ] [[package]] @@ -573,6 +719,22 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" @@ -597,6 +759,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -612,6 +783,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/server/Cargo.toml b/server/Cargo.toml index 303aa7a..6190239 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,12 +6,13 @@ license = "MIT" description = "Savegame storage service for DungeonCraft" [dependencies] -axum = "0.8" +axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" argon2 = "0.5" getrandom = "0.2" +futures-util = "0.3.34" [profile.release] opt-level = 3 diff --git a/server/src/main.rs b/server/src/main.rs index 4f9b433..f7d1595 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -39,6 +39,9 @@ use std::{ }; use tokio::sync::Mutex; +mod netplay; +use netplay::Netplay; + const MAX_SAVE: usize = 1024 * 1024; const MAX_TOTAL: u64 = 500 * 1024 * 1024; const WRITES_PER_MINUTE: usize = 30; @@ -59,6 +62,7 @@ struct AppState { sessions: Arc>>, // token -> user name writes: Arc>>, logins: Arc>>, + netplay: Arc>, } #[derive(Serialize, Deserialize, Clone)] @@ -193,6 +197,13 @@ impl AppState { self.store_sessions().await; token } + + // the user behind a WebSocket query token (login is optional in a match lobby) + pub async fn user_by_token(&self, token: &str) -> Option { + let token = token.trim(); + let name = self.sessions.lock().await.get(token).cloned()?; + self.users.lock().await.iter().find(|u| u.name == name).map(|u| u.name.clone()) + } } async fn total_size(dir: &PathBuf) -> u64 { @@ -506,6 +517,7 @@ async fn main() { sessions: Arc::new(Mutex::new(sessions)), writes: Arc::new(Mutex::new(Vec::new())), logins: Arc::new(Mutex::new(Vec::new())), + netplay: Arc::new(Mutex::new(Netplay::new())), }; let app = Router::new() .route("/api/health", get(health)) @@ -516,6 +528,7 @@ async fn main() { .route("/api/password", post(change_password)) .route("/api/users", get(list_users).post(create_user)) .route("/api/users/{name}", delete(delete_user)) + .route("/api/netplay/ws", get(netplay::netplay_ws)) .route("/api/saves", get(list).post(create)) .route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save)) .layer(DefaultBodyLimit::max(MAX_SAVE + 1024)) diff --git a/server/src/netplay.rs b/server/src/netplay.rs new file mode 100644 index 0000000..f78edb3 --- /dev/null +++ b/server/src/netplay.rs @@ -0,0 +1,727 @@ +// DungeonCraft service, match relay over WebSocket. +// +// The relay never simulates the dungeon. It keeps rooms, seats and a 100 ms turn clock: clients send +// their commands, the relay confirms a turn with the commands of all sides, and every client runs +// exactly five simulation steps. Checksums are relayed, not checked. +// +// Lobby messages (JSON, one object per WebSocket frame): +// {"t":"rooms"} -> list of open matches +// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?} +// {"t":"join","code":"ABC123"} +// {"t":"take_side","side":2} +// {"t":"ready","ready":true} +// {"t":"start"} -> side 0 starts the match +// {"t":"cmds","cmds":[Command]} -> commands of the sender's side +// {"t":"sum","turn":1,"sum":1234} -> checksum for a peer +// {"t":"chat","text":"hello"} +// {"t":"resume"} -> side 0 resumes after a side went missing +// {"t":"abort"} -> side 0 closes the match +// {"t":"leave"} +// +// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error. + +use crate::AppState; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Query, State, + }, + response::Response, +}; +use futures_util::{SinkExt, StreamExt}; +use getrandom::getrandom; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tokio::time::{sleep, Duration}; + +const TURN_MS: u64 = 100; +const CHAT_MAX: usize = 80; +const MAX_PENDING: usize = 200; +const MAX_ROOMS: usize = 64; +const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4]; + +#[derive(Debug, Default)] +pub struct Netplay { + rooms: HashMap, + clients: HashMap, + next_conn: u64, +} + +#[derive(Debug)] +struct Client { + name: String, + #[allow(dead_code)] // kept for the lobby: the name came from a logged-in user, not from the query + user: Option, + room: Option, + tx: mpsc::UnboundedSender, +} + +#[derive(Debug)] +struct Room { + code: String, + sides: Vec, + seats: HashMap, + pending: HashMap>, + turn: u32, + started: bool, + paused: bool, + missing: bool, + seed: Option, + map_w: usize, + map_h: usize, + difficulty: u8, +} + +#[derive(Debug)] +struct Seat { + conn: Option, + name: String, + ready: bool, +} + +#[derive(Debug, Deserialize)] +pub struct WsQuery { + name: Option, + token: Option, +} + +fn room_code() -> String { + let mut b = [0u8; 6]; + getrandom(&mut b).ok(); + let letters: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + b.iter().map(|x| letters[(*x as usize) % letters.len()] as char).collect() +} + +fn random_seed() -> u32 { + let mut b = [0u8; 4]; + getrandom(&mut b).ok(); + u32::from_le_bytes(b) +} + +fn clean_name(n: Option) -> String { + let s: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(24).collect(); + if s.trim().is_empty() { + "Guest".into() + } else { + s.trim().to_string() + } +} + +fn sides_of(v: Option<&Value>) -> Vec { + let mut out: Vec = match v { + Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).filter(|s| MASTER_SIDES.contains(s)).collect(), + _ => Vec::new(), + }; + out.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9)); + out.dedup(); + if !out.contains(&0) { + out.insert(0, 0); + } + out +} + +pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query, State(st): State) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, st, q)) +} + +async fn handle_socket(socket: WebSocket, st: AppState, q: WsQuery) { + let user = match q.token.as_deref() { + Some(t) => st.user_by_token(t).await, + None => None, + }; + let (mut ws_tx, mut ws_rx) = socket.split(); + let (net_tx, mut net_rx) = mpsc::unbounded_channel::(); + let conn = st.netplay.lock().await.connect(net_tx, q.name, user); + tokio::spawn(async move { + while let Some(msg) = net_rx.recv().await { + if ws_tx.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + while let Some(msg) = ws_rx.next().await { + match msg { + Ok(Message::Text(text)) => { + if let Ok(v) = serde_json::from_str::(&text) { + let new_rooms = st.netplay.lock().await.handle(conn, v); + for code in new_rooms { + spawn_turn_loop(st.clone(), code); + } + } + } + Ok(Message::Close(_)) => break, + Err(_) => break, + _ => {} + } + } + st.netplay.lock().await.disconnect(conn); +} + +pub fn spawn_turn_loop(st: AppState, code: String) { + tokio::spawn(async move { + loop { + sleep(Duration::from_millis(TURN_MS)).await; + let mut net = st.netplay.lock().await; + if !net.rooms.contains_key(&code) { + break; + } + net.tick(&code); + } + }); +} + +impl Netplay { + pub fn new() -> Self { + Self::default() + } + + pub fn connect(&mut self, tx: mpsc::UnboundedSender, name: Option, user: Option) -> u64 { + let id = self.next_conn.wrapping_add(1); + self.next_conn = id; + let name = user.clone().unwrap_or_else(|| clean_name(name)); + self.clients.insert(id, Client { name, user, room: None, tx }); + self.send(id, json!({"t":"hello","name":self.clients[&id].name})); + self.rooms_msg(id); + id + } + + pub fn disconnect(&mut self, conn: u64) { + if let Some(c) = self.clients.remove(&conn) { + if let Some(code) = c.room { + self.leave_room(conn, &code, true); + } + } + } + + // returns newly created room codes whose turn loop the service has to start + pub fn handle(&mut self, conn: u64, msg: Value) -> Vec { + let t = msg.get("t").and_then(|v| v.as_str()).unwrap_or("").to_string(); + match t.as_str() { + "rooms" => { + self.rooms_msg(conn); + } + "create" => { + if let Some(c) = self.create(conn, &msg) { + return vec![c]; + } + } + "join" => self.join(conn, &msg), + "take_side" => self.take_side(conn, &msg), + "ready" => self.ready(conn, &msg), + "start" => self.start(conn), + "cmds" => self.cmds(conn, &msg), + "sum" => self.sum(conn, &msg), + "chat" => self.chat(conn, &msg), + "resume" => self.resume(conn), + "abort" => self.abort(conn), + "leave" => { + let code = self.clients.get(&conn).and_then(|c| c.room.clone()); + if let Some(code) = code { + self.leave_room(conn, &code, false); + } + } + _ => self.error(conn, "unknown message"), + } + Vec::new() + } + + pub fn tick(&mut self, code: &str) { + let action = match self.rooms.get_mut(code) { + Some(r) => { + if r.started && !r.paused { + let mut cmds = Vec::new(); + for side in r.sides.clone() { + if let Some(v) = r.pending.remove(&side) { + cmds.extend(v); + } + } + r.turn += 1; + Some((r.turn, cmds)) + } else { + None + } + } + None => return, + }; + if let Some((turn, cmds)) = action { + self.broadcast_room(code, json!({"t":"turn","turn":turn,"cmds":cmds})); + } + } + + fn create(&mut self, conn: u64, msg: &Value) -> Option { + if self.rooms.len() >= MAX_ROOMS { + self.error(conn, "too many matches open"); + return None; + } + let code = loop { + let c = room_code(); + if !self.rooms.contains_key(&c) { + break c; + } + }; + let sides = sides_of(msg.get("sides")); + let map_w = msg.get("map_w").and_then(|v| v.as_u64()).unwrap_or(96).clamp(72, 256) as usize; + let map_h = msg.get("map_h").and_then(|v| v.as_u64()).unwrap_or(128).clamp(72, 256) as usize; + let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8; + let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); + let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false })).collect::>(); + let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty }; + self.rooms.insert(code.clone(), room); + if let Some(c) = self.clients.get_mut(&conn) { + c.room = Some(code.clone()); + } + if let Some(r) = self.rooms.get_mut(&code) { + if let Some(s) = r.seats.get_mut(&0) { + s.conn = Some(conn); + s.name = name; + s.ready = false; + } + } + self.rooms_msg(conn); + self.room_msg(conn, &code); + Some(code) + } + + fn join(&mut self, conn: u64, msg: &Value) { + let code = msg.get("code").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); + if code.len() != 6 { + self.error(conn, "match code needs 6 characters"); + return; + } + if !self.rooms.contains_key(&code) { + self.error(conn, "no such match"); + return; + } + if self.rooms[&code].started { + self.error(conn, "cannot join a match in progress"); + return; + } + let side = match self.rooms[&code].sides.iter().copied().find(|s| self.rooms[&code].seats.get(s).map(|seat| seat.conn.is_none()).unwrap_or(false)) { + Some(s) => s, + None => { + self.error(conn, "match is full"); + return; + } + }; + let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); + if let Some(c) = self.clients.get_mut(&conn) { + c.room = Some(code.clone()); + } + if let Some(r) = self.rooms.get_mut(&code) { + if let Some(s) = r.seats.get_mut(&side) { + s.conn = Some(conn); + s.name = name; + s.ready = false; + } + } + self.rooms_msg(conn); + self.room_msg_all(&code); + } + + fn take_side(&mut self, conn: u64, msg: &Value) { + let side = match msg.get("side").and_then(|v| v.as_u64()) { + Some(n) => n as u8, + None => return self.error(conn, "need a side"), + }; + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return self.error(conn, "not in a match"), + }; + if !MASTER_SIDES.contains(&side) { + return self.error(conn, "unknown side"); + } + let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); + let mut started = false; + let mut taken = false; + if let Some(r) = self.rooms.get_mut(&code) { + started = r.started; + if !started && r.sides.contains(&side) && r.seats.get(&side).map(|s| s.conn.is_none()).unwrap_or(false) { + for s in r.seats.values_mut() { + if s.conn == Some(conn) { + s.conn = None; + s.name.clear(); + s.ready = false; + } + } + if let Some(t) = r.seats.get_mut(&side) { + t.conn = Some(conn); + t.name = name; + t.ready = false; + taken = true; + } + } + } + if taken { + self.room_msg_all(&code); + } else if started { + self.error(conn, "match already started"); + } else { + self.error(conn, "side not free"); + } + } + + fn ready(&mut self, conn: u64, msg: &Value) { + let ready = msg.get("ready").and_then(|v| v.as_bool()).unwrap_or(true); + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return self.error(conn, "not in a match"), + }; + let mut ok = false; + if let Some(r) = self.rooms.get_mut(&code) { + for s in r.seats.values_mut() { + if s.conn == Some(conn) { + s.ready = ready; + ok = true; + } + } + } + if ok { + self.room_msg_all(&code); + } else { + self.error(conn, "no seat"); + } + } + + fn start(&mut self, conn: u64) { + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return self.error(conn, "not in a match"), + }; + if let Some(r) = self.rooms.get_mut(&code) { + if r.started { + return self.error(conn, "match already started"); + } + if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) { + return self.error(conn, "only the red master can start"); + } + for s in r.seats.values() { + if s.conn.is_some() && !s.ready { + return self.error(conn, "not everyone is ready"); + } + } + r.started = true; + r.paused = false; + r.missing = false; + r.seed = Some(random_seed()); + r.turn = 0; + } + let msg = match self.rooms.get(&code) { + Some(r) => json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides}), + None => return, + }; + self.room_msg(conn, &code); + self.broadcast_room(&code, msg); + } + + fn cmds(&mut self, conn: u64, msg: &Value) { + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return, + }; + let side = match self.side_of(&code, conn) { + Some(s) => s, + None => return, + }; + let cmds = match msg.get("cmds").and_then(|v| v.as_array()) { + Some(a) => a.clone(), + None => return, + }; + if cmds.iter().any(|c| c.get("side").and_then(|v| v.as_u64()) != Some(side as u64)) { + return self.error(conn, "bad command side"); + } + if let Some(r) = self.rooms.get_mut(&code) { + if !r.started || r.paused { + return; + } + let list = r.pending.entry(side).or_default(); + for c in cmds { + if list.len() < MAX_PENDING { + list.push(c); + } + } + } + } + + fn sum(&mut self, conn: u64, msg: &Value) { + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return, + }; + let from = self.side_of(&code, conn); + let turn = msg.get("turn").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let sum = match msg.get("sum").and_then(|v| v.as_u64()) { + Some(s) => s, + None => return, + }; + self.broadcast_room_except(&code, conn, json!({"t":"sum","from":from,"turn":turn,"sum":sum})); + } + + fn chat(&mut self, conn: u64, msg: &Value) { + let text: String = msg.get("text").and_then(|v| v.as_str()).unwrap_or("").chars().filter(|c| !c.is_control()).take(CHAT_MAX).collect(); + let text = text.trim().to_string(); + if text.is_empty() { + return; + } + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return, + }; + let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); + self.broadcast_room(&code, json!({"t":"chat","name":name,"text":text})); + } + + fn resume(&mut self, conn: u64) { + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return self.error(conn, "not in a match"), + }; + if let Some(r) = self.rooms.get_mut(&code) { + if !r.started { + return self.error(conn, "match not started"); + } + if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) { + return self.error(conn, "only the red master can resume"); + } + r.paused = false; + r.missing = false; + } + self.room_msg_all(&code); + } + + fn abort(&mut self, conn: u64) { + let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { + Some(c) => c, + None => return self.error(conn, "not in a match"), + }; + let side = self.side_of(&code, conn); + if side != Some(0) { + return self.error(conn, "only the red master can abort"); + } + self.broadcast_room(&code, json!({"t":"closed","why":"aborted"})); + if let Some(c) = self.clients.get_mut(&conn) { + c.room = None; + } + self.rooms.remove(&code); + self.rooms_msg(conn); + } + + fn leave_room(&mut self, conn: u64, code: &str, disconnect: bool) { + let (remove_room, messages) = match self.rooms.get_mut(code) { + Some(r) => { + let side0_left = r.seats.iter().any(|(&side, s)| s.conn == Some(conn) && side == 0); + for s in r.seats.values_mut() { + if s.conn == Some(conn) { + s.conn = None; + s.name.clear(); + s.ready = false; + } + } + if r.started { + if side0_left { + (true, vec![json!({"t":"closed","why":"host left"})]) + } else { + r.paused = true; + r.missing = true; + (false, vec![json!({"t":"room","why":"missing","code":code,"turn":r.turn,"started":r.started,"paused":r.paused,"missing":r.missing})]) + } + } else { + let empty = r.seats.values().all(|s| s.conn.is_none()); + if empty { + (true, Vec::new()) + } else if disconnect { + (false, vec![json!({"t":"room","why":"left","code":code,"turn":r.turn,"started":r.started,"paused":r.paused,"missing":r.missing})]) + } else { + (false, Vec::new()) + } + } + } + None => (false, Vec::new()), + }; + for msg in messages { + self.broadcast_room(code, msg); + } + if remove_room { + if let Some(c) = self.clients.get_mut(&conn) { + c.room = None; + } + self.rooms.remove(code); + self.rooms_msg(conn); + } + } + + fn side_of(&self, code: &str, conn: u64) -> Option { + let r = self.rooms.get(code)?; + r.seats.iter().find(|(_, s)| s.conn == Some(conn)).map(|(side, _)| *side) + } + + fn error(&mut self, conn: u64, why: &str) { + self.send(conn, json!({"t":"error","why":why})); + } + + fn send(&mut self, conn: u64, v: Value) { + if let Some(c) = self.clients.get_mut(&conn) { + let _ = c.tx.send(v.to_string()); + } + } + + fn broadcast_room(&mut self, code: &str, v: Value) { + let targets: Vec = self.rooms.get(code).map(|r| r.seats.values().filter_map(|s| s.conn).collect()).unwrap_or_default(); + for conn in targets { + self.send(conn, v.clone()); + } + } + + fn broadcast_room_except(&mut self, code: &str, except: u64, v: Value) { + let targets: Vec = self.rooms.get(code).map(|r| r.seats.values().filter_map(|s| s.conn).filter(|c| *c != except).collect()).unwrap_or_default(); + for conn in targets { + self.send(conn, v.clone()); + } + } + + fn room_msg(&mut self, conn: u64, code: &str) { + if let Some(v) = self.room_json(code) { + self.send(conn, v); + } + } + + fn room_msg_all(&mut self, code: &str) { + if let Some(v) = self.room_json(code) { + self.broadcast_room(code, v); + } + } + + fn room_json(&self, code: &str) -> Option { + let r = self.rooms.get(code)?; + let seats: Vec = r + .sides + .iter() + .filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some()}))) + .collect(); + Some(json!({ + "t":"room", + "code":r.code, + "sides":r.sides, + "seats":seats, + "turn":r.turn, + "started":r.started, + "paused":r.paused, + "missing":r.missing, + "map_w":r.map_w, + "map_h":r.map_h, + "difficulty":r.difficulty, + "seed":r.seed + })) + } + + fn rooms_msg(&mut self, conn: u64) { + let rooms: Vec = self + .rooms + .values() + .map(|r| { + let players = r.seats.values().filter(|s| s.conn.is_some()).count(); + json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused}) + }) + .collect(); + self.send(conn, json!({"t":"rooms","rooms":rooms})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc::UnboundedReceiver; + + fn pop(rx: &mut UnboundedReceiver) -> Value { + let s = rx.try_recv().expect("no message"); + serde_json::from_str(&s).expect("bad json") + } + + fn until(rx: &mut UnboundedReceiver, kind: &str) -> Value { + loop { + let v = pop(rx); + if v["t"] == kind { + return v; + } + } + } + + #[test] + fn the_relay_confirms_one_turn_after_the_next() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + let rooms = net.handle(red, json!({"t":"create","sides":[0,2]})); + assert_eq!(rooms.len(), 1); + let code = rooms[0].clone(); + + let (tx2, mut rx2) = mpsc::unbounded_channel(); + let green = net.connect(tx2, Some("green".into()), None); + net.handle(green, json!({"t":"join","code":&code})); + net.handle(red, json!({"t":"ready"})); + net.handle(green, json!({"t":"ready"})); + net.handle(red, json!({"t":"start"})); + + let start_red = until(&mut rx, "start"); + let start_green = until(&mut rx2, "start"); + assert_eq!(start_red["seed"], start_green["seed"]); + assert_eq!(start_red["sides"], json!([0, 2])); + + let cmd = json!({"side":0,"seq":0,"act":{"Dig":{"x0":5,"y0":5,"x1":6,"y1":6}}}); + net.handle(red, json!({"t":"cmds","cmds":[cmd.clone()]})); + net.tick(&code); + + let turn_red = until(&mut rx, "turn"); + let turn_green = until(&mut rx2, "turn"); + assert_eq!(turn_red["turn"].as_u64(), Some(1)); + assert_eq!(turn_red["cmds"].as_array().unwrap().len(), 1); + assert_eq!(turn_green["cmds"], turn_red["cmds"]); + + net.handle(red, json!({"t":"sum","turn":1,"sum":12345678901234567890u64})); + let sum = until(&mut rx2, "sum"); + assert_eq!(sum["sum"].as_u64(), Some(12345678901234567890u64)); + } + + #[test] + fn a_non_host_cannot_start_a_match() { + let mut net = Netplay::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + let rooms = net.handle(red, json!({"t":"create","sides":[0,2]})); + let code = rooms[0].clone(); + let (tx2, mut rx2) = mpsc::unbounded_channel(); + let green = net.connect(tx2, Some("green".into()), None); + net.handle(green, json!({"t":"join","code":&code})); + net.handle(green, json!({"t":"ready"})); + net.handle(green, json!({"t":"start"})); + let err = until(&mut rx2, "error"); + assert_eq!(err["why"].as_str(), Some("only the red master can start")); + assert!(!net.rooms[&code].started); + } + + #[test] + fn a_started_match_pauses_when_a_side_goes_missing() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + let rooms = net.handle(red, json!({"t":"create","sides":[0,2]})); + let code = rooms[0].clone(); + let (tx2, mut rx2) = mpsc::unbounded_channel(); + let green = net.connect(tx2, Some("green".into()), None); + net.handle(green, json!({"t":"join","code":&code})); + net.handle(red, json!({"t":"ready"})); + net.handle(green, json!({"t":"ready"})); + net.handle(red, json!({"t":"start"})); + until(&mut rx, "start"); + until(&mut rx2, "start"); + + net.disconnect(green); + let missing = until(&mut rx, "room"); + assert_eq!(missing["missing"].as_bool(), Some(true)); + net.handle(red, json!({"t":"resume"})); + net.tick(&code); + let turn = until(&mut rx, "turn"); + assert_eq!(turn["turn"].as_u64(), Some(1)); + } +}