// Multiplayer step C (client): the browser-side lobby and turn bridge. // // The WebSocket itself is opened by the browser plugin (`dc_ws_*` in www/dc_plugin.js). This module // only speaks the relay protocol from server/src/netplay.rs, turns `Command`s into JSON and feeds the // game with confirmed turns. It can also run without a socket in tests: requests go into `outbox`, // server answers are put back with `feed`. #![allow(dead_code)] use crate::commands::Command; use serde_json::{json, Value}; use std::collections::VecDeque; const MAX_CHAT: usize = 80; #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub enum Phase { #[default] Offline, Lobby, InLobby, Playing, Closed, } #[derive(Clone, Debug, Default)] pub struct SeatUi { pub side: u8, pub name: String, pub ready: bool, pub connected: bool, pub ai: bool, } #[derive(Clone, Debug, Default)] pub struct RoomUi { pub code: String, pub players: usize, pub sides: Vec, pub started: bool, pub paused: bool, } #[derive(Clone, Debug, Default)] pub struct MatchUi { pub phase: Phase, pub connected: bool, pub name: String, pub code: Option, pub local_side: Option, pub ready: bool, pub is_host: bool, pub sides: Vec, pub seats: Vec, pub ai: Vec, pub rooms: Vec, pub started: bool, pub paused: bool, pub missing: bool, pub turn: u32, pub error: Option, pub status: Option, pub chat: Vec<(Option, String, String)>, } impl MatchUi { pub fn room_open(&self) -> bool { self.code.is_some() || !self.seats.is_empty() } } #[derive(Clone, Debug)] pub struct StartInfo { pub seed: u32, pub map_w: usize, pub map_h: usize, pub difficulty: u8, pub sides: Vec, pub local_side: Option, pub mission: Option, pub ai: Vec, } #[derive(Clone, Debug, Default)] struct SeatInfo { side: u8, name: String, ready: bool, connected: bool, ai: bool, } #[derive(Clone, Debug, Default)] struct RoomInfo { code: String, sides: Vec, seats: Vec, ai: Vec, turn: u32, started: bool, paused: bool, missing: bool, } #[derive(Clone, Debug)] pub struct MatchClient { pub name: String, pub connected: bool, pub outbox: VecDeque, code: Option, room: Option, rooms: Vec, local_side: Option, error: Option, status: Option, chat: Vec<(Option, String, String)>, chat_seen: usize, start: Option, turns: Vec<(u32, Vec, Vec)>, checks: Vec<(Option, u32, u64)>, pub ping_ms: f32, ping_seq: u64, ping_next: f64, ping_pending: Option<(u64, f64)>, } impl MatchClient { pub fn new() -> Self { MatchClient { name: "Guest".into(), connected: false, outbox: VecDeque::new(), code: None, room: None, rooms: Vec::new(), local_side: None, error: None, status: None, chat: Vec::new(), chat_seen: 0, start: None, turns: Vec::new(), checks: Vec::new(), ping_ms: 0.0, ping_seq: 0, ping_next: 0.0, ping_pending: None, } } pub fn phase(&self) -> Phase { if !self.connected { return Phase::Offline; } match &self.room { Some(r) if r.started => Phase::Playing, Some(_) => Phase::InLobby, None if self.error.is_some() || self.status.as_deref().map(|s| s != "offline").unwrap_or(false) => Phase::Closed, None => Phase::Lobby, } } pub fn on_connect(&mut self, name: &str) { self.connected = true; self.name = name.to_string(); self.status = None; self.ping_ms = 0.0; self.ping_pending = None; self.ping_next = 0.0; self.rooms_request(); } pub fn on_disconnect(&mut self) { self.connected = false; self.room = None; self.code = None; self.local_side = None; self.start = None; self.turns.clear(); self.status = Some("offline".into()); self.ping_ms = 0.0; self.ping_pending = None; self.ping_next = 0.0; } pub fn tick_ping(&mut self, now: f64) { if let Some((_, start)) = self.ping_pending { if now - start > 5.0 { self.ping_pending = None; self.ping_ms = 0.0; } } if !self.connected || self.ping_pending.is_some() || now < self.ping_next { return; } self.ping_seq = self.ping_seq.wrapping_add(1); self.outbox.push_back(json!({"t":"ping","c":self.ping_seq}).to_string()); self.ping_pending = Some((self.ping_seq, now)); self.ping_next = now + 2.0; } pub fn rooms_request(&mut self) { self.outbox.push_back(json!({"t":"rooms"}).to_string()); } pub fn create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) { self.send_create(sides, map_w, map_h, difficulty, None); } pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) { self.send_create(sides, map_w, map_h, difficulty, Some(mission)); } fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option) { if !self.connected { return; } self.error = None; self.status = None; self.chat.clear(); self.chat_seen = 0; self.outbox.push_back(json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission}).to_string()); } pub fn join(&mut self, code: &str) { if !self.connected { return; } let code = code.trim().to_uppercase(); if code.len() != 6 { self.error = Some("match code needs 6 characters".into()); return; } self.error = None; self.status = None; self.chat.clear(); self.chat_seen = 0; self.outbox.push_back(json!({"t":"join","code":code}).to_string()); } pub fn take_side(&mut self, side: u8) { if !self.connected { return; } self.outbox.push_back(json!({"t":"take_side","side":side}).to_string()); } pub fn set_ai(&mut self, side: u8, ai: bool) { if !self.connected { return; } self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string()); } pub fn ready(&mut self, ready: bool) { if !self.connected { return; } self.outbox.push_back(json!({"t":"ready","ready":ready}).to_string()); } pub fn start(&mut self) { if !self.connected { return; } self.outbox.push_back(json!({"t":"start"}).to_string()); } pub fn resume(&mut self) { if !self.connected { return; } self.outbox.push_back(json!({"t":"resume"}).to_string()); } pub fn abort(&mut self) { if !self.connected { return; } self.outbox.push_back(json!({"t":"abort"}).to_string()); } pub fn leave(&mut self) { if !self.connected { return; } self.outbox.push_back(json!({"t":"leave"}).to_string()); } pub fn chat(&mut self, text: &str, team: bool) { let text: String = text.trim().chars().take(MAX_CHAT).collect(); if !self.connected || text.is_empty() { return; } let msg = if team { json!({"t":"chat","text":text,"channel":"team"}) } else { json!({"t":"chat","text":text,"channel":"all"}) }; self.outbox.push_back(msg.to_string()); } pub fn send_commands(&mut self, cmds: &[Command]) { if !self.connected || cmds.is_empty() { return; } self.outbox.push_back(json!({"t":"cmds","cmds":cmds}).to_string()); } pub fn send_checksum(&mut self, turn: u32, sum: u64) { if !self.connected { return; } self.outbox.push_back(json!({"t":"sum","turn":turn,"sum":sum}).to_string()); } pub fn feed(&mut self, text: &str) { self.feed_at(text, 0.0); } pub fn feed_at(&mut self, text: &str, now: f64) { let v: Value = match serde_json::from_str(text) { Ok(v) => v, Err(_) => return, }; match v.get("t").and_then(|x| x.as_str()).unwrap_or("") { "hello" => { if let Some(n) = v.get("name").and_then(|x| x.as_str()) { self.name = n.to_string(); } } "pong" => { if let Some(c) = v.get("c").and_then(|x| x.as_u64()) { if let Some((seq, start)) = self.ping_pending { if seq == c { self.ping_ms = ((now - start).max(0.0) * 1000.0) as f32; self.ping_pending = None; } } } } "rooms" => self.parse_rooms(&v), "room" => self.parse_room(&v), "start" => self.parse_start(&v), "turn" => self.parse_turn(&v), "sum" => self.parse_sum(&v), "chat" => { let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?").to_string(); let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(); let side = v.get("side").and_then(|x| x.as_u64()).map(|x| x as u8); self.chat.push((side, name, text)); while self.chat.len() > 40 { self.chat.remove(0); if self.chat_seen > 0 { self.chat_seen -= 1; } } } "closed" => { self.status = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("closed").to_string()); self.room = None; self.code = None; self.local_side = None; self.start = None; self.turns.clear(); self.chat.clear(); self.chat_seen = 0; } "error" => { self.error = Some(v.get("why").and_then(|x| x.as_str()).unwrap_or("error").to_string()); } _ => {} } } pub fn take_start(&mut self) -> Option { self.start.take() } pub fn take_turns(&mut self) -> Vec<(u32, Vec, Vec)> { std::mem::take(&mut self.turns) } pub fn take_checks(&mut self) -> Vec<(Option, u32, u64)> { std::mem::take(&mut self.checks) } pub fn take_new_chat(&mut self) -> Vec<(Option, String, String)> { if self.chat_seen >= self.chat.len() { return Vec::new(); } let out = self.chat[self.chat_seen..].to_vec(); self.chat_seen = self.chat.len(); out } fn parse_rooms(&mut self, v: &Value) { let Some(arr) = v.get("rooms").and_then(|x| x.as_array()) else { return }; self.rooms = arr .iter() .map(|r| RoomUi { code: r.get("code").and_then(|x| x.as_str()).unwrap_or("").to_string(), players: r.get("players").and_then(|x| x.as_u64()).unwrap_or(0) as usize, sides: array_bytes(r.get("sides")), started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false), paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false), }) .collect(); } fn parse_room(&mut self, v: &Value) { let mut room = self.room.take().unwrap_or_default(); if let Some(code) = v.get("code").and_then(|x| x.as_str()) { room.code = code.to_string(); self.code = Some(code.to_string()); } let sides = array_bytes(v.get("sides")); if !sides.is_empty() { room.sides = sides; } let seats = seats_from(v.get("seats")); if !seats.is_empty() { room.seats = seats; } if let Some(x) = v.get("turn").and_then(|x| x.as_u64()) { room.turn = x as u32; } room.ai = array_bytes(v.get("ai")); if let Some(x) = v.get("started").and_then(|x| x.as_bool()) { room.started = x; } if let Some(x) = v.get("paused").and_then(|x| x.as_bool()) { room.paused = x; } if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) { room.missing = x; } if let Some(why) = v.get("why").and_then(|x| x.as_str()) { self.status = Some(why.to_string()); } self.local_side = room.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side); if !room.code.is_empty() || !room.seats.is_empty() { self.room = Some(room); } else { self.room = None; } } fn parse_start(&mut self, v: &Value) { let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let map_w = v.get("map_w").and_then(|x| x.as_u64()).unwrap_or(96) as usize; let map_h = v.get("map_h").and_then(|x| x.as_u64()).unwrap_or(128) as usize; let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8; let sides = array_bytes(v.get("sides")); let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize); let mut ai = array_bytes(v.get("ai")); if ai.is_empty() { if let Some(r) = self.room.as_ref() { ai = r.ai.clone(); } } let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)); self.local_side = local_side; if let Some(r) = self.room.as_mut() { r.started = true; r.paused = false; r.missing = false; r.turn = 0; if !sides.is_empty() { r.sides = sides.clone(); } r.ai = ai.clone(); } self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai }); self.error = None; self.status = None; } fn parse_turn(&mut self, v: &Value) { let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32; if let Some(r) = self.room.as_mut() { r.turn = turn; r.ai = array_bytes(v.get("ai")); } let cmds = match v.get("cmds").cloned() { Some(v) => serde_json::from_value::>(v).unwrap_or_default(), None => Vec::new(), }; let ai = array_bytes(v.get("ai")); self.turns.push((turn, cmds, ai)); } fn parse_sum(&mut self, v: &Value) { let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let sum = match v.get("sum").and_then(|x| x.as_u64()) { Some(s) => s, None => return, }; let from = v.get("from").and_then(|x| x.as_u64()).map(|n| n as u8); self.checks.push((from, turn, sum)); } pub fn ui(&self) -> MatchUi { let room = self.room.clone().unwrap_or_default(); let local = self.local_side; let ready = room.seats.iter().find(|s| Some(s.side) == local).map(|s| s.ready).unwrap_or(false); let host = room.seats.iter().find(|s| s.side == 0); let is_host = local == Some(0) && host.map(|s| s.connected).unwrap_or(false); MatchUi { phase: self.phase(), connected: self.connected, name: self.name.clone(), code: room.code.is_empty().then(|| self.code.clone()).unwrap_or(Some(room.code.clone())).clone(), local_side: local, ready, is_host, sides: room.sides.clone(), ai: room.ai.clone(), seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai }).collect(), rooms: self.rooms.clone(), started: room.started, paused: room.paused, missing: room.missing, turn: room.turn, error: self.error.clone(), status: self.status.clone(), chat: self.chat.clone(), } } } fn array_bytes(v: Option<&Value>) -> Vec { v.and_then(|x| x.as_array()) .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect()) .unwrap_or_default() } fn seats_from(v: Option<&Value>) -> Vec { v.and_then(|x| x.as_array()) .map(|arr| { arr.iter() .map(|s| SeatInfo { side: s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8, name: s.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(), ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false), connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false), ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false), }) .collect() }) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; use crate::commands::{Act, Rect}; fn pop(client: &mut MatchClient) -> Value { let s = client.outbox.pop_front().expect("no request"); serde_json::from_str(&s).expect("bad json") } #[test] fn the_bridge_turns_orders_into_json_and_turns_back_into_commands() { let mut client = MatchClient::new(); client.on_connect("red"); let req = pop(&mut client); // rooms assert_eq!(req["t"].as_str(), Some("rooms")); client.create(&[0, 2], 96, 128, 1); let req = pop(&mut client); assert_eq!(req["t"].as_str(), Some("create")); client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":false,"connected":true}],"turn":0,"started":false,"paused":false,"missing":false}"#); client.feed(r#"{"t":"start","seed":7,"map_w":96,"map_h":128,"difficulty":1,"sides":[0,2]}"#); let start = client.take_start().expect("start info"); assert_eq!(start.seed, 7); assert_eq!(start.local_side, Some(0)); assert_eq!(start.mission, None); client.create_coop(2, &[0, 2], 80, 72, 1); let coop = pop(&mut client); assert_eq!(coop["t"].as_str(), Some("create")); assert_eq!(coop["mission"].as_u64(), Some(2)); client.feed(r#"{"t":"start","seed":8,"map_w":80,"map_h":72,"difficulty":1,"sides":[0,2],"mission":2}"#); let coop_start = client.take_start().expect("co-op start info"); assert_eq!(coop_start.mission, Some(2)); client.send_commands(&[Command { side: 0, seq: 0, act: Act::Dig(Rect::new(1, 2, 3, 4)) }]); let req = pop(&mut client); assert_eq!(req["t"].as_str(), Some("cmds")); assert_eq!(req["cmds"][0]["side"].as_u64(), Some(0)); client.feed(r#"{"t":"turn","turn":1,"cmds":[{"side":0,"seq":0,"act":{"Dig":{"x0":1,"y0":2,"x1":3,"y1":4}}}]}"#); let turns = client.take_turns(); assert_eq!(turns.len(), 1); assert_eq!(turns[0].1[0].side, 0); assert!(matches!(turns[0].1[0].act, Act::Dig(_))); client.feed(r#"{"t":"sum","from":2,"turn":1,"sum":42}"#); let checks = client.take_checks(); assert_eq!(checks[0], (Some(2), 1, 42)); client.chat("hello", true); let req = pop(&mut client); assert_eq!(req["t"].as_str(), Some("chat")); assert_eq!(req["channel"].as_str(), Some("team")); client.feed(r#"{"t":"chat","name":"red","text":"hello","side":0,"channel":"team"}"#); let chat = client.take_new_chat(); assert_eq!(chat[0], (Some(0), "red".to_string(), "hello".to_string())); } #[test] fn the_bridge_times_the_server_ping() { let mut client = MatchClient::new(); client.on_connect("red"); pop(&mut client); // rooms client.tick_ping(10.0); let req = pop(&mut client); assert_eq!(req["t"].as_str(), Some("ping")); assert_eq!(req["c"].as_u64(), Some(1)); client.feed_at(r#"{"t":"pong","c":1}"#, 10.25); assert!(client.ping_ms > 200.0 && client.ping_ms < 300.0, "the pong came 250 ms after the ping"); client.tick_ping(11.0); assert!(client.outbox.is_empty(), "the next ping waits for its two-second interval"); client.tick_ping(12.0); let next = pop(&mut client); assert_eq!(next["c"].as_u64(), Some(2)); } #[test] fn the_bridge_holds_ai_seats_through_the_lobby() { let mut client = MatchClient::new(); client.on_connect("red"); pop(&mut client); // rooms client.create(&[0, 2, 3, 4], 96, 128, 1); pop(&mut client); // create client.set_ai(3, true); let req = pop(&mut client); assert_eq!(req["t"].as_str(), Some("set_ai")); assert_eq!(req["side"].as_u64(), Some(3)); assert_eq!(req["ai"].as_bool(), Some(true)); client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2,3,4],"seats":[{"side":0,"name":"red","ready":false,"connected":true,"ai":false},{"side":2,"name":"","ready":false,"connected":false,"ai":false},{"side":3,"name":"","ready":false,"connected":false,"ai":true},{"side":4,"name":"","ready":false,"connected":false,"ai":false}],"ai":[3],"turn":0,"started":false,"paused":false,"missing":false}"#); let ui = client.ui(); assert_eq!(ui.ai, vec![3u8]); assert!(ui.seats.iter().any(|s| s.side == 3 && s.ai)); assert!(ui.seats.iter().any(|s| s.side == 2 && !s.connected && !s.ai)); client.feed(r#"{"t":"start","seed":7,"map_w":96,"map_h":128,"difficulty":1,"sides":[0,3],"ai":[3]}"#); let start = client.take_start().expect("start info with ai seats"); assert_eq!(start.sides, vec![0u8, 3u8]); assert_eq!(start.ai, vec![3u8]); } }