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    CreateRoom {
108        room_name: String,
109        max_players: u8,
110        format: GameFormat,
111        #[serde(default)]
112        protocol_version: u32,
113        #[serde(default)]
114        hosted: bool,
115        #[serde(default)]
116        engine: EngineKind,
117        #[serde(default)]
118        draft_config: Option<DraftConfig>,
119        #[serde(default)]
120        sealed_config: Option<SealedConfig>,
121        #[serde(default)]
122        official_key: Option<String>,
123        #[serde(default)]
124        password: Option<String>,
125        #[serde(default)]
126        reconnect_timeout_s: Option<u32>,
127    },
128
129    JoinRoom {
130        room_id: String,
131        #[serde(default)]
132        observe: bool,
133        #[serde(default)]
134        as_bot: bool,
135        #[serde(default)]
136        password: Option<String>,
137    },
138
139    ResumeRoom(ResumeRoomRequest),
140
141    LeaveRoom,
142
143    SetReady {
144        ready: bool,
145    },
146
147    SetDeckSelection {
148        deck_name: String,
149        deck: Deck,
150        #[serde(default)]
151        published_deck_id: Option<String>,
152        commander_name: Option<String>,
153        #[serde(default)]
154        avatar_url: Option<String>,
155    },
156
157    SetFormat {
158        format: GameFormat,
159    },
160
161    SetMaxPlayers {
162        max_players: u8,
163    },
164
165    StartGame {
166        #[serde(default)]
167        format: Option<GameFormat>,
168    },
169
170    EndGame {
171        game_id: String,
172    },
173
174    ReportEngineStats {
175        #[serde(default, skip_serializing_if = "Option::is_none")]
176        game_id: Option<String>,
177        stats: EnginePlayStats,
178    },
179
180    RequestResync,
181
182    BroadcastState {
183        state: serde_json::Value,
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        /// A null value will broadcast to the whole room
186        target_player: Option<String>,
187    },
188
189    TurnChange {
190        new_active_player: String,
191        turn_number: u32,
192    },
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(tag = "type")]
197pub enum ServerMessage {
198    Pong,
199
200    AuthResult {
201        success: bool,
202        player_id: Option<String>,
203        reconnected: Option<bool>,
204        error: Option<String>,
205    },
206
207    SessionTakenOver,
208
209    RoomList {
210        rooms: Vec<RoomInfo>,
211    },
212
213    PlayerList {
214        players: Vec<PlayerInfo>,
215    },
216
217    RoomCreated {
218        room_id: String,
219        room_name: String,
220        room: RoomInfo,
221        #[serde(default)]
222        resume_token: Option<String>,
223    },
224
225    RoomResumed {
226        room: RoomInfo,
227    },
228
229    PlayerJoined {
230        room_id: String,
231        username: String,
232    },
233
234    PlayerLeft {
235        room_id: String,
236        username: String,
237    },
238
239    PlayerConnected {
240        username: String,
241    },
242
243    PlayerDisconnected {
244        username: String,
245    },
246
247    ReadyStateChanged {
248        username: String,
249        ready: bool,
250    },
251
252    RoomUpdate {
253        room: RoomInfo,
254    },
255
256    GameStarted {
257        room_id: String,
258        game_id: String,
259        player_order: Vec<String>,
260        player_decks: Vec<PlayerDeckInfo>,
261        starting_life: i32,
262    },
263
264    StateUpdate {
265        from_player: String,
266        state: serde_json::Value,
267    },
268
269    TurnChanged {
270        from_player: String,
271        new_active_player: String,
272        turn_number: u32,
273    },
274
275    GameAborted {
276        room_id: String,
277    },
278
279    Error {
280        code: String,
281        message: String,
282    },
283
284    ServerShuttingDown {
285        reconnect_in_s: u32,
286    },
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, TS)]
290#[ts(export, export_to = "lobby/index.ts")]
291pub struct ResumeRoomRequest {
292    pub room_id: String,
293    pub resume_token: String,
294    pub room_name: String,
295    pub max_players: u8,
296    pub format: GameFormat,
297    #[serde(default)]
298    pub hosted: bool,
299    #[serde(default)]
300    pub engine: EngineKind,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    #[ts(optional)]
303    pub official_key: Option<String>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    #[ts(optional)]
306    pub password: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    #[ts(optional)]
309    pub reconnect_timeout_s: Option<u32>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    #[ts(optional)]
312    pub draft_config: Option<DraftConfig>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    #[ts(optional)]
315    pub sealed_config: Option<SealedConfig>,
316    pub player_order: Vec<String>,
317    pub player_decks: Vec<PlayerDeckInfo>,
318    pub starting_life: i32,
319    #[serde(default)]
320    pub bot_players: Vec<String>,
321    pub game_id: String,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct RoomInfo {
326    pub room_id: String,
327    pub room_name: String,
328    pub host: String,
329    #[serde(default)]
330    pub protocol_version: u32,
331    #[serde(default)]
332    pub hosted: bool,
333    #[serde(default)]
334    pub official: bool,
335    #[serde(default)]
336    pub password_protected: bool,
337    pub players: Vec<RoomPlayerInfo>,
338    pub max_players: u8,
339    pub format: GameFormat,
340    pub status: RoomStatus,
341    #[serde(default)]
342    pub engine: EngineKind,
343    #[serde(default = "default_reconnect_timeout_s")]
344    pub reconnect_timeout_s: u32,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub draft_config: Option<DraftConfig>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub sealed_config: Option<SealedConfig>,
349}
350
351pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
352
353fn default_reconnect_timeout_s() -> u32 {
354    DEFAULT_RECONNECT_TIMEOUT_S
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
358#[ts(export, export_to = "lobby/index.ts")]
359pub struct SealedConfig {
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    #[ts(optional)]
362    pub set_code: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    #[ts(optional)]
365    pub cube_id: Option<String>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    #[ts(optional)]
368    pub cube_name: Option<String>,
369    #[serde(default)]
370    pub singleton: bool,
371    pub num_boosters: u8,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    #[ts(optional, type = "number")]
374    pub base_seed: Option<u64>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
378#[ts(export, export_to = "lobby/index.ts")]
379pub struct DraftConfig {
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    #[ts(optional)]
382    pub set_code: Option<String>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    #[ts(optional)]
385    pub cube_id: Option<String>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    #[ts(optional)]
388    pub cube_name: Option<String>,
389    pub rounds: u8,
390    pub picks_per_pass: u8,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    #[ts(optional, type = "number")]
393    pub seed: Option<u64>,
394    pub fill_with_bots: bool,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct RoomPlayerInfo {
399    pub username: String,
400    pub ready: bool,
401    pub connected: bool,
402    #[serde(default)]
403    pub is_bot: bool,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub selected_deck_name: Option<String>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct PlayerInfo {
410    pub username: String,
411    pub player_id: String,
412    pub connected: bool,
413    #[serde(default)]
414    pub verified: bool,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub qualification: Option<String>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub room_id: Option<String>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
422pub enum RoomStatus {
423    Lobby,
424    InGame,
425}