Skip to main content

manabrew_server/
room.rs

1use std::time::Instant;
2
3use crate::protocol::{
4    DraftConfig, EngineKind, GameFormat, PlayerDeckInfo, RoomInfo, RoomPlayerInfo, RoomStatus,
5    SealedConfig,
6};
7use crate::replay::GameReplayCache;
8use manabrew_protocol::deck_dto::Deck;
9
10#[derive(Debug, Clone)]
11pub struct RoomSlot {
12    pub player_id: String,
13    pub username: String,
14    pub ready: bool,
15    pub connected: bool,
16    pub is_bot: bool,
17    pub selected_deck_name: Option<String>,
18    pub selected_deck: Option<Deck>,
19    pub selected_commander_name: Option<String>,
20    pub avatar: Option<String>,
21}
22
23#[derive(Debug, Clone)]
24pub struct RoomObserver {
25    pub player_id: String,
26    pub connected: bool,
27}
28
29#[derive(Debug)]
30pub struct Room {
31    pub room_id: String,
32    pub room_name: String,
33    pub protocol_version: u32,
34    pub host_player_id: String,
35    pub host_username: String,
36    pub hosted: bool,
37    pub official: bool,
38    pub password: Option<String>,
39    pub max_players: u8,
40    pub format: GameFormat,
41    pub engine: EngineKind,
42    pub status: RoomStatus,
43    pub players: Vec<RoomSlot>,
44    pub observers: Vec<RoomObserver>,
45    pub draft_config: Option<DraftConfig>,
46    pub sealed_config: Option<SealedConfig>,
47    pub reconnect_timeout_s: u32,
48    pub replay: Option<GameReplayCache>,
49    pub resume_token: String,
50    pub humanless_since: Option<Instant>,
51}
52
53impl Room {
54    pub fn new(
55        room_id: String,
56        room_name: String,
57        protocol_version: u32,
58        host_player_id: String,
59        host_username: String,
60        max_players: u8,
61        format: GameFormat,
62        engine: EngineKind,
63        host_plays: bool,
64        draft_config: Option<DraftConfig>,
65        sealed_config: Option<SealedConfig>,
66        official: bool,
67        password: Option<String>,
68        reconnect_timeout_s: u32,
69    ) -> Self {
70        let max_players = max_players.clamp(2, 8);
71        let (players, observers) = if host_plays {
72            (
73                vec![RoomSlot {
74                    player_id: host_player_id.clone(),
75                    username: host_username.clone(),
76                    ready: false,
77                    connected: true,
78                    is_bot: false,
79                    selected_deck_name: None,
80                    selected_deck: None,
81                    selected_commander_name: None,
82                    avatar: None,
83                }],
84                vec![],
85            )
86        } else {
87            (
88                vec![],
89                vec![RoomObserver {
90                    player_id: host_player_id.clone(),
91                    connected: true,
92                }],
93            )
94        };
95        Room {
96            room_id,
97            room_name,
98            protocol_version,
99            host_player_id: host_player_id.clone(),
100            host_username,
101            hosted: !host_plays,
102            official,
103            password,
104            max_players,
105            format,
106            engine,
107            status: RoomStatus::Lobby,
108            players,
109            observers,
110            draft_config,
111            sealed_config,
112            reconnect_timeout_s,
113            replay: None,
114            resume_token: String::new(),
115            humanless_since: None,
116        }
117    }
118
119    pub fn is_full(&self) -> bool {
120        self.players.len() >= self.max_players as usize
121    }
122
123    pub fn is_empty(&self) -> bool {
124        self.players.is_empty() && self.observers.is_empty()
125    }
126
127    pub fn is_limited_session(&self) -> bool {
128        matches!(
129            self.format,
130            GameFormat::Any | GameFormat::Draft | GameFormat::Sealed
131        ) && (self.draft_config.is_some() || self.sealed_config.is_some())
132    }
133
134    pub fn reset_lobby_settings(&mut self) {
135        if self.draft_config.is_none() && self.sealed_config.is_none() {
136            self.format = GameFormat::Any;
137            self.max_players = 4;
138        }
139    }
140
141    pub fn all_ready(&self) -> bool {
142        let min_players = if self.is_limited_session() { 1 } else { 2 };
143        let controller_id = self.controller_id();
144        self.players.len() >= min_players
145            && self
146                .players
147                .iter()
148                .filter(|p| Some(p.player_id.as_str()) != controller_id)
149                .all(|p| p.ready)
150    }
151
152    pub fn add_player(
153        &mut self,
154        player_id: String,
155        username: String,
156        is_bot: bool,
157    ) -> Result<(), String> {
158        if self.is_full() {
159            return Err("Room is full".into());
160        }
161        if self.status != RoomStatus::Lobby {
162            return Err("Game already started".into());
163        }
164        if self.players.iter().any(|p| p.player_id == player_id) {
165            return Err("Already in this room".into());
166        }
167        self.players.push(RoomSlot {
168            player_id,
169            username,
170            ready: false,
171            connected: true,
172            is_bot,
173            selected_deck_name: None,
174            selected_deck: None,
175            selected_commander_name: None,
176            avatar: None,
177        });
178        Ok(())
179    }
180
181    pub fn add_observer(&mut self, player_id: String, _username: String) -> Result<(), String> {
182        if self.players.iter().any(|p| p.player_id == player_id)
183            || self.observers.iter().any(|p| p.player_id == player_id)
184        {
185            return Err("Already in this room".into());
186        }
187        self.observers.push(RoomObserver {
188            player_id,
189            connected: true,
190        });
191        Ok(())
192    }
193
194    pub fn remove_player(&mut self, player_id: &str) -> Option<RoomSlot> {
195        if let Some(idx) = self.players.iter().position(|p| p.player_id == player_id) {
196            let slot = self.players.remove(idx);
197            // If the host left, promote the first remaining player
198            if self.host_player_id == player_id {
199                if let Some(new_host) = self.players.first() {
200                    self.host_player_id = new_host.player_id.clone();
201                    self.host_username = new_host.username.clone();
202                }
203            }
204            Some(slot)
205        } else {
206            None
207        }
208    }
209
210    pub fn remove_observer(&mut self, player_id: &str) -> Option<RoomObserver> {
211        self.observers
212            .iter()
213            .position(|p| p.player_id == player_id)
214            .map(|idx| self.observers.remove(idx))
215    }
216
217    pub fn remove_participant(&mut self, player_id: &str) -> bool {
218        self.remove_player(player_id).is_some() || self.remove_observer(player_id).is_some()
219    }
220
221    pub fn set_connected(&mut self, player_id: &str, connected: bool) {
222        if let Some(slot) = self.players.iter_mut().find(|p| p.player_id == player_id) {
223            slot.connected = connected;
224            return;
225        }
226        if let Some(observer) = self.observers.iter_mut().find(|p| p.player_id == player_id) {
227            observer.connected = connected;
228        }
229    }
230
231    /// Forward-ported for future use when room cleanup logic is implemented.
232    #[allow(dead_code)]
233    pub fn all_disconnected(&self) -> bool {
234        self.players.iter().all(|p| !p.connected) && self.observers.iter().all(|p| !p.connected)
235    }
236
237    pub fn has_connected_human(&self) -> bool {
238        self.players.iter().any(|p| p.connected && !p.is_bot)
239            || self
240                .observers
241                .iter()
242                .any(|o| o.connected && !(self.hosted && o.player_id == self.host_player_id))
243    }
244
245    pub fn set_ready(&mut self, player_id: &str, ready: bool) -> Result<(), String> {
246        if let Some(slot) = self.players.iter_mut().find(|p| p.player_id == player_id) {
247            slot.ready = ready;
248            Ok(())
249        } else {
250            Err("Player not in room".into())
251        }
252    }
253
254    pub fn set_deck_selection(
255        &mut self,
256        player_id: &str,
257        deck_name: String,
258        deck: Deck,
259        commander_name: Option<String>,
260        avatar: Option<String>,
261    ) -> Result<(), String> {
262        if let Some(slot) = self.players.iter_mut().find(|p| p.player_id == player_id) {
263            slot.selected_deck_name = Some(deck_name);
264            slot.selected_deck = Some(deck);
265            slot.selected_commander_name = commander_name;
266            slot.avatar = avatar;
267            slot.ready = false;
268            Ok(())
269        } else {
270            Err("Player not in room".into())
271        }
272    }
273
274    pub fn has_selected_deck(&self, player_id: &str) -> bool {
275        self.players
276            .iter()
277            .find(|p| p.player_id == player_id)
278            .map(|p| p.selected_deck.is_some() && p.selected_deck_name.is_some())
279            .unwrap_or(false)
280    }
281
282    pub fn player_decks(&self) -> Vec<PlayerDeckInfo> {
283        self.players
284            .iter()
285            .filter_map(|p| {
286                p.selected_deck.clone().map(|deck| PlayerDeckInfo {
287                    username: p.username.clone(),
288                    deck_name: p
289                        .selected_deck_name
290                        .clone()
291                        .unwrap_or_else(|| "Unknown Deck".to_string()),
292                    deck,
293                    commander_name: p.selected_commander_name.clone(),
294                    avatar: p.avatar.clone(),
295                })
296            })
297            .collect()
298    }
299
300    pub fn is_host(&self, player_id: &str) -> bool {
301        self.host_player_id == player_id
302    }
303
304    /// The room controller is the first human (non-bot) player to take a seat.
305    /// They drive the lobby (format, seats, bots, start) regardless of who holds
306    /// the engine (`host`). Computed dynamically from current seats, so if the
307    /// controlling human leaves, control passes to the next human — never to a
308    /// bot. Falls back to the first seat only if every player is a bot.
309    pub fn controller_id(&self) -> Option<&str> {
310        self.players
311            .iter()
312            .find(|p| !p.is_bot)
313            .or_else(|| self.players.first())
314            .map(|p| p.player_id.as_str())
315    }
316
317    pub fn is_controller(&self, player_id: &str) -> bool {
318        self.controller_id() == Some(player_id)
319    }
320
321    pub fn host_username(&self) -> String {
322        self.host_username.clone()
323    }
324
325    pub fn host_is_player(&self) -> bool {
326        self.players
327            .iter()
328            .any(|p| p.player_id == self.host_player_id)
329    }
330
331    pub fn host_connected(&self) -> bool {
332        self.players
333            .iter()
334            .any(|p| p.player_id == self.host_player_id && p.connected)
335            || self
336                .observers
337                .iter()
338                .any(|p| p.player_id == self.host_player_id && p.connected)
339    }
340
341    pub fn player_usernames(&self) -> Vec<String> {
342        self.players.iter().map(|p| p.username.clone()).collect()
343    }
344
345    /// Forward-ported for future use when full player ID tracking is needed.
346    #[allow(dead_code)]
347    pub fn player_ids(&self) -> Vec<String> {
348        self.players.iter().map(|p| p.player_id.clone()).collect()
349    }
350
351    pub fn connected_player_ids(&self) -> Vec<String> {
352        self.players
353            .iter()
354            .filter(|p| p.connected)
355            .map(|p| p.player_id.clone())
356            .chain(
357                self.observers
358                    .iter()
359                    .filter(|p| p.connected)
360                    .map(|p| p.player_id.clone()),
361            )
362            .collect()
363    }
364
365    pub fn to_room_info(&self) -> RoomInfo {
366        RoomInfo {
367            room_id: self.room_id.clone(),
368            room_name: self.room_name.clone(),
369            protocol_version: self.protocol_version,
370            host: self.host_username(),
371            hosted: self.hosted,
372            official: self.official,
373            password_protected: self.password.is_some(),
374            players: self
375                .players
376                .iter()
377                .map(|p| RoomPlayerInfo {
378                    username: p.username.clone(),
379                    ready: p.ready,
380                    connected: p.connected,
381                    is_bot: p.is_bot,
382                    selected_deck_name: p.selected_deck_name.clone(),
383                })
384                .collect(),
385            max_players: self.max_players,
386            format: self.format.clone(),
387            engine: self.engine,
388            reconnect_timeout_s: self.reconnect_timeout_s,
389            status: self.status.clone(),
390            draft_config: self.draft_config.clone(),
391            sealed_config: self.sealed_config.clone(),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn room(host_plays: bool) -> Room {
401        Room::new(
402            "r".into(),
403            "room".into(),
404            manabrew_protocol::protocol::PROTOCOL_VERSION,
405            "host".into(),
406            "host".into(),
407            4,
408            GameFormat::Commander,
409            EngineKind::Forge,
410            host_plays,
411            None,
412            None,
413            false,
414            None,
415            60,
416        )
417    }
418
419    #[test]
420    fn first_user_is_controller_in_hosted_room() {
421        let mut r = room(false);
422        assert!(r.is_host("host"));
423        assert!(!r.is_controller("host"));
424        r.add_player("human".into(), "human".into(), false).unwrap();
425        assert!(r.is_controller("human"));
426        r.add_player("bot".into(), "bot".into(), true).unwrap();
427        assert!(!r.is_controller("bot"));
428    }
429}