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};
9use serde::{Deserialize, Serialize};
10use ts_rs::TS;
11
12// The wire-compat number IS the crate major: a breaking wire change must ship
13// as a breaking (major) release of this crate, never as a separate constant.
14pub const PROTOCOL_VERSION: u32 = major_of(env!("CARGO_PKG_VERSION_MAJOR"));
15
16const fn major_of(major: &str) -> u32 {
17    let bytes = major.as_bytes();
18    let mut value = 0u32;
19    let mut i = 0;
20    while i < bytes.len() {
21        value = value * 10 + (bytes[i] - b'0') as u32;
22        i += 1;
23    }
24    value
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, TS)]
28#[ts(export, export_to = "lobby/index.ts")]
29pub struct PlayerDeckInfo {
30    pub username: String,
31    pub deck_name: String,
32    pub deck: Deck,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    #[ts(optional)]
35    pub published_deck_id: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    #[ts(optional)]
38    pub commander_name: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    #[ts(optional)]
41    pub avatar: Option<String>,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct IdentityProof {
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub token: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub device: Option<String>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
53#[serde(rename_all = "snake_case")]
54#[ts(export, export_to = "lobby/index.ts")]
55pub enum ClientPlatform {
56    Web,
57    Pwa,
58    Desktop,
59    Mobile,
60    #[default]
61    Unknown,
62}
63
64impl ClientPlatform {
65    pub const fn as_str(self) -> &'static str {
66        match self {
67            Self::Web => "web",
68            Self::Pwa => "pwa",
69            Self::Desktop => "desktop",
70            Self::Mobile => "mobile",
71            Self::Unknown => "unknown",
72        }
73    }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(tag = "type")]
78#[allow(clippy::large_enum_variant)]
79pub enum ClientMessage {
80    Authenticate {
81        /// Legacy name field, read only when `identity` carries no token.
82        /// The session name is the `handle` claim of `identity.token`
83        /// ([`identity_token`]); remove this field in the next major.
84        #[serde(default)]
85        username: String,
86        password: String,
87        #[serde(default)]
88        service: bool,
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        identity: Option<IdentityProof>,
91        #[serde(default)]
92        client_platform: ClientPlatform,
93        /// The client's app version (`APP_VERSION`), when it sends one. Absent
94        /// means a build older than the one that started reporting it, which is
95        /// how the relay decides what wire features that seat can handle.
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        client_version: Option<String>,
98    },
99
100    Ping,
101
102    ListRooms,
103
104    ListPlayers,
105
106    CreateRoom {
107        room_name: String,
108        max_players: u8,
109        format: GameFormat,
110        #[serde(default)]
111        protocol_version: u32,
112        #[serde(default)]
113        hosted: bool,
114        #[serde(default)]
115        engine: EngineKind,
116        #[serde(default)]
117        draft_config: Option<DraftConfig>,
118        #[serde(default)]
119        sealed_config: Option<SealedConfig>,
120        #[serde(default)]
121        official_key: Option<String>,
122        #[serde(default)]
123        password: Option<String>,
124        #[serde(default)]
125        reconnect_timeout_s: Option<u32>,
126    },
127
128    JoinRoom {
129        room_id: String,
130        #[serde(default)]
131        observe: bool,
132        #[serde(default)]
133        as_bot: bool,
134        #[serde(default)]
135        password: Option<String>,
136    },
137
138    ResumeRoom(ResumeRoomRequest),
139
140    LeaveRoom,
141
142    SetReady {
143        ready: bool,
144    },
145
146    SetDeckSelection {
147        deck_name: String,
148        deck: Deck,
149        #[serde(default)]
150        published_deck_id: Option<String>,
151        commander_name: Option<String>,
152        #[serde(default)]
153        avatar: Option<String>,
154    },
155
156    SetFormat {
157        format: GameFormat,
158    },
159
160    SetMaxPlayers {
161        max_players: u8,
162    },
163
164    StartGame {
165        #[serde(default)]
166        format: Option<GameFormat>,
167    },
168
169    EndGame {
170        game_id: String,
171    },
172
173    RequestResync,
174
175    BroadcastState {
176        state: serde_json::Value,
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        /// A null value will broadcast to the whole room
179        target_player: Option<String>,
180    },
181
182    TurnChange {
183        new_active_player: String,
184        turn_number: u32,
185    },
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(tag = "type")]
190pub enum ServerMessage {
191    Pong,
192
193    AuthResult {
194        success: bool,
195        player_id: Option<String>,
196        reconnected: Option<bool>,
197        error: Option<String>,
198    },
199
200    SessionTakenOver,
201
202    RoomList {
203        rooms: Vec<RoomInfo>,
204    },
205
206    PlayerList {
207        players: Vec<PlayerInfo>,
208    },
209
210    RoomCreated {
211        room_id: String,
212        room_name: String,
213        room: RoomInfo,
214        #[serde(default)]
215        resume_token: Option<String>,
216    },
217
218    RoomResumed {
219        room: RoomInfo,
220    },
221
222    PlayerJoined {
223        room_id: String,
224        username: String,
225    },
226
227    PlayerLeft {
228        room_id: String,
229        username: String,
230    },
231
232    PlayerConnected {
233        username: String,
234    },
235
236    PlayerDisconnected {
237        username: String,
238    },
239
240    ReadyStateChanged {
241        username: String,
242        ready: bool,
243    },
244
245    RoomUpdate {
246        room: RoomInfo,
247    },
248
249    GameStarted {
250        room_id: String,
251        game_id: String,
252        player_order: Vec<String>,
253        player_decks: Vec<PlayerDeckInfo>,
254        starting_life: i32,
255    },
256
257    StateUpdate {
258        from_player: String,
259        state: serde_json::Value,
260    },
261
262    TurnChanged {
263        from_player: String,
264        new_active_player: String,
265        turn_number: u32,
266    },
267
268    GameAborted {
269        room_id: String,
270    },
271
272    Error {
273        code: String,
274        message: String,
275    },
276
277    ServerShuttingDown {
278        reconnect_in_s: u32,
279    },
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, TS)]
283#[ts(export, export_to = "lobby/index.ts")]
284pub struct ResumeRoomRequest {
285    pub room_id: String,
286    pub resume_token: String,
287    pub room_name: String,
288    pub max_players: u8,
289    pub format: GameFormat,
290    #[serde(default)]
291    pub hosted: bool,
292    #[serde(default)]
293    pub engine: EngineKind,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    #[ts(optional)]
296    pub official_key: Option<String>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    #[ts(optional)]
299    pub password: Option<String>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    #[ts(optional)]
302    pub reconnect_timeout_s: Option<u32>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    #[ts(optional)]
305    pub draft_config: Option<DraftConfig>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    #[ts(optional)]
308    pub sealed_config: Option<SealedConfig>,
309    pub player_order: Vec<String>,
310    pub player_decks: Vec<PlayerDeckInfo>,
311    pub starting_life: i32,
312    #[serde(default)]
313    pub bot_players: Vec<String>,
314    pub game_id: String,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct RoomInfo {
319    pub room_id: String,
320    pub room_name: String,
321    pub host: String,
322    #[serde(default)]
323    pub protocol_version: u32,
324    #[serde(default)]
325    pub hosted: bool,
326    #[serde(default)]
327    pub official: bool,
328    #[serde(default)]
329    pub password_protected: bool,
330    pub players: Vec<RoomPlayerInfo>,
331    pub max_players: u8,
332    pub format: GameFormat,
333    pub status: RoomStatus,
334    #[serde(default)]
335    pub engine: EngineKind,
336    #[serde(default = "default_reconnect_timeout_s")]
337    pub reconnect_timeout_s: u32,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub draft_config: Option<DraftConfig>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub sealed_config: Option<SealedConfig>,
342}
343
344pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
345
346fn default_reconnect_timeout_s() -> u32 {
347    DEFAULT_RECONNECT_TIMEOUT_S
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
351#[ts(export, export_to = "lobby/index.ts")]
352pub struct SealedConfig {
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    #[ts(optional)]
355    pub set_code: Option<String>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    #[ts(optional)]
358    pub cube_id: Option<String>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    #[ts(optional)]
361    pub cube_name: Option<String>,
362    #[serde(default)]
363    pub singleton: bool,
364    pub num_boosters: u8,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    #[ts(optional, type = "number")]
367    pub base_seed: Option<u64>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
371#[ts(export, export_to = "lobby/index.ts")]
372pub struct DraftConfig {
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    pub rounds: u8,
383    pub picks_per_pass: u8,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    #[ts(optional, type = "number")]
386    pub seed: Option<u64>,
387    pub fill_with_bots: bool,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct RoomPlayerInfo {
392    pub username: String,
393    pub ready: bool,
394    pub connected: bool,
395    #[serde(default)]
396    pub is_bot: bool,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub selected_deck_name: Option<String>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct PlayerInfo {
403    pub username: String,
404    pub player_id: String,
405    pub connected: bool,
406    #[serde(default)]
407    pub verified: bool,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub qualification: Option<String>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub room_id: Option<String>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
415pub enum RoomStatus {
416    Lobby,
417    InGame,
418}