Skip to main content

manabrew_protocol/
protocol.rs

1pub use crate::deck_dto::Deck;
2use serde::{Deserialize, Serialize};
3use ts_rs::TS;
4
5pub const PROTOCOL_VERSION: u32 = 2;
6
7#[cfg(test)]
8const PROTOCOL_SCHEMA_FINGERPRINT: &str = "b8f0eb5cf679220a";
9
10#[derive(Debug, Clone, Serialize, Deserialize, TS)]
11#[ts(export, export_to = "lobby/index.ts")]
12pub struct PlayerDeckInfo {
13    pub username: String,
14    pub deck_name: String,
15    pub deck: Deck,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    #[ts(optional)]
18    pub commander_name: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    #[ts(optional)]
21    pub avatar: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(tag = "type")]
26#[allow(clippy::large_enum_variant)]
27pub enum ClientMessage {
28    Authenticate {
29        username: String,
30        password: String,
31        #[serde(default)]
32        service: bool,
33    },
34
35    Ping,
36
37    ListRooms,
38
39    ListPlayers,
40
41    CreateRoom {
42        room_name: String,
43        max_players: u8,
44        format: GameFormat,
45        #[serde(default)]
46        protocol_version: u32,
47        #[serde(default)]
48        hosted: bool,
49        #[serde(default)]
50        engine: EngineKind,
51        #[serde(default)]
52        draft_config: Option<DraftConfig>,
53        #[serde(default)]
54        sealed_config: Option<SealedConfig>,
55        #[serde(default)]
56        official_key: Option<String>,
57        #[serde(default)]
58        password: Option<String>,
59        #[serde(default)]
60        reconnect_timeout_s: Option<u32>,
61    },
62
63    JoinRoom {
64        room_id: String,
65        #[serde(default)]
66        observe: bool,
67        #[serde(default)]
68        as_bot: bool,
69        #[serde(default)]
70        password: Option<String>,
71    },
72
73    ResumeRoom(ResumeRoomRequest),
74
75    LeaveRoom,
76
77    SetReady {
78        ready: bool,
79    },
80
81    SetDeckSelection {
82        deck_name: String,
83        deck: Deck,
84        commander_name: Option<String>,
85        #[serde(default)]
86        avatar: Option<String>,
87    },
88
89    SetFormat {
90        format: GameFormat,
91    },
92
93    SetMaxPlayers {
94        max_players: u8,
95    },
96
97    StartGame {
98        #[serde(default)]
99        format: Option<GameFormat>,
100    },
101
102    EndGame {
103        game_id: String,
104    },
105
106    RequestResync,
107
108    BroadcastState {
109        state: serde_json::Value,
110    },
111
112    TurnChange {
113        new_active_player: String,
114        turn_number: u32,
115    },
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(tag = "type")]
120pub enum ServerMessage {
121    AuthResult {
122        success: bool,
123        player_id: Option<String>,
124        reconnected: Option<bool>,
125        error: Option<String>,
126    },
127
128    RoomList {
129        rooms: Vec<RoomInfo>,
130    },
131
132    PlayerList {
133        players: Vec<PlayerInfo>,
134    },
135
136    RoomCreated {
137        room_id: String,
138        room_name: String,
139        room: RoomInfo,
140        #[serde(default)]
141        resume_token: Option<String>,
142    },
143
144    RoomResumed {
145        room: RoomInfo,
146    },
147
148    PlayerJoined {
149        room_id: String,
150        username: String,
151    },
152
153    PlayerLeft {
154        room_id: String,
155        username: String,
156    },
157
158    PlayerConnected {
159        username: String,
160    },
161
162    PlayerDisconnected {
163        username: String,
164    },
165
166    ReadyStateChanged {
167        username: String,
168        ready: bool,
169    },
170
171    RoomUpdate {
172        room: RoomInfo,
173    },
174
175    GameStarted {
176        room_id: String,
177        game_id: String,
178        player_order: Vec<String>,
179        player_decks: Vec<PlayerDeckInfo>,
180        starting_life: i32,
181    },
182
183    StateUpdate {
184        from_player: String,
185        state: serde_json::Value,
186    },
187
188    TurnChanged {
189        from_player: String,
190        new_active_player: String,
191        turn_number: u32,
192    },
193
194    GameAborted {
195        room_id: String,
196    },
197
198    Error {
199        code: String,
200        message: String,
201    },
202
203    ServerShuttingDown {
204        reconnect_in_s: u32,
205    },
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, TS)]
209#[ts(export, export_to = "lobby/index.ts")]
210pub struct ResumeRoomRequest {
211    pub room_id: String,
212    pub resume_token: String,
213    pub room_name: String,
214    pub max_players: u8,
215    pub format: GameFormat,
216    #[serde(default)]
217    pub hosted: bool,
218    #[serde(default)]
219    pub engine: EngineKind,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    #[ts(optional)]
222    pub official_key: Option<String>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    #[ts(optional)]
225    pub password: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    #[ts(optional)]
228    pub reconnect_timeout_s: Option<u32>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    #[ts(optional)]
231    pub draft_config: Option<DraftConfig>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    #[ts(optional)]
234    pub sealed_config: Option<SealedConfig>,
235    pub player_order: Vec<String>,
236    pub player_decks: Vec<PlayerDeckInfo>,
237    pub starting_life: i32,
238    #[serde(default)]
239    pub bot_players: Vec<String>,
240    pub game_id: String,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct RoomInfo {
245    pub room_id: String,
246    pub room_name: String,
247    pub host: String,
248    #[serde(default)]
249    pub protocol_version: u32,
250    #[serde(default)]
251    pub hosted: bool,
252    #[serde(default)]
253    pub official: bool,
254    #[serde(default)]
255    pub password_protected: bool,
256    pub players: Vec<RoomPlayerInfo>,
257    pub max_players: u8,
258    pub format: GameFormat,
259    pub status: RoomStatus,
260    #[serde(default)]
261    pub engine: EngineKind,
262    #[serde(default = "default_reconnect_timeout_s")]
263    pub reconnect_timeout_s: u32,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub draft_config: Option<DraftConfig>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub sealed_config: Option<SealedConfig>,
268}
269
270pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
271
272fn default_reconnect_timeout_s() -> u32 {
273    DEFAULT_RECONNECT_TIMEOUT_S
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
277#[ts(export, export_to = "lobby/index.ts")]
278pub struct SealedConfig {
279    pub set_code: String,
280    pub num_boosters: u8,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    #[ts(optional, type = "number")]
283    pub base_seed: Option<u64>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
287#[ts(export, export_to = "lobby/index.ts")]
288pub struct DraftConfig {
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    #[ts(optional)]
291    pub set_code: Option<String>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    #[ts(optional)]
294    pub cube_id: Option<String>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    #[ts(optional)]
297    pub cube_name: Option<String>,
298    pub rounds: u8,
299    pub picks_per_pass: u8,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    #[ts(optional, type = "number")]
302    pub seed: Option<u64>,
303    pub fill_with_bots: bool,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct RoomPlayerInfo {
308    pub username: String,
309    pub ready: bool,
310    pub connected: bool,
311    #[serde(default)]
312    pub is_bot: bool,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub selected_deck_name: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct PlayerInfo {
319    pub username: String,
320    pub player_id: String,
321    pub connected: bool,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub room_id: Option<String>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
327pub enum RoomStatus {
328    Lobby,
329    InGame,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
333#[ts(export, export_to = "lobby/index.ts")]
334pub enum GameFormat {
335    Any,
336    Standard,
337    Pioneer,
338    Modern,
339    Legacy,
340    Vintage,
341    Pauper,
342    Commander,
343    Brawl,
344    Oathbreaker,
345    Draft,
346    Sealed,
347}
348
349#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
350#[ts(export, export_to = "lobby/index.ts")]
351pub enum EngineKind {
352    #[default]
353    Manabrew,
354    Forge,
355    Ironsmith,
356}
357
358#[cfg(test)]
359mod schema_fingerprint {
360    use super::PROTOCOL_SCHEMA_FINGERPRINT;
361    use crate::deck_dto::Deck;
362    use crate::display::DisplayEvent;
363    use crate::prompts::{PromptInput, PromptOutput};
364    use crate::protocol::ResumeRoomRequest;
365    use crate::transport::{AgentPrompt, ClientToServerMessage, DirectiveInput, StateUpdate};
366    use std::path::{Path, PathBuf};
367    use ts_rs::TS;
368
369    fn collect_ts(dir: &Path, out: &mut Vec<PathBuf>) {
370        let Ok(entries) = std::fs::read_dir(dir) else {
371            return;
372        };
373        for entry in entries.flatten() {
374            let path = entry.path();
375            if path.is_dir() {
376                collect_ts(&path, out);
377            } else if path.extension().is_some_and(|e| e == "ts") {
378                out.push(path);
379            }
380        }
381    }
382
383    fn fnv1a(bytes: &[u8]) -> u64 {
384        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
385        for &byte in bytes {
386            hash ^= byte as u64;
387            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
388        }
389        hash
390    }
391
392    fn rendered_schema() -> String {
393        let dir = std::env::temp_dir().join(format!(
394            "manabrew-protocol-fingerprint-{}",
395            std::process::id()
396        ));
397        let _ = std::fs::remove_dir_all(&dir);
398        PromptInput::export_all_to(&dir).unwrap();
399        PromptOutput::export_all_to(&dir).unwrap();
400        AgentPrompt::export_all_to(&dir).unwrap();
401        StateUpdate::export_all_to(&dir).unwrap();
402        DirectiveInput::export_all_to(&dir).unwrap();
403        ClientToServerMessage::export_all_to(&dir).unwrap();
404        DisplayEvent::export_all_to(&dir).unwrap();
405        Deck::export_all_to(&dir).unwrap();
406        ResumeRoomRequest::export_all_to(&dir).unwrap();
407
408        let mut files = Vec::new();
409        collect_ts(&dir, &mut files);
410        files.sort();
411
412        let mut schema = String::new();
413        for path in &files {
414            let rel = path
415                .strip_prefix(&dir)
416                .unwrap()
417                .to_string_lossy()
418                .into_owned();
419            schema.push_str(&rel);
420            schema.push('\n');
421            schema.push_str(&std::fs::read_to_string(path).unwrap());
422            schema.push('\n');
423        }
424        let _ = std::fs::remove_dir_all(&dir);
425        schema
426    }
427
428    #[test]
429    fn wire_schema_is_pinned() {
430        let actual = format!("{:016x}", fnv1a(rendered_schema().as_bytes()));
431        assert_eq!(
432            actual, PROTOCOL_SCHEMA_FINGERPRINT,
433            "protocol wire schema changed — bump PROTOCOL_VERSION and update PROTOCOL_SCHEMA_FINGERPRINT"
434        );
435    }
436}