zmazanie nedohratych hier z lobby

This commit is contained in:
tim committed 2026-10-17 15:34:05 +02:00
1 parent 115413a813
commit 3655e06587
2 files changed
+80 -37

No files matched your search

+56 -35
View File
@@ -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()
+24 -2
View File
@@ -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__":