Skip to main content

manabrew_server/
room.rs

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