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:
Tim
2026-06-13 23:56:02 +02:00
parent 821c7e81ce
commit b8e2d15e27
2 changed files with 95 additions and 1 deletions
+47 -1
View File
@@ -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)