Multi live

This commit is contained in:
Ethan O'Brien
2026-08-11 14:41:28 -05:00
parent 75613dafb0
commit 27d7b8de82
18 changed files with 5650 additions and 83 deletions
+970
View File
@@ -0,0 +1,970 @@
// Wire format for the multi-live WebSocket relay, per docs/multi-live-ws-protocol.md.
//
// The C# client rewrite encodes/decodes the exact same bytes, so this module is a
// straight transcription of the spec tables rather than idiomatic Rust: every field is
// written in declaration order, all integers are little-endian, strings are UTF-8 behind
// a u16 length, and counts are u8. Nothing here is serde-driven - the type tags mirror
// Photon's Hashtable boxing (an Int32 must arrive as Int32 or GetCurrentRoomEventId's
// exact `is int` check fails), and a derive would hide that.
//
// Both directions are implemented in both halves (encode + decode for client messages
// AND for server messages) even though the relay only ever decodes the former and
// encodes the latter. The unused halves are what the round-trip tests exercise, and they
// double as the reference the C# side is written against.
use std::fmt;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// Value tags (see "Value encoding" in the spec).
pub const TAG_NULL: u8 = 0;
pub const TAG_INT: u8 = 1;
pub const TAG_STRING: u8 = 2;
pub const TAG_BOOL: u8 = 3;
pub const TAG_DOUBLE: u8 = 4;
// Client -> server opcodes.
pub const OP_AUTH: u8 = 1;
pub const OP_JOIN_LOBBY: u8 = 2;
pub const OP_LEAVE_LOBBY: u8 = 3;
pub const OP_CREATE_ROOM: u8 = 4;
pub const OP_JOIN_ROOM: u8 = 5;
pub const OP_JOIN_RANDOM: u8 = 6;
pub const OP_LEAVE_ROOM: u8 = 7;
pub const OP_REJOIN: u8 = 8;
pub const OP_SET_PLAYER_PROPS: u8 = 9;
pub const OP_SET_ROOM_PROPS: u8 = 10;
pub const OP_RPC: u8 = 11;
pub const OP_PING: u8 = 12;
// Server -> client opcodes.
pub const OP_AUTH_OK: u8 = 20;
pub const OP_JOINED_LOBBY: u8 = 21;
pub const OP_LEFT_LOBBY: u8 = 22;
pub const OP_JOINED_ROOM: u8 = 23;
pub const OP_CREATE_FAILED: u8 = 24;
pub const OP_JOIN_FAILED: u8 = 25;
pub const OP_PLAYER_ENTERED: u8 = 26;
pub const OP_PLAYER_LEFT: u8 = 27;
pub const OP_LEFT_ROOM: u8 = 28;
pub const OP_ROOM_PROPS_CHANGED: u8 = 29;
pub const OP_PLAYER_PROPS_CHANGED: u8 = 30;
pub const OP_MASTER_SWITCHED: u8 = 31;
// Distinct name from the client-side OP_RPC (11); same message, opposite direction.
pub const OP_RPC_BROADCAST: u8 = 32;
pub const OP_PONG: u8 = 33;
pub const OP_KICKED: u8 = 34;
// Close codes. The spec names 4001 and 4002; 4000 covers the framing errors it
// describes ("Text frames are a protocol error -> close", malformed frame, unknown
// opcode) without naming a code for them.
pub const CLOSE_PROTOCOL: u16 = 4000;
pub const CLOSE_UNAUTHENTICATED: u16 = 4001;
pub const CLOSE_RATE_LIMIT: u16 = 4002;
// Added with the liveness sweep: nothing was heard from this connection for three ping
// intervals, so the relay stops holding its seat. See LIVENESS_TIMEOUT in ws.rs.
pub const CLOSE_IDLE_TIMEOUT: u16 = 4003;
// Photon ErrorCode mirrors, so the client's existing logging keeps its meaning.
pub const ERR_GAME_ID_ALREADY_EXISTS: i32 = 32766;
pub const ERR_GAME_FULL: i32 = 32765;
pub const ERR_GAME_CLOSED: i32 = 32764;
pub const ERR_NO_RANDOM_MATCH_FOUND: i32 = 32760;
pub const ERR_GAME_DOES_NOT_EXIST: i32 = 32758;
// Kicked causes. A room only dies once nobody is left to be told, so cause 1 is still
// reserved — encoded, decoded and tested so the C# side can be written against it now.
#[allow(dead_code)]
pub const KICK_ROOM_DESTROYED: i32 = 1;
// Cause 2 is real: the liveness sweep sends it immediately before close 4003, so a client
// that is alive enough to read (a suspended app coming back, a stalled network) can say
// why it was dropped instead of showing a bare socket error.
pub const KICK_IDLE_TIMEOUT: i32 = 2;
// ---------------------------------------------------------------------------
// Values and maps
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Null,
Int(i32),
Str(String),
Bool(bool),
Double(f64),
}
impl Value {
pub fn as_int(&self) -> Option<i32> {
match self {
Value::Int(v) => Some(*v),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(v) => Some(v),
_ => None,
}
}
}
// An ordered key -> value bag. Ordering is not semantically meaningful on the wire, but
// keeping insertion order makes the changed-subset broadcasts arrive in the order the
// sender wrote them, which is one less thing for the client to reason about (and makes
// the tests deterministic).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Map(Vec<(String, Value)>);
impl Map {
pub fn new() -> Self {
Map(Vec::new())
}
// Duplicate keys collapse last-writer-wins, exactly like the Hashtable this mirrors.
// Convenience for the tests and for any caller building a bag from scratch; the
// relay itself only ever merges into an existing bag.
#[allow(dead_code)]
pub fn from_pairs<I, K>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, Value)>,
K: Into<String>,
{
let mut map = Map::new();
for (key, value) in pairs {
map.set(key, value);
}
map
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub fn get_int(&self, key: &str) -> Option<i32> {
self.get(key).and_then(Value::as_int)
}
pub fn get_str(&self, key: &str) -> Option<&str> {
self.get(key).and_then(Value::as_str)
}
// Last-writer-wins in place: an existing key keeps its position.
pub fn set<K: Into<String>>(&mut self, key: K, value: Value) {
let key = key.into();
match self.0.iter_mut().find(|(k, _)| *k == key) {
Some(slot) => slot.1 = value,
None => self.0.push((key, value)),
}
}
// Last-writer-wins merge of `other` INTO self. The relay uses it for property updates
// and for seeding/merging a joiner's bag out of the seating op's `playerProps`.
pub fn merge(&mut self, other: &Map) {
for (key, value) in other.iter() {
self.set(key, value.clone());
}
}
pub fn len(&self) -> usize {
self.0.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
}
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub enum ClientMsg {
Auth { user_id: String, token: String },
JoinLobby { name: String },
LeaveLobby,
// `props` is the ROOM bag; `player_props` is the joiner's own bag, mirroring Photon's
// OpCreateRoom/OpJoinRoom actorProperties. See docs/multi-live-ws-protocol.md,
// "Player properties at join time".
CreateRoom {
name: String,
max_players: u8,
visible: bool,
open: bool,
props: Map,
lobby_prop_keys: Vec<String>,
player_props: Map,
},
JoinRoom { name: String, player_props: Map },
JoinRandom { power_min: i32, power_max: i32, levels: Vec<Value>, player_props: Map },
LeaveRoom,
Rejoin { name: String, player_props: Map },
SetPlayerProps { props: Map },
SetRoomProps { props: Map },
Rpc { name: String, params: Vec<Value> },
Ping,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ServerMsg {
AuthOk { actorless_time_ms: f64 },
JoinedLobby,
LeftLobby,
JoinedRoom {
room_name: String,
your_actor: i32,
master_actor: i32,
room_props: Map,
players: Vec<(i32, Map)>,
},
CreateFailed { code: i32, msg: String },
JoinFailed { code: i32, msg: String },
PlayerEntered { actor: i32, props: Map },
PlayerLeft { actor: i32, inactive: bool },
LeftRoom,
RoomPropsChanged { props: Map },
PlayerPropsChanged { actor: i32, props: Map },
MasterSwitched { new_master_actor: i32 },
Rpc { sender_actor: i32, name: String, params: Vec<Value> },
Pong { server_time_ms: f64 },
// Sent by the liveness sweep with KICK_IDLE_TIMEOUT; KICK_ROOM_DESTROYED is reserved.
Kicked { cause: i32 },
}
// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub enum DecodeError {
// Frame ran out before the field did.
Truncated,
// Zero-length frame: there is not even an opcode.
Empty,
UnknownOp(u8),
UnknownTag(u8),
BadUtf8,
// "One frame = one message" - anything after the message is a framing bug.
TrailingBytes,
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
DecodeError::Truncated => write!(f, "truncated frame"),
DecodeError::Empty => write!(f, "empty frame"),
DecodeError::UnknownOp(op) => write!(f, "unknown opcode {}", op),
DecodeError::UnknownTag(tag) => write!(f, "unknown value tag {}", tag),
DecodeError::BadUtf8 => write!(f, "string is not valid utf-8"),
DecodeError::TrailingBytes => write!(f, "trailing bytes after message"),
}
}
}
struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(buf: &'a [u8]) -> Self {
Reader { buf, pos: 0 }
}
fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
if end > self.buf.len() {
return Err(DecodeError::Truncated);
}
let out = &self.buf[self.pos..end];
self.pos = end;
Ok(out)
}
fn u8(&mut self) -> Result<u8, DecodeError> {
Ok(self.take(1)?[0])
}
fn bool(&mut self) -> Result<bool, DecodeError> {
// Photon booleans are one byte; anything non-zero is true.
Ok(self.u8()? != 0)
}
fn u16(&mut self) -> Result<u16, DecodeError> {
let b = self.take(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn i32(&mut self) -> Result<i32, DecodeError> {
let b = self.take(4)?;
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn f64(&mut self) -> Result<f64, DecodeError> {
let b = self.take(8)?;
Ok(f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]))
}
fn string(&mut self) -> Result<String, DecodeError> {
let len = self.u16()? as usize;
let bytes = self.take(len)?;
String::from_utf8(bytes.to_vec()).map_err(|_| DecodeError::BadUtf8)
}
fn value(&mut self) -> Result<Value, DecodeError> {
let tag = self.u8()?;
match tag {
TAG_NULL => Ok(Value::Null),
TAG_INT => Ok(Value::Int(self.i32()?)),
TAG_STRING => Ok(Value::Str(self.string()?)),
TAG_BOOL => Ok(Value::Bool(self.bool()?)),
TAG_DOUBLE => Ok(Value::Double(self.f64()?)),
other => Err(DecodeError::UnknownTag(other)),
}
}
fn map(&mut self) -> Result<Map, DecodeError> {
let count = self.u8()?;
let mut map = Map::new();
for _ in 0..count {
let key = self.string()?;
let value = self.value()?;
map.set(key, value);
}
Ok(map)
}
fn value_list(&mut self) -> Result<Vec<Value>, DecodeError> {
let count = self.u8()?;
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
out.push(self.value()?);
}
Ok(out)
}
fn string_list(&mut self) -> Result<Vec<String>, DecodeError> {
let count = self.u8()?;
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
out.push(self.string()?);
}
Ok(out)
}
fn finish<T>(&self, value: T) -> Result<T, DecodeError> {
if self.pos != self.buf.len() {
return Err(DecodeError::TrailingBytes);
}
Ok(value)
}
}
pub fn decode_client(buf: &[u8]) -> Result<ClientMsg, DecodeError> {
let mut r = Reader::new(buf);
let op = r.u8().map_err(|_| DecodeError::Empty)?;
let msg = match op {
OP_AUTH => ClientMsg::Auth { user_id: r.string()?, token: r.string()? },
OP_JOIN_LOBBY => ClientMsg::JoinLobby { name: r.string()? },
OP_LEAVE_LOBBY => ClientMsg::LeaveLobby,
OP_CREATE_ROOM => ClientMsg::CreateRoom {
name: r.string()?,
max_players: r.u8()?,
visible: r.bool()?,
open: r.bool()?,
props: r.map()?,
lobby_prop_keys: r.string_list()?,
player_props: r.map()?,
},
OP_JOIN_ROOM => ClientMsg::JoinRoom { name: r.string()?, player_props: r.map()? },
OP_JOIN_RANDOM => ClientMsg::JoinRandom {
power_min: r.i32()?,
power_max: r.i32()?,
levels: r.value_list()?,
player_props: r.map()?,
},
OP_LEAVE_ROOM => ClientMsg::LeaveRoom,
OP_REJOIN => ClientMsg::Rejoin { name: r.string()?, player_props: r.map()? },
OP_SET_PLAYER_PROPS => ClientMsg::SetPlayerProps { props: r.map()? },
OP_SET_ROOM_PROPS => ClientMsg::SetRoomProps { props: r.map()? },
OP_RPC => ClientMsg::Rpc { name: r.string()?, params: r.value_list()? },
OP_PING => ClientMsg::Ping,
other => return Err(DecodeError::UnknownOp(other)),
};
r.finish(msg)
}
// The client's half of the codec. The relay never calls it; it is the executable
// reference the C# rewrite is written against, and what the round-trip tests drive.
#[allow(dead_code)]
pub fn decode_server(buf: &[u8]) -> Result<ServerMsg, DecodeError> {
let mut r = Reader::new(buf);
let op = r.u8().map_err(|_| DecodeError::Empty)?;
let msg = match op {
OP_AUTH_OK => ServerMsg::AuthOk { actorless_time_ms: r.f64()? },
OP_JOINED_LOBBY => ServerMsg::JoinedLobby,
OP_LEFT_LOBBY => ServerMsg::LeftLobby,
OP_JOINED_ROOM => {
let room_name = r.string()?;
let your_actor = r.i32()?;
let master_actor = r.i32()?;
let room_props = r.map()?;
let count = r.u8()?;
let mut players = Vec::with_capacity(count as usize);
for _ in 0..count {
let actor = r.i32()?;
players.push((actor, r.map()?));
}
ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players }
}
OP_CREATE_FAILED => ServerMsg::CreateFailed { code: r.i32()?, msg: r.string()? },
OP_JOIN_FAILED => ServerMsg::JoinFailed { code: r.i32()?, msg: r.string()? },
OP_PLAYER_ENTERED => ServerMsg::PlayerEntered { actor: r.i32()?, props: r.map()? },
OP_PLAYER_LEFT => ServerMsg::PlayerLeft { actor: r.i32()?, inactive: r.bool()? },
OP_LEFT_ROOM => ServerMsg::LeftRoom,
OP_ROOM_PROPS_CHANGED => ServerMsg::RoomPropsChanged { props: r.map()? },
OP_PLAYER_PROPS_CHANGED => {
ServerMsg::PlayerPropsChanged { actor: r.i32()?, props: r.map()? }
}
OP_MASTER_SWITCHED => ServerMsg::MasterSwitched { new_master_actor: r.i32()? },
OP_RPC_BROADCAST => ServerMsg::Rpc {
sender_actor: r.i32()?,
name: r.string()?,
params: r.value_list()?,
},
OP_PONG => ServerMsg::Pong { server_time_ms: r.f64()? },
OP_KICKED => ServerMsg::Kicked { cause: r.i32()? },
other => return Err(DecodeError::UnknownOp(other)),
};
r.finish(msg)
}
// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------
// The relay never legitimately produces a string past 64KiB or a count past 255 (keys
// are one or two characters, rooms hold four players, RPC params are a handful of ints),
// so encoding is infallible and the guards below only exist to keep a bug from emitting
// a frame the peer would mis-frame. They log and clamp rather than panic in a handler.
fn put_str(out: &mut Vec<u8>, s: &str) {
let mut bytes = s.as_bytes();
if bytes.len() > u16::MAX as usize {
println!("multi_live/ws: string of {} bytes truncated to fit u16 length", bytes.len());
let mut end = u16::MAX as usize;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
bytes = &s.as_bytes()[..end];
}
out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
out.extend_from_slice(bytes);
}
fn put_count(out: &mut Vec<u8>, count: usize, what: &str) -> usize {
let clamped = if count > u8::MAX as usize {
println!("multi_live/ws: {} count {} clamped to 255", what, count);
u8::MAX as usize
} else {
count
};
out.push(clamped as u8);
clamped
}
fn put_value(out: &mut Vec<u8>, value: &Value) {
match value {
Value::Null => out.push(TAG_NULL),
Value::Int(v) => {
out.push(TAG_INT);
out.extend_from_slice(&v.to_le_bytes());
}
Value::Str(v) => {
out.push(TAG_STRING);
put_str(out, v);
}
Value::Bool(v) => {
out.push(TAG_BOOL);
out.push(if *v { 1 } else { 0 });
}
Value::Double(v) => {
out.push(TAG_DOUBLE);
out.extend_from_slice(&v.to_le_bytes());
}
}
}
fn put_map(out: &mut Vec<u8>, map: &Map) {
let count = put_count(out, map.len(), "map");
for (key, value) in map.iter().take(count) {
put_str(out, key);
put_value(out, value);
}
}
fn put_value_list(out: &mut Vec<u8>, values: &[Value]) {
let count = put_count(out, values.len(), "value list");
for value in values.iter().take(count) {
put_value(out, value);
}
}
#[allow(dead_code)]
fn put_string_list(out: &mut Vec<u8>, values: &[String]) {
let count = put_count(out, values.len(), "string list");
for value in values.iter().take(count) {
put_str(out, value);
}
}
// See decode_server: the client's half, kept honest by the round-trip tests.
#[allow(dead_code)]
pub fn encode_client(msg: &ClientMsg) -> Vec<u8> {
let mut out = Vec::new();
match msg {
ClientMsg::Auth { user_id, token } => {
out.push(OP_AUTH);
put_str(&mut out, user_id);
put_str(&mut out, token);
}
ClientMsg::JoinLobby { name } => {
out.push(OP_JOIN_LOBBY);
put_str(&mut out, name);
}
ClientMsg::LeaveLobby => out.push(OP_LEAVE_LOBBY),
ClientMsg::CreateRoom { name, max_players, visible, open, props, lobby_prop_keys, player_props } => {
out.push(OP_CREATE_ROOM);
put_str(&mut out, name);
out.push(*max_players);
out.push(if *visible { 1 } else { 0 });
out.push(if *open { 1 } else { 0 });
put_map(&mut out, props);
put_string_list(&mut out, lobby_prop_keys);
put_map(&mut out, player_props);
}
ClientMsg::JoinRoom { name, player_props } => {
out.push(OP_JOIN_ROOM);
put_str(&mut out, name);
put_map(&mut out, player_props);
}
ClientMsg::JoinRandom { power_min, power_max, levels, player_props } => {
out.push(OP_JOIN_RANDOM);
out.extend_from_slice(&power_min.to_le_bytes());
out.extend_from_slice(&power_max.to_le_bytes());
put_value_list(&mut out, levels);
put_map(&mut out, player_props);
}
ClientMsg::LeaveRoom => out.push(OP_LEAVE_ROOM),
ClientMsg::Rejoin { name, player_props } => {
out.push(OP_REJOIN);
put_str(&mut out, name);
put_map(&mut out, player_props);
}
ClientMsg::SetPlayerProps { props } => {
out.push(OP_SET_PLAYER_PROPS);
put_map(&mut out, props);
}
ClientMsg::SetRoomProps { props } => {
out.push(OP_SET_ROOM_PROPS);
put_map(&mut out, props);
}
ClientMsg::Rpc { name, params } => {
out.push(OP_RPC);
put_str(&mut out, name);
put_value_list(&mut out, params);
}
ClientMsg::Ping => out.push(OP_PING),
}
out
}
pub fn encode_server(msg: &ServerMsg) -> Vec<u8> {
let mut out = Vec::new();
match msg {
ServerMsg::AuthOk { actorless_time_ms } => {
out.push(OP_AUTH_OK);
out.extend_from_slice(&actorless_time_ms.to_le_bytes());
}
ServerMsg::JoinedLobby => out.push(OP_JOINED_LOBBY),
ServerMsg::LeftLobby => out.push(OP_LEFT_LOBBY),
ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players } => {
out.push(OP_JOINED_ROOM);
put_str(&mut out, room_name);
out.extend_from_slice(&your_actor.to_le_bytes());
out.extend_from_slice(&master_actor.to_le_bytes());
put_map(&mut out, room_props);
let count = put_count(&mut out, players.len(), "player");
for (actor, props) in players.iter().take(count) {
out.extend_from_slice(&actor.to_le_bytes());
put_map(&mut out, props);
}
}
ServerMsg::CreateFailed { code, msg } => {
out.push(OP_CREATE_FAILED);
out.extend_from_slice(&code.to_le_bytes());
put_str(&mut out, msg);
}
ServerMsg::JoinFailed { code, msg } => {
out.push(OP_JOIN_FAILED);
out.extend_from_slice(&code.to_le_bytes());
put_str(&mut out, msg);
}
ServerMsg::PlayerEntered { actor, props } => {
out.push(OP_PLAYER_ENTERED);
out.extend_from_slice(&actor.to_le_bytes());
put_map(&mut out, props);
}
ServerMsg::PlayerLeft { actor, inactive } => {
out.push(OP_PLAYER_LEFT);
out.extend_from_slice(&actor.to_le_bytes());
out.push(if *inactive { 1 } else { 0 });
}
ServerMsg::LeftRoom => out.push(OP_LEFT_ROOM),
ServerMsg::RoomPropsChanged { props } => {
out.push(OP_ROOM_PROPS_CHANGED);
put_map(&mut out, props);
}
ServerMsg::PlayerPropsChanged { actor, props } => {
out.push(OP_PLAYER_PROPS_CHANGED);
out.extend_from_slice(&actor.to_le_bytes());
put_map(&mut out, props);
}
ServerMsg::MasterSwitched { new_master_actor } => {
out.push(OP_MASTER_SWITCHED);
out.extend_from_slice(&new_master_actor.to_le_bytes());
}
ServerMsg::Rpc { sender_actor, name, params } => {
out.push(OP_RPC_BROADCAST);
out.extend_from_slice(&sender_actor.to_le_bytes());
put_str(&mut out, name);
put_value_list(&mut out, params);
}
ServerMsg::Pong { server_time_ms } => {
out.push(OP_PONG);
out.extend_from_slice(&server_time_ms.to_le_bytes());
}
ServerMsg::Kicked { cause } => {
out.push(OP_KICKED);
out.extend_from_slice(&cause.to_le_bytes());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn round_client(msg: ClientMsg) {
let bytes = encode_client(&msg);
let back = decode_client(&bytes).expect("client message decodes");
assert_eq!(back, msg);
assert_eq!(encode_client(&back), bytes);
}
fn round_server(msg: ServerMsg) {
let bytes = encode_server(&msg);
let back = decode_server(&bytes).expect("server message decodes");
assert_eq!(back, msg);
assert_eq!(encode_server(&back), bytes);
}
fn sample_map() -> Map {
Map::from_pairs(vec![
("A", Value::Int(-7)),
("B", Value::Str("ラブライブ".to_string())),
("C", Value::Bool(true)),
("D", Value::Double(0.5)),
("E", Value::Null),
])
}
#[test]
fn every_client_message_round_trips() {
round_client(ClientMsg::Auth { user_id: "12345".into(), token: "a-uuid".into() });
round_client(ClientMsg::JoinLobby { name: "MultiEventLobby_31".into() });
round_client(ClientMsg::LeaveLobby);
round_client(ClientMsg::CreateRoom {
name: "123456".into(),
max_players: 4,
visible: false,
open: true,
props: sample_map(),
lobby_prop_keys: vec!["C0".into(), "C1".into()],
player_props: sample_map(),
});
round_client(ClientMsg::JoinRoom {
name: "042719".into(),
player_props: sample_map(),
});
round_client(ClientMsg::JoinRandom {
power_min: 0,
power_max: i32::MAX,
levels: vec![Value::Int(1), Value::Int(4)],
player_props: sample_map(),
});
round_client(ClientMsg::LeaveRoom);
round_client(ClientMsg::Rejoin {
name: "1000000".into(),
player_props: sample_map(),
});
round_client(ClientMsg::SetPlayerProps { props: sample_map() });
round_client(ClientMsg::SetRoomProps { props: sample_map() });
round_client(ClientMsg::Rpc {
name: "SendStamp".into(),
params: vec![Value::Int(3), Value::Str("hi".into())],
});
round_client(ClientMsg::Ping);
}
#[test]
fn every_server_message_round_trips() {
round_server(ServerMsg::AuthOk { actorless_time_ms: 1_754_500_000_123.0 });
round_server(ServerMsg::JoinedLobby);
round_server(ServerMsg::LeftLobby);
round_server(ServerMsg::JoinedRoom {
room_name: "1000000".into(),
your_actor: 2,
master_actor: 1,
room_props: sample_map(),
players: vec![(1, sample_map()), (2, Map::new())],
});
round_server(ServerMsg::CreateFailed {
code: ERR_GAME_ID_ALREADY_EXISTS,
msg: "GameIdAlreadyExists".into(),
});
round_server(ServerMsg::JoinFailed { code: ERR_GAME_FULL, msg: "GameFull".into() });
round_server(ServerMsg::PlayerEntered { actor: 3, props: sample_map() });
round_server(ServerMsg::PlayerLeft { actor: 3, inactive: true });
round_server(ServerMsg::PlayerLeft { actor: 3, inactive: false });
round_server(ServerMsg::LeftRoom);
round_server(ServerMsg::RoomPropsChanged { props: sample_map() });
round_server(ServerMsg::PlayerPropsChanged { actor: 4, props: sample_map() });
round_server(ServerMsg::MasterSwitched { new_master_actor: 2 });
round_server(ServerMsg::Rpc {
sender_actor: 1,
name: "_MoveScene".into(),
params: vec![Value::Null],
});
round_server(ServerMsg::Pong { server_time_ms: -0.0 });
round_server(ServerMsg::Kicked { cause: KICK_ROOM_DESTROYED });
}
#[test]
fn empty_map_and_empty_strings_round_trip() {
round_client(ClientMsg::Auth { user_id: String::new(), token: String::new() });
round_client(ClientMsg::JoinLobby { name: String::new() });
round_client(ClientMsg::SetPlayerProps { props: Map::new() });
round_client(ClientMsg::Rpc { name: String::new(), params: Vec::new() });
round_client(ClientMsg::CreateRoom {
name: String::new(),
max_players: 0,
visible: true,
open: false,
props: Map::new(),
lobby_prop_keys: Vec::new(),
player_props: Map::new(),
});
// A joiner that has committed nothing yet sends an empty bag on every seating op.
round_client(ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() });
round_client(ClientMsg::Rejoin { name: String::new(), player_props: Map::new() });
round_client(ClientMsg::JoinRandom {
power_min: 0,
power_max: 0,
levels: Vec::new(),
player_props: Map::new(),
});
// The trailing playerProps of a JoinRoom is exactly one byte when empty.
assert_eq!(
encode_client(&ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() }),
vec![OP_JOIN_ROOM, 0, 0, 0]
);
round_server(ServerMsg::JoinedRoom {
room_name: String::new(),
your_actor: 1,
master_actor: 1,
room_props: Map::new(),
players: Vec::new(),
});
// An empty map is exactly one byte of payload.
assert_eq!(encode_client(&ClientMsg::SetRoomProps { props: Map::new() }), vec![OP_SET_ROOM_PROPS, 0]);
}
#[test]
fn max_u8_counts_round_trip() {
let map = Map::from_pairs((0..255).map(|i| (format!("k{}", i), Value::Int(i))));
assert_eq!(map.len(), 255);
round_client(ClientMsg::SetPlayerProps { props: map.clone() });
let params: Vec<Value> = (0..255).map(Value::Int).collect();
round_client(ClientMsg::Rpc { name: "SendStamp".into(), params });
let keys: Vec<String> = (0..255).map(|i| format!("k{}", i)).collect();
round_client(ClientMsg::CreateRoom {
name: "n".into(),
max_players: 255,
visible: true,
open: true,
props: map.clone(),
lobby_prop_keys: keys,
player_props: map.clone(),
});
// A full 255-key bag on each of the other three seating ops.
round_client(ClientMsg::JoinRoom { name: "n".into(), player_props: map.clone() });
round_client(ClientMsg::Rejoin { name: "n".into(), player_props: map.clone() });
round_client(ClientMsg::JoinRandom {
power_min: 0,
power_max: 1,
levels: vec![Value::Int(4)],
player_props: map,
});
let players: Vec<(i32, Map)> = (0..255).map(|i| (i, Map::new())).collect();
round_server(ServerMsg::JoinedRoom {
room_name: "n".into(),
your_actor: 1,
master_actor: 1,
room_props: Map::new(),
players,
});
}
#[test]
fn integer_and_double_extremes_survive() {
round_client(ClientMsg::JoinRandom {
power_min: i32::MIN,
power_max: i32::MAX,
levels: vec![Value::Int(i32::MIN), Value::Int(i32::MAX), Value::Int(0)],
player_props: Map::new(),
});
round_server(ServerMsg::PlayerEntered {
actor: i32::MIN,
props: Map::from_pairs(vec![
("a", Value::Double(f64::MAX)),
("b", Value::Double(f64::MIN_POSITIVE)),
("c", Value::Double(f64::INFINITY)),
]),
});
}
#[test]
fn wire_layout_is_byte_exact() {
// Guard the framing itself: little-endian everywhere, u16 string lengths,
// u8 counts, tag-then-payload values. If this test needs updating, the C#
// client needs updating too.
assert_eq!(
encode_client(&ClientMsg::Auth { user_id: "7".into(), token: "ab".into() }),
vec![OP_AUTH, 1, 0, b'7', 2, 0, b'a', b'b']
);
assert_eq!(
encode_client(&ClientMsg::JoinRandom {
power_min: 1,
power_max: 256,
levels: vec![Value::Int(4)],
player_props: Map::new(),
}),
// ... levels ValueList ..., then the trailing empty playerProps map (count 0).
vec![OP_JOIN_RANDOM, 1, 0, 0, 0, 0, 1, 0, 0, 1, TAG_INT, 4, 0, 0, 0, 0]
);
// The seating ops' trailing bag is an ordinary Map: count:u8 then (key,value)*.
assert_eq!(
encode_client(&ClientMsg::JoinRoom {
name: "42".into(),
player_props: Map::from_pairs(vec![("B", Value::Str("7".into()))]),
}),
vec![OP_JOIN_ROOM, 2, 0, b'4', b'2', 1, 1, 0, b'B', TAG_STRING, 1, 0, b'7']
);
assert_eq!(
encode_client(&ClientMsg::Rejoin {
name: "42".into(),
player_props: Map::from_pairs(vec![("F", Value::Int(5))]),
}),
vec![OP_REJOIN, 2, 0, b'4', b'2', 1, 1, 0, b'F', TAG_INT, 5, 0, 0, 0]
);
assert_eq!(
encode_client(&ClientMsg::SetPlayerProps {
props: Map::from_pairs(vec![("LB", Value::Int(12))]),
}),
vec![OP_SET_PLAYER_PROPS, 1, 2, 0, b'L', b'B', TAG_INT, 12, 0, 0, 0]
);
assert_eq!(
encode_server(&ServerMsg::PlayerLeft { actor: 2, inactive: true }),
vec![OP_PLAYER_LEFT, 2, 0, 0, 0, 1]
);
assert_eq!(
encode_server(&ServerMsg::AuthOk { actorless_time_ms: 1.0 }),
vec![OP_AUTH_OK, 0, 0, 0, 0, 0, 0, 0xf0, 0x3f]
);
}
#[test]
fn malformed_frames_are_rejected() {
assert_eq!(decode_client(&[]), Err(DecodeError::Empty));
assert_eq!(decode_client(&[99]), Err(DecodeError::UnknownOp(99)));
assert_eq!(decode_server(&[99]), Err(DecodeError::UnknownOp(99)));
// JoinLobby with a length prefix longer than the payload.
assert_eq!(decode_client(&[OP_JOIN_LOBBY, 4, 0, b'a']), Err(DecodeError::Truncated));
// Ping carries nothing.
assert_eq!(decode_client(&[OP_PING, 0]), Err(DecodeError::TrailingBytes));
// Unknown value tag inside a map.
assert_eq!(
decode_client(&[OP_SET_ROOM_PROPS, 1, 1, 0, b'A', 9]),
Err(DecodeError::UnknownTag(9))
);
// A map that promises two entries but carries one.
assert_eq!(
decode_client(&[OP_SET_ROOM_PROPS, 2, 1, 0, b'A', TAG_NULL]),
Err(DecodeError::Truncated)
);
// Invalid UTF-8 in a string.
assert_eq!(decode_client(&[OP_JOIN_ROOM, 1, 0, 0xff]), Err(DecodeError::BadUtf8));
}
#[test]
fn duplicate_map_keys_collapse_last_writer_wins() {
let bytes = vec![
OP_SET_ROOM_PROPS, 2,
1, 0, b'A', TAG_INT, 1, 0, 0, 0,
1, 0, b'A', TAG_INT, 2, 0, 0, 0,
];
let ClientMsg::SetRoomProps { props } = decode_client(&bytes).unwrap() else {
panic!("expected SetRoomProps");
};
assert_eq!(props.len(), 1);
assert_eq!(props.get_int("A"), Some(2));
}
#[test]
fn map_set_is_last_writer_wins_in_place() {
let mut map = Map::from_pairs(vec![("A", Value::Int(1)), ("B", Value::Int(2))]);
map.set("A", Value::Int(9));
assert_eq!(map.len(), 2);
assert_eq!(
map.iter().map(|(k, _)| k).collect::<Vec<_>>(),
vec!["A", "B"]
);
assert_eq!(map.get_int("A"), Some(9));
}
#[test]
fn bool_payload_accepts_any_nonzero() {
// Photon writes 0/1; be lenient reading, strict writing.
assert_eq!(
decode_server(&[OP_PLAYER_LEFT, 1, 0, 0, 0, 7]),
Ok(ServerMsg::PlayerLeft { actor: 1, inactive: true })
);
assert_eq!(
encode_server(&ServerMsg::PlayerLeft { actor: 1, inactive: true })[5],
1
);
}
}
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
// WebSocket end of the multi-live relay: GET /api/multi_live/ws.
//
// One task per connection reads frames, decodes them and drives the (synchronous,
// lock-serialised) registry in rooms.rs. A second task per connection drains that
// connection's unbounded queue into the socket. Nothing that touches the registry ever
// awaits while the lock is held, which is what lets the registry hand out a total order
// over every broadcast - see the header comment in rooms.rs.
//
// Everything the client can get wrong ends in a close: 4000 for a framing/protocol
// error, 4001 for an unauthenticated or unauthenticated-first frame, 4002 for blowing
// the inbound rate cap, 4003 for going silent long enough to be presumed dead (the
// liveness sweep - see LIVENESS_TIMEOUT in rooms.rs, and start_sweeper below).
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use actix_web::rt;
use actix_web::{web, HttpRequest, HttpResponse};
use actix_ws::{AggregatedMessage, AggregatedMessageStream, CloseCode, CloseReason, Session};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use crate::router::userdata;
use super::proto::{
self, ClientMsg, DecodeError, ServerMsg, CLOSE_PROTOCOL, CLOSE_RATE_LIMIT,
CLOSE_UNAUTHENTICATED,
};
use super::rooms::{self, ConnId, Outbound};
// "Server closes idle unauthenticated connections after 10s."
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
// "a per-connection cap of 30 inbound messages/sec ... over the cap -> close 4002".
// The honest client peaks at 3-5/sec.
const RATE_LIMIT_PER_SEC: usize = 30;
const RATE_WINDOW: Duration = Duration::from_secs(1);
// Property bags and RPC params are tens of bytes; nothing legitimate comes close.
const MAX_FRAME_BYTES: usize = 64 * 1024;
// How often the inactive-actor, empty-room and connection-liveness expiries are checked.
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
pub async fn ws(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, actix_web::Error> {
// Older client builds carry incompatible multi implementations (pre-relay wire
// framing), so the upgrade itself is gated on the extended-protocol version — a
// non-101 answer makes the client's ConnectAsync fail cleanly into its official
// disconnect flow instead of mis-framing against the relay.
let protocol = crate::router::global::client_protocol_version(&req);
if protocol < super::PROTOCOL_VERSION {
println!(
"multi_live/ws: upgrade refused, X-Protocol-Version {} < {}",
protocol,
super::PROTOCOL_VERSION
);
return Ok(HttpResponse::UpgradeRequired().finish());
}
let (response, session, stream) = match actix_ws::handle(&req, body) {
Ok(parts) => {
println!("multi_live/ws: upgrade accepted, awaiting Auth");
parts
}
Err(err) => {
println!("multi_live/ws: upgrade REJECTED (not a websocket handshake?): {}", err);
return Err(err);
}
};
let stream = stream
.max_frame_size(MAX_FRAME_BYTES)
.aggregate_continuations()
.max_continuation_size(MAX_FRAME_BYTES);
let (tx, rx) = unbounded_channel::<Outbound>();
rt::spawn(writer(session, rx));
rt::spawn(reader(stream, tx));
Ok(response)
}
// The expiry timers: held-slot TTL, empty-room TTL and the connection liveness window.
//
// One task for the whole process, started from run_server so it lives on the system
// arbiter. It used to be lazily started by the first WebSocket upgrade instead, which put
// it on whichever HTTP worker happened to serve that upgrade: a panic anywhere in that
// worker took the sweeper down with it, permanently and silently, and `Once` guaranteed
// nothing would ever start it again. Every room in the process would then hold its seats
// forever.
//
// Called once per run_server, unconditionally and not behind a "started already" flag:
// each run_server builds its own actix System, and a task spawned on the previous one died
// with it (the mobile path stops and restarts the server in-process). A flag would leave
// the restarted server with no sweeper at all; two sweepers, if they ever overlapped,
// would only mean the idempotent sweep runs twice a second.
pub fn start_sweeper() {
rt::spawn(async {
let mut ticker = rt::time::interval(SWEEP_INTERVAL);
loop {
ticker.tick().await;
// The guard is a temporary: it is released before the next await.
rooms::registry().sweep(Instant::now());
}
});
}
// Drains this connection's FIFO queue to the socket. The queue is unbounded so that the
// registry can push a whole broadcast while holding its lock without ever blocking.
async fn writer(mut session: Session, mut rx: UnboundedReceiver<Outbound>) {
while let Some(out) = rx.recv().await {
match out {
Outbound::Msg(msg) => {
if session.binary(proto::encode_server(&msg)).await.is_err() {
return;
}
}
Outbound::Pong(bytes) => {
if session.pong(&bytes).await.is_err() {
return;
}
}
Outbound::Close(code) => {
let _ = session
.close(Some(CloseReason { code: CloseCode::Other(code), description: None }))
.await;
return;
}
}
}
// Queue closed: the connection was deregistered, so the socket goes with it.
let _ = session.close(None).await;
}
async fn reader(mut stream: AggregatedMessageStream, tx: UnboundedSender<Outbound>) {
let Some(id) = authenticate(&mut stream, &tx).await else {
return;
};
let mut rate = RateLimiter::new();
loop {
let Some(frame) = stream.recv().await else {
break;
};
let payload = match frame {
Ok(AggregatedMessage::Binary(bytes)) => bytes,
Ok(AggregatedMessage::Ping(bytes)) => {
// WebSocket-level keepalive, unrelated to the protocol's Ping op. It is
// still proof the socket is alive, so it counts for the liveness window
// (the protocol ops are touched inside registry.handle).
rooms::registry().touch(id, Instant::now());
let _ = tx.send(Outbound::Pong(bytes.to_vec()));
continue;
}
Ok(AggregatedMessage::Pong(_)) => {
rooms::registry().touch(id, Instant::now());
continue;
}
Ok(AggregatedMessage::Close(_)) => break,
Ok(AggregatedMessage::Text(_)) => {
// "binary frames only ... Text frames are a protocol error -> close".
println!("multi_live/ws: text frame from conn {}", id);
close(&tx, CLOSE_PROTOCOL);
break;
}
Err(err) => {
println!("multi_live/ws: websocket error on conn {}: {}", id, err);
break;
}
};
let now = Instant::now();
if !rate.allow(now) {
println!("multi_live/ws: conn {} exceeded {} msg/sec", id, RATE_LIMIT_PER_SEC);
close(&tx, CLOSE_RATE_LIMIT);
break;
}
let msg = match proto::decode_client(&payload) {
Ok(msg) => msg,
Err(err) => {
println!("multi_live/ws: bad frame from conn {}: {}", id, err);
close(&tx, close_code_for(&err));
break;
}
};
if matches!(msg, ClientMsg::Auth { .. }) {
// Auth is the first message and only the first message.
println!("multi_live/ws: repeat Auth on conn {}", id);
close(&tx, CLOSE_PROTOCOL);
break;
}
rooms::registry().handle(id, msg, now);
}
rooms::registry().disconnect(id, Instant::now());
}
// Reads the mandatory first frame and registers the connection. Returns None when the
// connection was closed instead.
async fn authenticate(
stream: &mut AggregatedMessageStream,
tx: &UnboundedSender<Outbound>,
) -> Option<ConnId> {
let first = match rt::time::timeout(AUTH_TIMEOUT, stream.recv()).await {
Ok(Some(Ok(frame))) => frame,
Ok(Some(Err(_))) | Ok(None) => return None,
Err(_) => {
println!("multi_live/ws: no Auth within {}s", AUTH_TIMEOUT.as_secs());
close(tx, CLOSE_UNAUTHENTICATED);
return None;
}
};
let AggregatedMessage::Binary(payload) = first else {
// "First message on a connection MUST be Auth; anything else -> close 4001."
close(tx, CLOSE_UNAUTHENTICATED);
return None;
};
let Ok(ClientMsg::Auth { user_id, token }) = proto::decode_client(&payload) else {
println!("multi_live/ws: first message was not a decodable Auth frame");
close(tx, CLOSE_UNAUTHENTICATED);
return None;
};
let Some(uid) = resolve_user(&user_id, &token) else {
println!("multi_live/ws: rejecting uid {}: bad session", user_id);
close(tx, CLOSE_UNAUTHENTICATED);
return None;
};
let id = rooms::registry().connect(uid, tx.clone(), Instant::now());
println!("multi_live/ws: auth ok, uid {} connected as conn {}", uid, id);
let _ = tx.send(Outbound::Msg(ServerMsg::AuthOk {
actorless_time_ms: rooms::server_time_ms(),
}));
Some(id)
}
// The relay validates the same credential the HTTP layer does. Over HTTP the login token
// arrives inside the `a6573cbe` header (global::get_login) and every handler then keys
// userdata off it; here it arrives as the Auth payload instead, and the tokens table maps
// it back to the account. The claimed userId only has to agree with the token, so a
// client cannot relay as somebody else - which is a little stricter than the HTTP layer,
// where an unknown token simply resolves to an empty account.
fn resolve_user(user_id: &str, token: &str) -> Option<i64> {
if token.is_empty() {
return None;
}
match_claim(user_id, userdata::uid_from_login_token(token))
}
// The claim check, split out from the lookup so it can be tested without a database.
// `uid` is 0 when the token is unknown.
fn match_claim(user_id: &str, uid: i64) -> Option<i64> {
if uid == 0 {
return None;
}
match user_id.parse::<i64>() {
// A blank or unparsable userId is tolerated: the token is the authority.
Err(_) => Some(uid),
Ok(claimed) if claimed == 0 || claimed == uid => Some(uid),
Ok(_) => None,
}
}
fn close_code_for(err: &DecodeError) -> u16 {
// The spec names 4001 and 4002 only; every framing failure shares 4000.
match err {
DecodeError::Empty
| DecodeError::Truncated
| DecodeError::TrailingBytes
| DecodeError::BadUtf8
| DecodeError::UnknownOp(_)
| DecodeError::UnknownTag(_) => CLOSE_PROTOCOL,
}
}
fn close(tx: &UnboundedSender<Outbound>, code: u16) {
let _ = tx.send(Outbound::Close(code));
}
// Sliding one second window rather than a fixed bucket, so 30 messages either side of a
// second boundary still trips the cap.
struct RateLimiter {
seen: VecDeque<Instant>,
}
impl RateLimiter {
fn new() -> Self {
RateLimiter { seen: VecDeque::with_capacity(RATE_LIMIT_PER_SEC + 1) }
}
fn allow(&mut self, now: Instant) -> bool {
while let Some(front) = self.seen.front() {
if now.duration_since(*front) >= RATE_WINDOW {
self.seen.pop_front();
} else {
break;
}
}
if self.seen.len() >= RATE_LIMIT_PER_SEC {
return false;
}
self.seen.push_back(now);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limiter_allows_the_cap_and_rejects_the_next() {
let mut rate = RateLimiter::new();
let start = Instant::now();
for i in 0..RATE_LIMIT_PER_SEC {
assert!(rate.allow(start + Duration::from_millis(i as u64)), "message {} refused", i);
}
assert!(!rate.allow(start + Duration::from_millis(999)));
// The window slides: once the first message ages out there is room again.
assert!(rate.allow(start + Duration::from_millis(1001)));
}
#[test]
fn rate_limiter_catches_a_burst_straddling_a_second_boundary() {
let mut rate = RateLimiter::new();
let start = Instant::now();
// 29 messages at the end of one second...
for i in 0..RATE_LIMIT_PER_SEC - 1 {
assert!(rate.allow(start + Duration::from_millis(900 + i as u64)));
}
// ...and two more just after the boundary is still 31 inside one second.
assert!(rate.allow(start + Duration::from_millis(1000)));
assert!(!rate.allow(start + Duration::from_millis(1001)));
}
#[test]
fn every_decode_failure_closes_with_the_protocol_code() {
for err in [
DecodeError::Empty,
DecodeError::Truncated,
DecodeError::TrailingBytes,
DecodeError::BadUtf8,
DecodeError::UnknownOp(200),
DecodeError::UnknownTag(9),
] {
assert_eq!(close_code_for(&err), CLOSE_PROTOCOL);
}
}
#[test]
fn auth_needs_a_token_that_agrees_with_the_claimed_user() {
// An empty token never reaches the database.
assert_eq!(resolve_user("1", ""), None);
// An unknown token resolves to uid 0.
assert_eq!(match_claim("1", 0), None);
assert_eq!(match_claim("42", 42), Some(42));
// Claiming somebody else's account with your own token is refused.
assert_eq!(match_claim("43", 42), None);
// A blank, zero or unparsable userId defers to the token.
assert_eq!(match_claim("", 42), Some(42));
assert_eq!(match_claim("0", 42), Some(42));
assert_eq!(match_claim("not-a-number", 42), Some(42));
}
}