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 },
234
235 JoinRoom {
236 room_id: String,
237 #[serde(default)]
238 observe: bool,
239 #[serde(default)]
240 as_bot: bool,
241 #[serde(default)]
242 password: Option<String>,
243 },
244
245 ResumeRoom(ResumeRoomRequest),
246
247 LeaveRoom,
248
249 SetReady {
250 ready: bool,
251 },
252
253 SetDeckSelection {
254 deck_name: String,
255 deck: Deck,
256 #[serde(default)]
257 published_deck_id: Option<String>,
258 commander_name: Option<String>,
259 #[serde(default)]
260 avatar_url: Option<String>,
261 },
262
263 SetFormat {
264 format: GameFormat,
265 },
266
267 SetMaxPlayers {
268 max_players: u8,
269 },
270
271 StartGame {
272 #[serde(default)]
273 format: Option<GameFormat>,
274 },
275
276 EndGame {
277 game_id: String,
278 },
279
280 ReportEngineStats {
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 game_id: Option<String>,
283 stats: EnginePlayStats,
284 },
285
286 ReportGameOutcome {
290 game_id: String,
291 outcome: GameOutcomeReport,
292 },
293
294 RequestResync,
295
296 BroadcastState {
297 state: serde_json::Value,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 target_player: Option<String>,
301 },
302
303 TurnChange {
304 new_active_player: String,
305 turn_number: u32,
306 },
307 ReportTransport {
309 game_id: String,
310 seats: Vec<SeatTransportReport>,
311 },
312
313 AnnounceTransport {
315 #[serde(default)]
316 endpoint: Option<TransportEndpoint>,
317 },
318
319 SignalPeer {
321 to: String,
322 payload: serde_json::Value,
323 },
324
325 ReportPlaneQuality {
327 report: PlaneQualityReport,
328 },
329
330 SendChat {
331 scope: ChatScope,
332 text: String,
333 },
334
335 InviteToRoom {
336 username: String,
337 },
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(tag = "type")]
342pub enum ServerMessage {
343 Pong,
344
345 AuthResult {
346 success: bool,
347 player_id: Option<String>,
348 reconnected: Option<bool>,
349 error: Option<String>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 features: Vec<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
359 art_base_url: Option<String>,
360 },
361
362 SessionTakenOver,
363
364 RoomList {
365 rooms: Vec<RoomInfo>,
366 },
367
368 PlayerList {
369 players: Vec<PlayerInfo>,
370 },
371
372 RoomCreated {
373 room_id: String,
374 room_name: String,
375 room: RoomInfo,
376 #[serde(default)]
377 resume_token: Option<String>,
378 },
379
380 RoomResumed {
381 room: RoomInfo,
382 },
383
384 PlayerJoined {
385 room_id: String,
386 username: String,
387 },
388
389 PlayerLeft {
390 room_id: String,
391 username: String,
392 },
393
394 PlayerConnected {
395 username: String,
396 },
397
398 PlayerDisconnected {
399 username: String,
400 },
401
402 ReadyStateChanged {
403 username: String,
404 ready: bool,
405 },
406
407 RoomUpdate {
408 room: RoomInfo,
409 },
410
411 GameStarted {
412 room_id: String,
413 game_id: String,
414 player_order: Vec<String>,
415 player_decks: Vec<PlayerDeckInfo>,
416 starting_life: i32,
417 },
418
419 StateUpdate {
420 from_player: String,
421 state: serde_json::Value,
422 },
423
424 TurnChanged {
425 from_player: String,
426 new_active_player: String,
427 turn_number: u32,
428 },
429
430 GameAborted {
431 room_id: String,
432 },
433
434 Error {
435 code: String,
436 message: String,
437 },
438
439 ServerShuttingDown {
440 reconnect_in_s: u32,
441 },
442 RoomTransport {
444 room_id: String,
445 #[serde(default, skip_serializing_if = "Vec::is_empty")]
447 ice_servers: Vec<IceServer>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
449 host: Option<TransportMember>,
450 members: Vec<TransportMember>,
451 },
452
453 PeerSignal {
455 from: String,
456 payload: serde_json::Value,
457 },
458
459 ChatMessage(ChatMessage),
460
461 ChatHistory {
462 scope: ChatScope,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 room_id: Option<String>,
465 messages: Vec<ChatMessage>,
466 },
467
468 RoomInvite {
469 from: String,
470 room: RoomInfo,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 password: Option<String>,
473 },
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
477pub enum ChatScope {
478 Lobby,
479 Room,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ChatMessage {
484 pub scope: ChatScope,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub room_id: Option<String>,
487 pub from: String,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub avatar_url: Option<String>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub qualification: Option<String>,
492 pub text: String,
493 pub sent_at_ms: u64,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub seal: Option<String>,
496}
497
498pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
499pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
500pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
501pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
502
503#[derive(Debug, Clone, Serialize, Deserialize, TS)]
504#[ts(export, export_to = "lobby/index.ts")]
505pub struct ResumeRoomRequest {
506 pub room_id: String,
507 pub resume_token: String,
508 pub room_name: String,
509 pub max_players: u8,
510 pub format: GameFormat,
511 #[serde(default)]
512 pub hosted: bool,
513 #[serde(default)]
514 pub engine: EngineKind,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
516 #[ts(optional)]
517 pub official_key: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 #[ts(optional)]
520 pub password: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 #[ts(optional)]
523 pub reconnect_timeout_s: Option<u32>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 #[ts(optional)]
526 pub draft_config: Option<DraftConfig>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 #[ts(optional)]
529 pub sealed_config: Option<SealedConfig>,
530 pub player_order: Vec<String>,
531 pub player_decks: Vec<PlayerDeckInfo>,
532 pub starting_life: i32,
533 #[serde(default)]
534 pub bot_players: Vec<String>,
535 pub game_id: String,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct RoomInfo {
540 pub room_id: String,
541 pub room_name: String,
542 pub host: String,
543 #[serde(default)]
544 pub protocol_version: u32,
545 #[serde(default)]
546 pub hosted: bool,
547 #[serde(default)]
548 pub official: bool,
549 #[serde(default)]
550 pub password_protected: bool,
551 pub players: Vec<RoomPlayerInfo>,
552 pub max_players: u8,
553 pub format: GameFormat,
554 pub status: RoomStatus,
555 #[serde(default)]
556 pub engine: EngineKind,
557 #[serde(default = "default_reconnect_timeout_s")]
558 pub reconnect_timeout_s: u32,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub draft_config: Option<DraftConfig>,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub sealed_config: Option<SealedConfig>,
563}
564
565pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
566
567fn default_reconnect_timeout_s() -> u32 {
568 DEFAULT_RECONNECT_TIMEOUT_S
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
572#[ts(export, export_to = "lobby/index.ts")]
573pub struct SealedConfig {
574 #[serde(default, skip_serializing_if = "Option::is_none")]
575 #[ts(optional)]
576 pub set_code: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
578 #[ts(optional)]
579 pub cube_id: Option<String>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 #[ts(optional)]
582 pub cube_name: Option<String>,
583 #[serde(default)]
584 pub singleton: bool,
585 pub num_boosters: u8,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 #[ts(optional, type = "number")]
588 pub base_seed: Option<u64>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
592#[ts(export, export_to = "lobby/index.ts")]
593pub struct DraftConfig {
594 #[serde(default, skip_serializing_if = "Option::is_none")]
595 #[ts(optional)]
596 pub set_code: Option<String>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 #[ts(optional)]
599 pub cube_id: Option<String>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 #[ts(optional)]
602 pub cube_name: Option<String>,
603 pub rounds: u8,
604 pub picks_per_pass: u8,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 #[ts(optional, type = "number")]
607 pub seed: Option<u64>,
608 pub fill_with_bots: bool,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
612pub struct RoomPlayerInfo {
613 pub username: String,
614 pub ready: bool,
615 pub connected: bool,
616 #[serde(default)]
617 pub is_bot: bool,
618 #[serde(skip_serializing_if = "Option::is_none")]
619 pub selected_deck_name: Option<String>,
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct PlayerInfo {
624 pub username: String,
625 pub player_id: String,
626 pub connected: bool,
627 #[serde(default)]
628 pub verified: bool,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub qualification: Option<String>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub avatar_url: Option<String>,
633 #[serde(skip_serializing_if = "Option::is_none")]
634 pub room_id: Option<String>,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub local_game: Option<LocalGameKind>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub seal: Option<String>,
643}
644
645pub const FEATURE_LOCAL_GAME: &str = "local_game";
647pub const FEATURE_CHAT: &str = "chat";
649pub const FEATURE_ROOM_INVITES: &str = "room_invites";
651pub const FEATURE_GAME_OUTCOME: &str = "game_outcome";
653
654pub const FEATURE_ROOM_TRANSPORT: &str = "room_transport";
656
657pub const FEATURE_PEER_SIGNAL: &str = "peer_signal";
659
660pub const FEATURE_PLANE_QUALITY: &str = "plane_quality";
662
663pub const FEATURES: &[&str] = &[
664 FEATURE_LOCAL_GAME,
665 FEATURE_ROOM_TRANSPORT,
666 FEATURE_PEER_SIGNAL,
667 FEATURE_PLANE_QUALITY,
668 FEATURE_CHAT,
669 FEATURE_ROOM_INVITES,
670 FEATURE_GAME_OUTCOME,
671];
672
673pub const MAX_SIGNAL_BYTES: usize = 16 * 1024;
675
676pub const MAX_FATAL_MESSAGE_CHARS: usize = 500;
678
679#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
682pub struct GameOutcomeReport {
683 pub game_over: bool,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub winner_slot: Option<String>,
686 #[serde(default, skip_serializing_if = "Vec::is_empty")]
687 pub conceded_slots: Vec<String>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub fatal_message: Option<String>,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub turns: Option<u32>,
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
696pub enum LocalGameKind {
697 Singleplayer,
699}
700
701impl LocalGameKind {
702 pub const ALL: &'static [Self] = &[Self::Singleplayer];
705
706 pub fn as_str(self) -> &'static str {
707 match self {
708 Self::Singleplayer => "singleplayer",
709 }
710 }
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714pub enum RoomStatus {
715 Lobby,
716 InGame,
717}