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        #[serde(default)]
234        table_style: Option<String>,
235    },
236
237    JoinRoom {
238        room_id: String,
239        #[serde(default)]
240        observe: bool,
241        #[serde(default)]
242        as_bot: bool,
243        #[serde(default)]
244        password: Option<String>,
245    },
246
247    ResumeRoom(ResumeRoomRequest),
248
249    LeaveRoom,
250
251    SetReady {
252        ready: bool,
253    },
254
255    SetDeckSelection {
256        deck_name: String,
257        deck: Deck,
258        #[serde(default)]
259        published_deck_id: Option<String>,
260        commander_name: Option<String>,
261        #[serde(default)]
262        avatar_url: Option<String>,
263    },
264
265    SetFormat {
266        format: GameFormat,
267    },
268
269    SetMaxPlayers {
270        max_players: u8,
271    },
272
273    StartGame {
274        #[serde(default)]
275        format: Option<GameFormat>,
276    },
277
278    EndGame {
279        game_id: String,
280    },
281
282    ReportEngineStats {
283        #[serde(default, skip_serializing_if = "Option::is_none")]
284        game_id: Option<String>,
285        stats: EnginePlayStats,
286    },
287
288    /// How the game ended, from the seat that ran the engine. The relay
289    /// stopped reading it out of the state stream, which need not pass
290    /// through it any more.
291    ReportGameOutcome {
292        game_id: String,
293        outcome: GameOutcomeReport,
294    },
295
296    RequestResync,
297
298    BroadcastState {
299        state: serde_json::Value,
300        #[serde(default, skip_serializing_if = "Option::is_none")]
301        /// A null value will broadcast to the whole room
302        target_player: Option<String>,
303    },
304
305    TurnChange {
306        new_active_player: String,
307        turn_number: u32,
308    },
309    /// Which transport each seat's traffic took for this game. Analytics only.
310    ReportTransport {
311        game_id: String,
312        seats: Vec<SeatTransportReport>,
313    },
314
315    /// Publishes, or with `None` withdraws, this session's data-plane endpoint.
316    AnnounceTransport {
317        #[serde(default)]
318        endpoint: Option<TransportEndpoint>,
319    },
320
321    /// An opaque signalling blob for a room member, named by username.
322    SignalPeer {
323        to: String,
324        payload: serde_json::Value,
325    },
326
327    /// How one direct-plane attempt turned out, sent by the seat. Analytics only.
328    ReportPlaneQuality {
329        report: PlaneQualityReport,
330    },
331
332    SendChat {
333        scope: ChatScope,
334        text: String,
335    },
336
337    InviteToRoom {
338        username: String,
339    },
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(tag = "type")]
344pub enum ServerMessage {
345    Pong,
346
347    AuthResult {
348        success: bool,
349        player_id: Option<String>,
350        reconnected: Option<bool>,
351        error: Option<String>,
352        /// Wire features this relay understands, so a newer client can stay
353        /// quiet instead of sending a message an older one would reject with
354        /// a parse error. Absent from relays built before the list existed.
355        #[serde(default, skip_serializing_if = "Vec::is_empty")]
356        features: Vec<String>,
357        /// Where this relay serves card art, when it serves any. A self-hosted
358        /// box holding the images is the reason to run one, and the client
359        /// cannot guess the port. Absent means fall back to the CDN.
360        #[serde(default, skip_serializing_if = "Option::is_none")]
361        art_base_url: Option<String>,
362    },
363
364    SessionTakenOver,
365
366    RoomList {
367        rooms: Vec<RoomInfo>,
368    },
369
370    PlayerList {
371        players: Vec<PlayerInfo>,
372    },
373
374    RoomCreated {
375        room_id: String,
376        room_name: String,
377        room: RoomInfo,
378        #[serde(default)]
379        resume_token: Option<String>,
380    },
381
382    RoomResumed {
383        room: RoomInfo,
384    },
385
386    PlayerJoined {
387        room_id: String,
388        username: String,
389    },
390
391    PlayerLeft {
392        room_id: String,
393        username: String,
394    },
395
396    PlayerConnected {
397        username: String,
398    },
399
400    PlayerDisconnected {
401        username: String,
402    },
403
404    ReadyStateChanged {
405        username: String,
406        ready: bool,
407    },
408
409    RoomUpdate {
410        room: RoomInfo,
411    },
412
413    GameStarted {
414        room_id: String,
415        game_id: String,
416        player_order: Vec<String>,
417        player_decks: Vec<PlayerDeckInfo>,
418        starting_life: i32,
419    },
420
421    StateUpdate {
422        from_player: String,
423        state: serde_json::Value,
424    },
425
426    TurnChanged {
427        from_player: String,
428        new_active_player: String,
429        turn_number: u32,
430    },
431
432    GameAborted {
433        room_id: String,
434    },
435
436    Error {
437        code: String,
438        message: String,
439    },
440
441    ServerShuttingDown {
442        reconnect_in_s: u32,
443    },
444    /// The room's data-plane roster. Sent only to room members.
445    RoomTransport {
446        room_id: String,
447        /// STUN, and TURN where configured, for the WebRTC plane.
448        #[serde(default, skip_serializing_if = "Vec::is_empty")]
449        ice_servers: Vec<IceServer>,
450        #[serde(default, skip_serializing_if = "Option::is_none")]
451        host: Option<TransportMember>,
452        members: Vec<TransportMember>,
453    },
454
455    /// A signalling blob from a room member. `from` is relay-attested.
456    PeerSignal {
457        from: String,
458        payload: serde_json::Value,
459    },
460
461    ChatMessage(ChatMessage),
462
463    ChatHistory {
464        scope: ChatScope,
465        #[serde(default, skip_serializing_if = "Option::is_none")]
466        room_id: Option<String>,
467        messages: Vec<ChatMessage>,
468    },
469
470    RoomInvite {
471        from: String,
472        room: RoomInfo,
473        #[serde(default, skip_serializing_if = "Option::is_none")]
474        password: Option<String>,
475    },
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
479pub enum ChatScope {
480    Lobby,
481    Room,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct ChatMessage {
486    pub scope: ChatScope,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub room_id: Option<String>,
489    pub from: String,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub avatar_url: Option<String>,
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub qualification: Option<String>,
494    pub text: String,
495    pub sent_at_ms: u64,
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub seal: Option<String>,
498}
499
500pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
501pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
502pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
503pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
504
505#[derive(Debug, Clone, Serialize, Deserialize, TS)]
506#[ts(export, export_to = "lobby/index.ts")]
507pub struct ResumeRoomRequest {
508    pub room_id: String,
509    pub resume_token: String,
510    pub room_name: String,
511    pub max_players: u8,
512    pub format: GameFormat,
513    #[serde(default)]
514    pub hosted: bool,
515    #[serde(default)]
516    pub engine: EngineKind,
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    #[ts(optional)]
519    pub official_key: Option<String>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    #[ts(optional)]
522    pub password: Option<String>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    #[ts(optional)]
525    pub reconnect_timeout_s: Option<u32>,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    #[ts(optional)]
528    pub table_style: Option<String>,
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    #[ts(optional)]
531    pub draft_config: Option<DraftConfig>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    #[ts(optional)]
534    pub sealed_config: Option<SealedConfig>,
535    pub player_order: Vec<String>,
536    pub player_decks: Vec<PlayerDeckInfo>,
537    pub starting_life: i32,
538    #[serde(default)]
539    pub bot_players: Vec<String>,
540    pub game_id: String,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct RoomInfo {
545    pub room_id: String,
546    pub room_name: String,
547    pub host: String,
548    #[serde(default)]
549    pub protocol_version: u32,
550    #[serde(default)]
551    pub hosted: bool,
552    #[serde(default)]
553    pub official: bool,
554    #[serde(default)]
555    pub password_protected: bool,
556    pub players: Vec<RoomPlayerInfo>,
557    pub max_players: u8,
558    pub format: GameFormat,
559    pub status: RoomStatus,
560    #[serde(default)]
561    pub engine: EngineKind,
562    #[serde(default = "default_reconnect_timeout_s")]
563    pub reconnect_timeout_s: u32,
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub table_style: Option<String>,
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub draft_config: Option<DraftConfig>,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub sealed_config: Option<SealedConfig>,
570}
571
572pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
573
574fn default_reconnect_timeout_s() -> u32 {
575    DEFAULT_RECONNECT_TIMEOUT_S
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
579#[ts(export, export_to = "lobby/index.ts")]
580pub struct SealedConfig {
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    #[ts(optional)]
583    pub set_code: Option<String>,
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    #[ts(optional)]
586    pub cube_id: Option<String>,
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    #[ts(optional)]
589    pub cube_name: Option<String>,
590    #[serde(default)]
591    pub singleton: bool,
592    pub num_boosters: u8,
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    #[ts(optional, type = "number")]
595    pub base_seed: Option<u64>,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
599#[ts(export, export_to = "lobby/index.ts")]
600pub struct DraftConfig {
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    #[ts(optional)]
603    pub set_code: Option<String>,
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    #[ts(optional)]
606    pub cube_id: Option<String>,
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    #[ts(optional)]
609    pub cube_name: Option<String>,
610    pub rounds: u8,
611    pub picks_per_pass: u8,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    #[ts(optional, type = "number")]
614    pub seed: Option<u64>,
615    pub fill_with_bots: bool,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize)]
619pub struct RoomPlayerInfo {
620    pub username: String,
621    pub ready: bool,
622    pub connected: bool,
623    #[serde(default)]
624    pub is_bot: bool,
625    #[serde(skip_serializing_if = "Option::is_none")]
626    pub selected_deck_name: Option<String>,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct PlayerInfo {
631    pub username: String,
632    pub player_id: String,
633    pub connected: bool,
634    #[serde(default)]
635    pub verified: bool,
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub qualification: Option<String>,
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub avatar_url: Option<String>,
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub room_id: Option<String>,
642    /// In a game on their own machine rather than in a relay room. Never set
643    /// at the same time as `room_id`.
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub local_game: Option<LocalGameKind>,
646    /// Opaque to clients: the session's IP sealed together with its handle,
647    /// which only the hub can open. Carried verbatim into chat reports.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub seal: Option<String>,
650}
651
652/// Names [`ClientMessage::SetLocalGame`] in `AuthResult::features`.
653pub const FEATURE_LOCAL_GAME: &str = "local_game";
654/// Names [`ClientMessage::SendChat`] in `AuthResult::features`.
655pub const FEATURE_CHAT: &str = "chat";
656/// Names [`ClientMessage::InviteToRoom`] in `AuthResult::features`.
657pub const FEATURE_ROOM_INVITES: &str = "room_invites";
658/// Names [`ClientMessage::ReportGameOutcome`] in `AuthResult::features`.
659pub const FEATURE_GAME_OUTCOME: &str = "game_outcome";
660
661/// Names [`ClientMessage::AnnounceTransport`] in `AuthResult::features`.
662pub const FEATURE_ROOM_TRANSPORT: &str = "room_transport";
663
664/// Names [`ClientMessage::SignalPeer`] in `AuthResult::features`.
665pub const FEATURE_PEER_SIGNAL: &str = "peer_signal";
666
667/// Names [`ClientMessage::ReportPlaneQuality`] in `AuthResult::features`.
668pub const FEATURE_PLANE_QUALITY: &str = "plane_quality";
669
670pub const FEATURES: &[&str] = &[
671    FEATURE_LOCAL_GAME,
672    FEATURE_ROOM_TRANSPORT,
673    FEATURE_PEER_SIGNAL,
674    FEATURE_PLANE_QUALITY,
675    FEATURE_CHAT,
676    FEATURE_ROOM_INVITES,
677    FEATURE_GAME_OUTCOME,
678];
679
680/// Largest signalling blob the relay forwards.
681pub const MAX_SIGNAL_BYTES: usize = 16 * 1024;
682
683/// Longest `fatal_message` the relay keeps.
684pub const MAX_FATAL_MESSAGE_CHARS: usize = 500;
685
686/// Engine slots (`player-N`), never usernames: the relay holds the seat map
687/// and does not take a name from a client.
688#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
689pub struct GameOutcomeReport {
690    pub game_over: bool,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub winner_slot: Option<String>,
693    #[serde(default, skip_serializing_if = "Vec::is_empty")]
694    pub conceded_slots: Vec<String>,
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub fatal_message: Option<String>,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub turns: Option<u32>,
699}
700
701/// A game running on the player's own machine, which the relay never sees.
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
703pub enum LocalGameKind {
704    /// Play vs AI.
705    Singleplayer,
706}
707
708impl LocalGameKind {
709    /// So the exporter can publish a zero for an unplayed kind rather than
710    /// leaving its series stale at the last value it had.
711    pub const ALL: &'static [Self] = &[Self::Singleplayer];
712
713    pub fn as_str(self) -> &'static str {
714        match self {
715            Self::Singleplayer => "singleplayer",
716        }
717    }
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
721pub enum RoomStatus {
722    Lobby,
723    InGame,
724}