1use std::collections::HashMap;
2use std::time::Instant;
3
4use crate::chat::ChatHistory;
5use crate::protocol::{
6 DraftConfig, EngineKind, GameFormat, PlayerDeckInfo, RoomInfo, RoomPlayerInfo, RoomStatus,
7 SealedConfig, TransportEndpoint, TransportMember,
8};
9use crate::replay::GameReplayCache;
10use manabrew_protocol::deck_dto::Deck;
11
12#[derive(Debug, Clone)]
13pub struct RoomSlot {
14 pub player_id: String,
15 pub username: String,
16 pub ready: bool,
17 pub connected: bool,
18 pub is_bot: bool,
19 pub selected_deck_name: Option<String>,
20 pub selected_deck: Option<Deck>,
21 pub published_deck_id: Option<String>,
22 pub selected_commander_name: Option<String>,
23 pub avatar_url: Option<String>,
24}
25
26#[derive(Debug, Clone)]
27pub struct RoomObserver {
28 pub player_id: String,
29 pub connected: bool,
30}
31
32#[derive(Debug)]
33pub struct Room {
34 pub room_id: String,
35 pub room_name: String,
36 pub protocol_version: u32,
37 pub host_player_id: String,
38 pub host_username: String,
39 pub hosted: bool,
40 pub official: bool,
41 pub password: Option<String>,
42 pub max_players: u8,
43 pub format: GameFormat,
44 pub engine: EngineKind,
45 pub status: RoomStatus,
46 pub players: Vec<RoomSlot>,
47 pub observers: Vec<RoomObserver>,
48 pub draft_config: Option<DraftConfig>,
49 pub sealed_config: Option<SealedConfig>,
50 pub reconnect_timeout_s: u32,
51 pub table_style: Option<String>,
52 pub replay: Option<GameReplayCache>,
53 pub resume_token: String,
54 pub humanless_since: Option<Instant>,
55 pub limited_session_id: Option<String>,
56 pub draft_clock: Option<crate::draft_clock::DraftClock>,
57 pub parent_limited_room: Option<String>,
58 pub limited_matches: Vec<String>,
59 pub transports: HashMap<String, TransportEndpoint>,
61 pub chat: ChatHistory,
62}
63
64impl Room {
65 #[allow(clippy::too_many_arguments)]
66 pub fn new(
67 room_id: String,
68 room_name: String,
69 protocol_version: u32,
70 host_player_id: String,
71 host_username: String,
72 max_players: u8,
73 format: GameFormat,
74 engine: EngineKind,
75 host_plays: bool,
76 draft_config: Option<DraftConfig>,
77 sealed_config: Option<SealedConfig>,
78 official: bool,
79 password: Option<String>,
80 reconnect_timeout_s: u32,
81 table_style: Option<String>,
82 ) -> Self {
83 let max_players = max_players.clamp(2, 8);
84 let (players, observers) = if host_plays {
85 (
86 vec![RoomSlot {
87 player_id: host_player_id.clone(),
88 username: host_username.clone(),
89 ready: false,
90 connected: true,
91 is_bot: false,
92 selected_deck_name: None,
93 selected_deck: None,
94 published_deck_id: None,
95 selected_commander_name: None,
96 avatar_url: None,
97 }],
98 vec![],
99 )
100 } else {
101 (
102 vec![],
103 vec![RoomObserver {
104 player_id: host_player_id.clone(),
105 connected: true,
106 }],
107 )
108 };
109 Room {
110 room_id,
111 room_name,
112 protocol_version,
113 host_player_id: host_player_id.clone(),
114 host_username,
115 hosted: !host_plays,
116 official,
117 password,
118 max_players,
119 format,
120 engine,
121 status: RoomStatus::Lobby,
122 players,
123 observers,
124 draft_config,
125 sealed_config,
126 reconnect_timeout_s,
127 table_style,
128 replay: None,
129 resume_token: uuid::Uuid::new_v4().to_string(),
130 humanless_since: None,
131 limited_session_id: None,
132 draft_clock: None,
133 parent_limited_room: None,
134 limited_matches: Vec::new(),
135 transports: HashMap::new(),
136 chat: ChatHistory::default(),
137 }
138 }
139
140 pub fn is_full(&self) -> bool {
141 self.players.len() >= self.max_players as usize
142 }
143
144 pub fn is_empty(&self) -> bool {
145 self.players.is_empty() && self.observers.is_empty()
146 }
147
148 pub fn is_limited_session(&self) -> bool {
149 matches!(
150 self.format,
151 GameFormat::Any | GameFormat::Draft | GameFormat::Sealed
152 ) && (self.draft_config.is_some() || self.sealed_config.is_some())
153 }
154
155 pub fn reset_lobby_settings(&mut self) {
156 if self.draft_config.is_none() && self.sealed_config.is_none() {
157 self.format = GameFormat::Any;
158 self.max_players = 4;
159 }
160 }
161
162 pub fn all_ready(&self) -> bool {
163 let min_players = if self.is_limited_session() { 1 } else { 2 };
164 let controller_id = self.controller_id();
165 self.players.len() >= min_players
166 && self
167 .players
168 .iter()
169 .filter(|p| Some(p.player_id.as_str()) != controller_id)
170 .all(|p| p.ready)
171 }
172
173 pub fn add_player(
174 &mut self,
175 player_id: String,
176 username: String,
177 is_bot: bool,
178 ) -> Result<(), String> {
179 if self.is_full() {
180 return Err("Room is full".into());
181 }
182 if self.status != RoomStatus::Lobby {
183 return Err("Game already started".into());
184 }
185 if self.players.iter().any(|p| p.player_id == player_id) {
186 return Err("Already in this room".into());
187 }
188 self.players.push(RoomSlot {
189 player_id,
190 username,
191 ready: false,
192 connected: true,
193 is_bot,
194 selected_deck_name: None,
195 selected_deck: None,
196 published_deck_id: None,
197 selected_commander_name: None,
198 avatar_url: None,
199 });
200 Ok(())
201 }
202
203 pub fn add_observer(&mut self, player_id: String, _username: String) -> Result<(), String> {
204 if self.players.iter().any(|p| p.player_id == player_id)
205 || self.observers.iter().any(|p| p.player_id == player_id)
206 {
207 return Err("Already in this room".into());
208 }
209 self.observers.push(RoomObserver {
210 player_id,
211 connected: true,
212 });
213 Ok(())
214 }
215
216 pub fn remove_player(&mut self, player_id: &str) -> Option<RoomSlot> {
217 if let Some(idx) = self.players.iter().position(|p| p.player_id == player_id) {
218 let slot = self.players.remove(idx);
219 if self.host_player_id == player_id && self.limited_session_id.is_none() {
220 if let Some(new_host) = self.players.first() {
221 self.host_player_id = new_host.player_id.clone();
222 self.host_username = new_host.username.clone();
223 }
224 }
225 Some(slot)
226 } else {
227 None
228 }
229 }
230
231 pub fn remove_observer(&mut self, player_id: &str) -> Option<RoomObserver> {
232 self.observers
233 .iter()
234 .position(|p| p.player_id == player_id)
235 .map(|idx| self.observers.remove(idx))
236 }
237
238 pub fn remove_participant(&mut self, player_id: &str) -> bool {
239 self.transports.remove(player_id);
240 self.remove_player(player_id).is_some() || self.remove_observer(player_id).is_some()
241 }
242
243 pub fn set_connected(&mut self, player_id: &str, connected: bool) {
244 if let Some(slot) = self.players.iter_mut().find(|p| p.player_id == player_id) {
245 slot.connected = connected;
246 return;
247 }
248 if let Some(observer) = self.observers.iter_mut().find(|p| p.player_id == player_id) {
249 observer.connected = connected;
250 }
251 }
252
253 #[allow(dead_code)]
255 pub fn all_disconnected(&self) -> bool {
256 self.players.iter().all(|p| !p.connected) && self.observers.iter().all(|p| !p.connected)
257 }
258
259 pub fn has_connected_human(&self) -> bool {
260 self.players.iter().any(|p| p.connected && !p.is_bot)
261 || self
262 .observers
263 .iter()
264 .any(|o| o.connected && !(self.hosted && o.player_id == self.host_player_id))
265 }
266
267 pub fn set_ready(&mut self, player_id: &str, ready: bool) -> Result<(), String> {
268 if let Some(slot) = self.players.iter_mut().find(|p| p.player_id == player_id) {
269 slot.ready = ready;
270 Ok(())
271 } else {
272 Err("Player not in room".into())
273 }
274 }
275
276 pub fn set_deck_selection(
277 &mut self,
278 player_id: &str,
279 deck_name: String,
280 deck: Deck,
281 published_deck_id: Option<String>,
282 commander_name: Option<String>,
283 avatar_url: Option<String>,
284 ) -> Result<(), String> {
285 if let Some(slot) = self.players.iter_mut().find(|p| p.player_id == player_id) {
286 slot.selected_deck_name = Some(deck_name);
287 slot.selected_deck = Some(deck);
288 slot.published_deck_id = published_deck_id;
289 slot.selected_commander_name = commander_name;
290 slot.avatar_url = avatar_url;
291 slot.ready = false;
292 Ok(())
293 } else {
294 Err("Player not in room".into())
295 }
296 }
297
298 pub fn has_selected_deck(&self, player_id: &str) -> bool {
299 self.players
300 .iter()
301 .find(|p| p.player_id == player_id)
302 .map(|p| p.selected_deck.is_some() && p.selected_deck_name.is_some())
303 .unwrap_or(false)
304 }
305
306 pub fn player_decks(&self) -> Vec<PlayerDeckInfo> {
307 self.players
308 .iter()
309 .filter_map(|p| {
310 p.selected_deck.clone().map(|deck| PlayerDeckInfo {
311 username: p.username.clone(),
312 deck_name: p
313 .selected_deck_name
314 .clone()
315 .unwrap_or_else(|| "Unknown Deck".to_string()),
316 deck,
317 published_deck_id: p.published_deck_id.clone(),
318 commander_name: p.selected_commander_name.clone(),
319 avatar_url: p.avatar_url.clone(),
320 })
321 })
322 .collect()
323 }
324
325 pub fn is_host(&self, player_id: &str) -> bool {
326 self.host_player_id == player_id
327 }
328
329 pub fn controller_id(&self) -> Option<&str> {
335 self.players
336 .iter()
337 .find(|p| !p.is_bot)
338 .or_else(|| self.players.first())
339 .map(|p| p.player_id.as_str())
340 }
341
342 pub fn is_controller(&self, player_id: &str) -> bool {
343 self.controller_id() == Some(player_id)
344 }
345
346 pub fn host_username(&self) -> String {
347 self.host_username.clone()
348 }
349
350 pub fn host_is_player(&self) -> bool {
351 self.players
352 .iter()
353 .any(|p| p.player_id == self.host_player_id)
354 }
355
356 pub fn host_connected(&self) -> bool {
357 self.players
358 .iter()
359 .any(|p| p.player_id == self.host_player_id && p.connected)
360 || self
361 .observers
362 .iter()
363 .any(|p| p.player_id == self.host_player_id && p.connected)
364 }
365
366 pub fn player_usernames(&self) -> Vec<String> {
367 self.players.iter().map(|p| p.username.clone()).collect()
368 }
369
370 #[allow(dead_code)]
372 pub fn player_ids(&self) -> Vec<String> {
373 self.players.iter().map(|p| p.player_id.clone()).collect()
374 }
375
376 pub fn connected_player_ids(&self) -> Vec<String> {
377 self.players
378 .iter()
379 .filter(|p| p.connected)
380 .map(|p| p.player_id.clone())
381 .chain(
382 self.observers
383 .iter()
384 .filter(|p| p.connected)
385 .map(|p| p.player_id.clone()),
386 )
387 .collect()
388 }
389
390 pub fn to_room_info(&self) -> RoomInfo {
391 RoomInfo {
392 room_id: self.room_id.clone(),
393 room_name: self.room_name.clone(),
394 protocol_version: self.protocol_version,
395 host: self.host_username(),
396 hosted: self.hosted,
397 official: self.official,
398 password_protected: self.password.is_some(),
399 players: self
400 .players
401 .iter()
402 .map(|p| RoomPlayerInfo {
403 username: p.username.clone(),
404 ready: p.ready,
405 connected: p.connected,
406 is_bot: p.is_bot,
407 selected_deck_name: p.selected_deck_name.clone(),
408 })
409 .collect(),
410 max_players: self.max_players,
411 format: self.format.clone(),
412 engine: self.engine,
413 reconnect_timeout_s: self.reconnect_timeout_s,
414 table_style: self.table_style.clone(),
415 status: self.status.clone(),
416 draft_config: self.draft_config.clone(),
417 sealed_config: self.sealed_config.clone(),
418 }
419 }
420}
421
422const MAX_DIRECT_ADDRS: usize = 16;
423const MAX_ADDR_LEN: usize = 64;
424
425impl Room {
426 pub fn participant_id_by_username(&self, username: &str) -> Option<String> {
428 if let Some(slot) = self.players.iter().find(|p| p.username == username) {
429 return Some(slot.player_id.clone());
430 }
431 (self.host_username == username).then(|| self.host_player_id.clone())
432 }
433
434 fn participant_username(&self, player_id: &str) -> Option<String> {
435 if let Some(slot) = self.players.iter().find(|p| p.player_id == player_id) {
436 return Some(slot.username.clone());
437 }
438 (self.host_player_id == player_id).then(|| self.host_username.clone())
439 }
440
441 pub fn set_transport(&mut self, player_id: &str, endpoint: Option<TransportEndpoint>) -> bool {
443 let participant = self.players.iter().any(|p| p.player_id == player_id)
444 || self.observers.iter().any(|p| p.player_id == player_id);
445 if !participant {
446 return false;
447 }
448 let Some(mut endpoint) = endpoint else {
449 self.transports.remove(player_id);
450 return true;
451 };
452 let taken = self.transports.iter().any(|(other, existing)| {
454 other != player_id && existing.endpoint_id == endpoint.endpoint_id
455 });
456 if taken {
457 return false;
458 }
459 endpoint
460 .direct_addrs
461 .retain(|addr| addr.len() <= MAX_ADDR_LEN);
462 endpoint.direct_addrs.truncate(MAX_DIRECT_ADDRS);
463 self.transports.insert(player_id.to_string(), endpoint);
464 true
465 }
466
467 pub fn transport_members(&self) -> Vec<TransportMember> {
469 let mut members: Vec<TransportMember> = self
470 .transports
471 .iter()
472 .filter_map(|(player_id, endpoint)| {
473 self.participant_username(player_id)
474 .map(|username| TransportMember {
475 username,
476 endpoint: endpoint.clone(),
477 host: player_id == &self.host_player_id,
478 })
479 })
480 .collect();
481 members.sort_by(|a, b| a.username.cmp(&b.username));
482 members
483 }
484
485 pub fn transport_host(&self) -> Option<TransportMember> {
486 self.transport_members().into_iter().find(|m| m.host)
487 }
488
489 pub fn transport_consented(&self) -> bool {
491 self.players
492 .iter()
493 .filter(|p| !p.is_bot)
494 .all(|p| self.transports.contains_key(&p.player_id))
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn room(host_plays: bool) -> Room {
503 Room::new(
504 "r".into(),
505 "room".into(),
506 manabrew_relay_protocol::PROTOCOL_VERSION,
507 "host".into(),
508 "host".into(),
509 4,
510 GameFormat::Commander,
511 EngineKind::Forge,
512 host_plays,
513 None,
514 None,
515 false,
516 None,
517 60,
518 None,
519 )
520 }
521
522 #[test]
523 fn first_user_is_controller_in_hosted_room() {
524 let mut r = room(false);
525 assert!(r.is_host("host"));
526 assert!(!r.is_controller("host"));
527 r.add_player("human".into(), "human".into(), false).unwrap();
528 assert!(r.is_controller("human"));
529 r.add_player("bot".into(), "bot".into(), true).unwrap();
530 assert!(!r.is_controller("bot"));
531 }
532
533 #[test]
534 fn only_room_members_are_addressable_by_name() {
535 let mut r = room(false);
536 r.add_player("p-human".into(), "human".into(), false)
537 .unwrap();
538 r.add_observer("p-watcher".into(), "watcher".into())
539 .unwrap();
540
541 assert_eq!(
542 r.participant_id_by_username("human"),
543 Some("p-human".into())
544 );
545 assert_eq!(r.participant_id_by_username("host"), Some("host".into()));
546 assert_eq!(r.participant_id_by_username("stranger"), None);
547 assert_eq!(r.participant_id_by_username("watcher"), None);
548 }
549
550 #[test]
551 fn an_endpoint_without_kinds_speaks_nothing() {
552 assert!(!endpoint("a").speaks(manabrew_relay_protocol::TRANSPORT_KIND_WEBRTC));
553
554 let browser = TransportEndpoint {
555 kinds: vec![manabrew_relay_protocol::TRANSPORT_KIND_WEBRTC.into()],
556 ..endpoint("b")
557 };
558 assert!(browser.speaks(manabrew_relay_protocol::TRANSPORT_KIND_WEBRTC));
559 assert!(!browser.speaks("other"));
560 }
561
562 fn endpoint(id: &str) -> TransportEndpoint {
563 TransportEndpoint {
564 endpoint_id: id.into(),
565 relay_url: None,
566 direct_addrs: vec![],
567 kinds: vec![],
568 }
569 }
570
571 #[test]
572 fn only_members_announce_and_nobody_takes_another_endpoint() {
573 let mut r = room(false);
574 r.add_player("human".into(), "human".into(), false).unwrap();
575 r.add_player("squatter".into(), "squatter".into(), false)
576 .unwrap();
577
578 assert!(r.set_transport("human", Some(endpoint("ep-human"))));
579 assert!(!r.set_transport("stranger", Some(endpoint("ep-stranger"))));
580 assert!(!r.set_transport("squatter", Some(endpoint("ep-human"))));
581 assert_eq!(r.transports["human"].endpoint_id, "ep-human");
582
583 r.set_transport("host", Some(endpoint("ep-host")));
584 assert_eq!(r.transport_host().unwrap().username, "host");
585 assert_eq!(r.transport_members().len(), 2);
586
587 r.remove_participant("human");
588 assert!(!r.transports.contains_key("human"));
589 }
590
591 #[test]
592 fn every_human_seat_has_to_announce_before_the_room_leaves_the_relay() {
593 let mut r = room(false);
594 r.add_player("alice".into(), "alice".into(), false).unwrap();
595 r.add_player("bob".into(), "bob".into(), false).unwrap();
596 r.add_player("bot".into(), "bot".into(), true).unwrap();
597 r.set_transport("host", Some(endpoint("ep-host")));
598
599 assert!(r.set_transport("alice", Some(endpoint("ep-alice"))));
600 assert!(!r.transport_consented(), "bob has not opted in");
601
602 assert!(r.set_transport("bob", Some(endpoint("ep-bob"))));
603 assert!(r.transport_consented(), "bots do not have to announce");
604
605 assert!(r.set_transport("bob", None));
606 assert!(!r.transport_consented());
607
608 r.remove_participant("bob");
609 assert!(r.transport_consented());
610 }
611
612 #[test]
613 fn an_announcement_cannot_flood_the_address_book() {
614 let mut r = room(false);
615 r.add_player("human".into(), "human".into(), false).unwrap();
616 let mut flood = endpoint("ep-human");
617 flood.direct_addrs = (0..64).map(|i| format!("10.0.0.{i}:1234")).collect();
618 flood.direct_addrs.push("x".repeat(MAX_ADDR_LEN + 1));
619
620 assert!(r.set_transport("human", Some(flood)));
621 let stored = &r.transports["human"].direct_addrs;
622 assert_eq!(stored.len(), MAX_DIRECT_ADDRS);
623 assert!(stored.iter().all(|addr| addr.len() <= MAX_ADDR_LEN));
624 }
625}