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