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
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "type")]
205pub enum ServerMessage {
206    Pong,
207
208    AuthResult {
209        success: bool,
210        player_id: Option<String>,
211        reconnected: Option<bool>,
212        error: Option<String>,
213        /// Wire features this relay understands, so a newer client can stay
214        /// quiet instead of sending a message an older one would reject with
215        /// a parse error. Absent from relays built before the list existed.
216        #[serde(default, skip_serializing_if = "Vec::is_empty")]
217        features: Vec<String>,
218        /// Where this relay serves card art, when it serves any. A self-hosted
219        /// box holding the images is the reason to run one, and the client
220        /// cannot guess the port. Absent means fall back to the CDN.
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        art_base_url: Option<String>,
223    },
224
225    SessionTakenOver,
226
227    RoomList {
228        rooms: Vec<RoomInfo>,
229    },
230
231    PlayerList {
232        players: Vec<PlayerInfo>,
233    },
234
235    RoomCreated {
236        room_id: String,
237        room_name: String,
238        room: RoomInfo,
239        #[serde(default)]
240        resume_token: Option<String>,
241    },
242
243    RoomResumed {
244        room: RoomInfo,
245    },
246
247    PlayerJoined {
248        room_id: String,
249        username: String,
250    },
251
252    PlayerLeft {
253        room_id: String,
254        username: String,
255    },
256
257    PlayerConnected {
258        username: String,
259    },
260
261    PlayerDisconnected {
262        username: String,
263    },
264
265    ReadyStateChanged {
266        username: String,
267        ready: bool,
268    },
269
270    RoomUpdate {
271        room: RoomInfo,
272    },
273
274    GameStarted {
275        room_id: String,
276        game_id: String,
277        player_order: Vec<String>,
278        player_decks: Vec<PlayerDeckInfo>,
279        starting_life: i32,
280    },
281
282    StateUpdate {
283        from_player: String,
284        state: serde_json::Value,
285    },
286
287    TurnChanged {
288        from_player: String,
289        new_active_player: String,
290        turn_number: u32,
291    },
292
293    GameAborted {
294        room_id: String,
295    },
296
297    Error {
298        code: String,
299        message: String,
300    },
301
302    ServerShuttingDown {
303        reconnect_in_s: u32,
304    },
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, TS)]
308#[ts(export, export_to = "lobby/index.ts")]
309pub struct ResumeRoomRequest {
310    pub room_id: String,
311    pub resume_token: String,
312    pub room_name: String,
313    pub max_players: u8,
314    pub format: GameFormat,
315    #[serde(default)]
316    pub hosted: bool,
317    #[serde(default)]
318    pub engine: EngineKind,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    #[ts(optional)]
321    pub official_key: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    #[ts(optional)]
324    pub password: Option<String>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    #[ts(optional)]
327    pub reconnect_timeout_s: Option<u32>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    #[ts(optional)]
330    pub draft_config: Option<DraftConfig>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    #[ts(optional)]
333    pub sealed_config: Option<SealedConfig>,
334    pub player_order: Vec<String>,
335    pub player_decks: Vec<PlayerDeckInfo>,
336    pub starting_life: i32,
337    #[serde(default)]
338    pub bot_players: Vec<String>,
339    pub game_id: String,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct RoomInfo {
344    pub room_id: String,
345    pub room_name: String,
346    pub host: String,
347    #[serde(default)]
348    pub protocol_version: u32,
349    #[serde(default)]
350    pub hosted: bool,
351    #[serde(default)]
352    pub official: bool,
353    #[serde(default)]
354    pub password_protected: bool,
355    pub players: Vec<RoomPlayerInfo>,
356    pub max_players: u8,
357    pub format: GameFormat,
358    pub status: RoomStatus,
359    #[serde(default)]
360    pub engine: EngineKind,
361    #[serde(default = "default_reconnect_timeout_s")]
362    pub reconnect_timeout_s: u32,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub draft_config: Option<DraftConfig>,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub sealed_config: Option<SealedConfig>,
367}
368
369pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
370
371fn default_reconnect_timeout_s() -> u32 {
372    DEFAULT_RECONNECT_TIMEOUT_S
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
376#[ts(export, export_to = "lobby/index.ts")]
377pub struct SealedConfig {
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    #[ts(optional)]
380    pub set_code: Option<String>,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    #[ts(optional)]
383    pub cube_id: Option<String>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    #[ts(optional)]
386    pub cube_name: Option<String>,
387    #[serde(default)]
388    pub singleton: bool,
389    pub num_boosters: u8,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    #[ts(optional, type = "number")]
392    pub base_seed: Option<u64>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
396#[ts(export, export_to = "lobby/index.ts")]
397pub struct DraftConfig {
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    #[ts(optional)]
400    pub set_code: Option<String>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    #[ts(optional)]
403    pub cube_id: Option<String>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    #[ts(optional)]
406    pub cube_name: Option<String>,
407    pub rounds: u8,
408    pub picks_per_pass: u8,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    #[ts(optional, type = "number")]
411    pub seed: Option<u64>,
412    pub fill_with_bots: bool,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct RoomPlayerInfo {
417    pub username: String,
418    pub ready: bool,
419    pub connected: bool,
420    #[serde(default)]
421    pub is_bot: bool,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub selected_deck_name: Option<String>,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct PlayerInfo {
428    pub username: String,
429    pub player_id: String,
430    pub connected: bool,
431    #[serde(default)]
432    pub verified: bool,
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub qualification: Option<String>,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub avatar_url: Option<String>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub room_id: Option<String>,
439    /// In a game on their own machine rather than in a relay room. Never set
440    /// at the same time as `room_id`.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub local_game: Option<LocalGameKind>,
443}
444
445/// Names [`ClientMessage::SetLocalGame`] in `AuthResult::features`.
446pub const FEATURE_LOCAL_GAME: &str = "local_game";
447
448pub const FEATURES: &[&str] = &[FEATURE_LOCAL_GAME];
449
450/// A game running on the player's own machine, which the relay never sees.
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
452pub enum LocalGameKind {
453    /// Play vs AI.
454    Singleplayer,
455}
456
457impl LocalGameKind {
458    /// So the exporter can publish a zero for an unplayed kind rather than
459    /// leaving its series stale at the last value it had.
460    pub const ALL: &'static [Self] = &[Self::Singleplayer];
461
462    pub fn as_str(self) -> &'static str {
463        match self {
464            Self::Singleplayer => "singleplayer",
465        }
466    }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
470pub enum RoomStatus {
471    Lobby,
472    InGame,
473}