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 replay: Option<GameReplayCache>,
52    pub resume_token: String,
53    pub humanless_since: Option<Instant>,
54    /// Announced endpoints by player id. Only the relay binds one to a username.
55    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 the host left, promote the first remaining player
209            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    /// Forward-ported for future use when room cleanup logic is implemented.
244    #[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    /// The room controller is the first human (non-bot) player to take a seat.
320    /// They drive the lobby (format, seats, bots, start) regardless of who holds
321    /// the engine (`host`). Computed dynamically from current seats, so if the
322    /// controlling human leaves, control passes to the next human — never to a
323    /// bot. Falls back to the first seat only if every player is a bot.
324    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    /// Forward-ported for future use when full player ID tracking is needed.
361    #[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    /// Seats and the host only; observers are not signalling targets.
416    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    /// Records a member's endpoint, or withdraws it with `None`. False means refused.
431    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        // First claim on an endpoint id holds it.
442        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    /// The attested roster; every username is the relay's own record.
457    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    /// Whether every human seat has announced. Bots never count.
479    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}