From b8e2d15e2791186d0e54e98d26232fde07368601 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 13 Jun 2026 23:56:02 +0200 Subject: [PATCH] Add leave_game and enrich game_status for frontend integration - leave_game: explicit exit from the lobby/game. Before start it frees the seat; after start it keeps the seat (token reconnect still works) and marks the player offline, dropping the game once empty. - Seat assignment now picks the lowest free order, so leaving a lobby before start no longer collides order numbers on the next join. - game_status is now self-contained: adds players roster (order/name/connected), series_number, round_number and cards_in_round (= trick count / max bid), so the game view no longer has to stitch the lobby snapshot. - Add Game.player_by_order helper. Tests: +5 (roster/round meta, leave-before-start frees seat, leave drops empty game, leave started game keeps seat offline, leave noop). Suite: 34. Co-Authored-By: Claude Opus 4.8 --- api/__init__.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++- test_socket.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/api/__init__.py b/api/__init__.py index 5de3d9f..4e89a6a 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -80,6 +80,9 @@ class Game: def player_by_sid(self, sid: str) -> "Player | None": return next((p for p in self.players if p.sid == sid), None) + def player_by_order(self, order: int) -> "Player | None": + return next((p for p in self.players if p.order == order), None) + class Player: def __init__(self, sid: str, name: str, order: int): @@ -124,11 +127,20 @@ async def broadcast_lobby(): async def send_game_status(gid: str): game = games[gid] core = game.bridzik_core + last_round = core.series[-1].get_last_round() await sio.emit( "game_status", { "gid": gid, "completed": core.is_completed(), + # Self-contained roster so the game view doesn't depend on the lobby snapshot. + "players": [ + {"order": p.order, "name": p.name, "connected": p.connected} + for p in sorted(game.players, key=lambda p: p.order) + ], + "series_number": core.series[-1].series_number, + "round_number": last_round.round_number, + "cards_in_round": 8 - last_round.round_number, # tricks == max bid "status": json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)), }, room=gid, @@ -213,7 +225,9 @@ async def register_player(sid, gid, player_name): if len(game.players) >= 4: return await send_error(sid, "Prekroceny pocet hracov.") - order = len(game.players) + # Lowest free seat (robust if someone left the lobby before start). + used = {p.order for p in game.players} + order = next(o for o in range(4) if o not in used) player = Player(sid, player_name, order) game.players.append(player) sessions[sid] = {"gid": gid, "order": order} @@ -227,6 +241,38 @@ async def register_player(sid, gid, player_name): await broadcast_lobby() +@sio.on("leave_game") +async def leave_game(sid): + """Explicit exit (e.g. a 'Back to lobby' button). The socket stays + connected and remains in the lobby room.""" + sess = sessions.pop(sid, None) + if sess is None: + return # not in a game; nothing to do + game = games.get(sess["gid"]) + if game is not None: + await sio.leave_room(sid, game.gid) + player = game.player_by_sid(sid) + if game.started: + # Game in progress: keep the seat (reconnect via token still works), + # just mark the player offline. + if player is not None: + player.connected = False + await sio.emit( + "player_connection", + {"order": player.order, "connected": False}, + room=game.gid, + ) + if not any(p.connected for p in game.players): + del games[game.gid] + else: + # Not started yet: free the seat entirely. + if player is not None: + game.players.remove(player) + if not game.players: + del games[game.gid] + await broadcast_lobby() + + @sio.on("start_game") async def start_game(sid, gid): game = games.get(gid) diff --git a/test_socket.py b/test_socket.py index 42bf78e..55dc6b6 100644 --- a/test_socket.py +++ b/test_socket.py @@ -168,6 +168,54 @@ class SocketLayerCase(unittest.IsolatedAsyncioTestCase): await api.reconnect_to_game("zzz", gid, "wrong-token") self.assertEqual(self.last("error")["data"]["error"], "Neplatny token pre pripojenie.") + # --- self-contained game_status -------------------------------------- + + async def test_game_status_carries_roster_and_round_meta(self): + gid = await self.make_started_game() + self.emits.clear() + await api.game_status("s0") + data = self.last("game_status")["data"] + self.assertEqual(data["round_number"], 0) + self.assertEqual(data["cards_in_round"], 8) + self.assertEqual(data["series_number"], 0) + roster = data["players"] + self.assertEqual([p["order"] for p in roster], [0, 1, 2, 3]) + self.assertEqual([p["name"] for p in roster], ["P0", "P1", "P2", "P3"]) + self.assertTrue(all(p["connected"] for p in roster)) + + # --- leave_game ------------------------------------------------------- + + async def test_leave_before_start_frees_the_seat(self): + gid = await self.make_game() + await api.register_player("s0", gid, "P0") + await api.register_player("s1", gid, "P1") + await api.leave_game("s0") # order 0 leaves + self.assertNotIn("s0", api.sessions) + self.assertEqual([p.order for p in api.games[gid].players], [1]) + # the freed seat 0 is reused by the next joiner + await api.register_player("s2", gid, "P2") + self.assertEqual(api.sessions["s2"]["order"], 0) + + async def test_leave_last_lobby_player_drops_game(self): + gid = await self.make_game() + await api.register_player("s0", gid, "P0") + await api.leave_game("s0") + self.assertNotIn(gid, api.games) + + async def test_leave_started_game_keeps_seat_marks_offline(self): + gid = await self.make_started_game() + await api.leave_game("s1") + self.assertNotIn("s1", api.sessions) + player = api.games[gid].player_by_order(1) + self.assertIsNotNone(player) # seat kept for reconnect + self.assertFalse(player.connected) + self.assertIn(gid, api.games) # others still in + + async def test_leave_without_session_is_a_noop(self): + await api.leave_game("ghost") + # no exception, and no error emitted to a non-participant + self.assertEqual(self.events("error"), []) + # --- disconnect & cleanup -------------------------------------------- async def test_disconnect_marks_player_then_drops_empty_game(self):