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