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 let (username, room_id) = {
250 if let Some(mut player) = state.players.get_mut(player_id) {
251 if player.generation != our_generation {
252 info!(
253 "[disconnect] '{}' old connection cleaned up (session reclaimed by new connection)",
254 player.username
255 );
256 return;
257 }
258 player.connected = false;
259 player.disconnected_at = Some(Instant::now());
260 (player.username.clone(), player.room_id.clone())
261 } else {
262 return;
263 }
264 };
265
266 if let Some(rid) = &room_id {
267 let room_status = state.rooms.get(rid).map(|r| r.status.clone());
268
269 match room_status {
270 Some(RoomStatus::InGame) => {
271 let host_without_player = state
272 .rooms
273 .get(rid)
274 .map(|room| room.is_host(player_id) && !room.host_is_player())
275 .unwrap_or(false);
276 if host_without_player {
277 if let Some(mut room) = state.rooms.get_mut(rid) {
281 room.set_connected(player_id, false);
282 } else {
283 return;
284 }
285 info!(
286 "[disconnect] hosted in-game room {} lost its non-playing host -- awaiting resume",
287 &rid[..8]
288 );
289 broadcast_to_room(
290 state,
291 rid,
292 &ServerMessage::PlayerDisconnected {
293 username: username.clone(),
294 },
295 );
296 schedule_host_resume_abort(state.clone(), rid.clone(), player_id.to_string());
297 return;
298 }
299
300 let holds_seat = {
301 if let Some(mut room) = state.rooms.get_mut(rid) {
302 room.set_connected(player_id, false);
303 room.players.iter().any(|slot| slot.player_id == player_id)
304 } else {
305 return;
306 }
307 };
308
309 info!(
310 "[disconnect] '{}' marked disconnected in in-game room {} (session preserved)",
311 username,
312 &rid[..8]
313 );
314 if holds_seat {
315 schedule_seat_forfeit(state.clone(), rid.clone(), player_id.to_string());
316 }
317 broadcast_to_room(
318 state,
319 rid,
320 &ServerMessage::PlayerDisconnected {
321 username: username.clone(),
322 },
323 );
324
325 if let Some(room) = state.rooms.get(rid) {
326 broadcast_to_room(
327 state,
328 rid,
329 &ServerMessage::RoomUpdate {
330 room: room.to_room_info(),
331 },
332 );
333 }
334 }
335 Some(RoomStatus::Lobby) => {
336 info!(
337 "[disconnect] '{}' disconnected from lobby room {} -- treating as leave",
338 username,
339 &rid[..8]
340 );
341
342 let remove_hosted_room = state
343 .rooms
344 .get(rid)
345 .map(|room| room.is_host(player_id) && !room.host_is_player())
346 .unwrap_or(false);
347 if remove_hosted_room {
348 info!(
349 "[cleanup] hosted lobby room {} lost its non-playing host -- removing",
350 &rid[..8]
351 );
352 remove_room_and_clear_sessions(state, rid, GameEndReason::HostLost);
353 return;
354 }
355
356 let room_empty = {
357 if let Some(mut room) = state.rooms.get_mut(rid) {
358 room.remove_participant(player_id);
359 let empty = room.is_empty();
360 if !empty && room.players.is_empty() {
361 room.reset_lobby_settings();
362 }
363 empty
364 } else {
365 false
366 }
367 };
368
369 if let Some(mut player) = state.players.get_mut(player_id) {
370 player.room_id = None;
371 }
372
373 if room_empty {
374 info!(
375 "[cleanup] lobby room {} is now empty -- removing",
376 &rid[..8]
377 );
378 remove_room_and_clear_sessions(state, rid, GameEndReason::Abandoned);
379 } else {
380 broadcast_to_room(
381 state,
382 rid,
383 &ServerMessage::PlayerLeft {
384 room_id: rid.clone(),
385 username: username.clone(),
386 },
387 );
388 if let Some(room) = state.rooms.get(rid) {
389 broadcast_to_room(
390 state,
391 rid,
392 &ServerMessage::RoomUpdate {
393 room: room.to_room_info(),
394 },
395 );
396 }
397 }
398
399 info!("[cleanup] '{}' removed (disconnected from lobby)", username);
400 state.players.remove(player_id);
401 }
402 None => {
403 info!("[cleanup] '{}' removed (room no longer exists)", username);
404 state.players.remove(player_id);
405 }
406 }
407 } else {
408 info!("[cleanup] '{}' removed (was not in a room)", username);
409 state.players.remove(player_id);
410 }
411}
412
413pub fn remove_room_and_clear_sessions(
414 state: &Arc<ServerState>,
415 room_id: &str,
416 reason: GameEndReason,
417) {
418 if let Some((_, room)) = state.rooms.remove(room_id) {
419 if let Some(replay) = room.replay.as_ref() {
420 analytics::emit_game_ended(&state.analytics, &room, replay, reason);
421 }
422 }
423 release_room_sessions(state, room_id);
424}
425
426fn release_room_sessions(state: &Arc<ServerState>, room_id: &str) {
427 let player_ids = state
428 .players
429 .iter()
430 .filter_map(|entry| {
431 entry
432 .value()
433 .room_id
434 .as_deref()
435 .is_some_and(|rid| rid == room_id)
436 .then(|| entry.key().clone())
437 })
438 .collect::<Vec<_>>();
439 for player_id in player_ids {
440 let connected = state
441 .players
442 .get(&player_id)
443 .map(|player| player.connected)
444 .unwrap_or(false);
445 if connected {
446 if let Some(mut player) = state.players.get_mut(&player_id) {
447 player.room_id = None;
448 }
449 } else {
450 state.players.remove(&player_id);
451 }
452 }
453}