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
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
69#[serde(rename_all = "snake_case")]
70#[ts(export, export_to = "lobby/index.ts")]
71pub enum EngineGate {
72 InBrowser,
73 Ios,
74 Isolation,
75 TrialTimeout,
76 TrialFailed,
77 Disabled,
78 #[default]
79 Unknown,
80}
81
82impl EngineGate {
83 pub const fn as_str(self) -> &'static str {
84 match self {
85 Self::InBrowser => "in_browser",
86 Self::Ios => "ios",
87 Self::Isolation => "isolation",
88 Self::TrialTimeout => "trial_timeout",
89 Self::TrialFailed => "trial_failed",
90 Self::Disabled => "disabled",
91 Self::Unknown => "unknown",
92 }
93 }
94}
95
96impl ClientPlatform {
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::Web => "web",
100 Self::Pwa => "pwa",
101 Self::Desktop => "desktop",
102 Self::Mobile => "mobile",
103 Self::Unknown => "unknown",
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct TransportEndpoint {
111 pub endpoint_id: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub relay_url: Option<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub direct_addrs: Vec<String>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub kinds: Vec<String>,
119}
120
121impl TransportEndpoint {
122 pub fn speaks(&self, kind: &str) -> bool {
124 self.kinds.iter().any(|k| k == kind)
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct IceServer {
131 pub urls: Vec<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub username: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub credential: Option<String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SeatTransportReport {
141 pub username: String,
142 pub transport: String,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct PlaneQualityReport {
149 pub peer: String,
151 pub outcome: String,
153 pub plane: String,
155 pub phase: String,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub connect_ms: Option<u32>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub rtt_ms: Option<u32>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub relay_rtt_ms: Option<u32>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub candidate_pair: Option<String>,
169}
170
171pub const PLANE_PHASE_SETTLED: &str = "settled";
172pub const PLANE_PHASE_MEASURED: &str = "measured";
173
174pub const PLANE_OUTCOME_CONNECTED: &str = "connected";
175pub const PLANE_OUTCOME_FAILED: &str = "failed";
176pub const PLANE_OUTCOME_TIMEOUT: &str = "timeout";
177
178pub const PLANE_OUTCOMES: &[&str] = &[
180 PLANE_OUTCOME_CONNECTED,
181 PLANE_OUTCOME_FAILED,
182 PLANE_OUTCOME_TIMEOUT,
183];
184
185pub const MAX_CANDIDATE_PAIR_BYTES: usize = 64;
187
188pub const MAX_PLANE_MS: u32 = 600_000;
190
191pub const TRANSPORT_WEBRTC: &str = "webrtc";
193
194pub const TRANSPORT_KIND_WEBRTC: &str = "webrtc";
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct TransportMember {
200 pub username: String,
201 pub endpoint: TransportEndpoint,
202 #[serde(default)]
203 pub host: bool,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(tag = "type")]
208#[allow(clippy::large_enum_variant)]
209pub enum ClientMessage {
210 Authenticate {
211 #[serde(default)]
215 username: String,
216 password: String,
217 #[serde(default)]
218 service: bool,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 identity: Option<IdentityProof>,
221 #[serde(default)]
222 client_platform: ClientPlatform,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
227 client_version: Option<String>,
228 #[serde(default)]
229 engine_gate: EngineGate,
230 },
231
232 Ping,
233
234 ListRooms,
235
236 ListPlayers,
237
238 SetLocalGame {
242 #[serde(default)]
243 kind: Option<LocalGameKind>,
244 },
245
246 CreateRoom {
247 room_name: String,
248 max_players: u8,
249 format: GameFormat,
250 #[serde(default)]
251 protocol_version: u32,
252 #[serde(default)]
253 hosted: bool,
254 #[serde(default)]
255 engine: EngineKind,
256 #[serde(default)]
257 draft_config: Option<DraftConfig>,
258 #[serde(default)]
259 sealed_config: Option<SealedConfig>,
260 #[serde(default)]
261 official_key: Option<String>,
262 #[serde(default)]
263 password: Option<String>,
264 #[serde(default)]
265 reconnect_timeout_s: Option<u32>,
266 #[serde(default)]
267 table_style: Option<String>,
268 },
269
270 JoinRoom {
271 room_id: String,
272 #[serde(default)]
273 observe: bool,
274 #[serde(default)]
275 as_bot: bool,
276 #[serde(default)]
277 password: Option<String>,
278 },
279
280 ResumeRoom(ResumeRoomRequest),
281
282 LeaveRoom,
283
284 SetReady {
285 ready: bool,
286 },
287
288 SetDeckSelection {
289 deck_name: String,
290 deck: Deck,
291 #[serde(default)]
292 published_deck_id: Option<String>,
293 commander_name: Option<String>,
294 #[serde(default)]
295 avatar_url: Option<String>,
296 },
297
298 SetFormat {
299 format: GameFormat,
300 },
301
302 SetMaxPlayers {
303 max_players: u8,
304 },
305
306 StartGame {
307 #[serde(default)]
308 format: Option<GameFormat>,
309 },
310
311 EndGame {
312 game_id: String,
313 },
314
315 ReportEngineStats {
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 game_id: Option<String>,
318 stats: EnginePlayStats,
319 },
320
321 ReportGameOutcome {
325 game_id: String,
326 outcome: GameOutcomeReport,
327 },
328
329 RequestResync,
330
331 BroadcastState {
332 state: serde_json::Value,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 target_player: Option<String>,
336 },
337
338 TurnChange {
339 new_active_player: String,
340 turn_number: u32,
341 },
342 ReportTransport {
344 game_id: String,
345 seats: Vec<SeatTransportReport>,
346 },
347
348 AnnounceTransport {
350 #[serde(default)]
351 endpoint: Option<TransportEndpoint>,
352 },
353
354 SignalPeer {
356 to: String,
357 payload: serde_json::Value,
358 },
359
360 ReportPlaneQuality {
362 report: PlaneQualityReport,
363 },
364
365 SendChat {
366 scope: ChatScope,
367 text: String,
368 },
369
370 InviteToRoom {
371 username: String,
372 },
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(tag = "type")]
377pub enum ServerMessage {
378 Pong,
379
380 AuthResult {
381 success: bool,
382 player_id: Option<String>,
383 reconnected: Option<bool>,
384 error: Option<String>,
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 features: Vec<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
394 art_base_url: Option<String>,
395 },
396
397 SessionTakenOver,
398
399 RoomList {
400 rooms: Vec<RoomInfo>,
401 },
402
403 PlayerList {
404 players: Vec<PlayerInfo>,
405 },
406
407 RoomCreated {
408 room_id: String,
409 room_name: String,
410 room: RoomInfo,
411 #[serde(default)]
412 resume_token: Option<String>,
413 },
414
415 RoomResumed {
416 room: RoomInfo,
417 },
418
419 PlayerJoined {
420 room_id: String,
421 username: String,
422 },
423
424 PlayerLeft {
425 room_id: String,
426 username: String,
427 },
428
429 PlayerConnected {
430 username: String,
431 },
432
433 PlayerDisconnected {
434 username: String,
435 },
436
437 ReadyStateChanged {
438 username: String,
439 ready: bool,
440 },
441
442 RoomUpdate {
443 room: RoomInfo,
444 },
445
446 GameStarted {
447 room_id: String,
448 game_id: String,
449 player_order: Vec<String>,
450 player_decks: Vec<PlayerDeckInfo>,
451 starting_life: i32,
452 },
453
454 StateUpdate {
455 from_player: String,
456 state: serde_json::Value,
457 },
458
459 TurnChanged {
460 from_player: String,
461 new_active_player: String,
462 turn_number: u32,
463 },
464
465 GameAborted {
466 room_id: String,
467 },
468
469 Error {
470 code: String,
471 message: String,
472 },
473
474 ServerShuttingDown {
475 reconnect_in_s: u32,
476 },
477 RoomTransport {
479 room_id: String,
480 #[serde(default, skip_serializing_if = "Vec::is_empty")]
482 ice_servers: Vec<IceServer>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 host: Option<TransportMember>,
485 members: Vec<TransportMember>,
486 },
487
488 PeerSignal {
490 from: String,
491 payload: serde_json::Value,
492 },
493
494 ChatMessage(ChatMessage),
495
496 ChatHistory {
497 scope: ChatScope,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 room_id: Option<String>,
500 messages: Vec<ChatMessage>,
501 },
502
503 RoomInvite {
504 from: String,
505 room: RoomInfo,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 password: Option<String>,
508 },
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512pub enum ChatScope {
513 Lobby,
514 Room,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct ChatMessage {
519 pub scope: ChatScope,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub room_id: Option<String>,
522 pub from: String,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub avatar_url: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub qualification: Option<String>,
527 pub text: String,
528 pub sent_at_ms: u64,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub seal: Option<String>,
531}
532
533pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
534pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
535pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
536pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
537
538#[derive(Debug, Clone, Serialize, Deserialize, TS)]
539#[ts(export, export_to = "lobby/index.ts")]
540pub struct ResumeRoomRequest {
541 pub room_id: String,
542 pub resume_token: String,
543 pub room_name: String,
544 pub max_players: u8,
545 pub format: GameFormat,
546 #[serde(default)]
547 pub hosted: bool,
548 #[serde(default)]
549 pub engine: EngineKind,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 #[ts(optional)]
552 pub official_key: Option<String>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
554 #[ts(optional)]
555 pub password: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 #[ts(optional)]
558 pub reconnect_timeout_s: Option<u32>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
560 #[ts(optional)]
561 pub table_style: Option<String>,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
563 #[ts(optional)]
564 pub draft_config: Option<DraftConfig>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
566 #[ts(optional)]
567 pub sealed_config: Option<SealedConfig>,
568 pub player_order: Vec<String>,
569 pub player_decks: Vec<PlayerDeckInfo>,
570 pub starting_life: i32,
571 #[serde(default)]
572 pub bot_players: Vec<String>,
573 pub game_id: String,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct RoomInfo {
578 pub room_id: String,
579 pub room_name: String,
580 pub host: String,
581 #[serde(default)]
582 pub protocol_version: u32,
583 #[serde(default)]
584 pub hosted: bool,
585 #[serde(default)]
586 pub official: bool,
587 #[serde(default)]
588 pub password_protected: bool,
589 pub players: Vec<RoomPlayerInfo>,
590 pub max_players: u8,
591 pub format: GameFormat,
592 pub status: RoomStatus,
593 #[serde(default)]
594 pub engine: EngineKind,
595 #[serde(default = "default_reconnect_timeout_s")]
596 pub reconnect_timeout_s: u32,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub table_style: Option<String>,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub draft_config: Option<DraftConfig>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub sealed_config: Option<SealedConfig>,
603}
604
605pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
606
607fn default_reconnect_timeout_s() -> u32 {
608 DEFAULT_RECONNECT_TIMEOUT_S
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
612#[ts(export, export_to = "lobby/index.ts")]
613pub struct SealedConfig {
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 #[ts(optional)]
616 pub set_code: Option<String>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 #[ts(optional)]
619 pub cube_id: Option<String>,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
621 #[ts(optional)]
622 pub cube_name: Option<String>,
623 #[serde(default)]
624 pub singleton: bool,
625 pub num_boosters: u8,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
627 #[ts(optional, type = "number")]
628 pub base_seed: Option<u64>,
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
632#[ts(export, export_to = "lobby/index.ts")]
633pub struct DraftConfig {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 #[ts(optional)]
636 pub set_code: Option<String>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
638 #[ts(optional)]
639 pub cube_id: Option<String>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 #[ts(optional)]
642 pub cube_name: Option<String>,
643 pub rounds: u8,
644 pub picks_per_pass: u8,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
646 #[ts(optional)]
647 pub pick_seconds: Option<u16>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 #[ts(optional, type = "number")]
650 pub seed: Option<u64>,
651 pub fill_with_bots: bool,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct RoomPlayerInfo {
656 pub username: String,
657 pub ready: bool,
658 pub connected: bool,
659 #[serde(default)]
660 pub is_bot: bool,
661 #[serde(skip_serializing_if = "Option::is_none")]
662 pub selected_deck_name: Option<String>,
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct PlayerInfo {
667 pub username: String,
668 pub player_id: String,
669 pub connected: bool,
670 #[serde(default)]
671 pub verified: bool,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub qualification: Option<String>,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub avatar_url: Option<String>,
676 #[serde(skip_serializing_if = "Option::is_none")]
677 pub room_id: Option<String>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub local_game: Option<LocalGameKind>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub seal: Option<String>,
686}
687
688pub const FEATURE_LOCAL_GAME: &str = "local_game";
690pub const FEATURE_CHAT: &str = "chat";
692pub const FEATURE_ROOM_INVITES: &str = "room_invites";
694pub const FEATURE_GAME_OUTCOME: &str = "game_outcome";
696pub const FEATURE_LIMITED_SESSIONS: &str = "limited_sessions";
697pub const FEATURE_LIMITED_DRAFT_CLOCKS: &str = "limited_draft_clocks";
698pub const FEATURE_LIMITED_SESSION_RECOVERY: &str = "limited_session_recovery";
699
700pub const FEATURE_ROOM_TRANSPORT: &str = "room_transport";
702
703pub const FEATURE_PEER_SIGNAL: &str = "peer_signal";
705
706pub const FEATURE_PLANE_QUALITY: &str = "plane_quality";
708
709pub const FEATURE_CARD_COSMETICS: &str = "card_cosmetics";
710
711pub const FEATURES: &[&str] = &[
712 FEATURE_LOCAL_GAME,
713 FEATURE_ROOM_TRANSPORT,
714 FEATURE_PEER_SIGNAL,
715 FEATURE_PLANE_QUALITY,
716 FEATURE_CHAT,
717 FEATURE_ROOM_INVITES,
718 FEATURE_GAME_OUTCOME,
719 FEATURE_CARD_COSMETICS,
720 FEATURE_LIMITED_SESSIONS,
721 FEATURE_LIMITED_DRAFT_CLOCKS,
722 FEATURE_LIMITED_SESSION_RECOVERY,
723];
724
725pub const MAX_SIGNAL_BYTES: usize = 16 * 1024;
727
728pub const MAX_FATAL_MESSAGE_CHARS: usize = 500;
730
731#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
734pub struct GameOutcomeReport {
735 pub game_over: bool,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub winner_slot: Option<String>,
738 #[serde(default, skip_serializing_if = "Vec::is_empty")]
739 pub conceded_slots: Vec<String>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub fatal_message: Option<String>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub turns: Option<u32>,
744}
745
746#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
748pub enum LocalGameKind {
749 Singleplayer,
751}
752
753impl LocalGameKind {
754 pub const ALL: &'static [Self] = &[Self::Singleplayer];
757
758 pub fn as_str(self) -> &'static str {
759 match self {
760 Self::Singleplayer => "singleplayer",
761 }
762 }
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
766pub enum RoomStatus {
767 Lobby,
768 InGame,
769}