1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use tracing::{info, warn};
5
6use crate::analytics::{self, GameEndReason};
7use crate::connection::{broadcast_room_transport, 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| room_is_stale(state, entry.value(), now).then(|| entry.key().clone()))
76 .collect::<Vec<_>>();
77
78 for room_id in rooms_to_remove {
79 info!(
80 "[cleanup] removing stale room {}",
81 &room_id[..8.min(room_id.len())]
82 );
83 remove_room_and_clear_sessions(state, &room_id, GameEndReason::StaleExpired);
84 }
85}
86
87fn room_is_stale(state: &Arc<ServerState>, room: &Room, now: Instant) -> bool {
88 if room.parent_limited_room.is_some() {
89 return !paired_room_has_participants(state, room, now);
90 }
91 match room.status {
92 RoomStatus::Lobby => room.all_disconnected(),
93 RoomStatus::InGame => in_game_room_expired(state, room, now),
94 }
95}
96
97pub(crate) fn paired_room_has_participants(
98 state: &Arc<ServerState>,
99 room: &Room,
100 now: Instant,
101) -> bool {
102 let grace = Duration::from_secs(room.reconnect_timeout_s as u64) + RECONNECT_ABORT_MARGIN;
103 room.players
104 .iter()
105 .filter(|seat| !seat.is_bot)
106 .map(|seat| seat.player_id.as_str())
107 .chain(
108 room.observers
109 .iter()
110 .map(|observer| observer.player_id.as_str()),
111 )
112 .any(|player_id| {
113 state.players.get(player_id).is_some_and(|player| {
114 player.room_id.as_deref() == Some(room.room_id.as_str())
115 && (player.connected
116 || player
117 .disconnected_at
118 .is_some_and(|since| now.duration_since(since) < grace))
119 })
120 })
121}
122
123fn abort_humanless_room(state: &Arc<ServerState>, room_id: &str) {
124 let Some((info, notify)) = lobby::reset_room_to_lobby(state, room_id, GameEndReason::Abandoned)
125 else {
126 return;
127 };
128 info!(
129 "[cleanup] in-game room {} had no connected human players -- reset to lobby",
130 &room_id[..8.min(room_id.len())]
131 );
132 broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
133 let aborted = ServerMessage::GameAborted {
134 room_id: room_id.to_string(),
135 };
136 if let Ok(json) = serde_json::to_string(&aborted) {
137 for pid in ¬ify {
138 emit_to(state, pid, &aborted, &json);
139 }
140 }
141}
142
143pub fn schedule_host_resume_abort(
144 state: Arc<ServerState>,
145 room_id: String,
146 host_player_id: String,
147) {
148 let Some(timeout_s) = state
149 .rooms
150 .get(&room_id)
151 .map(|room| room.reconnect_timeout_s)
152 else {
153 return;
154 };
155 let timeout = Duration::from_secs(timeout_s as u64);
156
157 tokio::spawn(async move {
158 tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
159
160 let host_still_gone = state
163 .rooms
164 .get(&room_id)
165 .map(|room| room.status == RoomStatus::InGame && room.host_player_id == host_player_id)
166 .unwrap_or(false)
167 && state
168 .players
169 .get(&host_player_id)
170 .map(|player| !player.connected)
171 .unwrap_or(true);
172 if !host_still_gone {
173 return;
174 }
175
176 info!(
177 "[cleanup] hosted in-game room {} host never resumed -- removing",
178 &room_id[..8.min(room_id.len())]
179 );
180 broadcast_to_room(
181 &state,
182 &room_id,
183 &ServerMessage::GameAborted {
184 room_id: room_id.clone(),
185 },
186 );
187 remove_room_and_clear_sessions(&state, &room_id, GameEndReason::HostLost);
188 });
189}
190
191pub fn schedule_peer_host_loss(state: Arc<ServerState>, room_id: String, player_id: String) {
192 let Some(timeout_s) = state
193 .rooms
194 .get(&room_id)
195 .filter(|room| room.parent_limited_room.is_some() || !room.is_limited_session())
196 .map(|room| room.reconnect_timeout_s)
197 else {
198 return;
199 };
200 let timeout = Duration::from_secs(timeout_s as u64);
201 let disconnected_at = state
202 .players
203 .get(&player_id)
204 .and_then(|p| p.disconnected_at);
205
206 tokio::spawn(async move {
207 tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
208
209 let still_hosting = state
210 .rooms
211 .get(&room_id)
212 .map(|room| room.status == RoomStatus::InGame && room.host_player_id == player_id)
213 .unwrap_or(false);
214 let still_gone = state
215 .players
216 .get(&player_id)
217 .map(|player| !player.connected && player.disconnected_at == disconnected_at)
218 .unwrap_or(true);
219 if !still_hosting || !still_gone {
220 return;
221 }
222
223 let Some((info, notify)) =
224 lobby::reset_room_to_lobby(&state, &room_id, GameEndReason::HostLost)
225 else {
226 return;
227 };
228 info!(
229 "[cleanup] room {} lost the seat holding its engine -- game ended, room reset to lobby",
230 &room_id[..8.min(room_id.len())]
231 );
232 broadcast_to_room(&state, &room_id, &ServerMessage::RoomUpdate { room: info });
233 let aborted = ServerMessage::GameAborted {
234 room_id: room_id.clone(),
235 };
236 if let Ok(json) = serde_json::to_string(&aborted) {
237 for pid in ¬ify {
238 emit_to(&state, pid, &aborted, &json);
239 }
240 }
241 });
242}
243
244pub fn schedule_seat_forfeit(state: Arc<ServerState>, room_id: String, player_id: String) {
245 let Some(timeout_s) = state
246 .rooms
247 .get(&room_id)
248 .filter(|room| room.parent_limited_room.is_some() || !room.is_limited_session())
249 .map(|room| room.reconnect_timeout_s)
250 else {
251 return;
252 };
253 let timeout = Duration::from_secs(timeout_s as u64);
254 let disconnected_at = state
255 .players
256 .get(&player_id)
257 .and_then(|p| p.disconnected_at);
258
259 tokio::spawn(async move {
260 tokio::time::sleep(timeout + RECONNECT_ABORT_MARGIN).await;
261
262 let still_gone = state
267 .players
268 .get(&player_id)
269 .map(|player| {
270 !player.connected
271 && player.room_id.as_deref() == Some(room_id.as_str())
272 && player.disconnected_at == disconnected_at
273 })
274 .unwrap_or(disconnected_at.is_none());
275 let retained_seat = state
276 .rooms
277 .get(&room_id)
278 .map(|room| {
279 (room.status == RoomStatus::InGame || room.parent_limited_room.is_some())
280 && room.players.iter().any(|slot| slot.player_id == player_id)
281 })
282 .unwrap_or(false);
283 if !still_gone || !retained_seat {
284 return;
285 }
286 forfeit_seat(&state, &room_id, &player_id);
287 });
288}
289
290fn forfeit_seat(state: &Arc<ServerState>, room_id: &str, player_id: &str) {
291 let (username, info, parent_id) = {
292 let Some(mut room) = state.rooms.get_mut(room_id) else {
293 return;
294 };
295 let Some(slot) = room.remove_player(player_id) else {
296 return;
297 };
298 (
299 slot.username,
300 room.to_room_info(),
301 room.parent_limited_room.clone(),
302 )
303 };
304 release_room_session(state, room_id, player_id, parent_id.as_deref());
305 let abandoned = state.rooms.get(room_id).is_some_and(|room| {
306 room.parent_limited_room.is_some()
307 && !paired_room_has_participants(state, &room, Instant::now())
308 });
309 if abandoned {
310 remove_room_and_clear_sessions(state, room_id, GameEndReason::Abandoned);
311 return;
312 }
313 info!(
314 "[cleanup] '{}' did not reconnect within grace -- forfeiting seat in room {}",
315 username,
316 &room_id[..8.min(room_id.len())]
317 );
318 broadcast_to_room(
319 state,
320 room_id,
321 &ServerMessage::PlayerLeft {
322 room_id: room_id.to_string(),
323 username,
324 },
325 );
326 broadcast_to_room(state, room_id, &ServerMessage::RoomUpdate { room: info });
327}
328
329fn in_game_room_expired(state: &Arc<ServerState>, room: &Room, now: Instant) -> bool {
330 if !room.all_disconnected() {
331 return false;
332 }
333
334 room.players
335 .iter()
336 .map(|slot| slot.player_id.as_str())
337 .chain(
338 room.observers
339 .iter()
340 .map(|observer| observer.player_id.as_str()),
341 )
342 .filter_map(|player_id| state.players.get(player_id).and_then(|p| p.disconnected_at))
343 .max()
344 .is_some_and(|latest| now.duration_since(latest) >= IN_GAME_DISCONNECTED_GRACE)
345}
346
347pub fn mark_disconnected(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
348 mark_disconnected_inner(state, player_id, our_generation);
349 crate::connection::broadcast_player_list(state);
350}
351
352fn mark_disconnected_inner(state: &Arc<ServerState>, player_id: &str, our_generation: u64) {
353 let (username, room_id) = {
354 if let Some(mut player) = state.players.get_mut(player_id) {
355 if player.generation != our_generation {
356 info!(
357 "[disconnect] '{}' old connection cleaned up (session reclaimed by new connection)",
358 player.username
359 );
360 return;
361 }
362 player.connected = false;
363 player.disconnected_at = Some(Instant::now());
364 (player.username.clone(), player.room_id.clone())
365 } else {
366 return;
367 }
368 };
369 let parent_id = room_id.as_ref().and_then(|id| {
370 crate::draft_clock::pause_disconnected_host(state, id, player_id);
371 state
372 .rooms
373 .get(id)
374 .and_then(|room| room.parent_limited_room.clone())
375 });
376 if let Some(parent_id) = parent_id {
377 let lost_host = state
378 .rooms
379 .get_mut(&parent_id)
380 .map(|mut room| {
381 room.set_connected(player_id, false);
382 room.is_host(player_id)
383 })
384 .unwrap_or(false);
385 let info = state.rooms.get(&parent_id).map(|room| room.to_room_info());
386 if let Some(room) = info {
387 broadcast_to_room(state, &parent_id, &ServerMessage::RoomUpdate { room });
388 }
389 if lost_host {
390 schedule_peer_host_loss(state.clone(), parent_id, player_id.to_string());
391 }
392 }
393
394 if let Some(rid) = &room_id {
395 let room_status = state.rooms.get(rid).map(|r| r.status.clone());
396
397 match room_status {
398 Some(RoomStatus::InGame) => {
399 let host_without_player = state
400 .rooms
401 .get(rid)
402 .map(|room| room.is_host(player_id) && !room.host_is_player())
403 .unwrap_or(false);
404 if host_without_player {
405 if let Some(mut room) = state.rooms.get_mut(rid) {
409 room.set_connected(player_id, false);
410 } else {
411 return;
412 }
413 info!(
414 "[disconnect] hosted in-game room {} lost its non-playing host -- awaiting resume",
415 &rid[..8]
416 );
417 broadcast_to_room(
418 state,
419 rid,
420 &ServerMessage::PlayerDisconnected {
421 username: username.clone(),
422 },
423 );
424 schedule_host_resume_abort(state.clone(), rid.clone(), player_id.to_string());
425 return;
426 }
427
428 let peer_host_lost = state
429 .rooms
430 .get(rid)
431 .map(|room| !room.hosted && room.is_host(player_id) && room.host_is_player())
432 .unwrap_or(false);
433
434 let paired_limited = state
435 .rooms
436 .get(rid)
437 .is_some_and(|room| room.parent_limited_room.is_some());
438 let holds_seat = {
439 if let Some(mut room) = state.rooms.get_mut(rid) {
440 room.set_connected(player_id, false);
441 room.players.iter().any(|slot| slot.player_id == player_id)
442 } else {
443 return;
444 }
445 };
446
447 if peer_host_lost {
448 info!(
449 "[disconnect] in-game room {} lost the seat holding its engine -- awaiting resume",
450 &rid[..8]
451 );
452 broadcast_to_room(
453 state,
454 rid,
455 &ServerMessage::PlayerDisconnected {
456 username: username.clone(),
457 },
458 );
459 schedule_peer_host_loss(state.clone(), rid.clone(), player_id.to_string());
460 if paired_limited {
461 schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
462 }
463 return;
464 }
465
466 info!(
467 "[disconnect] '{}' marked disconnected in in-game room {} (session preserved)",
468 username,
469 &rid[..8]
470 );
471 if holds_seat {
472 schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
473 }
474 broadcast_to_room(
475 state,
476 rid,
477 &ServerMessage::PlayerDisconnected {
478 username: username.clone(),
479 },
480 );
481
482 let info = state.rooms.get(rid).map(|room| room.to_room_info());
483 if let Some(room) = info {
484 broadcast_to_room(state, rid, &ServerMessage::RoomUpdate { room });
485 }
486 }
487 Some(RoomStatus::Lobby) => {
488 let paired_limited = state
489 .rooms
490 .get(rid)
491 .is_some_and(|room| room.parent_limited_room.is_some());
492 if paired_limited {
493 if let Some(mut room) = state.rooms.get_mut(rid) {
494 room.set_connected(player_id, false);
495 }
496 schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
497 broadcast_to_room(
498 state,
499 rid,
500 &ServerMessage::PlayerDisconnected {
501 username: username.clone(),
502 },
503 );
504 let info = state.rooms.get(rid).map(|room| room.to_room_info());
505 if let Some(info) = info {
506 broadcast_to_room(state, rid, &ServerMessage::RoomUpdate { room: info });
507 }
508 return;
509 }
510 info!(
511 "[disconnect] '{}' disconnected from lobby room {} -- treating as leave",
512 username,
513 &rid[..8]
514 );
515
516 let remove_hosted_room = state
517 .rooms
518 .get(rid)
519 .map(|room| room.is_host(player_id) && !room.host_is_player())
520 .unwrap_or(false);
521 if remove_hosted_room {
522 info!(
523 "[cleanup] hosted lobby room {} lost its non-playing host -- removing",
524 &rid[..8]
525 );
526 remove_room_and_clear_sessions(state, rid, GameEndReason::HostLost);
527 return;
528 }
529
530 let room_empty = {
531 if let Some(mut room) = state.rooms.get_mut(rid) {
532 room.remove_participant(player_id);
533 let empty = room.is_empty();
534 if !empty && room.players.is_empty() {
535 room.reset_lobby_settings();
536 }
537 empty
538 } else {
539 false
540 }
541 };
542
543 if let Some(mut player) = state.players.get_mut(player_id) {
544 player.room_id = None;
545 }
546
547 if room_empty {
548 info!(
549 "[cleanup] lobby room {} is now empty -- removing",
550 &rid[..8]
551 );
552 remove_room_and_clear_sessions(state, rid, GameEndReason::Abandoned);
553 } else {
554 broadcast_to_room(
555 state,
556 rid,
557 &ServerMessage::PlayerLeft {
558 room_id: rid.clone(),
559 username: username.clone(),
560 },
561 );
562 if let Some(room) = state.rooms.get(rid) {
563 broadcast_to_room(
564 state,
565 rid,
566 &ServerMessage::RoomUpdate {
567 room: room.to_room_info(),
568 },
569 );
570 }
571 broadcast_room_transport(state, rid);
572 }
573
574 info!("[cleanup] '{}' removed (disconnected from lobby)", username);
575 state.players.remove(player_id);
576 }
577 None => {
578 info!("[cleanup] '{}' removed (room no longer exists)", username);
579 state.players.remove(player_id);
580 }
581 }
582 } else {
583 info!("[cleanup] '{}' removed (was not in a room)", username);
584 state.players.remove(player_id);
585 }
586}
587
588pub fn remove_room_and_clear_sessions(
589 state: &Arc<ServerState>,
590 room_id: &str,
591 reason: GameEndReason,
592) {
593 let Some((_, room)) = state.rooms.remove_if(room_id, |_, room| {
594 reason != GameEndReason::StaleExpired || room_is_stale(state, room, Instant::now())
595 }) else {
596 return;
597 };
598 clear_removed_room(state, room, reason);
599}
600
601fn clear_removed_room(state: &Arc<ServerState>, room: Room, reason: GameEndReason) {
602 if let Some(replay) = room.replay.as_ref() {
603 analytics::emit_game_ended(&state.analytics, &room, replay, reason);
604 }
605 if let Some(parent_id) = &room.parent_limited_room {
606 if let Some(mut parent) = state.rooms.get_mut(parent_id) {
607 parent.limited_matches.retain(|id| id != &room.room_id);
608 }
609 }
610 for child_id in &room.limited_matches {
611 let active = state
612 .rooms
613 .get(child_id)
614 .is_some_and(|child| child.status == RoomStatus::InGame);
615 if active {
616 broadcast_to_room(
617 state,
618 child_id,
619 &ServerMessage::GameAborted {
620 room_id: child_id.clone(),
621 },
622 );
623 }
624 if let Some((_, child)) = state.rooms.remove(child_id) {
625 clear_removed_room(state, child, reason);
626 }
627 }
628 release_room_sessions(state, &room.room_id, room.parent_limited_room.as_deref());
629}
630
631fn release_room_sessions(state: &Arc<ServerState>, room_id: &str, parent_id: Option<&str>) {
632 let player_ids = state
633 .players
634 .iter()
635 .filter_map(|entry| {
636 entry
637 .value()
638 .room_id
639 .as_deref()
640 .is_some_and(|rid| rid == room_id)
641 .then(|| entry.key().clone())
642 })
643 .collect::<Vec<_>>();
644 for player_id in player_ids {
645 release_room_session(state, room_id, &player_id, parent_id);
646 }
647}
648
649fn release_room_session(
650 state: &Arc<ServerState>,
651 room_id: &str,
652 player_id: &str,
653 parent_id: Option<&str>,
654) {
655 let parent = parent_id.and_then(|id| state.rooms.get(id)).filter(|room| {
656 room.is_limited_session() && room.players.iter().any(|seat| seat.player_id == player_id)
657 });
658 state.players.remove_if_mut(player_id, |_, player| {
659 if player.room_id.as_deref() != Some(room_id) {
660 return false;
661 }
662 if let Some(parent) = &parent {
663 player.room_id = Some(parent.room_id.clone());
664 false
665 } else if player.connected {
666 player.room_id = None;
667 false
668 } else {
669 true
670 }
671 });
672}