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#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(tag = "type")]
79#[allow(clippy::large_enum_variant)]
80pub enum ClientMessage {
81    Authenticate {
82        /// Legacy name field, read only when `identity` carries no token.
83        /// The session name is the `handle` claim of `identity.token`
84        /// ([`identity_token`]); remove this field in the next major.
85        #[serde(default)]
86        username: String,
87        password: String,
88        #[serde(default)]
89        service: bool,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        identity: Option<IdentityProof>,
92        #[serde(default)]
93        client_platform: ClientPlatform,
94        /// The client's app version (`APP_VERSION`), when it sends one. Absent
95        /// means a build older than the one that started reporting it, which is
96        /// how the relay decides what wire features that seat can handle.
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        client_version: Option<String>,
99    },
100
101    Ping,
102
103    ListRooms,
104
105    ListPlayers,
106
107    /// The session entered or left a game running on its own machine. `None`
108    /// clears it. Such a game opens no room and sends no state, so the relay
109    /// has no other way to know it is happening.
110    SetLocalGame {
111        #[serde(default)]
112        kind: Option<LocalGameKind>,
113    },
114
115    CreateRoom {
116        room_name: String,
117        max_players: u8,
118        format: GameFormat,
119        #[serde(default)]
120        protocol_version: u32,
121        #[serde(default)]
122        hosted: bool,
123        #[serde(default)]
124        engine: EngineKind,
125        #[serde(default)]
126        draft_config: Option<DraftConfig>,
127        #[serde(default)]
128        sealed_config: Option<SealedConfig>,
129        #[serde(default)]
130        official_key: Option<String>,
131        #[serde(default)]
132        password: Option<String>,
133        #[serde(default)]
134        reconnect_timeout_s: Option<u32>,
135    },
136
137    JoinRoom {
138        room_id: String,
139        #[serde(default)]
140        observe: bool,
141        #[serde(default)]
142        as_bot: bool,
143        #[serde(default)]
144        password: Option<String>,
145    },
146
147    ResumeRoom(ResumeRoomRequest),
148
149    LeaveRoom,
150
151    SetReady {
152        ready: bool,
153    },
154
155    SetDeckSelection {
156        deck_name: String,
157        deck: Deck,
158        #[serde(default)]
159        published_deck_id: Option<String>,
160        commander_name: Option<String>,
161        #[serde(default)]
162        avatar_url: Option<String>,
163    },
164
165    SetFormat {
166        format: GameFormat,
167    },
168
169    SetMaxPlayers {
170        max_players: u8,
171    },
172
173    StartGame {
174        #[serde(default)]
175        format: Option<GameFormat>,
176    },
177
178    EndGame {
179        game_id: String,
180    },
181
182    ReportEngineStats {
183        #[serde(default, skip_serializing_if = "Option::is_none")]
184        game_id: Option<String>,
185        stats: EnginePlayStats,
186    },
187
188    RequestResync,
189
190    BroadcastState {
191        state: serde_json::Value,
192        #[serde(default, skip_serializing_if = "Option::is_none")]
193        /// A null value will broadcast to the whole room
194        target_player: Option<String>,
195    },
196
197    TurnChange {
198        new_active_player: String,
199        turn_number: u32,
200    },
201
202    SendChat {
203        scope: ChatScope,
204        text: String,
205    },
206
207    InviteToRoom {
208        username: String,
209    },
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type")]
214pub enum ServerMessage {
215    Pong,
216
217    AuthResult {
218        success: bool,
219        player_id: Option<String>,
220        reconnected: Option<bool>,
221        error: Option<String>,
222        /// Wire features this relay understands, so a newer client can stay
223        /// quiet instead of sending a message an older one would reject with
224        /// a parse error. Absent from relays built before the list existed.
225        #[serde(default, skip_serializing_if = "Vec::is_empty")]
226        features: Vec<String>,
227        /// Where this relay serves card art, when it serves any. A self-hosted
228        /// box holding the images is the reason to run one, and the client
229        /// cannot guess the port. Absent means fall back to the CDN.
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        art_base_url: Option<String>,
232    },
233
234    SessionTakenOver,
235
236    RoomList {
237        rooms: Vec<RoomInfo>,
238    },
239
240    PlayerList {
241        players: Vec<PlayerInfo>,
242    },
243
244    RoomCreated {
245        room_id: String,
246        room_name: String,
247        room: RoomInfo,
248        #[serde(default)]
249        resume_token: Option<String>,
250    },
251
252    RoomResumed {
253        room: RoomInfo,
254    },
255
256    PlayerJoined {
257        room_id: String,
258        username: String,
259    },
260
261    PlayerLeft {
262        room_id: String,
263        username: String,
264    },
265
266    PlayerConnected {
267        username: String,
268    },
269
270    PlayerDisconnected {
271        username: String,
272    },
273
274    ReadyStateChanged {
275        username: String,
276        ready: bool,
277    },
278
279    RoomUpdate {
280        room: RoomInfo,
281    },
282
283    GameStarted {
284        room_id: String,
285        game_id: String,
286        player_order: Vec<String>,
287        player_decks: Vec<PlayerDeckInfo>,
288        starting_life: i32,
289    },
290
291    StateUpdate {
292        from_player: String,
293        state: serde_json::Value,
294    },
295
296    TurnChanged {
297        from_player: String,
298        new_active_player: String,
299        turn_number: u32,
300    },
301
302    GameAborted {
303        room_id: String,
304    },
305
306    Error {
307        code: String,
308        message: String,
309    },
310
311    ServerShuttingDown {
312        reconnect_in_s: u32,
313    },
314
315    ChatMessage(ChatMessage),
316
317    ChatHistory {
318        scope: ChatScope,
319        #[serde(default, skip_serializing_if = "Option::is_none")]
320        room_id: Option<String>,
321        messages: Vec<ChatMessage>,
322    },
323
324    RoomInvite {
325        from: String,
326        room: RoomInfo,
327        #[serde(default, skip_serializing_if = "Option::is_none")]
328        password: Option<String>,
329    },
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333pub enum ChatScope {
334    Lobby,
335    Room,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct ChatMessage {
340    pub scope: ChatScope,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub room_id: Option<String>,
343    pub from: String,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub avatar_url: Option<String>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub qualification: Option<String>,
348    pub text: String,
349    pub sent_at_ms: u64,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub seal: Option<String>,
352}
353
354pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
355pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
356pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
357pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
358
359#[derive(Debug, Clone, Serialize, Deserialize, TS)]
360#[ts(export, export_to = "lobby/index.ts")]
361pub struct ResumeRoomRequest {
362    pub room_id: String,
363    pub resume_token: String,
364    pub room_name: String,
365    pub max_players: u8,
366    pub format: GameFormat,
367    #[serde(default)]
368    pub hosted: bool,
369    #[serde(default)]
370    pub engine: EngineKind,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    #[ts(optional)]
373    pub official_key: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    #[ts(optional)]
376    pub password: Option<String>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    #[ts(optional)]
379    pub reconnect_timeout_s: Option<u32>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    #[ts(optional)]
382    pub draft_config: Option<DraftConfig>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    #[ts(optional)]
385    pub sealed_config: Option<SealedConfig>,
386    pub player_order: Vec<String>,
387    pub player_decks: Vec<PlayerDeckInfo>,
388    pub starting_life: i32,
389    #[serde(default)]
390    pub bot_players: Vec<String>,
391    pub game_id: String,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct RoomInfo {
396    pub room_id: String,
397    pub room_name: String,
398    pub host: String,
399    #[serde(default)]
400    pub protocol_version: u32,
401    #[serde(default)]
402    pub hosted: bool,
403    #[serde(default)]
404    pub official: bool,
405    #[serde(default)]
406    pub password_protected: bool,
407    pub players: Vec<RoomPlayerInfo>,
408    pub max_players: u8,
409    pub format: GameFormat,
410    pub status: RoomStatus,
411    #[serde(default)]
412    pub engine: EngineKind,
413    #[serde(default = "default_reconnect_timeout_s")]
414    pub reconnect_timeout_s: u32,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub draft_config: Option<DraftConfig>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub sealed_config: Option<SealedConfig>,
419}
420
421pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
422
423fn default_reconnect_timeout_s() -> u32 {
424    DEFAULT_RECONNECT_TIMEOUT_S
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
428#[ts(export, export_to = "lobby/index.ts")]
429pub struct SealedConfig {
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    #[ts(optional)]
432    pub set_code: Option<String>,
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    #[ts(optional)]
435    pub cube_id: Option<String>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    #[ts(optional)]
438    pub cube_name: Option<String>,
439    #[serde(default)]
440    pub singleton: bool,
441    pub num_boosters: u8,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    #[ts(optional, type = "number")]
444    pub base_seed: Option<u64>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
448#[ts(export, export_to = "lobby/index.ts")]
449pub struct DraftConfig {
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    #[ts(optional)]
452    pub set_code: Option<String>,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    #[ts(optional)]
455    pub cube_id: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    #[ts(optional)]
458    pub cube_name: Option<String>,
459    pub rounds: u8,
460    pub picks_per_pass: u8,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    #[ts(optional, type = "number")]
463    pub seed: Option<u64>,
464    pub fill_with_bots: bool,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct RoomPlayerInfo {
469    pub username: String,
470    pub ready: bool,
471    pub connected: bool,
472    #[serde(default)]
473    pub is_bot: bool,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub selected_deck_name: Option<String>,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct PlayerInfo {
480    pub username: String,
481    pub player_id: String,
482    pub connected: bool,
483    #[serde(default)]
484    pub verified: bool,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub qualification: Option<String>,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub avatar_url: Option<String>,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub room_id: Option<String>,
491    /// In a game on their own machine rather than in a relay room. Never set
492    /// at the same time as `room_id`.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub local_game: Option<LocalGameKind>,
495    /// Opaque to clients: the session's IP sealed together with its handle,
496    /// which only the hub can open. Carried verbatim into chat reports.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub seal: Option<String>,
499}
500
501/// Names [`ClientMessage::SetLocalGame`] in `AuthResult::features`.
502pub const FEATURE_LOCAL_GAME: &str = "local_game";
503/// Names [`ClientMessage::SendChat`] in `AuthResult::features`.
504pub const FEATURE_CHAT: &str = "chat";
505/// Names [`ClientMessage::InviteToRoom`] in `AuthResult::features`.
506pub const FEATURE_ROOM_INVITES: &str = "room_invites";
507
508pub const FEATURES: &[&str] = &[FEATURE_LOCAL_GAME, FEATURE_CHAT, FEATURE_ROOM_INVITES];
509
510/// A game running on the player's own machine, which the relay never sees.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512pub enum LocalGameKind {
513    /// Play vs AI.
514    Singleplayer,
515}
516
517impl LocalGameKind {
518    /// So the exporter can publish a zero for an unplayed kind rather than
519    /// leaving its series stale at the last value it had.
520    pub const ALL: &'static [Self] = &[Self::Singleplayer];
521
522    pub fn as_str(self) -> &'static str {
523        match self {
524            Self::Singleplayer => "singleplayer",
525        }
526    }
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530pub enum RoomStatus {
531    Lobby,
532    InGame,
533}