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)