From 3655e065871afd3d6e46feff105b6b0e0b2418bb Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 17 Oct 2026 15:34:05 +0200 Subject: [PATCH] zmazanie nedohratych hier z lobby --- api/__init__.py | 91 +++++++++++++++++++++++++++----------------- tests/test_socket.py | 26 ++++++++++++- 2 files changed, 80 insertions(+), 37 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index baf5a96..bae606f 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -184,6 +184,9 @@ class Game: # Serializuje tahovu slucku botov -- dva sucasne _run_bot_turns tasky # by inak mohli tahat za to iste sedadlo. self.bot_lock = asyncio.Lock() + # Grace timer po odchode posledneho cloveka. Novy odchod ho zrusi a + # spusti odznova, takze navrat neresetuje cudzie okno. + self.abandon_task: asyncio.Task | None = None def start(self): self.bridzik_core = Bridzik() @@ -342,37 +345,64 @@ async def send_error(sid: str, message: str): await sio.emit("error", {"error": message}, to=sid) -# Ako dlho prezije nezacata hra, ked su vsetci hraci naraz offline. Na mobile -# sa socket bezne strati uz pri zamknuti obrazovky, takze okamzite zmazanie -# hry rusilo lobby, v ktorom hraci len cakali so zhasnutym telefonom. +# Ako dlho prezije prazdna hra. Na mobile sa socket bezne strati uz pri +# zamknuti obrazovky, takze okamzite zmazanie rusilo lobby aj rozohrate partie. LOBBY_ABANDON_GRACE_SECONDS = 10 * 60 - - -async def _cleanup_abandoned_lobby(gid: str): - """Po grace periode zmaz nezacatu hru, ak sa medzitym nikto nevratil. - Podmienka sa overuje az po uplynuti casu, takze pri navrate hraca je - task neskodny no-op (netreba nic rusit).""" - await asyncio.sleep(LOBBY_ABANDON_GRACE_SECONDS) - game = games.get(gid) - if game is not None and not game.started and not _any_human_connected(game): - del games[gid] - await broadcast_lobby() +GAME_ABANDON_GRACE_SECONDS = 30 * 60 def _any_human_connected(game: "Game") -> bool: - """Boti su 'pripojeni' stale, pre opustenost lobby sa pocitaju len ludia.""" + """Boti su 'pripojeni' stale, pre opustenost sa pocitaju len ludia.""" return any(p.connected for p in game.players if not p.is_bot) +def _arm_abandon(game: "Game") -> None: + """Spusti grace odznova. Predosly timer sa zrusi, aby navrat + novy odchod + nedal staremu tasku zmazat hru hned.""" + if game.abandon_task is not None: + game.abandon_task.cancel() + delay = GAME_ABANDON_GRACE_SECONDS if game.started else LOBBY_ABANDON_GRACE_SECONDS + game.abandon_task = asyncio.create_task(_abandon_if_empty(game.gid, delay)) + + +async def _abandon_if_empty(gid: str, delay: float) -> None: + await asyncio.sleep(delay) + game = games.get(gid) + # Porovnanie s current_task odfiltruje aj hru s tym istym gid, ktoru medzitym + # niekto ukoncil a obnovil (ma vlastny timer). + if game is None or game.abandon_task is not asyncio.current_task() or _any_human_connected(game): + return + if not game.started: + del games[gid] + await broadcast_lobby() + elif not game.bridzik_core.is_completed(): + await _finish_game(game) + + +async def _finish_game(game: "Game") -> None: + if games.pop(game.gid, None) is not game: + return + try: + await history.mark_game_ended(game.gid) + except Exception: + games[game.gid] = game + raise + await sio.emit("game_ended", {"gid": game.gid}, room=game.gid) + for player in game.players: + if player.sid: + sessions.pop(player.sid, None) + await sio.leave_room(player.sid, game.gid) + await broadcast_lobby() + + async def _mark_player_offline(game: "Game", player: "Player"): - """Mark player disconnected. An unstarted game with nobody left gets a - delayed cleanup (mobile sockets drop on screen lock, so an immediate - delete would kill lobbies where everyone is just waiting); a started game - is kept in memory so it stays in the lobby and can be resumed (it's torn - down only by end_game).""" + """Oznac hraca offline. Ked nezostal ziadny clovek, grace timer hru + neskor zmaze (nezacatu z lobby, rozohratu aj z DB).""" + if games.get(game.gid) is not game: + return player.connected = False - if not _any_human_connected(game) and not game.started: - asyncio.create_task(_cleanup_abandoned_lobby(game.gid)) + if not _any_human_connected(game): + _arm_abandon(game) await sio.emit( "player_connection", {"order": player.order, "connected": False}, @@ -400,7 +430,7 @@ async def _restore_unfinished_games(): for info in await history.get_unfinished_games(): if info["gid"] in games: continue - _load_game_into_memory(info) + _arm_abandon(_load_game_into_memory(info)) def _load_game_into_memory(info: dict) -> "Game": @@ -786,8 +816,7 @@ async def start_game(sid, gid): async def end_game(sid, gid): """Any seated player can permanently end a game that won't be finished -- not just the host, so the other players aren't stuck forever if the host - abandons the game. Marks it ended in the DB (so it won't be restored) and - sends everyone back to the lobby.""" + abandons the game.""" sess = sessions.get(sid) if sess is None or sess["gid"] != gid: return await send_error(sid, "Nie ste v tejto hre.") @@ -795,15 +824,7 @@ async def end_game(sid, gid): if game is None: return await send_error(sid, "Hra neexistuje.") - await history.mark_game_ended(gid) - # Notify the room first (while players are still in it), then tear it down. - await sio.emit("game_ended", {"gid": gid}, room=gid) - for player in game.players: - if player.sid: - sessions.pop(player.sid, None) - await sio.leave_room(player.sid, gid) - del games[gid] - await broadcast_lobby() + await _finish_game(game) @sio.on("reconnect_to_game") @@ -899,7 +920,7 @@ async def restore_game(sid, gid): info = await history.reopen_game(gid, account["player_id"]) if info is None: return await send_error(sid, "Hru sa nepodarilo obnovit.") - _load_game_into_memory(info) + _arm_abandon(_load_game_into_memory(info)) await sio.emit("game_restored", {"gid": gid}, to=sid) await broadcast_lobby() diff --git a/tests/test_socket.py b/tests/test_socket.py index 55dc6b6..5b88f72 100644 --- a/tests/test_socket.py +++ b/tests/test_socket.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import AsyncMock, patch import api @@ -218,7 +219,7 @@ class SocketLayerCase(unittest.IsolatedAsyncioTestCase): # --- disconnect & cleanup -------------------------------------------- - async def test_disconnect_marks_player_then_drops_empty_game(self): + async def test_disconnect_marks_player_and_schedules_started_game_expiry(self): gid = await self.make_started_game() await api.disconnect("s0") self.assertFalse(api.games[gid].player_by_sid("s0").connected) @@ -227,7 +228,28 @@ class SocketLayerCase(unittest.IsolatedAsyncioTestCase): for i in range(1, 4): await api.disconnect(f"s{i}") - self.assertNotIn(gid, api.games) # everyone gone -> game removed + self.assertIn(gid, api.games) # started game stays until its grace period expires + self.assertIsNotNone(api.games[gid].abandon_task) + + async def test_abandoned_started_game_expires_after_grace_period(self): + gid = "abandoned" + game = api.Game(gid, "Abandoned") + game.start() + game.players = [api.Player(None, f"P{seat}", seat, seat) for seat in range(4)] + for player in game.players: + player.connected = False + api.games[gid] = game + + with ( + patch.object(api, "GAME_ABANDON_GRACE_SECONDS", 0), + patch.object(api.history, "mark_game_ended", new_callable=AsyncMock) as mark_ended, + ): + api._arm_abandon(game) + await game.abandon_task + + self.assertNotIn(gid, api.games) + mark_ended.assert_awaited_once_with(gid) + self.assertEqual(len(self.events("game_ended")), 1) if __name__ == "__main__":