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