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    /// Announced endpoints by player id. Only the relay binds one to a username.
56    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 the host left, promote the first remaining player
212            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    /// Forward-ported for future use when room cleanup logic is implemented.
247    #[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    /// The room controller is the first human (non-bot) player to take a seat.
323    /// They drive the lobby (format, seats, bots, start) regardless of who holds
324    /// the engine (`host`). Computed dynamically from current seats, so if the
325    /// controlling human leaves, control passes to the next human — never to a
326    /// bot. Falls back to the first seat only if every player is a bot.
327    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    /// Forward-ported for future use when full player ID tracking is needed.
364    #[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    /// Seats and the host only; observers are not signalling targets.
420    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    /// Records a member's endpoint, or withdraws it with `None`. False means refused.
435    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        // First claim on an endpoint id holds it.
446        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    /// The attested roster; every username is the relay's own record.
461    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    /// Whether every human seat has announced. Bots never count.
483    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}