1pub 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
13pub 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 #[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 #[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 SetLocalGame {
111 #[serde(default)]
112 kind: Option<LocalGameKind>,
113 },
114
115 CreateRoom {
116 room_name: String,
117 max_players: u8,
118 format: GameFormat,
119 #[serde(default)]
120 protocol_version: u32,
121 #[serde(default)]
122 hosted: bool,
123 #[serde(default)]
124 engine: EngineKind,
125 #[serde(default)]
126 draft_config: Option<DraftConfig>,
127 #[serde(default)]
128 sealed_config: Option<SealedConfig>,
129 #[serde(default)]
130 official_key: Option<String>,
131 #[serde(default)]
132 password: Option<String>,
133 #[serde(default)]
134 reconnect_timeout_s: Option<u32>,
135 },
136
137 JoinRoom {
138 room_id: String,
139 #[serde(default)]
140 observe: bool,
141 #[serde(default)]
142 as_bot: bool,
143 #[serde(default)]
144 password: Option<String>,
145 },
146
147 ResumeRoom(ResumeRoomRequest),
148
149 LeaveRoom,
150
151 SetReady {
152 ready: bool,
153 },
154
155 SetDeckSelection {
156 deck_name: String,
157 deck: Deck,
158 #[serde(default)]
159 published_deck_id: Option<String>,
160 commander_name: Option<String>,
161 #[serde(default)]
162 avatar_url: Option<String>,
163 },
164
165 SetFormat {
166 format: GameFormat,
167 },
168
169 SetMaxPlayers {
170 max_players: u8,
171 },
172
173 StartGame {
174 #[serde(default)]
175 format: Option<GameFormat>,
176 },
177
178 EndGame {
179 game_id: String,
180 },
181
182 ReportEngineStats {
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 game_id: Option<String>,
185 stats: EnginePlayStats,
186 },
187
188 RequestResync,
189
190 BroadcastState {
191 state: serde_json::Value,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 target_player: Option<String>,
195 },
196
197 TurnChange {
198 new_active_player: String,
199 turn_number: u32,
200 },
201
202 SendChat {
203 scope: ChatScope,
204 text: String,
205 },
206
207 InviteToRoom {
208 username: String,
209 },
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type")]
214pub enum ServerMessage {
215 Pong,
216
217 AuthResult {
218 success: bool,
219 player_id: Option<String>,
220 reconnected: Option<bool>,
221 error: Option<String>,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 features: Vec<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
231 art_base_url: Option<String>,
232 },
233
234 SessionTakenOver,
235
236 RoomList {
237 rooms: Vec<RoomInfo>,
238 },
239
240 PlayerList {
241 players: Vec<PlayerInfo>,
242 },
243
244 RoomCreated {
245 room_id: String,
246 room_name: String,
247 room: RoomInfo,
248 #[serde(default)]
249 resume_token: Option<String>,
250 },
251
252 RoomResumed {
253 room: RoomInfo,
254 },
255
256 PlayerJoined {
257 room_id: String,
258 username: String,
259 },
260
261 PlayerLeft {
262 room_id: String,
263 username: String,
264 },
265
266 PlayerConnected {
267 username: String,
268 },
269
270 PlayerDisconnected {
271 username: String,
272 },
273
274 ReadyStateChanged {
275 username: String,
276 ready: bool,
277 },
278
279 RoomUpdate {
280 room: RoomInfo,
281 },
282
283 GameStarted {
284 room_id: String,
285 game_id: String,
286 player_order: Vec<String>,
287 player_decks: Vec<PlayerDeckInfo>,
288 starting_life: i32,
289 },
290
291 StateUpdate {
292 from_player: String,
293 state: serde_json::Value,
294 },
295
296 TurnChanged {
297 from_player: String,
298 new_active_player: String,
299 turn_number: u32,
300 },
301
302 GameAborted {
303 room_id: String,
304 },
305
306 Error {
307 code: String,
308 message: String,
309 },
310
311 ServerShuttingDown {
312 reconnect_in_s: u32,
313 },
314
315 ChatMessage(ChatMessage),
316
317 ChatHistory {
318 scope: ChatScope,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 room_id: Option<String>,
321 messages: Vec<ChatMessage>,
322 },
323
324 RoomInvite {
325 from: String,
326 room: RoomInfo,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 password: Option<String>,
329 },
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333pub enum ChatScope {
334 Lobby,
335 Room,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct ChatMessage {
340 pub scope: ChatScope,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub room_id: Option<String>,
343 pub from: String,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub avatar_url: Option<String>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub qualification: Option<String>,
348 pub text: String,
349 pub sent_at_ms: u64,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub seal: Option<String>,
352}
353
354pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
355pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
356pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
357pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
358
359#[derive(Debug, Clone, Serialize, Deserialize, TS)]
360#[ts(export, export_to = "lobby/index.ts")]
361pub struct ResumeRoomRequest {
362 pub room_id: String,
363 pub resume_token: String,
364 pub room_name: String,
365 pub max_players: u8,
366 pub format: GameFormat,
367 #[serde(default)]
368 pub hosted: bool,
369 #[serde(default)]
370 pub engine: EngineKind,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 #[ts(optional)]
373 pub official_key: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 #[ts(optional)]
376 pub password: Option<String>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 #[ts(optional)]
379 pub reconnect_timeout_s: Option<u32>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 #[ts(optional)]
382 pub draft_config: Option<DraftConfig>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 #[ts(optional)]
385 pub sealed_config: Option<SealedConfig>,
386 pub player_order: Vec<String>,
387 pub player_decks: Vec<PlayerDeckInfo>,
388 pub starting_life: i32,
389 #[serde(default)]
390 pub bot_players: Vec<String>,
391 pub game_id: String,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct RoomInfo {
396 pub room_id: String,
397 pub room_name: String,
398 pub host: String,
399 #[serde(default)]
400 pub protocol_version: u32,
401 #[serde(default)]
402 pub hosted: bool,
403 #[serde(default)]
404 pub official: bool,
405 #[serde(default)]
406 pub password_protected: bool,
407 pub players: Vec<RoomPlayerInfo>,
408 pub max_players: u8,
409 pub format: GameFormat,
410 pub status: RoomStatus,
411 #[serde(default)]
412 pub engine: EngineKind,
413 #[serde(default = "default_reconnect_timeout_s")]
414 pub reconnect_timeout_s: u32,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub draft_config: Option<DraftConfig>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub sealed_config: Option<SealedConfig>,
419}
420
421pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
422
423fn default_reconnect_timeout_s() -> u32 {
424 DEFAULT_RECONNECT_TIMEOUT_S
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
428#[ts(export, export_to = "lobby/index.ts")]
429pub struct SealedConfig {
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 #[ts(optional)]
432 pub set_code: Option<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
434 #[ts(optional)]
435 pub cube_id: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 #[ts(optional)]
438 pub cube_name: Option<String>,
439 #[serde(default)]
440 pub singleton: bool,
441 pub num_boosters: u8,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 #[ts(optional, type = "number")]
444 pub base_seed: Option<u64>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
448#[ts(export, export_to = "lobby/index.ts")]
449pub struct DraftConfig {
450 #[serde(default, skip_serializing_if = "Option::is_none")]
451 #[ts(optional)]
452 pub set_code: Option<String>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 #[ts(optional)]
455 pub cube_id: Option<String>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 #[ts(optional)]
458 pub cube_name: Option<String>,
459 pub rounds: u8,
460 pub picks_per_pass: u8,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 #[ts(optional, type = "number")]
463 pub seed: Option<u64>,
464 pub fill_with_bots: bool,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct RoomPlayerInfo {
469 pub username: String,
470 pub ready: bool,
471 pub connected: bool,
472 #[serde(default)]
473 pub is_bot: bool,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub selected_deck_name: Option<String>,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct PlayerInfo {
480 pub username: String,
481 pub player_id: String,
482 pub connected: bool,
483 #[serde(default)]
484 pub verified: bool,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub qualification: Option<String>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub avatar_url: Option<String>,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 pub room_id: Option<String>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub local_game: Option<LocalGameKind>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub seal: Option<String>,
499}
500
501pub const FEATURE_LOCAL_GAME: &str = "local_game";
503pub const FEATURE_CHAT: &str = "chat";
505pub const FEATURE_ROOM_INVITES: &str = "room_invites";
507
508pub const FEATURES: &[&str] = &[FEATURE_LOCAL_GAME, FEATURE_CHAT, FEATURE_ROOM_INVITES];
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512pub enum LocalGameKind {
513 Singleplayer,
515}
516
517impl LocalGameKind {
518 pub const ALL: &'static [Self] = &[Self::Singleplayer];
521
522 pub fn as_str(self) -> &'static str {
523 match self {
524 Self::Singleplayer => "singleplayer",
525 }
526 }
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530pub enum RoomStatus {
531 Lobby,
532 InGame,
533}