Skip to main content

manabrew_server/
lobby.rs

1use std::sync::Arc;
2
3use crate::analytics::{self, GameEndReason};
4use crate::error::ServerError;
5use crate::protocol::{
6    DraftConfig, EngineKind, GameFormat, PlayerDeckInfo, ResumeRoomRequest, RoomInfo, RoomStatus,
7    SealedConfig,
8};
9use crate::replay::GameReplayCache;
10use crate::room::{Room, RoomSlot};
11use crate::state::ServerState;
12use manabrew_protocol::deck_dto::Deck;
13use manabrew_protocol::game::PlaymatSettings;
14use manabrew_relay_protocol::{DEFAULT_RECONNECT_TIMEOUT_S, PROTOCOL_VERSION};
15
16const MIN_RECONNECT_TIMEOUT_S: u32 = 10;
17// Must stay below manabrew_game_runtime 's 120s relay response_timeout
18const MAX_RECONNECT_TIMEOUT_S: u32 = 90;
19
20#[allow(clippy::too_many_arguments)]
21pub fn create_room_sync(
22    state: &Arc<ServerState>,
23    player_id: &str,
24    room_name: String,
25    max_players: u8,
26    format: GameFormat,
27    protocol_version: u32,
28    hosted: bool,
29    engine: EngineKind,
30    draft_config: Option<DraftConfig>,
31    sealed_config: Option<SealedConfig>,
32    official_key: Option<String>,
33    password: Option<String>,
34    reconnect_timeout_s: Option<u32>,
35    table_style: Option<String>,
36) -> Result<(RoomInfo, String), ServerError> {
37    if let Some(cfg) = &draft_config {
38        if cfg
39            .pick_seconds
40            .is_some_and(|seconds| !(5..=600).contains(&seconds))
41        {
42            return Err(ServerError::InvalidDraftConfig(
43                "pick_seconds must be between 5 and 600".into(),
44            ));
45        }
46        match (cfg.set_code.as_ref(), cfg.cube_id.as_ref()) {
47            (Some(_), Some(_)) => {
48                return Err(ServerError::InvalidDraftConfig(
49                    "set_code and cube_id are mutually exclusive".into(),
50                ));
51            }
52            (None, None) => {
53                return Err(ServerError::InvalidDraftConfig(
54                    "set_code or cube_id required".into(),
55                ));
56            }
57            _ => {}
58        }
59    }
60    if let Some(cfg) = &sealed_config {
61        match (cfg.set_code.as_ref(), cfg.cube_id.as_ref()) {
62            (Some(_), Some(_)) => {
63                return Err(ServerError::InvalidDraftConfig(
64                    "sealed set_code and cube_id are mutually exclusive".into(),
65                ));
66            }
67            (None, None) => {
68                return Err(ServerError::InvalidDraftConfig(
69                    "sealed set_code or cube_id required".into(),
70                ));
71            }
72            _ => {}
73        }
74    }
75
76    {
77        if let Some(player) = state.players.get(player_id) {
78            if let Some(rid) = &player.room_id {
79                return Err(ServerError::AlreadyInRoom(rid.clone()));
80            }
81        } else {
82            return Err(ServerError::AuthFailed("Player not found".into()));
83        }
84    }
85
86    if state.rooms.len() >= state.max_rooms {
87        return Err(ServerError::RoomFull("Server room limit reached".into()));
88    }
89
90    let username = state
91        .players
92        .get(player_id)
93        .map(|p| p.username.clone())
94        .unwrap_or_default();
95
96    let official = match &state.official_key {
97        Some(key) => official_key.as_deref() == Some(key.as_str()),
98        None => false,
99    };
100
101    let room_id = uuid::Uuid::new_v4().to_string();
102    let room = Room::new(
103        room_id.clone(),
104        room_name,
105        protocol_version,
106        player_id.to_string(),
107        username,
108        max_players,
109        format,
110        engine,
111        !hosted,
112        draft_config,
113        sealed_config,
114        official,
115        password.filter(|value| !value.is_empty()),
116        reconnect_timeout_s
117            .unwrap_or(DEFAULT_RECONNECT_TIMEOUT_S)
118            .clamp(MIN_RECONNECT_TIMEOUT_S, MAX_RECONNECT_TIMEOUT_S),
119        table_style,
120    );
121    let info = room.to_room_info();
122    let resume_token = room.resume_token.clone();
123
124    state.rooms.insert(room_id.clone(), room);
125
126    if let Some(mut player) = state.players.get_mut(player_id) {
127        player.room_id = Some(room_id);
128    }
129
130    Ok((info, resume_token))
131}
132
133pub struct ResumedRoom {
134    pub room_info: RoomInfo,
135    pub awaiting_rejoin: Vec<String>,
136}
137
138pub fn resume_room_sync(
139    state: &Arc<ServerState>,
140    player_id: &str,
141    spec: ResumeRoomRequest,
142) -> Result<ResumedRoom, ServerError> {
143    let (username, current_room_id) = {
144        let player = state
145            .players
146            .get(player_id)
147            .ok_or_else(|| ServerError::AuthFailed("Player not found".into()))?;
148        (player.username.clone(), player.room_id.clone())
149    };
150    if let Some(rid) = &current_room_id {
151        if rid != &spec.room_id
152            && !state.rooms.get(rid).is_some_and(|room| {
153                room.parent_limited_room.as_deref() == Some(spec.room_id.as_str())
154            })
155        {
156            return Err(ServerError::AlreadyInRoom(rid.clone()));
157        }
158    }
159
160    if let Some(mut room) = state.rooms.get_mut(&spec.room_id) {
161        if room.parent_limited_room.is_some() || room.resume_token != spec.resume_token {
162            return Err(ServerError::InvalidResumeToken);
163        }
164        if room.is_limited_session() && room.host_username != username {
165            return Err(ServerError::InvalidResumeToken);
166        }
167        if room.is_limited_session() && room.status == RoomStatus::Lobby {
168            room.players = resumed_slots(&spec, player_id, &username);
169            room.status = RoomStatus::InGame;
170            room.replay = Some(GameReplayCache::new(
171                spec.game_id.clone(),
172                spec.player_order.clone(),
173                spec.player_decks.clone(),
174                spec.starting_life,
175            ));
176        }
177
178        let old_host_pid = room.host_player_id.clone();
179        room.host_player_id = player_id.to_string();
180        room.host_username = username.clone();
181        if room.hosted {
182            room.remove_observer(&old_host_pid);
183            let _ = room.add_observer(player_id.to_string(), username.clone());
184            room.set_connected(player_id, true);
185        } else if let Some(slot) = room
186            .players
187            .iter_mut()
188            .find(|slot| slot.username == username)
189        {
190            slot.player_id = player_id.to_string();
191            slot.connected = true;
192        }
193        let resumed = ResumedRoom {
194            room_info: room.to_room_info(),
195            awaiting_rejoin: awaiting_rejoin(&room),
196        };
197        drop(room);
198
199        if old_host_pid != player_id {
200            state.players.remove(&old_host_pid);
201        }
202        let current_room_id = state
203            .players
204            .get(player_id)
205            .and_then(|player| player.room_id.clone());
206        let attached_child = current_room_id.as_ref().is_some_and(|id| {
207            state.rooms.get(id).is_some_and(|room| {
208                room.parent_limited_room.as_deref() == Some(spec.room_id.as_str())
209            })
210        });
211        if let Some(mut player) = state.players.get_mut(player_id) {
212            if !attached_child && player.room_id == current_room_id {
213                player.room_id = Some(spec.room_id);
214            }
215        }
216        return Ok(resumed);
217    }
218
219    // Unknown room: the relay restarted and forgot it. The claim is taken on
220    // trust — the presented token is stored so later duplicate claims must
221    // match, but there is nothing left server-side to verify the first one
222    // against, and squatting a UUID room id inside the reconnect window is
223    // not a realistic threat.
224    if state.rooms.len() >= state.max_rooms {
225        return Err(ServerError::RoomFull("Server room limit reached".into()));
226    }
227
228    let official = match &state.official_key {
229        Some(key) => spec.official_key.as_deref() == Some(key.as_str()),
230        None => false,
231    };
232
233    let restored_players = resumed_slots(&spec, player_id, &username);
234    let mut room = Room::new(
235        spec.room_id.clone(),
236        spec.room_name,
237        PROTOCOL_VERSION,
238        player_id.to_string(),
239        username.clone(),
240        spec.max_players,
241        spec.format,
242        spec.engine,
243        !spec.hosted,
244        spec.draft_config,
245        spec.sealed_config,
246        official,
247        spec.password.filter(|value| !value.is_empty()),
248        spec.reconnect_timeout_s
249            .unwrap_or(DEFAULT_RECONNECT_TIMEOUT_S)
250            .clamp(MIN_RECONNECT_TIMEOUT_S, MAX_RECONNECT_TIMEOUT_S),
251        spec.table_style,
252    );
253    room.resume_token = spec.resume_token;
254    room.status = RoomStatus::InGame;
255    room.players = restored_players;
256    room.replay = Some(GameReplayCache::new(
257        spec.game_id,
258        spec.player_order,
259        spec.player_decks,
260        spec.starting_life,
261    ));
262
263    let resumed = ResumedRoom {
264        room_info: room.to_room_info(),
265        awaiting_rejoin: awaiting_rejoin(&room),
266    };
267    let pending_seats: Vec<String> = room
268        .players
269        .iter()
270        .filter(|slot| !slot.connected)
271        .map(|slot| slot.player_id.clone())
272        .collect();
273
274    state.rooms.insert(spec.room_id.clone(), room);
275    for seat_player_id in pending_seats {
276        crate::cleanup::schedule_seat_forfeit(state.clone(), spec.room_id.clone(), seat_player_id);
277    }
278    if let Some(mut player) = state.players.get_mut(player_id) {
279        player.room_id = Some(spec.room_id);
280    }
281
282    Ok(resumed)
283}
284
285fn resumed_slots(spec: &ResumeRoomRequest, player_id: &str, username: &str) -> Vec<RoomSlot> {
286    spec.player_order
287        .iter()
288        .map(|seat_username| {
289            let deck = spec
290                .player_decks
291                .iter()
292                .find(|deck| &deck.username == seat_username);
293            let is_bot = spec.bot_players.contains(seat_username);
294            let is_requester = !spec.hosted && seat_username == username;
295            RoomSlot {
296                player_id: if is_requester {
297                    player_id.to_string()
298                } else {
299                    format!("pending-rejoin-{}", uuid::Uuid::new_v4())
300                },
301                username: seat_username.clone(),
302                ready: true,
303                connected: is_requester,
304                is_bot,
305                selected_deck_name: deck.map(|d| d.deck_name.clone()),
306                selected_deck: deck.map(|d| d.deck.clone()),
307                published_deck_id: deck.and_then(|d| d.published_deck_id.clone()),
308                selected_commander_name: deck.and_then(|d| d.commander_name.clone()),
309                avatar_url: deck.and_then(|d| d.avatar_url.clone()),
310            }
311        })
312        .collect()
313}
314
315fn awaiting_rejoin(room: &Room) -> Vec<String> {
316    room.players
317        .iter()
318        .filter(|slot| !slot.connected && !slot.is_bot)
319        .map(|slot| slot.username.clone())
320        .collect()
321}
322
323pub fn join_room_sync(
324    state: &Arc<ServerState>,
325    player_id: &str,
326    room_id: &str,
327    observe: bool,
328    as_bot: bool,
329    password: Option<String>,
330) -> Result<(RoomInfo, bool), ServerError> {
331    {
332        if let Some(player) = state.players.get(player_id) {
333            if let Some(rid) = &player.room_id {
334                return Err(ServerError::AlreadyInRoom(rid.clone()));
335            }
336        } else {
337            return Err(ServerError::AuthFailed("Player not found".into()));
338        }
339    }
340
341    let username = state
342        .players
343        .get(player_id)
344        .map(|p| p.username.clone())
345        .unwrap_or_default();
346
347    let (info, rejoined) = {
348        let mut room = state
349            .rooms
350            .get_mut(room_id)
351            .ok_or_else(|| ServerError::RoomNotFound(room_id.to_string()))?;
352
353        if let Some(required) = &room.password {
354            if password.as_deref() != Some(required.as_str()) {
355                return Err(ServerError::IncorrectPassword);
356            }
357        }
358
359        if room.status != RoomStatus::Lobby {
360            if observe {
361                return Err(ServerError::GameAlreadyStarted);
362            }
363            let slot = room
364                .players
365                .iter_mut()
366                .find(|slot| slot.username == username && slot.is_bot == as_bot && !slot.connected)
367                .ok_or(ServerError::GameAlreadyStarted)?;
368            slot.player_id = player_id.to_string();
369            slot.connected = true;
370            (room.to_room_info(), true)
371        } else {
372            if observe {
373                room.add_observer(player_id.to_string(), username)
374                    .map_err(|_| ServerError::AlreadyInRoom(room_id.to_string()))?;
375            } else {
376                room.add_player(player_id.to_string(), username, as_bot)
377                    .map_err(|msg| {
378                        if msg.contains("full") {
379                            ServerError::RoomFull(room_id.to_string())
380                        } else {
381                            ServerError::AlreadyInRoom(room_id.to_string())
382                        }
383                    })?;
384            }
385            (room.to_room_info(), false)
386        }
387    };
388
389    if let Some(mut player) = state.players.get_mut(player_id) {
390        player.room_id = Some(room_id.to_string());
391    }
392
393    Ok((info, rejoined))
394}
395
396pub fn leave_room_sync(state: &Arc<ServerState>, player_id: &str) -> Result<(), ServerError> {
397    let room_id = {
398        state
399            .players
400            .get(player_id)
401            .and_then(|p| p.room_id.clone())
402            .ok_or(ServerError::NotInRoom)?
403    };
404    let parent_id = state
405        .rooms
406        .get(&room_id)
407        .and_then(|room| room.parent_limited_room.clone());
408    let mut parent_empty = false;
409    if let Some(parent_id) = &parent_id {
410        let info = state.rooms.get_mut(parent_id).map(|mut parent| {
411            parent.remove_participant(player_id);
412            parent_empty = parent.is_empty();
413            parent.to_room_info()
414        });
415        if let Some(info) = info {
416            crate::connection::broadcast_to_room(
417                state,
418                parent_id,
419                &crate::protocol::ServerMessage::RoomUpdate { room: info },
420            );
421        }
422    }
423
424    let (room_empty, no_connected_players) = {
425        let mut room = state
426            .rooms
427            .get_mut(&room_id)
428            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
429
430        room.remove_participant(player_id);
431        let empty = room.is_empty();
432        let no_connected = if room.parent_limited_room.is_some() {
433            !crate::cleanup::paired_room_has_participants(state, &room, std::time::Instant::now())
434        } else {
435            room.all_disconnected()
436        };
437        if !empty && !no_connected && room.players.is_empty() {
438            room.reset_lobby_settings();
439        }
440        (empty, no_connected)
441    };
442
443    if room_empty || no_connected_players {
444        crate::cleanup::remove_room_and_clear_sessions(state, &room_id, GameEndReason::Abandoned);
445    }
446
447    if let Some(mut player) = state.players.get_mut(player_id) {
448        player.room_id = None;
449    }
450    if parent_empty {
451        if let Some(parent_id) = parent_id {
452            crate::cleanup::remove_room_and_clear_sessions(
453                state,
454                &parent_id,
455                GameEndReason::Abandoned,
456            );
457        }
458    }
459
460    Ok(())
461}
462
463pub fn set_ready_sync(
464    state: &Arc<ServerState>,
465    player_id: &str,
466    ready: bool,
467) -> Result<String, ServerError> {
468    let room_id = {
469        state
470            .players
471            .get(player_id)
472            .and_then(|p| p.room_id.clone())
473            .ok_or(ServerError::NotInRoom)?
474    };
475
476    {
477        let mut room = state
478            .rooms
479            .get_mut(&room_id)
480            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
481
482        if room.status != RoomStatus::Lobby {
483            return Err(ServerError::GameAlreadyStarted);
484        }
485
486        let format_requires_deck = room.format != GameFormat::Any && !room.is_limited_session();
487        if ready && format_requires_deck && !room.has_selected_deck(player_id) {
488            return Err(ServerError::DeckNotSelected);
489        }
490
491        room.set_ready(player_id, ready)
492            .map_err(|_| ServerError::NotInRoom)?;
493    }
494
495    Ok(room_id)
496}
497
498const MAX_COLOR_LEN: usize = 32;
499const MAX_PUBLISHED_DECK_ID_LEN: usize = 160;
500
501fn sanitize_playmat_settings(settings: &mut PlaymatSettings) {
502    settings.color = settings.color.take().filter(|s| s.len() <= MAX_COLOR_LEN);
503    settings.border_color = settings
504        .border_color
505        .take()
506        .filter(|s| s.len() <= MAX_COLOR_LEN);
507}
508
509fn sanitize_published_deck_id(value: Option<String>) -> Option<String> {
510    value.filter(|id| {
511        !id.is_empty()
512            && id.len() <= MAX_PUBLISHED_DECK_ID_LEN
513            && id
514                .bytes()
515                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':'))
516    })
517}
518
519pub fn set_deck_selection_sync(
520    state: &Arc<ServerState>,
521    player_id: &str,
522    deck_name: String,
523    mut deck: Deck,
524    published_deck_id: Option<String>,
525    commander_name: Option<String>,
526    avatar_url: Option<String>,
527) -> Result<String, ServerError> {
528    let room_id = {
529        state
530            .players
531            .get(player_id)
532            .and_then(|p| p.room_id.clone())
533            .ok_or(ServerError::NotInRoom)?
534    };
535
536    if let Some(settings) = deck.playmat_settings.as_mut() {
537        sanitize_playmat_settings(settings);
538    }
539    let published_deck_id = sanitize_published_deck_id(published_deck_id);
540
541    {
542        let mut room = state
543            .rooms
544            .get_mut(&room_id)
545            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
546
547        if room.status != RoomStatus::Lobby {
548            return Err(ServerError::GameAlreadyStarted);
549        }
550
551        room.set_deck_selection(
552            player_id,
553            deck_name,
554            deck,
555            published_deck_id,
556            commander_name,
557            avatar_url,
558        )
559        .map_err(|_| ServerError::NotInRoom)?;
560    }
561
562    Ok(room_id)
563}
564
565pub struct StartedGame {
566    pub game_id: String,
567    pub room_id: String,
568    pub player_order: Vec<String>,
569    pub player_decks: Vec<PlayerDeckInfo>,
570    pub starting_life: i32,
571    pub room_info: RoomInfo,
572}
573
574pub fn set_format_sync(
575    state: &Arc<ServerState>,
576    player_id: &str,
577    format: GameFormat,
578) -> Result<String, ServerError> {
579    let room_id = {
580        state
581            .players
582            .get(player_id)
583            .and_then(|p| p.room_id.clone())
584            .ok_or(ServerError::NotInRoom)?
585    };
586
587    {
588        let mut room = state
589            .rooms
590            .get_mut(&room_id)
591            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
592
593        if !room.is_controller(player_id) {
594            return Err(ServerError::NotHost);
595        }
596
597        if room.status != RoomStatus::Lobby {
598            return Err(ServerError::GameAlreadyStarted);
599        }
600
601        if room.format != format {
602            room.format = format;
603            for player in room.players.iter_mut().filter(|p| !p.is_bot) {
604                player.ready = false;
605            }
606        }
607    }
608
609    Ok(room_id)
610}
611
612pub fn set_max_players_sync(
613    state: &Arc<ServerState>,
614    player_id: &str,
615    max_players: u8,
616) -> Result<String, ServerError> {
617    let room_id = {
618        state
619            .players
620            .get(player_id)
621            .and_then(|p| p.room_id.clone())
622            .ok_or(ServerError::NotInRoom)?
623    };
624
625    {
626        let mut room = state
627            .rooms
628            .get_mut(&room_id)
629            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
630
631        if !room.is_controller(player_id) {
632            return Err(ServerError::NotHost);
633        }
634
635        if room.status != RoomStatus::Lobby {
636            return Err(ServerError::GameAlreadyStarted);
637        }
638
639        let floor = (room.players.len() as u8).max(2);
640        room.max_players = max_players.clamp(floor, 4);
641    }
642
643    Ok(room_id)
644}
645
646pub fn start_game_sync(
647    state: &Arc<ServerState>,
648    player_id: &str,
649    format: Option<GameFormat>,
650) -> Result<StartedGame, ServerError> {
651    let room_id = {
652        state
653            .players
654            .get(player_id)
655            .and_then(|p| p.room_id.clone())
656            .ok_or(ServerError::NotInRoom)?
657    };
658
659    let (game_id, player_order, player_decks, starting_life, room_info) = {
660        let mut room = state
661            .rooms
662            .get_mut(&room_id)
663            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
664
665        if !room.is_controller(player_id) {
666            return Err(ServerError::NotHost);
667        }
668
669        if room.status != RoomStatus::Lobby {
670            return Err(ServerError::GameAlreadyStarted);
671        }
672
673        if room.format == GameFormat::Any {
674            match format {
675                Some(chosen) if chosen != GameFormat::Any => room.format = chosen,
676                _ => return Err(ServerError::FormatNotChosen),
677            }
678        }
679
680        if !room.all_ready() {
681            return Err(ServerError::PlayersNotReady);
682        }
683
684        if !room.is_limited_session()
685            && room
686                .players
687                .iter()
688                .any(|p| p.selected_deck_name.is_none() || p.selected_deck.is_none())
689        {
690            return Err(ServerError::DeckNotSelected);
691        }
692
693        room.status = RoomStatus::InGame;
694        let starting_life = match room.format {
695            GameFormat::Commander => 40,
696            GameFormat::Brawl => 25,
697            GameFormat::PauperCommander => 30,
698            GameFormat::Standard
699            | GameFormat::Pioneer
700            | GameFormat::Modern
701            | GameFormat::Legacy
702            | GameFormat::Vintage
703            | GameFormat::Pauper
704            | GameFormat::Premodern
705            | GameFormat::Oathbreaker
706            | GameFormat::Draft
707            | GameFormat::Sealed => 20,
708            GameFormat::Any => unreachable!("Any resolved to concrete format above"),
709        };
710        let player_order = room.player_usernames();
711        let player_decks = room.player_decks();
712        let game_id = uuid::Uuid::new_v4().to_string();
713        room.replay = Some(GameReplayCache::new(
714            game_id.clone(),
715            player_order.clone(),
716            player_decks.clone(),
717            starting_life,
718        ));
719        (
720            game_id,
721            player_order,
722            player_decks,
723            starting_life,
724            room.to_room_info(),
725        )
726    };
727
728    Ok(StartedGame {
729        game_id,
730        room_id,
731        player_order,
732        player_decks,
733        starting_life,
734        room_info,
735    })
736}
737
738pub fn end_game_sync(
739    state: &Arc<ServerState>,
740    player_id: &str,
741    game_id: &str,
742) -> Result<(String, RoomInfo, Vec<String>), ServerError> {
743    let room_id = state
744        .players
745        .get(player_id)
746        .and_then(|p| p.room_id.clone())
747        .ok_or(ServerError::NotInRoom)?;
748
749    {
750        let room = state
751            .rooms
752            .get(&room_id)
753            .ok_or_else(|| ServerError::RoomNotFound(room_id.clone()))?;
754        if !room.is_host(player_id) {
755            return Err(ServerError::NotHost);
756        }
757        if room.status != RoomStatus::InGame {
758            return Err(ServerError::GameNotInProgress);
759        }
760        if room.replay.as_ref().is_some_and(|r| r.game_id != game_id) {
761            return Err(ServerError::GameNotInProgress);
762        }
763    }
764
765    let (info, notify) = reset_room_to_lobby(state, &room_id, GameEndReason::HostEnded)
766        .ok_or(ServerError::RoomNotFound(room_id.clone()))?;
767
768    Ok((room_id, info, notify))
769}
770
771pub fn reset_room_to_lobby(
772    state: &Arc<ServerState>,
773    room_id: &str,
774    reason: GameEndReason,
775) -> Option<(RoomInfo, Vec<String>)> {
776    let (info, cleared, paired_limited, children) = {
777        let mut room = state.rooms.get_mut(room_id)?;
778        if let Some(replay) = room.replay.take() {
779            analytics::emit_game_ended(&state.analytics, &room, &replay, reason);
780        }
781        let cleared: Vec<String> = room.players.iter().map(|p| p.player_id.clone()).collect();
782        room.status = RoomStatus::Lobby;
783        let paired_limited = room.parent_limited_room.is_some();
784        let children = if paired_limited {
785            for seat in &mut room.players {
786                seat.ready = false;
787            }
788            Vec::new()
789        } else {
790            std::mem::take(&mut room.limited_matches)
791        };
792        if !paired_limited {
793            room.players.clear();
794            room.limited_session_id = None;
795            room.draft_clock = None;
796        }
797        if !paired_limited {
798            room.reset_lobby_settings();
799        }
800        (room.to_room_info(), cleared, paired_limited, children)
801    };
802    for child_id in children {
803        let active = state
804            .rooms
805            .get(&child_id)
806            .is_some_and(|child| child.status == RoomStatus::InGame);
807        if active {
808            crate::connection::broadcast_to_room(
809                state,
810                &child_id,
811                &crate::protocol::ServerMessage::GameAborted {
812                    room_id: child_id.clone(),
813                },
814            );
815        }
816        crate::cleanup::remove_room_and_clear_sessions(state, &child_id, reason);
817    }
818
819    let mut notify = Vec::new();
820    for pid in cleared {
821        if paired_limited {
822            notify.push(pid);
823            continue;
824        }
825        match state.players.get(&pid).map(|p| p.disconnected_at.is_some()) {
826            Some(true) => {
827                state.players.remove(&pid);
828            }
829            Some(false) => {
830                if let Some(mut p) = state.players.get_mut(&pid) {
831                    p.room_id = None;
832                }
833                notify.push(pid);
834            }
835            None => {}
836        }
837    }
838
839    Some((info, notify))
840}