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