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