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