manabrew-relay-protocol 5.17.2

Relay wire protocol for manabrew multiplayer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
//! Relay wire protocol: the session handshake, lobby management and game
//! transport envelopes exchanged between clients and `manabrew-server`.
//! Engine and UI DTOs live in `manabrew-protocol`.
pub mod identity_token;
pub mod state_delta;

pub use manabrew_protocol::deck_dto::Deck;
pub use manabrew_protocol::game::{EngineKind, GameFormat};
pub use manabrew_protocol::telemetry::EnginePlayStats;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

// The wire-compat number IS the crate major: a breaking wire change must ship
// as a breaking (major) release of this crate, never as a separate constant.
pub const PROTOCOL_VERSION: u32 = major_of(env!("CARGO_PKG_VERSION_MAJOR"));

const fn major_of(major: &str) -> u32 {
    let bytes = major.as_bytes();
    let mut value = 0u32;
    let mut i = 0;
    while i < bytes.len() {
        value = value * 10 + (bytes[i] - b'0') as u32;
        i += 1;
    }
    value
}

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "lobby/index.ts")]
pub struct PlayerDeckInfo {
    pub username: String,
    pub deck_name: String,
    pub deck: Deck,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub published_deck_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub commander_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub avatar_url: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentityProof {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub device: Option<String>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "lobby/index.ts")]
pub enum ClientPlatform {
    Web,
    Pwa,
    Desktop,
    Mobile,
    #[default]
    Unknown,
}

/// Why the client is or is not running the engine in its own browser. The
/// relay never acts on it; it exists so the hosted-node population can be told
/// apart from the browsers that chose not to use it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "lobby/index.ts")]
pub enum EngineGate {
    InBrowser,
    Ios,
    Isolation,
    TrialTimeout,
    TrialFailed,
    Disabled,
    #[default]
    Unknown,
}

impl EngineGate {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::InBrowser => "in_browser",
            Self::Ios => "ios",
            Self::Isolation => "isolation",
            Self::TrialTimeout => "trial_timeout",
            Self::TrialFailed => "trial_failed",
            Self::Disabled => "disabled",
            Self::Unknown => "unknown",
        }
    }
}

impl ClientPlatform {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Web => "web",
            Self::Pwa => "pwa",
            Self::Desktop => "desktop",
            Self::Mobile => "mobile",
            Self::Unknown => "unknown",
        }
    }
}

/// A peer's addressing information for the direct data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransportEndpoint {
    pub endpoint_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relay_url: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub direct_addrs: Vec<String>,
    /// Data planes this peer speaks, most preferred first. Seats follow the host's.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub kinds: Vec<String>,
}

impl TransportEndpoint {
    /// Whether this peer advertises a plane.
    pub fn speaks(&self, kind: &str) -> bool {
        self.kinds.iter().any(|k| k == kind)
    }
}

/// An ICE server, shaped like the `RTCIceServer` dictionary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IceServer {
    pub urls: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credential: Option<String>,
}

/// How one seat's game traffic travelled, reported by the room's engine host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeatTransportReport {
    pub username: String,
    /// One of the `TRANSPORT_*` constants.
    pub transport: String,
}

/// One end's account of one direct-plane attempt. Every field is a client claim.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlaneQualityReport {
    /// The other end's relay-attested username.
    pub peer: String,
    /// One of [`PLANE_OUTCOMES`]; anything else is dropped.
    pub outcome: String,
    /// The plane attempted, a `TRANSPORT_*` constant.
    pub plane: String,
    /// [`PLANE_PHASE_SETTLED`] once per attempt, then [`PLANE_PHASE_MEASURED`].
    pub phase: String,
    /// First offer to open channel.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub connect_ms: Option<u32>,
    /// Median round trip measured on the channel itself.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rtt_ms: Option<u32>,
    /// The same session's relay round trip, sampled at the same time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relay_rtt_ms: Option<u32>,
    /// The winning ICE pair as `local/remote`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub candidate_pair: Option<String>,
}

pub const PLANE_PHASE_SETTLED: &str = "settled";
pub const PLANE_PHASE_MEASURED: &str = "measured";

pub const PLANE_OUTCOME_CONNECTED: &str = "connected";
pub const PLANE_OUTCOME_FAILED: &str = "failed";
pub const PLANE_OUTCOME_TIMEOUT: &str = "timeout";

/// Outcomes the relay records; anything else is dropped.
pub const PLANE_OUTCOMES: &[&str] = &[
    PLANE_OUTCOME_CONNECTED,
    PLANE_OUTCOME_FAILED,
    PLANE_OUTCOME_TIMEOUT,
];

/// Longest `candidate_pair` the relay records.
pub const MAX_CANDIDATE_PAIR_BYTES: usize = 64;

/// Longest duration the relay records; ten minutes.
pub const MAX_PLANE_MS: u32 = 600_000;

/// A browser pair on an `RTCDataChannel`.
pub const TRANSPORT_WEBRTC: &str = "webrtc";

/// Names for [`TransportEndpoint::kinds`].
pub const TRANSPORT_KIND_WEBRTC: &str = "webrtc";

/// One room member's endpoint. `username` is relay-attested, never client supplied.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransportMember {
    pub username: String,
    pub endpoint: TransportEndpoint,
    #[serde(default)]
    pub host: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
#[allow(clippy::large_enum_variant)]
pub enum ClientMessage {
    Authenticate {
        /// Legacy name field, read only when `identity` carries no token.
        /// The session name is the `handle` claim of `identity.token`
        /// ([`identity_token`]); remove this field in the next major.
        #[serde(default)]
        username: String,
        password: String,
        #[serde(default)]
        service: bool,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        identity: Option<IdentityProof>,
        #[serde(default)]
        client_platform: ClientPlatform,
        /// The client's app version (`APP_VERSION`), when it sends one. Absent
        /// means a build older than the one that started reporting it, which is
        /// how the relay decides what wire features that seat can handle.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        client_version: Option<String>,
        #[serde(default)]
        engine_gate: EngineGate,
    },

    Ping,

    ListRooms,

    ListPlayers,

    /// The session entered or left a game running on its own machine. `None`
    /// clears it. Such a game opens no room and sends no state, so the relay
    /// has no other way to know it is happening.
    SetLocalGame {
        #[serde(default)]
        kind: Option<LocalGameKind>,
    },

    CreateRoom {
        room_name: String,
        max_players: u8,
        format: GameFormat,
        #[serde(default)]
        protocol_version: u32,
        #[serde(default)]
        hosted: bool,
        #[serde(default)]
        engine: EngineKind,
        #[serde(default)]
        draft_config: Option<DraftConfig>,
        #[serde(default)]
        sealed_config: Option<SealedConfig>,
        #[serde(default)]
        official_key: Option<String>,
        #[serde(default)]
        password: Option<String>,
        #[serde(default)]
        reconnect_timeout_s: Option<u32>,
        #[serde(default)]
        table_style: Option<String>,
    },

    JoinRoom {
        room_id: String,
        #[serde(default)]
        observe: bool,
        #[serde(default)]
        as_bot: bool,
        #[serde(default)]
        password: Option<String>,
    },

    ResumeRoom(ResumeRoomRequest),

    LeaveRoom,

    SetReady {
        ready: bool,
    },

    SetDeckSelection {
        deck_name: String,
        deck: Deck,
        #[serde(default)]
        published_deck_id: Option<String>,
        commander_name: Option<String>,
        #[serde(default)]
        avatar_url: Option<String>,
    },

    SetFormat {
        format: GameFormat,
    },

    SetMaxPlayers {
        max_players: u8,
    },

    StartGame {
        #[serde(default)]
        format: Option<GameFormat>,
    },

    EndGame {
        game_id: String,
    },

    ReportEngineStats {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        game_id: Option<String>,
        stats: EnginePlayStats,
    },

    /// How the game ended, from the seat that ran the engine. The relay
    /// stopped reading it out of the state stream, which need not pass
    /// through it any more.
    ReportGameOutcome {
        game_id: String,
        outcome: GameOutcomeReport,
    },

    RequestResync,

    BroadcastState {
        state: serde_json::Value,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        /// A null value will broadcast to the whole room
        target_player: Option<String>,
    },

    TurnChange {
        new_active_player: String,
        turn_number: u32,
    },
    /// Which transport each seat's traffic took for this game. Analytics only.
    ReportTransport {
        game_id: String,
        seats: Vec<SeatTransportReport>,
    },

    /// Publishes, or with `None` withdraws, this session's data-plane endpoint.
    AnnounceTransport {
        #[serde(default)]
        endpoint: Option<TransportEndpoint>,
    },

    /// An opaque signalling blob for a room member, named by username.
    SignalPeer {
        to: String,
        payload: serde_json::Value,
    },

    /// How one direct-plane attempt turned out, sent by the seat. Analytics only.
    ReportPlaneQuality {
        report: PlaneQualityReport,
    },

    SendChat {
        scope: ChatScope,
        text: String,
    },

    InviteToRoom {
        username: String,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ServerMessage {
    Pong,

    AuthResult {
        success: bool,
        player_id: Option<String>,
        reconnected: Option<bool>,
        error: Option<String>,
        /// Wire features this relay understands, so a newer client can stay
        /// quiet instead of sending a message an older one would reject with
        /// a parse error. Absent from relays built before the list existed.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        features: Vec<String>,
        /// Where this relay serves card art, when it serves any. A self-hosted
        /// box holding the images is the reason to run one, and the client
        /// cannot guess the port. Absent means fall back to the CDN.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        art_base_url: Option<String>,
    },

    SessionTakenOver,

    RoomList {
        rooms: Vec<RoomInfo>,
    },

    PlayerList {
        players: Vec<PlayerInfo>,
    },

    RoomCreated {
        room_id: String,
        room_name: String,
        room: RoomInfo,
        #[serde(default)]
        resume_token: Option<String>,
    },

    RoomResumed {
        room: RoomInfo,
    },

    PlayerJoined {
        room_id: String,
        username: String,
    },

    PlayerLeft {
        room_id: String,
        username: String,
    },

    PlayerConnected {
        username: String,
    },

    PlayerDisconnected {
        username: String,
    },

    ReadyStateChanged {
        username: String,
        ready: bool,
    },

    RoomUpdate {
        room: RoomInfo,
    },

    GameStarted {
        room_id: String,
        game_id: String,
        player_order: Vec<String>,
        player_decks: Vec<PlayerDeckInfo>,
        starting_life: i32,
    },

    StateUpdate {
        from_player: String,
        state: serde_json::Value,
    },

    TurnChanged {
        from_player: String,
        new_active_player: String,
        turn_number: u32,
    },

    GameAborted {
        room_id: String,
    },

    Error {
        code: String,
        message: String,
    },

    ServerShuttingDown {
        reconnect_in_s: u32,
    },
    /// The room's data-plane roster. Sent only to room members.
    RoomTransport {
        room_id: String,
        /// STUN, and TURN where configured, for the WebRTC plane.
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        ice_servers: Vec<IceServer>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        host: Option<TransportMember>,
        members: Vec<TransportMember>,
    },

    /// A signalling blob from a room member. `from` is relay-attested.
    PeerSignal {
        from: String,
        payload: serde_json::Value,
    },

    ChatMessage(ChatMessage),

    ChatHistory {
        scope: ChatScope,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        room_id: Option<String>,
        messages: Vec<ChatMessage>,
    },

    RoomInvite {
        from: String,
        room: RoomInfo,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        password: Option<String>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatScope {
    Lobby,
    Room,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
    pub scope: ChatScope,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub room_id: Option<String>,
    pub from: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub avatar_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub qualification: Option<String>,
    pub text: String,
    pub sent_at_ms: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seal: Option<String>,
}

pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
pub const CHAT_MIN_INTERVAL_MS: u64 = 400;

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "lobby/index.ts")]
pub struct ResumeRoomRequest {
    pub room_id: String,
    pub resume_token: String,
    pub room_name: String,
    pub max_players: u8,
    pub format: GameFormat,
    #[serde(default)]
    pub hosted: bool,
    #[serde(default)]
    pub engine: EngineKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub official_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub password: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub reconnect_timeout_s: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub table_style: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub draft_config: Option<DraftConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub sealed_config: Option<SealedConfig>,
    pub player_order: Vec<String>,
    pub player_decks: Vec<PlayerDeckInfo>,
    pub starting_life: i32,
    #[serde(default)]
    pub bot_players: Vec<String>,
    pub game_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomInfo {
    pub room_id: String,
    pub room_name: String,
    pub host: String,
    #[serde(default)]
    pub protocol_version: u32,
    #[serde(default)]
    pub hosted: bool,
    #[serde(default)]
    pub official: bool,
    #[serde(default)]
    pub password_protected: bool,
    pub players: Vec<RoomPlayerInfo>,
    pub max_players: u8,
    pub format: GameFormat,
    pub status: RoomStatus,
    #[serde(default)]
    pub engine: EngineKind,
    #[serde(default = "default_reconnect_timeout_s")]
    pub reconnect_timeout_s: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub table_style: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub draft_config: Option<DraftConfig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sealed_config: Option<SealedConfig>,
}

pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;

fn default_reconnect_timeout_s() -> u32 {
    DEFAULT_RECONNECT_TIMEOUT_S
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "lobby/index.ts")]
pub struct SealedConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub set_code: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub cube_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub cube_name: Option<String>,
    #[serde(default)]
    pub singleton: bool,
    pub num_boosters: u8,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional, type = "number")]
    pub base_seed: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "lobby/index.ts")]
pub struct DraftConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub set_code: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub cube_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub cube_name: Option<String>,
    pub rounds: u8,
    pub picks_per_pass: u8,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub pick_seconds: Option<u16>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional, type = "number")]
    pub seed: Option<u64>,
    pub fill_with_bots: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomPlayerInfo {
    pub username: String,
    pub ready: bool,
    pub connected: bool,
    #[serde(default)]
    pub is_bot: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected_deck_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerInfo {
    pub username: String,
    pub player_id: String,
    pub connected: bool,
    #[serde(default)]
    pub verified: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub qualification: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub avatar_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub room_id: Option<String>,
    /// In a game on their own machine rather than in a relay room. Never set
    /// at the same time as `room_id`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub local_game: Option<LocalGameKind>,
    /// Opaque to clients: the session's IP sealed together with its handle,
    /// which only the hub can open. Carried verbatim into chat reports.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seal: Option<String>,
}

/// Names [`ClientMessage::SetLocalGame`] in `AuthResult::features`.
pub const FEATURE_LOCAL_GAME: &str = "local_game";
/// Names [`ClientMessage::SendChat`] in `AuthResult::features`.
pub const FEATURE_CHAT: &str = "chat";
/// Names [`ClientMessage::InviteToRoom`] in `AuthResult::features`.
pub const FEATURE_ROOM_INVITES: &str = "room_invites";
/// Names [`ClientMessage::ReportGameOutcome`] in `AuthResult::features`.
pub const FEATURE_GAME_OUTCOME: &str = "game_outcome";
pub const FEATURE_LIMITED_SESSIONS: &str = "limited_sessions";
pub const FEATURE_LIMITED_DRAFT_CLOCKS: &str = "limited_draft_clocks";
pub const FEATURE_LIMITED_SESSION_RECOVERY: &str = "limited_session_recovery";

/// Names [`ClientMessage::AnnounceTransport`] in `AuthResult::features`.
pub const FEATURE_ROOM_TRANSPORT: &str = "room_transport";

/// Names [`ClientMessage::SignalPeer`] in `AuthResult::features`.
pub const FEATURE_PEER_SIGNAL: &str = "peer_signal";

/// Names [`ClientMessage::ReportPlaneQuality`] in `AuthResult::features`.
pub const FEATURE_PLANE_QUALITY: &str = "plane_quality";

pub const FEATURE_CARD_COSMETICS: &str = "card_cosmetics";

pub const FEATURES: &[&str] = &[
    FEATURE_LOCAL_GAME,
    FEATURE_ROOM_TRANSPORT,
    FEATURE_PEER_SIGNAL,
    FEATURE_PLANE_QUALITY,
    FEATURE_CHAT,
    FEATURE_ROOM_INVITES,
    FEATURE_GAME_OUTCOME,
    FEATURE_CARD_COSMETICS,
    FEATURE_LIMITED_SESSIONS,
    FEATURE_LIMITED_DRAFT_CLOCKS,
    FEATURE_LIMITED_SESSION_RECOVERY,
];

/// Largest signalling blob the relay forwards.
pub const MAX_SIGNAL_BYTES: usize = 16 * 1024;

/// Longest `fatal_message` the relay keeps.
pub const MAX_FATAL_MESSAGE_CHARS: usize = 500;

/// Engine slots (`player-N`), never usernames: the relay holds the seat map
/// and does not take a name from a client.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameOutcomeReport {
    pub game_over: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub winner_slot: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub conceded_slots: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fatal_message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub turns: Option<u32>,
}

/// A game running on the player's own machine, which the relay never sees.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LocalGameKind {
    /// Play vs AI.
    Singleplayer,
}

impl LocalGameKind {
    /// So the exporter can publish a zero for an unplayed kind rather than
    /// leaving its series stale at the last value it had.
    pub const ALL: &'static [Self] = &[Self::Singleplayer];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Singleplayer => "singleplayer",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum RoomStatus {
    Lobby,
    InGame,
}