Match relay in the save/login service: WebSocket rooms, seats, ready, 100 ms turn confirmation and checksum relay

This commit is contained in:
Richard Zink committed 2026-11-04 11:48:09 +01:00
1 parent 42034b062b
commit 848ab0f677
6 files changed
+956 -6

No files matched your search

+727
View File
@@ -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<String, Room>,
clients: HashMap<u64, Client>,
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<String>,
room: Option<String>,
tx: mpsc::UnboundedSender<String>,
}
#[derive(Debug)]
struct Room {
code: String,
sides: Vec<u8>,
seats: HashMap<u8, Seat>,
pending: HashMap<u8, Vec<Value>>,
turn: u32,
started: bool,
paused: bool,
missing: bool,
seed: Option<u32>,
map_w: usize,
map_h: usize,
difficulty: u8,
}
#[derive(Debug)]
struct Seat {
conn: Option<u64>,
name: String,
ready: bool,
}
#[derive(Debug, Deserialize)]
pub struct WsQuery {
name: Option<String>,
token: Option<String>,
}
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>) -> 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<u8> {
let mut out: Vec<u8> = 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<WsQuery>, State(st): State<AppState>) -> 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::<String>();
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::<Value>(&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<String>, name: Option<String>, user: Option<String>) -> 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<String> {
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<String> {
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::<HashMap<_, _>>();
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<u8> {
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<u64> = 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<u64> = 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<Value> {
let r = self.rooms.get(code)?;
let seats: Vec<Value> = 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<Value> = 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<String>) -> Value {
let s = rx.try_recv().expect("no message");
serde_json::from_str(&s).expect("bad json")
}
fn until(rx: &mut UnboundedReceiver<String>, 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));
}
}