diff --git a/server/config.py b/server/config.py index 9b02125..4e9ff71 100644 --- a/server/config.py +++ b/server/config.py @@ -142,6 +142,7 @@ class ServerConfig: MAX_PLAYERS_PER_ROOM: int = 6 ROOM_TIMEOUT_MINUTES: int = 60 ROOM_CODE_LENGTH: int = 4 + ROOM_IDLE_TIMEOUT_SECONDS: int = 300 # 5 minutes of inactivity # Security (for future auth system) SECRET_KEY: str = "" @@ -198,6 +199,7 @@ class ServerConfig: MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6), ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60), ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4), + ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300), SECRET_KEY=get_env("SECRET_KEY", ""), INVITE_ONLY=get_env_bool("INVITE_ONLY", True), DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0), diff --git a/server/handlers.py b/server/handlers.py index 3c9c76f..2bcaab4 100644 --- a/server/handlers.py +++ b/server/handlers.py @@ -69,6 +69,7 @@ async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player") room = room_manager.create_room() room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) + room.touch() ctx.current_room = room await ctx.websocket.send_json({ @@ -114,6 +115,7 @@ async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, return room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id) + room.touch() ctx.current_room = room await ctx.websocket.send_json({ @@ -189,6 +191,7 @@ async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None: async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() room_player = ctx.current_room.get_player(ctx.player_id) if not room_player or not room_player.is_host: @@ -235,6 +238,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() positions = data.get("positions", []) async with ctx.current_room.game_lock: @@ -250,6 +254,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() source = data.get("source", "deck") async with ctx.current_room.game_lock: @@ -277,6 +282,7 @@ async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_stat async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() position = data.get("position", 0) async with ctx.current_room.game_lock: @@ -303,6 +309,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() async with ctx.current_room.game_lock: drawn_card = ctx.current_room.game.drawn_card @@ -349,6 +356,7 @@ async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_ga async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() position = data.get("position", 0) async with ctx.current_room.game_lock: @@ -370,6 +378,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() async with ctx.current_room.game_lock: player = ctx.current_room.game.get_player(ctx.player_id) @@ -386,6 +395,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() position = data.get("position", 0) async with ctx.current_room.game_lock: @@ -406,6 +416,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() async with ctx.current_room.game_lock: player = ctx.current_room.game.get_player(ctx.player_id) @@ -424,6 +435,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() room_player = ctx.current_room.get_player(ctx.player_id) if not room_player or not room_player.is_host: @@ -467,6 +479,7 @@ async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None: if not ctx.current_room: return + ctx.current_room.touch() room_player = ctx.current_room.get_player(ctx.player_id) if not room_player or not room_player.is_host: diff --git a/server/main.py b/server/main.py index 0a7bb0f..b6369be 100644 --- a/server/main.py +++ b/server/main.py @@ -64,6 +64,7 @@ _matchmaking_service = None _replay_service = None _spectator_manager = None _leaderboard_refresh_task = None +_room_cleanup_task = None _redis_client = None _rate_limiter = None _shutdown_event = asyncio.Event() @@ -83,6 +84,60 @@ async def _periodic_leaderboard_refresh(): logger.error(f"Leaderboard refresh failed: {e}") +async def _periodic_room_cleanup(): + """Periodic task to clean up rooms idle for longer than ROOM_IDLE_TIMEOUT_SECONDS.""" + import time + while True: + try: + await asyncio.sleep(60) + now = time.time() + timeout = config.ROOM_IDLE_TIMEOUT_SECONDS + stale_rooms = [ + room for room in room_manager.rooms.values() + if now - room.last_activity > timeout + ] + for room in stale_rooms: + logger.info( + f"Cleaning up stale room {room.code} " + f"(idle {int(now - room.last_activity)}s, " + f"{len(room.players)} players)" + ) + # Cancel CPU turn task + if room.cpu_turn_task: + room.cpu_turn_task.cancel() + try: + await room.cpu_turn_task + except (asyncio.CancelledError, Exception): + pass + room.cpu_turn_task = None + + # Notify and close human WebSocket connections + for player in list(room.players.values()): + if player.websocket and not player.is_cpu: + try: + await player.websocket.send_json({ + "type": "room_expired", + "message": "Room closed due to inactivity", + }) + await player.websocket.close(code=4002, reason="Room expired") + except Exception: + pass + + # Clean up players and profiles + room_code = room.code + for cpu in list(room.get_cpu_players()): + room.remove_player(cpu.id) + cleanup_room_profiles(room_code) + room_manager.remove_room(room_code) + + if stale_rooms: + logger.info(f"Cleaned up {len(stale_rooms)} stale room(s)") + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Room cleanup failed: {e}") + + async def _init_redis(): """Initialize Redis client, rate limiter, and signup limiter.""" global _redis_client, _rate_limiter @@ -254,6 +309,14 @@ async def _shutdown_services(): reset_all_profiles() logger.info("All rooms and CPU profiles cleaned up") + if _room_cleanup_task: + _room_cleanup_task.cancel() + try: + await _room_cleanup_task + except asyncio.CancelledError: + pass + logger.info("Room cleanup task stopped") + if _leaderboard_refresh_task: _leaderboard_refresh_task.cancel() try: @@ -312,6 +375,11 @@ async def lifespan(app: FastAPI): room_manager=room_manager, ) + # Start periodic room cleanup + global _room_cleanup_task + _room_cleanup_task = asyncio.create_task(_periodic_room_cleanup()) + logger.info(f"Room cleanup task started (timeout={config.ROOM_IDLE_TIMEOUT_SECONDS}s)") + logger.info(f"Golf server started (environment={config.ENVIRONMENT})") yield @@ -761,6 +829,8 @@ async def _run_cpu_chain(room: Room): if not room_player or not room_player.is_cpu: return + room.touch() + # Brief pause before CPU starts - animations are faster now await asyncio.sleep(0.25) diff --git a/server/room.py b/server/room.py index 57076cb..ebf97f7 100644 --- a/server/room.py +++ b/server/room.py @@ -14,6 +14,7 @@ A Room contains: import asyncio import random import string +import time from dataclasses import dataclass, field from typing import Optional @@ -70,6 +71,11 @@ class Room: game_log_id: Optional[str] = None game_lock: asyncio.Lock = field(default_factory=asyncio.Lock) cpu_turn_task: Optional[asyncio.Task] = None + last_activity: float = field(default_factory=time.time) + + def touch(self) -> None: + """Update last_activity timestamp to mark room as active.""" + self.last_activity = time.time() def add_player( self,