Skip to main content

manabrew_server/
cleanup.rs

1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use tracing::{info, warn};
5
6use crate::analytics::{self, GameEndReason};
7use crate::connection::{broadcast_to_room, emit_to};
8use crate::lobby;
9use crate::protocol::{RoomStatus, ServerMessage};
10use crate::room::Room;
11use crate::state::ServerState;
12
13const CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
14const STALE_CONNECTED_TIMEOUT: Duration = Duration::from_secs(180);
15const IN_GAME_DISCONNECTED_GRACE: Duration = Duration::from_secs(3600);
16const RECONNECT_ABORT_MARGIN: Duration = Duration::from_secs(5);
17
18pub async fn cleanup_loop(state: Arc<ServerState>) {
19    let mut ticker = tokio::time::interval(CLEANUP_INTERVAL);
20    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
21
22    loop {
23        ticker.tick().await;
24        cleanup_stale_state(&state);
25    }
26}
27
28fn cleanup_stale_state(state: &Arc<ServerState>) {
29    let now = Instant::now();
30    let stale_players = state
31        .players
32        .iter()
33        .filter_map(|entry| {
34            let player = entry.value();
35            (player.connected && now.duration_since(player.last_seen) >= STALE_CONNECTED_TIMEOUT)
36                .then(|| {
37                    (
38                        entry.key().clone(),
39                        player.username.clone(),
40                        player.generation,
41                    )
42                })
43        })
44        .collect::<Vec<_>>();
45
46    for (player_id, username, generation) in stale_players {
47        warn!(
48            "[cleanup] '{}' had no websocket frames for {}s -- marking disconnected",
49            username,
50            STALE_CONNECTED_TIMEOUT.as_secs()
51        );
52        mark_disconnected(state, &player_id, generation);
53    }
54
55    let mut humanless_rooms = Vec::new();
56    for mut entry in state.rooms.iter_mut() {
57        let room = entry.value_mut();
58        if room.status != RoomStatus::InGame || room.has_connected_human() {
59            room.humanless_since = None;
60            continue;
61        }
62        let since = *room.humanless_since.get_or_insert(now);
63        let grace = Duration::from_secs(room.reconnect_timeout_s as u64) + RECONNECT_ABORT_MARGIN;
64        if now.duration_since(since) >= grace {
65            humanless_rooms.push(entry.key().clone());
66        }
67    }
68    for room_id in humanless_rooms {
69        abort_humanless_room(state, &room_id);
70    }
71
72    let rooms_to_remove = state
73        .rooms
74        .iter()
75        .filter_map(|entry| {
76            let room = entry.value();
77            match room.status {
78                RoomStatus::Lobby => room
79                    .connected_player_ids()
80                    .is_empty()
81                    .then(|| entry.key().clone()),
82                RoomStatus::InGame => {
83                    in_game_room_expired(state, room, now).then(|| entry.key().clone())
84                }
85            }
86        })
87        .collect::<Vec<_>>();
88
89    for room_id in rooms_to_remove {
90        info!(
91            "[cleanup] removing stale room {}",
92            &room_id[..8.min(room_id.len())]
93        );
94        remove_room_and_clear_sessions(state, &room_id, GameEndReason::StaleExpired);
95    }
96}
97
98fn abort_humanless_room(state: &Arc<ServerState>, room_id: &str) {
99    let Some((info, notify)) = lobby::reset_room_to_lobby(state, room_id, GameEndReason::Abandoned)
100    else {
101        return;
102    };
103    info!(
104        "[cleanup] in-game room {} had no connected human players -- reset to lobby",
105        &room_id[..8.min(room_id.len())]
106    );
107    broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
108    let aborted = ServerMessage::GameAborted {
109        room_id: room_id.to_string(),
110    };
111    if let Ok(json) = serde_json::to_string(&aborted) {
112        for pid in &notify {
113            emit_to(state, pid, &aborted, &json);
114        }
115    }
116}
117
118pub fn schedule_host_resume_abort(
119    state: Arc<ServerState>,
120    room_id: String,
121    host_player_id: String,
122) {
123    let Some(timeout_s) = state
124        .rooms
125        .get(&room_id)
126        .map(|room| room.reconnect_timeout_s)
127    else {
128        return;
129    };
130    let timeout = Duration::from_secs(timeout_s as u64);
131
132    tokio::spawn(async move {
133        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
134
135        // ResumeRoom rotates host_player_id and session reclaim flips the
136        // player back to connected; either one disarms this abort.
137        let host_still_gone = state
138            .rooms
139            .get(&room_id)
140            .map(|room| room.status == RoomStatus::InGame && room.host_player_id == host_player_id)
141            .unwrap_or(false)
142            && state
143                .players
144                .get(&host_player_id)
145                .map(|player| !player.connected)
146                .unwrap_or(true);
147        if !host_still_gone {
148            return;
149        }
150
151        info!(
152            "[cleanup] hosted in-game room {} host never resumed -- removing",
153            &room_id[..8.min(room_id.len())]
154        );
155        broadcast_to_room(
156            &state,
157            &room_id,
158            &ServerMessage::GameAborted {
159                room_id: room_id.clone(),
160            },
161        );
162        remove_room_and_clear_sessions(&state, &room_id, GameEndReason::HostLost);
163    });
164}
165
166pub fn schedule_seat_forfeit(state: Arc<ServerState>, room_id: String, player_id: String) {
167    let Some(timeout_s) = state
168        .rooms
169        .get(&room_id)
170        .map(|room| room.reconnect_timeout_s)
171    else {
172        return;
173    };
174    let timeout = Duration::from_secs(timeout_s as u64);
175
176    tokio::spawn(async move {
177        tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
178
179        // A reconnect flips the player back to connected; a rejoin re-keys the
180        // seat's player_id (disarming via the seat check below). No player
181        // entry at all means a pending-rejoin seat resurrected after a relay
182        // restart — nobody is connected under that id, so it counts as gone.
183        let still_gone = state
184            .players
185            .get(&player_id)
186            .map(|player| !player.connected)
187            .unwrap_or(true);
188        let seat_in_live_game = state
189            .rooms
190            .get(&room_id)
191            .map(|room| {
192                room.status == RoomStatus::InGame
193                    && room.players.iter().any(|slot| slot.player_id == player_id)
194            })
195            .unwrap_or(false);
196        if !still_gone || !seat_in_live_game {
197            return;
198        }
199        forfeit_seat(&state, &room_id, &player_id);
200    });
201}
202
203fn forfeit_seat(state: &Arc<ServerState>, room_id: &str, player_id: &str) {
204    let (username, info) = {
205        let Some(mut room) = state.rooms.get_mut(room_id) else {
206            return;
207        };
208        let Some(slot) = room.remove_player(player_id) else {
209            return;
210        };
211        (slot.username, room.to_room_info())
212    };
213    state.players.remove(player_id);
214    info!(
215        "[cleanup] '{}' did not reconnect within grace -- forfeiting seat in room {}",
216        username,
217        &room_id[..8.min(room_id.len())]
218    );
219    broadcast_to_room(
220        state,
221        room_id,
222        &ServerMessage::PlayerLeft {
223            room_id: room_id.to_string(),
224            username,
225        },
226    );
227    broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
228}
229
230fn in_game_room_expired(state: &Arc<ServerState>, room: &Room, now: Instant) -> bool {
231    if !room.all_disconnected() {
232        return false;
233    }
234
235    room.players
236        .iter()
237        .map(|slot| slot.player_id.as_str())
238        .chain(
239            room.observers
240                .iter()
241                .map(|observer| observer.player_id.as_str()),
242        )
243        .filter_map(|player_id| state.players.get(player_id).and_then(|p| p.disconnected_at))
244        .max()
245        .is_some_and(|latest| now.duration_since(latest) >= IN_GAME_DISCONNECTED_GRACE)
246}
247
248pub fn mark_disconnected(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
249    mark_disconnected_inner(state, player_id, our_generation);
250    crate::connection::broadcast_player_list(state);
251}
252
253fn mark_disconnected_inner(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
254    let (username, room_id) = {
255        if let Some(mut player) = state.players.get_mut(player_id) {
256            if player.generation != our_generation {
257                info!(
258                    "[disconnect] '{}' old connection cleaned up (session reclaimed by new connection)",
259                    player.username
260                );
261                return;
262            }
263            player.connected = false;
264            player.disconnected_at = Some(Instant::now());
265            (player.username.clone(), player.room_id.clone())
266        } else {
267            return;
268        }
269    };
270
271    if let Some(rid) = &room_id {
272        let room_status = state.rooms.get(rid).map(|r| r.status.clone());
273
274        match room_status {
275            Some(RoomStatus::InGame) => {
276                let host_without_player = state
277                    .rooms
278                    .get(rid)
279                    .map(|room| room.is_host(player_id) && !room.host_is_player())
280                    .unwrap_or(false);
281                if host_without_player {
282                    // Give the host the same reconnect grace as a player: it can
283                    // come back via session reclaim or ResumeRoom. Killing the
284                    // room here would abort every guest's game on a host blip.
285                    if let Some(mut room) = state.rooms.get_mut(rid) {
286                        room.set_connected(player_id, false);
287                    } else {
288                        return;
289                    }
290                    info!(
291                        "[disconnect] hosted in-game room {} lost its non-playing host -- awaiting resume",
292                        &rid[..8]
293                    );
294                    broadcast_to_room(
295                        state,
296                        rid,
297                        &ServerMessage::PlayerDisconnected {
298                            username: username.clone(),
299                        },
300                    );
301                    schedule_host_resume_abort(state.clone(), rid.clone(), player_id.to_string());
302                    return;
303                }
304
305                let holds_seat = {
306                    if let Some(mut room) = state.rooms.get_mut(rid) {
307                        room.set_connected(player_id, false);
308                        room.players.iter().any(|slot| slot.player_id == player_id)
309                    } else {
310                        return;
311                    }
312                };
313
314                info!(
315                    "[disconnect] '{}' marked disconnected in in-game room {} (session preserved)",
316                    username,
317                    &rid[..8]
318                );
319                if holds_seat {
320                    schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
321                }
322                broadcast_to_room(
323                    state,
324                    rid,
325                    &ServerMessage::PlayerDisconnected {
326                        username: username.clone(),
327                    },
328                );
329
330                if let Some(room) = state.rooms.get(rid) {
331                    broadcast_to_room(
332                        state,
333                        rid,
334                        &ServerMessage::RoomUpdate {
335                            room: room.to_room_info(),
336                        },
337                    );
338                }
339            }
340            Some(RoomStatus::Lobby) => {
341                info!(
342                    "[disconnect] '{}' disconnected from lobby room {} -- treating as leave",
343                    username,
344                    &rid[..8]
345                );
346
347                let remove_hosted_room = state
348                    .rooms
349                    .get(rid)
350                    .map(|room| room.is_host(player_id) && !room.host_is_player())
351                    .unwrap_or(false);
352                if remove_hosted_room {
353                    info!(
354                        "[cleanup] hosted lobby room {} lost its non-playing host -- removing",
355                        &rid[..8]
356                    );
357                    remove_room_and_clear_sessions(state, rid, GameEndReason::HostLost);
358                    return;
359                }
360
361                let room_empty = {
362                    if let Some(mut room) = state.rooms.get_mut(rid) {
363                        room.remove_participant(player_id);
364                        let empty = room.is_empty();
365                        if !empty && room.players.is_empty() {
366                            room.reset_lobby_settings();
367                        }
368                        empty
369                    } else {
370                        false
371                    }
372                };
373
374                if let Some(mut player) = state.players.get_mut(player_id) {
375                    player.room_id = None;
376                }
377
378                if room_empty {
379                    info!(
380                        "[cleanup] lobby room {} is now empty -- removing",
381                        &rid[..8]
382                    );
383                    remove_room_and_clear_sessions(state, rid, GameEndReason::Abandoned);
384                } else {
385                    broadcast_to_room(
386                        state,
387                        rid,
388                        &ServerMessage::PlayerLeft {
389                            room_id: rid.clone(),
390                            username: username.clone(),
391                        },
392                    );
393                    if let Some(room) = state.rooms.get(rid) {
394                        broadcast_to_room(
395                            state,
396                            rid,
397                            &ServerMessage::RoomUpdate {
398                                room: room.to_room_info(),
399                            },
400                        );
401                    }
402                }
403
404                info!("[cleanup] '{}' removed (disconnected from lobby)", username);
405                state.players.remove(player_id);
406            }
407            None => {
408                info!("[cleanup] '{}' removed (room no longer exists)", username);
409                state.players.remove(player_id);
410            }
411        }
412    } else {
413        info!("[cleanup] '{}' removed (was not in a room)", username);
414        state.players.remove(player_id);
415    }
416}
417
418pub fn remove_room_and_clear_sessions(
419    state: &Arc<ServerState>,
420    room_id: &str,
421    reason: GameEndReason,
422) {
423    if let Some((_, room)) = state.rooms.remove(room_id) {
424        if let Some(replay) = room.replay.as_ref() {
425            state.deck_play_events.game_ended(replay);
426            analytics::emit_game_ended(&state.analytics, &room, replay, reason);
427        }
428    }
429    release_room_sessions(state, room_id);
430}
431
432fn release_room_sessions(state: &Arc<ServerState>, room_id: &str) {
433    let player_ids = state
434        .players
435        .iter()
436        .filter_map(|entry| {
437            entry
438                .value()
439                .room_id
440                .as_deref()
441                .is_some_and(|rid| rid == room_id)
442                .then(|| entry.key().clone())
443        })
444        .collect::<Vec<_>>();
445    for player_id in player_ids {
446        let connected = state
447            .players
448            .get(&player_id)
449            .map(|player| player.connected)
450            .unwrap_or(false);
451        if connected {
452            if let Some(mut player) = state.players.get_mut(&player_id) {
453                player.room_id = None;
454            }
455        } else {
456            state.players.remove(&player_id);
457        }
458    }
459}