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 ¬ify {
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 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 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 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}