use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
pub use crate::friends::FRIEND_CODE_ALPHABET;
use crate::envelope::{Ack, ServerPush};
use crate::error::{ApiError, ValidationDetails};
use crate::ids::{LobbyId, RoomId, UserId};
use crate::kinds;
use crate::page::{Cursor, Page, PageRequest};
use crate::time::UnixMillis;
pub const LOBBY_CODE_LEN: usize = 8;
pub const MAX_META_KEY_BYTES: usize = 64;
pub const MAX_META_VALUE_CHARS: usize = 256;
pub const MAX_FILTERS: usize = 8;
pub fn is_valid_meta_key(key: &str) -> bool {
(1..=MAX_META_KEY_BYTES).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
}
pub fn meta_value_problem(value: &str) -> Option<String> {
if value.chars().count() > MAX_META_VALUE_CHARS {
return Some(format!("is longer than {MAX_META_VALUE_CHARS} characters"));
}
crate::text::name_problem(value).map(str::to_string)
}
fn check_metadata<'a>(details: &mut ValidationDetails, entries: impl Iterator<Item = (&'a String, Option<&'a String>)>) {
for (key, value) in entries {
if !is_valid_meta_key(key) {
details.add("metadata", format!("`{key}` is not a valid key (1 to {MAX_META_KEY_BYTES} bytes of letters, digits, _ . : -)"));
} else if let Some(problem) = value.and_then(|v| meta_value_problem(v)) {
details.add("metadata", format!("`{key}` {problem}"));
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct LobbyCode(String);
impl LobbyCode {
pub fn parse(input: &str) -> Option<Self> {
crate::friends::normalize_friend_code(input).map(Self)
}
pub fn from_u64(number: u64) -> Option<Self> {
if number >= 1 << 40 {
return None;
}
let code = (0..LOBBY_CODE_LEN)
.rev()
.map(|place| {
let digit = (number >> (5 * place)) & 31;
char::from(FRIEND_CODE_ALPHABET[digit as usize])
})
.collect();
Some(Self(code))
}
pub fn to_u64(&self) -> u64 {
self.0.bytes().fold(0u64, |acc, b| (acc << 5) | FRIEND_CODE_ALPHABET.iter().position(|a| *a == b).unwrap_or(0) as u64)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn grouped(&self) -> String {
format!("{}-{}", &self.0[..4], &self.0[4..])
}
}
impl fmt::Display for LobbyCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for LobbyCode {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value).ok_or_else(|| format!("not a lobby code: {value:?}"))
}
}
impl From<LobbyCode> for String {
fn from(code: LobbyCode) -> Self {
code.0
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LobbyVisibility {
#[default]
Public,
Private,
Friends,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LobbyState {
#[default]
Open,
InGame,
Closed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyMember {
pub user: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub ready: bool,
pub joined_at: UnixMillis,
}
impl LobbyMember {
pub fn new(user: UserId, joined_at: UnixMillis) -> Self {
Self { user, name: None, ready: false, joined_at }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_ready(mut self, ready: bool) -> Self {
self.ready = ready;
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyInfo {
pub id: LobbyId,
pub visibility: LobbyVisibility,
pub state: LobbyState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<UserId>,
pub max_players: u32,
pub members: u32,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, String>,
pub created_at: UnixMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<LobbyCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_room: Option<RoomId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub players: Vec<LobbyMember>,
}
impl LobbyInfo {
pub fn new(id: LobbyId, visibility: LobbyVisibility, state: LobbyState, max_players: u32, members: u32, created_at: UnixMillis) -> Self {
Self {
id,
visibility,
state,
host: None,
max_players,
members,
metadata: BTreeMap::new(),
created_at,
code: None,
code_number: None,
chat_room: None,
players: Vec::new(),
}
}
pub fn with_host(mut self, host: UserId) -> Self {
self.host = Some(host);
self
}
pub fn with_metadata(mut self, metadata: BTreeMap<String, String>) -> Self {
self.metadata = metadata;
self
}
pub fn with_code(mut self, code: LobbyCode) -> Self {
self.code_number = Some(code.to_u64());
self.code = Some(code);
self
}
pub fn with_chat_room(mut self, room: RoomId) -> Self {
self.chat_room = Some(room);
self
}
pub fn with_players(mut self, players: Vec<LobbyMember>) -> Self {
self.players = players;
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyList {
pub lobbies: Vec<LobbyInfo>,
}
impl LobbyList {
pub fn new(lobbies: Vec<LobbyInfo>) -> Self {
Self { lobbies }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CreateLobby {
#[serde(default)]
pub visibility: LobbyVisibility,
pub max_players: u32,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, String>,
}
impl CreateLobby {
pub fn new(max_players: u32) -> Self {
Self { visibility: LobbyVisibility::Public, max_players, metadata: BTreeMap::new() }
}
pub fn with_visibility(mut self, visibility: LobbyVisibility) -> Self {
self.visibility = visibility;
self
}
pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if self.visibility == LobbyVisibility::Unknown {
details.add("visibility", "must be public, private or friends");
}
if self.max_players == 0 {
details.add("max_players", "must be at least 1");
}
check_metadata(&mut details, self.metadata.iter().map(|(k, v)| (k, Some(v))));
details.into_result()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateLobby {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<LobbyVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_players: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<LobbyState>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, Option<String>>,
}
impl UpdateLobby {
pub fn new() -> Self {
Self::default()
}
pub fn with_visibility(mut self, visibility: LobbyVisibility) -> Self {
self.visibility = Some(visibility);
self
}
pub fn with_max_players(mut self, max_players: u32) -> Self {
self.max_players = Some(max_players);
self
}
pub fn with_state(mut self, state: LobbyState) -> Self {
self.state = Some(state);
self
}
pub fn set_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), Some(value.into()));
self
}
pub fn remove_meta(mut self, key: impl Into<String>) -> Self {
self.metadata.insert(key.into(), None);
self
}
pub fn is_empty(&self) -> bool {
self.visibility.is_none() && self.max_players.is_none() && self.state.is_none() && self.metadata.is_empty()
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if self.visibility == Some(LobbyVisibility::Unknown) {
details.add("visibility", "must be public, private or friends");
}
if self.max_players == Some(0) {
details.add("max_players", "must be at least 1");
}
if self.state == Some(LobbyState::Unknown) {
details.add("state", "must be open, in_game or closed");
}
check_metadata(&mut details, self.metadata.iter().map(|(k, v)| (k, v.as_ref())));
details.into_result()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SetReady {
pub ready: bool,
}
impl SetReady {
pub fn new(ready: bool) -> Self {
Self { ready }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyPlayer {
pub user: UserId,
}
impl LobbyPlayer {
pub fn new(user: UserId) -> Self {
Self { user }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JoinLobbyByCode {
pub code: String,
}
impl JoinLobbyByCode {
pub fn new(code: impl Into<String>) -> Self {
Self { code: code.into() }
}
pub fn from_number(number: u64) -> Option<Self> {
LobbyCode::from_u64(number).map(|code| Self { code: code.0 })
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if LobbyCode::parse(&self.code).is_none() {
details.add("code", format!("must be {LOBBY_CODE_LEN} characters of {}", String::from_utf8_lossy(FRIEND_CODE_ALPHABET)));
}
details.into_result()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyFilter {
pub key: String,
pub value: String,
}
impl LobbyFilter {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self { key: key.into(), value: value.into() }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbySearch {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filters: Vec<LobbyFilter>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub friends: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_full: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl LobbySearch {
pub fn new() -> Self {
Self::default()
}
pub fn with_filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.filters.push(LobbyFilter::new(key, value));
self
}
pub fn of_friends(mut self) -> Self {
self.friends = true;
self
}
pub fn including_full(mut self) -> Self {
self.include_full = true;
self
}
pub fn after(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn page(&self) -> PageRequest {
PageRequest { cursor: self.cursor.clone(), limit: self.limit }
}
pub fn validate(&self) -> Result<(), ApiError> {
self.page().validate()?;
let mut details = ValidationDetails::new();
if self.filters.len() > MAX_FILTERS {
details.add("filters", format!("must hold at most {MAX_FILTERS} filters"));
}
for filter in &self.filters {
if !is_valid_meta_key(&filter.key) {
details.add("filters", format!("`{}` is not a valid key", filter.key));
} else if let Some(problem) = meta_value_problem(&filter.value) {
details.add("filters", format!("`{}` {problem}", filter.key));
}
}
details.into_result()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MemberChange {
Joined,
Left,
Kicked,
Ready,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyMemberUpdate {
pub lobby: LobbyId,
pub change: MemberChange,
pub member: LobbyMember,
}
impl LobbyMemberUpdate {
pub fn new(lobby: LobbyId, change: MemberChange, member: LobbyMember) -> Self {
Self { lobby, change, member }
}
}
impl ServerPush for LobbyMemberUpdate {
const KIND: &'static str = kinds::LOBBY_MEMBER;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LobbyChange {
Host,
Metadata,
Settings,
State,
Code,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LobbyUpdate {
pub changes: Vec<LobbyChange>,
pub lobby: LobbyInfo,
}
impl LobbyUpdate {
pub fn new(changes: Vec<LobbyChange>, lobby: LobbyInfo) -> Self {
Self { changes, lobby }
}
}
impl ServerPush for LobbyUpdate {
const KIND: &'static str = kinds::LOBBY_CHANGED;
}
mod calls {
use super::*;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
payload_call!(CreateLobby, Post, routes::lobbies::LIST, true, Json, LobbyInfo);
payload_call!(LobbySearch, Post, routes::lobbies::SEARCH, true, Json, Page<LobbyInfo>);
payload_call!(JoinLobbyByCode, Post, routes::lobbies::JOIN_CODE, true, Json, LobbyInfo);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct MyLobbies {}
impl MyLobbies {
pub fn new() -> Self {
Self {}
}
}
impl HttpCall for MyLobbies {
type Payload = NoPayload;
type Response = LobbyList;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::lobbies::MINE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn from_parts(_params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new())
}
}
macro_rules! lobby_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The lobby.
pub lobby: LobbyId,
}
impl $name {
pub fn new(lobby: LobbyId) -> Self {
Self { lobby }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("lobby", self.lobby)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("lobby")?))
}
}
};
}
lobby_call!(
GetLobby,
Get,
routes::lobbies::ONE,
LobbyInfo
);
lobby_call!(
JoinLobby,
Post,
routes::lobbies::JOIN,
LobbyInfo
);
lobby_call!(
LeaveLobby,
Post,
routes::lobbies::LEAVE,
Ack
);
lobby_call!(
NewLobbyCode,
Post,
routes::lobbies::CODE,
LobbyInfo
);
macro_rules! lobby_body_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $body:ident, $field:ident, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The lobby.
pub lobby: LobbyId,
pub $field: $body,
}
impl $name {
pub fn new(lobby: LobbyId, $field: $body) -> Self {
Self { lobby, $field }
}
}
impl HttpCall for $name {
type Payload = $body;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &$body {
&self.$field
}
fn path_params(&self) -> PathParams {
PathParams::new().with("lobby", self.lobby)
}
fn from_parts(params: &PathParams, $field: $body) -> Result<Self, ApiError> {
Ok(Self::new(params.id("lobby")?, $field))
}
}
};
}
lobby_body_call!(
EditLobby,
Patch,
routes::lobbies::ONE,
UpdateLobby,
update,
LobbyInfo
);
lobby_body_call!(
SetLobbyReady,
Put,
routes::lobbies::READY,
SetReady,
ready,
Ack
);
lobby_body_call!(
TransferLobby,
Post,
routes::lobbies::HOST,
LobbyPlayer,
to,
Ack
);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct KickFromLobby {
pub lobby: LobbyId,
pub user: UserId,
}
impl KickFromLobby {
pub fn new(lobby: LobbyId, user: UserId) -> Self {
Self { lobby, user }
}
}
impl HttpCall for KickFromLobby {
type Payload = NoPayload;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Delete, routes::lobbies::MEMBER, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("lobby", self.lobby).with("user", self.user)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("lobby")?, params.id("user")?))
}
}
}
pub use calls::{EditLobby, GetLobby, JoinLobby, KickFromLobby, LeaveLobby, MyLobbies, NewLobbyCode, SetLobbyReady, TransferLobby};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_are_numbers() {
assert_eq!(LobbyCode::from_u64(0).map(|c| c.0).as_deref(), Some("22222222"));
assert_eq!(LobbyCode::from_u64((1 << 40) - 1).map(|c| c.0).as_deref(), Some("ZZZZZZZZ"));
assert_eq!(LobbyCode::from_u64(1 << 40), None);
assert_eq!(LobbyCode::parse("ZZZZ-ZZZZ").map(|c| c.to_u64()), Some((1 << 40) - 1));
assert_eq!(LobbyCode::parse("22222223").map(|c| c.to_u64()), Some(1));
assert_eq!(LobbyCode::parse("K7M2Q9XD").map(|c| c.to_u64()), Some(590_122_524_587), "the documented example");
for number in [1u64, 31, 32, 683_102_947_653, (1 << 40) - 2] {
let code = LobbyCode::from_u64(number).expect("code");
assert_eq!(code.to_u64(), number);
assert_eq!(LobbyCode::parse(&code.grouped()), Some(code));
}
assert_eq!(LobbyCode::parse("K7M2Q9X0"), None);
assert_eq!(serde_json::to_string(&LobbyCode::parse("k7m2q9xd")).ok().as_deref(), Some(r#""K7M2Q9XD""#));
assert!(serde_json::from_str::<LobbyCode>(r#""nope""#).is_err());
assert_eq!(JoinLobbyByCode::from_number(1).map(|j| j.code).as_deref(), Some("22222223"));
assert!(JoinLobbyByCode::new("k7m2-q9xd").validate().is_ok() && JoinLobbyByCode::new("k7m2").validate().is_err());
}
#[test]
fn json_and_rules() {
assert!(is_valid_meta_key("map.name") && is_valid_meta_key("Mode:2") && !is_valid_meta_key("") && !is_valid_meta_key("a b"));
assert!(!is_valid_meta_key(&"k".repeat(MAX_META_KEY_BYTES + 1)));
assert!(meta_value_problem("").is_none() && meta_value_problem(&"é".repeat(MAX_META_VALUE_CHARS + 1)).is_some());
assert!(meta_value_problem("a\u{202E}b").is_some());
let create = CreateLobby::new(4).with_meta("mode", "ranked");
assert_eq!(serde_json::to_string(&create).ok().as_deref(), Some(r#"{"visibility":"public","max_players":4,"metadata":{"mode":"ranked"}}"#));
assert!(create.validate().is_ok() && CreateLobby::new(0).validate().is_err());
assert!(CreateLobby::new(2).with_meta("bad key", "x").validate().is_err());
assert!(serde_json::from_str::<CreateLobby>(r#"{"max_players":2,"visibility":"secret"}"#).map(|c| c.validate().is_err()).unwrap_or(false));
let update: UpdateLobby = serde_json::from_str(r#"{"state":"in_game","metadata":{"map":"dust","password":null}}"#).expect("update");
assert_eq!(update.state, Some(LobbyState::InGame));
assert_eq!(update.metadata.get("password"), Some(&None));
assert!(update.validate().is_ok() && !update.is_empty() && UpdateLobby::new().is_empty());
assert_eq!(serde_json::to_string(&UpdateLobby::new().remove_meta("x")).ok().as_deref(), Some(r#"{"metadata":{"x":null}}"#));
let info = LobbyInfo::new(LobbyId(7), LobbyVisibility::Friends, LobbyState::InGame, 4, 1, UnixMillis(1))
.with_host(UserId(42))
.with_code(LobbyCode::from_u64(1).expect("code"));
assert_eq!(
serde_json::to_string(&info).ok().as_deref(),
Some(r#"{"id":7,"visibility":"friends","state":"in_game","host":42,"max_players":4,"members":1,"created_at":1,"code":"22222223","code_number":1}"#)
);
let search = LobbySearch::new().with_filter("mode", "ranked").including_full();
assert_eq!(serde_json::to_string(&search).ok().as_deref(), Some(r#"{"filters":[{"key":"mode","value":"ranked"}],"include_full":true}"#));
assert!(search.validate().is_ok());
let too_many = (0..=MAX_FILTERS).fold(LobbySearch::new(), |s, n| s.with_filter(format!("k{n}"), "v"));
assert!(too_many.validate().is_err());
assert_eq!(serde_json::from_str::<MemberChange>(r#""promoted""#).ok(), Some(MemberChange::Unknown));
let push = LobbyMemberUpdate::new(LobbyId(7), MemberChange::Ready, LobbyMember::new(UserId(42), UnixMillis(1)).with_ready(true));
assert_eq!(serde_json::to_string(&push).ok().as_deref(), Some(r#"{"lobby":7,"change":"ready","member":{"user":42,"ready":true,"joined_at":1}}"#));
}
}