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 <noreply@anthropic.com>
This commit is contained in:
+47
-1
@@ -80,6 +80,9 @@ class Game:
|
|||||||
def player_by_sid(self, sid: str) -> "Player | None":
|
def player_by_sid(self, sid: str) -> "Player | None":
|
||||||
return next((p for p in self.players if p.sid == sid), 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:
|
class Player:
|
||||||
def __init__(self, sid: str, name: str, order: int):
|
def __init__(self, sid: str, name: str, order: int):
|
||||||
@@ -124,11 +127,20 @@ async def broadcast_lobby():
|
|||||||
async def send_game_status(gid: str):
|
async def send_game_status(gid: str):
|
||||||
game = games[gid]
|
game = games[gid]
|
||||||
core = game.bridzik_core
|
core = game.bridzik_core
|
||||||
|
last_round = core.series[-1].get_last_round()
|
||||||
await sio.emit(
|
await sio.emit(
|
||||||
"game_status",
|
"game_status",
|
||||||
{
|
{
|
||||||
"gid": gid,
|
"gid": gid,
|
||||||
"completed": core.is_completed(),
|
"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)),
|
"status": json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)),
|
||||||
},
|
},
|
||||||
room=gid,
|
room=gid,
|
||||||
@@ -213,7 +225,9 @@ async def register_player(sid, gid, player_name):
|
|||||||
if len(game.players) >= 4:
|
if len(game.players) >= 4:
|
||||||
return await send_error(sid, "Prekroceny pocet hracov.")
|
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)
|
player = Player(sid, player_name, order)
|
||||||
game.players.append(player)
|
game.players.append(player)
|
||||||
sessions[sid] = {"gid": gid, "order": order}
|
sessions[sid] = {"gid": gid, "order": order}
|
||||||
@@ -227,6 +241,38 @@ async def register_player(sid, gid, player_name):
|
|||||||
await broadcast_lobby()
|
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")
|
@sio.on("start_game")
|
||||||
async def start_game(sid, gid):
|
async def start_game(sid, gid):
|
||||||
game = games.get(gid)
|
game = games.get(gid)
|
||||||
|
|||||||
@@ -168,6 +168,54 @@ class SocketLayerCase(unittest.IsolatedAsyncioTestCase):
|
|||||||
await api.reconnect_to_game("zzz", gid, "wrong-token")
|
await api.reconnect_to_game("zzz", gid, "wrong-token")
|
||||||
self.assertEqual(self.last("error")["data"]["error"], "Neplatny token pre pripojenie.")
|
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 --------------------------------------------
|
# --- disconnect & cleanup --------------------------------------------
|
||||||
|
|
||||||
async def test_disconnect_marks_player_then_drops_empty_game(self):
|
async def test_disconnect_marks_player_then_drops_empty_game(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user