Skip to main content

manabrew_relay_protocol/
lib.rs

1//! Relay wire protocol: the session handshake, lobby management and game
2//! transport envelopes exchanged between clients and `manabrew-server`.
3//! Engine and UI DTOs live in `manabrew-protocol`.
4pub mod identity_token;
5pub mod state_delta;
6
7pub use manabrew_protocol::deck_dto::Deck;
8pub use manabrew_protocol::game::{EngineKind, GameFormat};
9pub use manabrew_protocol::telemetry::EnginePlayStats;
10use serde::{Deserialize, Serialize};
11use ts_rs::TS;
12
13// The wire-compat number IS the crate major: a breaking wire change must ship
14// as a breaking (major) release of this crate, never as a separate constant.
15pub const PROTOCOL_VERSION: u32 = major_of(env!("CARGO_PKG_VERSION_MAJOR"));
16
17const fn major_of(major: &str) -> u32 {
18    let bytes = major.as_bytes();
19    let mut value = 0u32;
20    let mut i = 0;
21    while i < bytes.len() {
22        value = value * 10 + (bytes[i] - b'0') as u32;
23        i += 1;
24    }
25    value
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, TS)]
29#[ts(export, export_to = "lobby/index.ts")]
30pub struct PlayerDeckInfo {
31    pub username: String,
32    pub deck_name: String,
33    pub deck: Deck,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    #[ts(optional)]
36    pub published_deck_id: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    #[ts(optional)]
39    pub commander_name: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    #[ts(optional)]
42    pub avatar_url: Option<String>,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct IdentityProof {
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub token: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub device: Option<String>,
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
54#[serde(rename_all = "snake_case")]
55#[ts(export, export_to = "lobby/index.ts")]
56pub enum ClientPlatform {
57    Web,
58    Pwa,
59    Desktop,
60    Mobile,
61    #[default]
62    Unknown,
63}
64
65impl ClientPlatform {
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Web => "web",
69            Self::Pwa => "pwa",
70            Self::Desktop => "desktop",
71            Self::Mobile => "mobile",
72            Self::Unknown => "unknown",
73        }
74    }
75}
76
77/// A peer's addressing information for the direct data plane.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct TransportEndpoint {
80    pub endpoint_id: String,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub relay_url: Option<String>,
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub direct_addrs: Vec<String>,
85    /// Data planes this peer speaks, most preferred first. Seats follow the host's.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub kinds: Vec<String>,
88}
89
90impl TransportEndpoint {
91    /// Whether this peer advertises a plane.
92    pub fn speaks(&self, kind: &str) -> bool {
93        self.kinds.iter().any(|k| k == kind)
94    }
95}
96
97/// An ICE server, shaped like the `RTCIceServer` dictionary.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct IceServer {
100    pub urls: Vec<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub username: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub credential: Option<String>,
105}
106
107/// How one seat's game traffic travelled, reported by the room's engine host.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SeatTransportReport {
110    pub username: String,
111    /// One of the `TRANSPORT_*` constants.
112    pub transport: String,
113}
114
115/// One end's account of one direct-plane attempt. Every field is a client claim.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct PlaneQualityReport {
118    /// The other end's relay-attested username.
119    pub peer: String,
120    /// One of [`PLANE_OUTCOMES`]; anything else is dropped.
121    pub outcome: String,
122    /// The plane attempted, a `TRANSPORT_*` constant.
123    pub plane: String,
124    /// [`PLANE_PHASE_SETTLED`] once per attempt, then [`PLANE_PHASE_MEASURED`].
125    pub phase: String,
126    /// First offer to open channel.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub connect_ms: Option<u32>,
129    /// Median round trip measured on the channel itself.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub rtt_ms: Option<u32>,
132    /// The same session's relay round trip, sampled at the same time.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub relay_rtt_ms: Option<u32>,
135    /// The winning ICE pair as `local/remote`.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub candidate_pair: Option<String>,
138}
139
140pub const PLANE_PHASE_SETTLED: &str = "settled";
141pub const PLANE_PHASE_MEASURED: &str = "measured";
142
143pub const PLANE_OUTCOME_CONNECTED: &str = "connected";
144pub const PLANE_OUTCOME_FAILED: &str = "failed";
145pub const PLANE_OUTCOME_TIMEOUT: &str = "timeout";
146
147/// Outcomes the relay records; anything else is dropped.
148pub const PLANE_OUTCOMES: &[&str] = &[
149    PLANE_OUTCOME_CONNECTED,
150    PLANE_OUTCOME_FAILED,
151    PLANE_OUTCOME_TIMEOUT,
152];
153
154/// Longest `candidate_pair` the relay records.
155pub const MAX_CANDIDATE_PAIR_BYTES: usize = 64;
156
157/// Longest duration the relay records; ten minutes.
158pub const MAX_PLANE_MS: u32 = 600_000;
159
160/// A browser pair on an `RTCDataChannel`.
161pub const TRANSPORT_WEBRTC: &str = "webrtc";
162
163/// Names for [`TransportEndpoint::kinds`].
164pub const TRANSPORT_KIND_WEBRTC: &str = "webrtc";
165
166/// One room member's endpoint. `username` is relay-attested, never client supplied.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct TransportMember {
169    pub username: String,
170    pub endpoint: TransportEndpoint,
171    #[serde(default)]
172    pub host: bool,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(tag = "type")]
177#[allow(clippy::large_enum_variant)]
178pub enum ClientMessage {
179    Authenticate {
180        /// Legacy name field, read only when `identity` carries no token.
181        /// The session name is the `handle` claim of `identity.token`
182        /// ([`identity_token`]); remove this field in the next major.
183        #[serde(default)]
184        username: String,
185        password: String,
186        #[serde(default)]
187        service: bool,
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        identity: Option<IdentityProof>,
190        #[serde(default)]
191        client_platform: ClientPlatform,
192        /// The client's app version (`APP_VERSION`), when it sends one. Absent
193        /// means a build older than the one that started reporting it, which is
194        /// how the relay decides what wire features that seat can handle.
195        #[serde(default, skip_serializing_if = "Option::is_none")]
196        client_version: Option<String>,
197    },
198
199    Ping,
200
201    ListRooms,
202
203    ListPlayers,
204
205    /// The session entered or left a game running on its own machine. `None`
206    /// clears it. Such a game opens no room and sends no state, so the relay
207    /// has no other way to know it is happening.
208    SetLocalGame {
209        #[serde(default)]
210        kind: Option<LocalGameKind>,
211    },
212
213    CreateRoom {
214        room_name: String,
215        max_players: u8,
216        format: GameFormat,
217        #[serde(default)]
218        protocol_version: u32,
219        #[serde(default)]
220        hosted: bool,
221        #[serde(default)]
222        engine: EngineKind,
223        #[serde(default)]
224        draft_config: Option<DraftConfig>,
225        #[serde(default)]
226        sealed_config: Option<SealedConfig>,
227        #[serde(default)]
228        official_key: Option<String>,
229        #[serde(default)]
230        password: Option<String>,
231        #[serde(default)]
232        reconnect_timeout_s: Option<u32>,
233    },
234
235    JoinRoom {
236        room_id: String,
237        #[serde(default)]
238        observe: bool,
239        #[serde(default)]
240        as_bot: bool,
241        #[serde(default)]
242        password: Option<String>,
243    },
244
245    ResumeRoom(ResumeRoomRequest),
246
247    LeaveRoom,
248
249    SetReady {
250        ready: bool,
251    },
252
253    SetDeckSelection {
254        deck_name: String,
255        deck: Deck,
256        #[serde(default)]
257        published_deck_id: Option<String>,
258        commander_name: Option<String>,
259        #[serde(default)]
260        avatar_url: Option<String>,
261    },
262
263    SetFormat {
264        format: GameFormat,
265    },
266
267    SetMaxPlayers {
268        max_players: u8,
269    },
270
271    StartGame {
272        #[serde(default)]
273        format: Option<GameFormat>,
274    },
275
276    EndGame {
277        game_id: String,
278    },
279
280    ReportEngineStats {
281        #[serde(default, skip_serializing_if = "Option::is_none")]
282        game_id: Option<String>,
283        stats: EnginePlayStats,
284    },
285
286    /// How the game ended, from the seat that ran the engine. The relay
287    /// stopped reading it out of the state stream, which need not pass
288    /// through it any more.
289    ReportGameOutcome {
290        game_id: String,
291        outcome: GameOutcomeReport,
292    },
293
294    RequestResync,
295
296    BroadcastState {
297        state: serde_json::Value,
298        #[serde(default, skip_serializing_if = "Option::is_none")]
299        /// A null value will broadcast to the whole room
300        target_player: Option<String>,
301    },
302
303    TurnChange {
304        new_active_player: String,
305        turn_number: u32,
306    },
307    /// Which transport each seat's traffic took for this game. Analytics only.
308    ReportTransport {
309        game_id: String,
310        seats: Vec<SeatTransportReport>,
311    },
312
313    /// Publishes, or with `None` withdraws, this session's data-plane endpoint.
314    AnnounceTransport {
315        #[serde(default)]
316        endpoint: Option<TransportEndpoint>,
317    },
318
319    /// An opaque signalling blob for a room member, named by username.
320    SignalPeer {
321        to: String,
322        payload: serde_json::Value,
323    },
324
325    /// How one direct-plane attempt turned out, sent by the seat. Analytics only.
326    ReportPlaneQuality {
327        report: PlaneQualityReport,
328    },
329
330    SendChat {
331        scope: ChatScope,
332        text: String,
333    },
334
335    InviteToRoom {
336        username: String,
337    },
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(tag = "type")]
342pub enum ServerMessage {
343    Pong,
344
345    AuthResult {
346        success: bool,
347        player_id: Option<String>,
348        reconnected: Option<bool>,
349        error: Option<String>,
350        /// Wire features this relay understands, so a newer client can stay
351        /// quiet instead of sending a message an older one would reject with
352        /// a parse error. Absent from relays built before the list existed.
353        #[serde(default, skip_serializing_if = "Vec::is_empty")]
354        features: Vec<String>,
355        /// Where this relay serves card art, when it serves any. A self-hosted
356        /// box holding the images is the reason to run one, and the client
357        /// cannot guess the port. Absent means fall back to the CDN.
358        #[serde(default, skip_serializing_if = "Option::is_none")]
359        art_base_url: Option<String>,
360    },
361
362    SessionTakenOver,
363
364    RoomList {
365        rooms: Vec<RoomInfo>,
366    },
367
368    PlayerList {
369        players: Vec<PlayerInfo>,
370    },
371
372    RoomCreated {
373        room_id: String,
374        room_name: String,
375        room: RoomInfo,
376        #[serde(default)]
377        resume_token: Option<String>,
378    },
379
380    RoomResumed {
381        room: RoomInfo,
382    },
383
384    PlayerJoined {
385        room_id: String,
386        username: String,
387    },
388
389    PlayerLeft {
390        room_id: String,
391        username: String,
392    },
393
394    PlayerConnected {
395        username: String,
396    },
397
398    PlayerDisconnected {
399        username: String,
400    },
401
402    ReadyStateChanged {
403        username: String,
404        ready: bool,
405    },
406
407    RoomUpdate {
408        room: RoomInfo,
409    },
410
411    GameStarted {
412        room_id: String,
413        game_id: String,
414        player_order: Vec<String>,
415        player_decks: Vec<PlayerDeckInfo>,
416        starting_life: i32,
417    },
418
419    StateUpdate {
420        from_player: String,
421        state: serde_json::Value,
422    },
423
424    TurnChanged {
425        from_player: String,
426        new_active_player: String,
427        turn_number: u32,
428    },
429
430    GameAborted {
431        room_id: String,
432    },
433
434    Error {
435        code: String,
436        message: String,
437    },
438
439    ServerShuttingDown {
440        reconnect_in_s: u32,
441    },
442    /// The room's data-plane roster. Sent only to room members.
443    RoomTransport {
444        room_id: String,
445        /// STUN, and TURN where configured, for the WebRTC plane.
446        #[serde(default, skip_serializing_if = "Vec::is_empty")]
447        ice_servers: Vec<IceServer>,
448        #[serde(default, skip_serializing_if = "Option::is_none")]
449        host: Option<TransportMember>,
450        members: Vec<TransportMember>,
451    },
452
453    /// A signalling blob from a room member. `from` is relay-attested.
454    PeerSignal {
455        from: String,
456        payload: serde_json::Value,
457    },
458
459    ChatMessage(ChatMessage),
460
461    ChatHistory {
462        scope: ChatScope,
463        #[serde(default, skip_serializing_if = "Option::is_none")]
464        room_id: Option<String>,
465        messages: Vec<ChatMessage>,
466    },
467
468    RoomInvite {
469        from: String,
470        room: RoomInfo,
471        #[serde(default, skip_serializing_if = "Option::is_none")]
472        password: Option<String>,
473    },
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
477pub enum ChatScope {
478    Lobby,
479    Room,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ChatMessage {
484    pub scope: ChatScope,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub room_id: Option<String>,
487    pub from: String,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub avatar_url: Option<String>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub qualification: Option<String>,
492    pub text: String,
493    pub sent_at_ms: u64,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub seal: Option<String>,
496}
497
498pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
499pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
500pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
501pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
502
503#[derive(Debug, Clone, Serialize, Deserialize, TS)]
504#[ts(export, export_to = "lobby/index.ts")]
505pub struct ResumeRoomRequest {
506    pub room_id: String,
507    pub resume_token: String,
508    pub room_name: String,
509    pub max_players: u8,
510    pub format: GameFormat,
511    #[serde(default)]
512    pub hosted: bool,
513    #[serde(default)]
514    pub engine: EngineKind,
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    #[ts(optional)]
517    pub official_key: Option<String>,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    #[ts(optional)]
520    pub password: Option<String>,
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    #[ts(optional)]
523    pub reconnect_timeout_s: Option<u32>,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    #[ts(optional)]
526    pub draft_config: Option<DraftConfig>,
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    #[ts(optional)]
529    pub sealed_config: Option<SealedConfig>,
530    pub player_order: Vec<String>,
531    pub player_decks: Vec<PlayerDeckInfo>,
532    pub starting_life: i32,
533    #[serde(default)]
534    pub bot_players: Vec<String>,
535    pub game_id: String,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct RoomInfo {
540    pub room_id: String,
541    pub room_name: String,
542    pub host: String,
543    #[serde(default)]
544    pub protocol_version: u32,
545    #[serde(default)]
546    pub hosted: bool,
547    #[serde(default)]
548    pub official: bool,
549    #[serde(default)]
550    pub password_protected: bool,
551    pub players: Vec<RoomPlayerInfo>,
552    pub max_players: u8,
553    pub format: GameFormat,
554    pub status: RoomStatus,
555    #[serde(default)]
556    pub engine: EngineKind,
557    #[serde(default = "default_reconnect_timeout_s")]
558    pub reconnect_timeout_s: u32,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub draft_config: Option<DraftConfig>,
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub sealed_config: Option<SealedConfig>,
563}
564
565pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
566
567fn default_reconnect_timeout_s() -> u32 {
568    DEFAULT_RECONNECT_TIMEOUT_S
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
572#[ts(export, export_to = "lobby/index.ts")]
573pub struct SealedConfig {
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    #[ts(optional)]
576    pub set_code: Option<String>,
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    #[ts(optional)]
579    pub cube_id: Option<String>,
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    #[ts(optional)]
582    pub cube_name: Option<String>,
583    #[serde(default)]
584    pub singleton: bool,
585    pub num_boosters: u8,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    #[ts(optional, type = "number")]
588    pub base_seed: Option<u64>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
592#[ts(export, export_to = "lobby/index.ts")]
593pub struct DraftConfig {
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    #[ts(optional)]
596    pub set_code: Option<String>,
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    #[ts(optional)]
599    pub cube_id: Option<String>,
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    #[ts(optional)]
602    pub cube_name: Option<String>,
603    pub rounds: u8,
604    pub picks_per_pass: u8,
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    #[ts(optional, type = "number")]
607    pub seed: Option<u64>,
608    pub fill_with_bots: bool,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
612pub struct RoomPlayerInfo {
613    pub username: String,
614    pub ready: bool,
615    pub connected: bool,
616    #[serde(default)]
617    pub is_bot: bool,
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub selected_deck_name: Option<String>,
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct PlayerInfo {
624    pub username: String,
625    pub player_id: String,
626    pub connected: bool,
627    #[serde(default)]
628    pub verified: bool,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub qualification: Option<String>,
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub avatar_url: Option<String>,
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub room_id: Option<String>,
635    /// In a game on their own machine rather than in a relay room. Never set
636    /// at the same time as `room_id`.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub local_game: Option<LocalGameKind>,
639    /// Opaque to clients: the session's IP sealed together with its handle,
640    /// which only the hub can open. Carried verbatim into chat reports.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub seal: Option<String>,
643}
644
645/// Names [`ClientMessage::SetLocalGame`] in `AuthResult::features`.
646pub const FEATURE_LOCAL_GAME: &str = "local_game";
647/// Names [`ClientMessage::SendChat`] in `AuthResult::features`.
648pub const FEATURE_CHAT: &str = "chat";
649/// Names [`ClientMessage::InviteToRoom`] in `AuthResult::features`.
650pub const FEATURE_ROOM_INVITES: &str = "room_invites";
651/// Names [`ClientMessage::ReportGameOutcome`] in `AuthResult::features`.
652pub const FEATURE_GAME_OUTCOME: &str = "game_outcome";
653
654/// Names [`ClientMessage::AnnounceTransport`] in `AuthResult::features`.
655pub const FEATURE_ROOM_TRANSPORT: &str = "room_transport";
656
657/// Names [`ClientMessage::SignalPeer`] in `AuthResult::features`.
658pub const FEATURE_PEER_SIGNAL: &str = "peer_signal";
659
660/// Names [`ClientMessage::ReportPlaneQuality`] in `AuthResult::features`.
661pub const FEATURE_PLANE_QUALITY: &str = "plane_quality";
662
663pub const FEATURES: &[&str] = &[
664    FEATURE_LOCAL_GAME,
665    FEATURE_ROOM_TRANSPORT,
666    FEATURE_PEER_SIGNAL,
667    FEATURE_PLANE_QUALITY,
668    FEATURE_CHAT,
669    FEATURE_ROOM_INVITES,
670    FEATURE_GAME_OUTCOME,
671];
672
673/// Largest signalling blob the relay forwards.
674pub const MAX_SIGNAL_BYTES: usize = 16 * 1024;
675
676/// Longest `fatal_message` the relay keeps.
677pub const MAX_FATAL_MESSAGE_CHARS: usize = 500;
678
679/// Engine slots (`player-N`), never usernames: the relay holds the seat map
680/// and does not take a name from a client.
681#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
682pub struct GameOutcomeReport {
683    pub game_over: bool,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub winner_slot: Option<String>,
686    #[serde(default, skip_serializing_if = "Vec::is_empty")]
687    pub conceded_slots: Vec<String>,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub fatal_message: Option<String>,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub turns: Option<u32>,
692}
693
694/// A game running on the player's own machine, which the relay never sees.
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
696pub enum LocalGameKind {
697    /// Play vs AI.
698    Singleplayer,
699}
700
701impl LocalGameKind {
702    /// So the exporter can publish a zero for an unplayed kind rather than
703    /// leaving its series stale at the last value it had.
704    pub const ALL: &'static [Self] = &[Self::Singleplayer];
705
706    pub fn as_str(self) -> &'static str {
707        match self {
708            Self::Singleplayer => "singleplayer",
709        }
710    }
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714pub enum RoomStatus {
715    Lobby,
716    InGame,
717}