b8e2d15e27
- 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>
375 lines
12 KiB
Python
375 lines
12 KiB
Python
import json
|
|
import os
|
|
import uuid
|
|
from json import JSONEncoder
|
|
|
|
import socketio
|
|
|
|
from bridzik import Bridzik, BridzikException, Card
|
|
|
|
|
|
def _env_bool(name: str, default: bool) -> bool:
|
|
val = os.environ.get(name)
|
|
if val is None:
|
|
return default
|
|
return val.lower() in ("1", "true", "yes", "on")
|
|
|
|
|
|
# --- configuration (env-driven, dev-friendly defaults) --------------------
|
|
_cors = os.environ.get("CORS_ALLOWED_ORIGINS", "*")
|
|
CORS_ALLOWED_ORIGINS = "*" if _cors == "*" else [o.strip() for o in _cors.split(",")]
|
|
SIO_LOGGER = _env_bool("SOCKETIO_LOGGER", False)
|
|
|
|
LOBBY = "lobby" # room every connection joins to receive the public game list
|
|
|
|
sio = socketio.AsyncServer(
|
|
async_mode="asgi",
|
|
cors_allowed_origins=CORS_ALLOWED_ORIGINS,
|
|
logger=SIO_LOGGER,
|
|
engineio_logger=SIO_LOGGER,
|
|
)
|
|
|
|
|
|
async def _health_app(scope, receive, send):
|
|
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks)."""
|
|
if scope["type"] == "lifespan":
|
|
while True:
|
|
message = await receive()
|
|
if message["type"] == "lifespan.startup":
|
|
await send({"type": "lifespan.startup.complete"})
|
|
elif message["type"] == "lifespan.shutdown":
|
|
await send({"type": "lifespan.shutdown.complete"})
|
|
return
|
|
if scope["type"] == "http":
|
|
ok = scope.get("path", "") in ("/health", "/healthz")
|
|
status = 200 if ok else 404
|
|
body = b"ok" if ok else b"not found"
|
|
await send({"type": "http.response.start", "status": status,
|
|
"headers": [(b"content-type", b"text/plain")]})
|
|
await send({"type": "http.response.body", "body": body})
|
|
|
|
|
|
# Run with: uvicorn api:app --host 0.0.0.0 --port 5000
|
|
app = socketio.ASGIApp(sio, other_asgi_app=_health_app)
|
|
|
|
# --- in-memory state ------------------------------------------------------
|
|
# Single-process only. For multi-worker deployments this moves to Redis
|
|
# (socketio.AsyncRedisManager) plus a shared game store.
|
|
games: dict[str, "Game"] = {}
|
|
# Maps a live connection (sid) to the seat it controls: {"gid": str, "order": int}.
|
|
# This is the source of truth for "who is acting" — never trust a client-supplied
|
|
# player number.
|
|
sessions: dict[str, dict] = {}
|
|
|
|
|
|
class Game:
|
|
def __init__(self, gid: str, name: str):
|
|
self.gid = gid
|
|
self.name = name
|
|
self.players: list["Player"] = []
|
|
self.started = False
|
|
self.bridzik_core: Bridzik | None = None
|
|
|
|
def start(self):
|
|
self.bridzik_core = Bridzik()
|
|
self.started = True
|
|
|
|
def player_by_token(self, token: str) -> "Player | None":
|
|
return next((p for p in self.players if p.token == token), None)
|
|
|
|
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):
|
|
self.sid = sid
|
|
self.name = name
|
|
self.order = order
|
|
self.token = str(uuid.uuid4()) # secret token used for secure reconnect
|
|
self.connected = True
|
|
|
|
|
|
class CardStatusEncoder(JSONEncoder):
|
|
"""Serializes the engine status, which may contain Card objects."""
|
|
|
|
def default(self, obj):
|
|
if isinstance(obj, Card):
|
|
return {"color": obj.color.name, "value": obj.value.name}
|
|
return JSONEncoder.default(self, obj)
|
|
|
|
|
|
def public_games() -> list:
|
|
"""Public lobby view — no sids, no reconnect tokens."""
|
|
return [
|
|
{
|
|
"gid": g.gid,
|
|
"name": g.name,
|
|
"started": g.started,
|
|
"players": [
|
|
{"order": p.order, "name": p.name, "connected": p.connected}
|
|
for p in g.players
|
|
],
|
|
}
|
|
for g in games.values()
|
|
]
|
|
|
|
|
|
# --- emit helpers ---------------------------------------------------------
|
|
|
|
async def broadcast_lobby():
|
|
await sio.emit("get_games", {"games": public_games()}, room=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,
|
|
)
|
|
|
|
|
|
async def send_player_cards(gid: str, order: int, to: str):
|
|
core = games[gid].bridzik_core
|
|
await sio.emit(
|
|
"player_cards",
|
|
{"cards": json.loads(json.dumps(core.get_player_cards(int(order)), cls=Card.JSONEncoder))},
|
|
to=to,
|
|
)
|
|
|
|
|
|
async def send_error(sid: str, message: str):
|
|
await sio.emit("error", {"error": message}, to=sid)
|
|
|
|
|
|
def _active_game(sid: str) -> "tuple[Game, dict] | None":
|
|
"""Resolve the started game and seat for a connection, or None."""
|
|
sess = sessions.get(sid)
|
|
if sess is None:
|
|
return None
|
|
game = games.get(sess["gid"])
|
|
if game is None or not game.started:
|
|
return None
|
|
return game, sess
|
|
|
|
|
|
# --- connection lifecycle -------------------------------------------------
|
|
|
|
@sio.event
|
|
async def connect(sid, environ, auth=None):
|
|
await sio.enter_room(sid, LOBBY)
|
|
await sio.emit("get_games", {"games": public_games()}, to=sid)
|
|
|
|
|
|
@sio.event
|
|
async def disconnect(sid):
|
|
sessions.pop(sid, None)
|
|
game = next((g for g in games.values() if g.player_by_sid(sid)), None)
|
|
if game is not None:
|
|
player = game.player_by_sid(sid)
|
|
player.connected = False
|
|
if not any(p.connected for p in game.players):
|
|
# Everybody left — drop the game so it can't leak memory forever.
|
|
del games[game.gid]
|
|
else:
|
|
await sio.emit(
|
|
"player_connection",
|
|
{"order": player.order, "connected": False},
|
|
room=game.gid,
|
|
)
|
|
await broadcast_lobby()
|
|
|
|
|
|
# --- lobby ----------------------------------------------------------------
|
|
|
|
@sio.on("create_game")
|
|
async def create_game(sid, name):
|
|
gid = str(uuid.uuid4())
|
|
games[gid] = Game(gid, name)
|
|
await sio.emit("create_game", {"gid": gid}, to=sid)
|
|
await broadcast_lobby()
|
|
|
|
|
|
@sio.on("get_games")
|
|
async def get_games(sid, *args):
|
|
await sio.emit("get_games", {"games": public_games()}, to=sid)
|
|
|
|
|
|
@sio.on("register_player")
|
|
async def register_player(sid, gid, player_name):
|
|
if sid in sessions:
|
|
return await send_error(sid, "Uz ste v hre.")
|
|
game = games.get(gid)
|
|
if game is None:
|
|
return await send_error(sid, "Hra neexistuje.")
|
|
if game.started:
|
|
return await send_error(sid, "Hra uz zacala.")
|
|
if len(game.players) >= 4:
|
|
return await send_error(sid, "Prekroceny pocet hracov.")
|
|
|
|
# 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}
|
|
await sio.enter_room(sid, gid)
|
|
# The token is private to this player and required for a secure reconnect.
|
|
await sio.emit(
|
|
"register_player",
|
|
{"player": {"order": order, "name": player_name}, "token": player.token},
|
|
to=sid,
|
|
)
|
|
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)
|
|
if game is None:
|
|
return await send_error(sid, "Hra neexistuje.")
|
|
if game.started:
|
|
return await send_error(sid, "Hra uz zacala.")
|
|
if len(game.players) != 4:
|
|
return await send_error(sid, "Nedostatocny pocet hracov.")
|
|
|
|
game.start()
|
|
await broadcast_lobby()
|
|
await send_game_status(gid)
|
|
for player in game.players:
|
|
await send_player_cards(gid, player.order, player.sid)
|
|
|
|
|
|
@sio.on("reconnect_to_game")
|
|
async def reconnect_to_game(sid, gid, token):
|
|
game = games.get(gid)
|
|
if game is None:
|
|
return await send_error(sid, "Hra neexistuje.")
|
|
player = game.player_by_token(token)
|
|
if player is None:
|
|
return await send_error(sid, "Neplatny token pre pripojenie.")
|
|
|
|
player.sid = sid
|
|
player.connected = True
|
|
sessions[sid] = {"gid": gid, "order": player.order}
|
|
await sio.enter_room(sid, gid)
|
|
await sio.emit(
|
|
"register_player",
|
|
{"player": {"order": player.order, "name": player.name}, "token": player.token},
|
|
to=sid,
|
|
)
|
|
if game.started:
|
|
await send_game_status(gid)
|
|
await send_player_cards(gid, player.order, sid)
|
|
await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid)
|
|
await broadcast_lobby()
|
|
|
|
|
|
# --- in-game actions (seat derived from the connection, never the client) -
|
|
|
|
@sio.on("game_status")
|
|
async def game_status(sid, *args):
|
|
resolved = _active_game(sid)
|
|
if resolved is None:
|
|
return await send_error(sid, "Nie ste v rozohratej hre.")
|
|
game, _ = resolved
|
|
await send_game_status(game.gid)
|
|
|
|
|
|
@sio.on("player_cards")
|
|
async def player_cards(sid, *args):
|
|
resolved = _active_game(sid)
|
|
if resolved is None:
|
|
return await send_error(sid, "Nie ste v rozohratej hre.")
|
|
game, sess = resolved
|
|
await send_player_cards(game.gid, sess["order"], sid)
|
|
|
|
|
|
@sio.on("add_guess")
|
|
async def add_guess(sid, guess):
|
|
resolved = _active_game(sid)
|
|
if resolved is None:
|
|
return await send_error(sid, "Nie ste v rozohratej hre.")
|
|
game, sess = resolved
|
|
try:
|
|
value = int(guess)
|
|
except (TypeError, ValueError):
|
|
return await send_error(sid, "Neplatny tip.")
|
|
try:
|
|
game.bridzik_core.add_player_guess(sess["order"], value)
|
|
except BridzikException as exc:
|
|
return await send_error(sid, str(exc))
|
|
await send_game_status(game.gid)
|
|
|
|
|
|
@sio.on("play_card")
|
|
async def play_card(sid, card_key):
|
|
resolved = _active_game(sid)
|
|
if resolved is None:
|
|
return await send_error(sid, "Nie ste v rozohratej hre.")
|
|
game, sess = resolved
|
|
core = game.bridzik_core
|
|
hand = core.get_player_cards(sess["order"])
|
|
try:
|
|
key = int(card_key)
|
|
except (TypeError, ValueError):
|
|
return await send_error(sid, "Neplatna karta.")
|
|
if key not in hand:
|
|
return await send_error(sid, "Neplatna karta.")
|
|
try:
|
|
core.play_card(sess["order"], hand[key])
|
|
except BridzikException as exc:
|
|
return await send_error(sid, str(exc))
|
|
await send_game_status(game.gid)
|
|
await send_player_cards(game.gid, sess["order"], sid)
|