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