// 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?,"mission":12?,"doc":{..}?,"surface":true?} // -> "surface": a free match whose eastern half lies above ground // -> "doc": an editor campaign (CampaignDoc JSON) for a co-op // match of its mission "mission"; it goes along with "start" // "seed":77?,"carry":[..]? with a doc: an interrupted run goes // on - "start" then has this seed, the carry and "resume":true // {"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 a paused match // {"t":"abort"} -> side 0 closes the match // {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again // {"t":"set_team","team":1,"side":3?} -> team 0..3 (A-D) of the sender's seat before the start; the // host may name an empty or AI seat with "side" // {"t":"next","mission":3,"seed":77?,"carry":[..]?} -> the host starts the next mission of an editor campaign // co-op match; every client gets "next" (no campaign: they // have it) with the seed, sides, AI seats and the carry // {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected // // Server messages include: hello, rooms, room, start, next, turn, sum, chat, closed, error, pong. 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]; const MAX_MISSIONS: usize = 30; // keep this in step with the campaign mission count in the client // an editor campaign travels in one WebSocket message (create, start): as large as a stored campaign pub const MAX_WS_MESSAGE: usize = 40 << 20; #[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, mission: Option, doc: Option, // editor campaign of a co-op match (sent to every client at the start) resume: Option<(u32, Value)>, // a resumed editor campaign run: its seed and what the masters carry surface: bool, // a free match half above ground events: bool, // random events in a free match } #[derive(Debug)] struct Seat { conn: Option, name: String, ready: bool, ai: bool, team: u8, // 0..3 (A-D) in a versus match; by default every seat is a team of its own } fn default_team(side: u8) -> u8 { MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8 } fn new_seat(side: u8, ai: bool) -> Seat { Seat { conn: None, name: String::new(), ready: false, ai, team: default_team(side) } } impl Room { fn has_connection(&self) -> bool { self.seats.values().any(|s| s.conn.is_some()) } fn active_sides(&self) -> Vec { self.sides .iter() .copied() .filter(|side| self.seats.get(side).map(|seat| seat.conn.is_some() || seat.ai).unwrap_or(false)) .collect() } fn desired_ai_sides(&self) -> Vec { self.sides .iter() .filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none() && seat.ai).unwrap_or(false)) .copied() .collect() } fn started_ai_sides(&self) -> Vec { self.active_sides() .iter() .filter(|side| self.seats.get(side).map(|seat| seat.conn.is_none()).unwrap_or(false)) .copied() .collect() } fn room_ai_sides(&self) -> Vec { if self.started { self.started_ai_sides() } else { self.desired_ai_sides() } } // the team of every side of `sides`, in the same order fn teams_of(&self, sides: &[u8]) -> Vec { sides.iter().map(|s| self.seats.get(s).map_or(default_team(*s), |seat| seat.team)).collect() } // the name of the editor campaign and the title of its current mission (room list) fn campaign_names(&self) -> (Option, Option) { let Some(doc) = &self.doc else { return (None, None) }; let name = doc.get("name").and_then(|n| n.as_str()).map(|n| n.chars().take(40).collect::()); let title = self.mission.and_then(|m| doc.get("missions").and_then(|ms| ms.get(m))).and_then(|m| m.get("title")).and_then(|t| t.as_str()).map(|t| t.chars().take(40).collect::()); (name, title) } } #[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 } fn sort_sides(sides: &mut Vec) { sides.sort_by_key(|s| MASTER_SIDES.iter().position(|m| m == s).unwrap_or(9)); } fn mission_of(v: Option<&Value>) -> Option { match v { None | Some(Value::Null) => None, Some(x) => x.as_u64().map(|m| m as usize), } } pub async fn netplay_ws(ws: WebSocketUpgrade, Query(q): Query, State(st): State) -> Response { ws.max_message_size(MAX_WS_MESSAGE).max_frame_size(MAX_WS_MESSAGE).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), "set_ai" => self.set_ai(conn, &msg), "set_team" => self.set_team(conn, &msg), "next" => self.next(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); } } "ping" => { self.send(conn, json!({"t":"pong","c":msg.get("c").cloned().unwrap_or(Value::Null)})); } _ => 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, r.started_ai_sides())) } else { None } } None => return, }; if let Some((turn, cmds, ai)) = action { self.broadcast_room(code, json!({"t":"turn","turn":turn,"cmds":cmds,"ai":ai})); } } 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 mission = mission_of(msg.get("mission")); let doc = msg.get("doc").filter(|d| !d.is_null()).cloned(); let resume = match (&doc, msg.get("seed").and_then(|v| v.as_u64())) { (Some(_), Some(seed)) => Some((seed as u32, msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or_else(|| json!([])))), _ => None, }; match &doc { Some(d) => { let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len()); if !d.is_object() || mission.map_or(true, |m| m >= count) { self.error(conn, "unknown campaign mission"); return None; } } None => { if mission.map_or(false, |m| m >= MAX_MISSIONS) { self.error(conn, "unknown campaign mission"); return None; } } } let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); let seats = sides.iter().map(|s| (*s, new_seat(*s, 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, mission, doc, resume, surface: msg.get("surface").and_then(|v| v.as_bool()).unwrap_or(false), events: msg.get("events").and_then(|v| v.as_bool()).unwrap_or(false) }; 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() && !seat.ai).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() && !s.ai).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; t.ai = 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 set_ai(&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 ai = msg.get("ai").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"), }; if !MASTER_SIDES.contains(&side) { return self.error(conn, "unknown side"); } if side == 0 { return self.error(conn, "the host seat stays human"); } if let Some(r) = self.rooms.get_mut(&code) { if r.started { return self.error(conn, "AI seats can only be changed before the start"); } if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) { return self.error(conn, "only the red master can set AI seats"); } if !r.seats.contains_key(&side) { if !ai { return self.error(conn, "side not in match"); } r.sides.push(side); sort_sides(&mut r.sides); r.seats.insert(side, new_seat(side, true)); } else { if r.seats[&side].conn.is_some() { return self.error(conn, "seat is taken"); } if let Some(s) = r.seats.get_mut(&side) { s.ai = ai; s.ready = false; if !ai { s.name.clear(); } } } self.room_msg_all(&code); } else { self.error(conn, "not in a match"); } } fn set_team(&mut self, conn: u64, msg: &Value) { let team = match msg.get("team").and_then(|v| v.as_u64()) { Some(n) if n < MASTER_SIDES.len() as u64 => n as u8, _ => return self.error(conn, "unknown team"), }; 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 Some(own) = self.side_of(&code, conn) else { return self.error(conn, "no seat") }; let side = msg.get("side").and_then(|v| v.as_u64()).map_or(own, |n| n as u8); if let Some(r) = self.rooms.get_mut(&code) { if r.started { return self.error(conn, "teams can only be changed before the start"); } let Some(seat) = r.seats.get(&side) else { return self.error(conn, "side not in match") }; if side != own && (own != 0 || seat.conn.is_some()) { return self.error(conn, "only the red master can set the team of a free or AI seat"); } if let Some(seat) = r.seats.get_mut(&side) { seat.team = team; } } self.room_msg_all(&code); } // the next mission of an editor campaign co-op match: the host decided it (the branch follows // the reached goal) and hands the carry-over of every side along; the clients already have the // campaign. The turn clock starts again. fn next(&mut self, conn: u64, msg: &Value) { 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 mission = mission_of(msg.get("mission")); let seed = msg.get("seed").and_then(|v| v.as_u64()).map(|s| s as u32); let carry = msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or(Value::Array(Vec::new())); let out = match self.rooms.get_mut(&code) { Some(r) => { 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 start the next mission"); } let count = r.doc.as_ref().and_then(|d| d.get("missions")).and_then(|m| m.as_array()).map_or(0, |m| m.len()); let Some(m) = mission.filter(|m| *m < count) else { return self.error(conn, "unknown campaign mission") }; r.mission = Some(m); r.seed = Some(seed.unwrap_or_else(random_seed)); r.turn = 0; r.pending.clear(); r.paused = false; r.missing = false; r.sides = r.active_sides(); json!({"t":"next","seed":r.seed.unwrap_or(0),"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":m,"ai":r.started_ai_sides(),"carry":carry}) } None => return self.error(conn, "not in a match"), }; self.room_msg_all(&code); self.broadcast_room(&code, out); } 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"); } } let active = r.active_sides(); if active.is_empty() || !active.contains(&0) { return self.error(conn, "no playable master seat"); } if active.len() < 2 && r.mission.is_none() { return self.error(conn, "a free match needs at least two masters"); } r.started = true; r.paused = false; r.missing = false; r.seed = Some(r.resume.as_ref().map_or_else(random_seed, |x| x.0)); r.turn = 0; r.sides = active; } let msg = match self.rooms.get(&code) { Some(r) => { let mut m = 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,"teams":r.teams_of(&r.sides),"mission":r.mission,"ai":r.started_ai_sides()}); if let Some(doc) = &r.doc { m["doc"] = doc.clone(); } if r.surface { m["surface"] = json!(true); } if r.events { m["events"] = json!(true); } if let Some((_, carry)) = &r.resume { m["carry"] = carry.clone(); m["resume"] = json!(true); } m } None => return, }; self.room_msg_all(&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()); let side = self.side_of(&code, conn); let channel = msg.get("channel").and_then(|v| v.as_str()).unwrap_or("all"); let chat = json!({"t":"chat","name":name,"text":text,"side":side,"channel":channel}); if channel == "team" { match side { Some(side) => self.broadcast_room_side(&code, side, chat), None => self.broadcast_room(&code, chat), } } else { self.broadcast_room(&code, chat); } } 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) => { for s in r.seats.values_mut() { if s.conn == Some(conn) { if r.started { s.ai = true; } s.conn = None; s.name.clear(); s.ready = false; } } if r.started { if r.has_connection() { r.paused = false; r.missing = false; (false, Vec::new()) } else { (true, vec![json!({"t":"closed","why":"last player left"})]) } } else { let empty = r.seats.values().all(|s| s.conn.is_none()); if empty { (true, Vec::new()) } else { (false, Vec::new()) } } } None => (false, Vec::new()), }; for msg in messages { self.broadcast_room(code, msg); } if !remove_room && self.rooms.contains_key(code) { self.room_msg_all(code); } 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 broadcast_room_side(&mut self, code: &str, side: u8, v: Value) { let targets: Vec = self .rooms .get(code) .map(|r| r.seats.iter().filter(|(s, seat)| **s == side && seat.conn.is_some()).filter_map(|(_, seat)| seat.conn).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 sides: Vec = if r.started { r.active_sides() } else { r.sides.clone() }; let seats: Vec = 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(),"ai":s.ai,"team":s.team}))) .collect(); let ai: Vec = r.room_ai_sides(); let (campaign, title) = r.campaign_names(); Some(json!({ "t":"room", "code":r.code, "sides":sides, "seats":seats, "ai":ai, "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, "mission":r.mission, "editor":r.doc.is_some(), "campaign":campaign, "title":title })) } 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(); let (campaign, title) = r.campaign_names(); json!({"code":r.code,"players":players,"sides":r.sides,"teams":r.teams_of(&r.sides),"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some(),"campaign":campaign,"title":title}) }) .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_room_remembers_a_coop_campaign_mission() { 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],"mission":2})); let code = rooms[0].clone(); let room = until(&mut rx, "room"); assert_eq!(room["mission"].as_u64(), Some(2)); 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["mission"], start_green["mission"]); assert_eq!(start_red["mission"].as_u64(), Some(2)); } #[test] fn a_resumed_run_starts_with_its_seed_and_carry() { let mut net = Netplay::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let red = net.connect(tx, Some("red".into()), None); let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]}); let carry = json!([[0, {"gold": 100.0, "creatures": []}]]); net.handle(red, json!({"t":"create","sides":[0,2],"mission":1,"doc":doc,"seed":4242,"carry":carry})); net.handle(red, json!({"t":"ready"})); net.handle(red, json!({"t":"start"})); let start = until(&mut rx, "start"); assert_eq!(start["seed"].as_u64(), Some(4242)); assert_eq!(start["carry"], carry); assert_eq!(start["resume"].as_bool(), Some(true)); } #[test] fn an_editor_campaign_goes_from_the_create_to_every_start() { let mut net = Netplay::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let red = net.connect(tx, Some("red".into()), None); let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]}); // a mission the campaign does not have is refused assert!(net.handle(red, json!({"t":"create","sides":[0,2],"mission":2,"doc":doc})).is_empty()); assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission")); let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":1,"doc":doc})); let code = rooms[0].clone(); let room = until(&mut rx, "room"); assert_eq!(room["editor"].as_bool(), Some(true)); assert!(room.get("doc").is_none(), "room updates stay small"); 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_green["mission"].as_u64(), Some(1)); assert_eq!(start_red["doc"], doc); assert_eq!(start_green["doc"], doc, "the joined player gets the campaign too"); } #[test] fn a_built_in_start_carries_no_campaign() { let mut net = Netplay::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let red = net.connect(tx, Some("red".into()), None); net.handle(red, json!({"t":"create","sides":[0,2],"mission":2})); net.handle(red, json!({"t":"ready"})); net.handle(red, json!({"t":"start"})); let start = until(&mut rx, "start"); assert!(start.get("doc").is_none()); } #[test] fn players_choose_their_teams_and_the_start_carries_them() { 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,3]})); let code = rooms[0].clone(); let room = until(&mut rx, "room"); let teams: Vec = room["seats"].as_array().unwrap().iter().map(|s| s["team"].as_u64().unwrap()).collect(); assert_eq!(teams, vec![0, 1, 2], "every seat is its own team at first"); let (tx2, mut rx2) = mpsc::unbounded_channel(); let green = net.connect(tx2, Some("green".into()), None); net.handle(green, json!({"t":"join","code":&code})); // green joins red's team; green may not decide for blue's seat, red (host) may net.handle(green, json!({"t":"set_team","team":0})); net.handle(green, json!({"t":"set_team","team":3,"side":3})); assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can set the team of a free or AI seat")); net.handle(red, json!({"t":"set_ai","side":3,"ai":true})); net.handle(red, json!({"t":"set_team","team":3,"side":3})); net.handle(red, json!({"t":"set_team","team":9})); assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown team")); let room = net.room_json(&code).unwrap(); let team = |side: u64| room["seats"].as_array().unwrap().iter().find(|s| s["side"].as_u64() == Some(side)).unwrap()["team"].as_u64(); assert_eq!((team(0), team(2), team(3)), (Some(0), Some(0), Some(3))); net.handle(red, json!({"t":"rooms"})); let list = until(&mut rx, "rooms"); assert_eq!(list["rooms"][0]["teams"], json!([0, 0, 3])); net.handle(red, json!({"t":"ready"})); net.handle(green, json!({"t":"ready"})); net.handle(red, json!({"t":"start"})); let start = until(&mut rx2, "start"); assert_eq!(start["sides"], json!([0, 2, 3])); assert_eq!(start["teams"], json!([0, 0, 3])); net.handle(red, json!({"t":"set_team","team":1})); assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("teams can only be changed before the start")); } #[test] fn the_host_starts_the_next_editor_mission_for_everybody() { let mut net = Netplay::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let red = net.connect(tx, Some("red".into()), None); let doc = json!({"name":"Deep Run","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second"}]}); let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":0,"doc":doc})); 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":"rooms"})); let list = until(&mut rx2, "rooms"); assert_eq!(list["rooms"][0]["campaign"].as_str(), Some("Deep Run"), "the room list names the campaign"); assert_eq!(list["rooms"][0]["title"].as_str(), Some("First")); 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.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":0,"act":"CancelOrders"}]})); net.tick(&code); net.tick(&code); assert_eq!(net.rooms[&code].turn, 2); // only the host decides; the mission must exist net.handle(green, json!({"t":"next","mission":1})); assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can start the next mission")); net.handle(red, json!({"t":"next","mission":5})); assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission")); net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":1,"act":"CancelOrders"}]})); let carry = json!([[0, {"creatures":[["Ogre",3,10.0]],"gold":300.0}], [2, {"creatures":[],"gold":40.0}]]); net.handle(red, json!({"t":"next","mission":1,"seed":4711,"carry":carry})); let next_red = until(&mut rx, "next"); let next_green = until(&mut rx2, "next"); assert_eq!(next_green["mission"].as_u64(), Some(1)); assert_eq!(next_green["seed"].as_u64(), Some(4711), "the run seed of the host"); assert_eq!(next_green["sides"], json!([0, 2])); assert_eq!(next_green["carry"], carry, "the carry goes to everybody"); assert_eq!(next_red, next_green); assert!(next_green.get("doc").is_none(), "the clients have the campaign already"); let room = net.room_json(&code).unwrap(); assert_eq!(room["title"].as_str(), Some("Second")); assert_eq!(room["turn"].as_u64(), Some(0), "the turn clock starts again"); net.tick(&code); let turn = until(&mut rx2, "turn"); assert_eq!(turn["turn"].as_u64(), Some(1)); assert_eq!(turn["cmds"], json!([]), "orders of the old mission are dropped"); } #[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_continues_with_an_ai_side_when_a_player_leaves() { 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 room = until(&mut rx, "room"); assert_eq!(room["ai"].as_array().unwrap()[0].as_u64(), Some(2)); net.tick(&code); let turn = until(&mut rx, "turn"); assert_eq!(turn["ai"].as_array().unwrap()[0].as_u64(), Some(2)); } #[test] fn a_host_can_mark_an_empty_seat_as_ai() { 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(); net.handle(red, json!({"t":"set_ai","side":2,"ai":true})); net.handle(red, json!({"t":"ready"})); net.handle(red, json!({"t":"start"})); let start = until(&mut rx, "start"); assert_eq!(start["sides"], json!([0, 2])); assert_eq!(start["ai"], json!([2])); net.tick(&code); let turn = until(&mut rx, "turn"); assert_eq!(turn["ai"], json!([2])); } #[test] fn a_coop_host_can_clear_an_ai_seat_and_start_alone() { 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],"mission":2})); net.handle(red, json!({"t":"set_ai","side":2,"ai":true})); net.handle(red, json!({"t":"set_ai","side":2,"ai":false})); net.handle(red, json!({"t":"ready"})); net.handle(red, json!({"t":"start"})); let start = until(&mut rx, "start"); assert_eq!(start["sides"], json!([0])); assert_eq!(start["ai"], json!([])); } #[test] fn a_free_match_does_not_turn_empty_seats_into_ai_by_itself() { 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(); net.handle(red, json!({"t":"ready"})); net.handle(red, json!({"t":"start"})); let err = until(&mut rx, "error"); assert_eq!(err["why"].as_str(), Some("a free match needs at least two masters")); assert!(!net.rooms[&code].started); } #[test] fn the_relay_closes_a_started_match_after_the_last_player_leaves() { 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); until(&mut rx, "room"); net.disconnect(red); assert!(net.rooms.get(&code).is_none()); } #[test] fn team_chat_only_reaches_the_same_side() { 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":"chat","text":"hello","channel":"all"})); let all_red = until(&mut rx, "chat"); assert_eq!(all_red["text"].as_str(), Some("hello")); assert_eq!(all_red["side"].as_u64(), Some(0)); let all_green = until(&mut rx2, "chat"); assert_eq!(all_green["text"].as_str(), Some("hello")); assert_eq!(all_green["side"].as_u64(), Some(0)); net.handle(red, json!({"t":"chat","text":"secret","channel":"team"})); let team_red = until(&mut rx, "chat"); assert_eq!(team_red["text"].as_str(), Some("secret")); assert_eq!(team_red["side"].as_u64(), Some(0)); assert!(rx2.try_recv().is_err()); } #[test] fn the_relay_answers_a_ping_with_the_same_correlation_number() { let mut net = Netplay::new(); let (tx, mut rx) = mpsc::unbounded_channel(); let red = net.connect(tx, Some("red".into()), None); net.handle(red, json!({"t":"ping","c":7})); let pong = until(&mut rx, "pong"); assert_eq!(pong["c"].as_u64(), Some(7)); } }