Skip to main content

manabrew_server/
room.rs

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    /// Announced endpoints by player id. Only the relay binds one to a username.
60    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    /// Forward-ported for future use when room cleanup logic is implemented.
254    #[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    /// The room controller is the first human (non-bot) player to take a seat.
330    /// They drive the lobby (format, seats, bots, start) regardless of who holds
331    /// the engine (`host`). Computed dynamically from current seats, so if the
332    /// controlling human leaves, control passes to the next human — never to a
333    /// bot. Falls back to the first seat only if every player is a bot.
334    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    /// Forward-ported for future use when full player ID tracking is needed.
371    #[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    /// Seats and the host only; observers are not signalling targets.
427    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    /// Records a member's endpoint, or withdraws it with `None`. False means refused.
442    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        // First claim on an endpoint id holds it.
453        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    /// The attested roster; every username is the relay's own record.
468    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    /// Whether every human seat has announced. Bots never count.
490    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}