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