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 RequestResync,
287
288 BroadcastState {
289 state: serde_json::Value,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 target_player: Option<String>,
293 },
294
295 TurnChange {
296 new_active_player: String,
297 turn_number: u32,
298 },
299 ReportTransport {
301 game_id: String,
302 seats: Vec<SeatTransportReport>,
303 },
304
305 AnnounceTransport {
307 #[serde(default)]
308 endpoint: Option<TransportEndpoint>,
309 },
310
311 SignalPeer {
313 to: String,
314 payload: serde_json::Value,
315 },
316
317 ReportPlaneQuality {
319 report: PlaneQualityReport,
320 },
321
322 SendChat {
323 scope: ChatScope,
324 text: String,
325 },
326
327 InviteToRoom {
328 username: String,
329 },
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[serde(tag = "type")]
334pub enum ServerMessage {
335 Pong,
336
337 AuthResult {
338 success: bool,
339 player_id: Option<String>,
340 reconnected: Option<bool>,
341 error: Option<String>,
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 features: Vec<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
351 art_base_url: Option<String>,
352 },
353
354 SessionTakenOver,
355
356 RoomList {
357 rooms: Vec<RoomInfo>,
358 },
359
360 PlayerList {
361 players: Vec<PlayerInfo>,
362 },
363
364 RoomCreated {
365 room_id: String,
366 room_name: String,
367 room: RoomInfo,
368 #[serde(default)]
369 resume_token: Option<String>,
370 },
371
372 RoomResumed {
373 room: RoomInfo,
374 },
375
376 PlayerJoined {
377 room_id: String,
378 username: String,
379 },
380
381 PlayerLeft {
382 room_id: String,
383 username: String,
384 },
385
386 PlayerConnected {
387 username: String,
388 },
389
390 PlayerDisconnected {
391 username: String,
392 },
393
394 ReadyStateChanged {
395 username: String,
396 ready: bool,
397 },
398
399 RoomUpdate {
400 room: RoomInfo,
401 },
402
403 GameStarted {
404 room_id: String,
405 game_id: String,
406 player_order: Vec<String>,
407 player_decks: Vec<PlayerDeckInfo>,
408 starting_life: i32,
409 },
410
411 StateUpdate {
412 from_player: String,
413 state: serde_json::Value,
414 },
415
416 TurnChanged {
417 from_player: String,
418 new_active_player: String,
419 turn_number: u32,
420 },
421
422 GameAborted {
423 room_id: String,
424 },
425
426 Error {
427 code: String,
428 message: String,
429 },
430
431 ServerShuttingDown {
432 reconnect_in_s: u32,
433 },
434 RoomTransport {
436 room_id: String,
437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 ice_servers: Vec<IceServer>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 host: Option<TransportMember>,
442 members: Vec<TransportMember>,
443 },
444
445 PeerSignal {
447 from: String,
448 payload: serde_json::Value,
449 },
450
451 ChatMessage(ChatMessage),
452
453 ChatHistory {
454 scope: ChatScope,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 room_id: Option<String>,
457 messages: Vec<ChatMessage>,
458 },
459
460 RoomInvite {
461 from: String,
462 room: RoomInfo,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 password: Option<String>,
465 },
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
469pub enum ChatScope {
470 Lobby,
471 Room,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct ChatMessage {
476 pub scope: ChatScope,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub room_id: Option<String>,
479 pub from: String,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub avatar_url: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub qualification: Option<String>,
484 pub text: String,
485 pub sent_at_ms: u64,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub seal: Option<String>,
488}
489
490pub const CHAT_MESSAGE_MAX_CHARS: usize = 500;
491pub const CHAT_HISTORY_MAX_MESSAGES: usize = 100;
492pub const CHAT_HISTORY_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
493pub const CHAT_MIN_INTERVAL_MS: u64 = 400;
494
495#[derive(Debug, Clone, Serialize, Deserialize, TS)]
496#[ts(export, export_to = "lobby/index.ts")]
497pub struct ResumeRoomRequest {
498 pub room_id: String,
499 pub resume_token: String,
500 pub room_name: String,
501 pub max_players: u8,
502 pub format: GameFormat,
503 #[serde(default)]
504 pub hosted: bool,
505 #[serde(default)]
506 pub engine: EngineKind,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 #[ts(optional)]
509 pub official_key: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 #[ts(optional)]
512 pub password: Option<String>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 #[ts(optional)]
515 pub reconnect_timeout_s: Option<u32>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 #[ts(optional)]
518 pub draft_config: Option<DraftConfig>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 #[ts(optional)]
521 pub sealed_config: Option<SealedConfig>,
522 pub player_order: Vec<String>,
523 pub player_decks: Vec<PlayerDeckInfo>,
524 pub starting_life: i32,
525 #[serde(default)]
526 pub bot_players: Vec<String>,
527 pub game_id: String,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct RoomInfo {
532 pub room_id: String,
533 pub room_name: String,
534 pub host: String,
535 #[serde(default)]
536 pub protocol_version: u32,
537 #[serde(default)]
538 pub hosted: bool,
539 #[serde(default)]
540 pub official: bool,
541 #[serde(default)]
542 pub password_protected: bool,
543 pub players: Vec<RoomPlayerInfo>,
544 pub max_players: u8,
545 pub format: GameFormat,
546 pub status: RoomStatus,
547 #[serde(default)]
548 pub engine: EngineKind,
549 #[serde(default = "default_reconnect_timeout_s")]
550 pub reconnect_timeout_s: u32,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub draft_config: Option<DraftConfig>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub sealed_config: Option<SealedConfig>,
555}
556
557pub const DEFAULT_RECONNECT_TIMEOUT_S: u32 = 60;
558
559fn default_reconnect_timeout_s() -> u32 {
560 DEFAULT_RECONNECT_TIMEOUT_S
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
564#[ts(export, export_to = "lobby/index.ts")]
565pub struct SealedConfig {
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 #[ts(optional)]
568 pub set_code: Option<String>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
570 #[ts(optional)]
571 pub cube_id: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 #[ts(optional)]
574 pub cube_name: Option<String>,
575 #[serde(default)]
576 pub singleton: bool,
577 pub num_boosters: u8,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 #[ts(optional, type = "number")]
580 pub base_seed: Option<u64>,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
584#[ts(export, export_to = "lobby/index.ts")]
585pub struct DraftConfig {
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 #[ts(optional)]
588 pub set_code: Option<String>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 #[ts(optional)]
591 pub cube_id: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 #[ts(optional)]
594 pub cube_name: Option<String>,
595 pub rounds: u8,
596 pub picks_per_pass: u8,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 #[ts(optional, type = "number")]
599 pub seed: Option<u64>,
600 pub fill_with_bots: bool,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct RoomPlayerInfo {
605 pub username: String,
606 pub ready: bool,
607 pub connected: bool,
608 #[serde(default)]
609 pub is_bot: bool,
610 #[serde(skip_serializing_if = "Option::is_none")]
611 pub selected_deck_name: Option<String>,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct PlayerInfo {
616 pub username: String,
617 pub player_id: String,
618 pub connected: bool,
619 #[serde(default)]
620 pub verified: bool,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub qualification: Option<String>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub avatar_url: Option<String>,
625 #[serde(skip_serializing_if = "Option::is_none")]
626 pub room_id: Option<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub local_game: Option<LocalGameKind>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub seal: Option<String>,
635}
636
637pub const FEATURE_LOCAL_GAME: &str = "local_game";
639pub const FEATURE_CHAT: &str = "chat";
641pub const FEATURE_ROOM_INVITES: &str = "room_invites";
643
644pub const FEATURE_ROOM_TRANSPORT: &str = "room_transport";
646
647pub const FEATURE_PEER_SIGNAL: &str = "peer_signal";
649
650pub const FEATURE_PLANE_QUALITY: &str = "plane_quality";
652
653pub const FEATURES: &[&str] = &[
654 FEATURE_LOCAL_GAME,
655 FEATURE_ROOM_TRANSPORT,
656 FEATURE_PEER_SIGNAL,
657 FEATURE_PLANE_QUALITY,
658 FEATURE_CHAT,
659 FEATURE_ROOM_INVITES,
660];
661
662pub const MAX_SIGNAL_BYTES: usize = 16 * 1024;
664
665#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
667pub enum LocalGameKind {
668 Singleplayer,
670}
671
672impl LocalGameKind {
673 pub const ALL: &'static [Self] = &[Self::Singleplayer];
676
677 pub fn as_str(self) -> &'static str {
678 match self {
679 Self::Singleplayer => "singleplayer",
680 }
681 }
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
685pub enum RoomStatus {
686 Lobby,
687 InGame,
688}