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}