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