import json import os import uuid from json import JSONEncoder import socketio from bridzik import Bridzik, BridzikException, Card from db.db import init_db from api import auth as auth_module, history from api.auth import AuthError 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 init_db() await _restore_unfinished_games() 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] = {} # Maps a live connection (sid) to its authenticated account: {"player_id": int, # "username": str}. Set on login/confirm or on connect via the auth token. # Required before a connection may create or join a game. accounts: 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, player_id: int): self.sid = sid self.name = name # display name == account username self.order = order self.player_id = player_id # persistent account id (db.models.Player.id) 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, "player_id": p.player_id, } 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() status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)) # Use DB-backed standings so the score is correct even after a server restart # (the engine only knows rounds completed since restart). status["standings"] = await history.get_standings(gid) 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": status, }, 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) async def _mark_player_offline(game: "Game", player: "Player"): """Mark player disconnected, delete the game if everyone left, else notify the room.""" player.connected = False if not any(p.connected for p in game.players): del games[game.gid] else: await sio.emit( "player_connection", {"order": player.order, "connected": False}, room=game.gid, ) 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 async def _restore_unfinished_games(): """Po starte servera obnov rozohrate hry z DB do pamate (hraci offline). Karty su rozdane nanovo (pozicia z `series`/`round`); hraci sa vratia cez `rejoin_game` podla svojej trvalej identity (per-hra tokeny restart neprezili). """ for info in await history.get_unfinished_games(): if info["gid"] in games: continue game = Game(info["gid"], info["name"]) game.bridzik_core = info["core"] game.started = True for seat, (pid, uname) in enumerate(info["seats"]): player = Player(None, uname, seat, pid) player.connected = False game.players.append(player) games[info["gid"]] = game # --- connection lifecycle ------------------------------------------------- @sio.event async def connect(sid, environ, auth=None): await sio.enter_room(sid, LOBBY) # Auto-login via the session token the client stored after a previous login. token = auth.get("token") if isinstance(auth, dict) else None identity = await auth_module.player_by_token(token) if identity is not None: accounts[sid] = identity await sio.emit("login", {"player": identity}, to=sid) await sio.emit("get_games", {"games": public_games()}, to=sid) @sio.event async def disconnect(sid): accounts.pop(sid, None) sess = sessions.pop(sid, None) game = games.get(sess["gid"]) if sess else None if game is not None: player = game.player_by_sid(sid) if player is not None: await _mark_player_offline(game, player) await broadcast_lobby() # --- authentication (TOTP) ------------------------------------------------ @sio.on("register_account") async def register_account(sid, username): try: data = await auth_module.register_account(username) except AuthError as exc: return await send_error(sid, str(exc)) # otpauth_uri -> the client renders it as a QR code to scan into the app. await sio.emit("register_account", data, to=sid) @sio.on("confirm_account") async def confirm_account(sid, username, code): try: identity = await auth_module.confirm_account(username, code) except AuthError as exc: return await send_error(sid, str(exc)) accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid) @sio.on("login") async def login(sid, username, code): try: identity = await auth_module.login(username, code) except AuthError as exc: return await send_error(sid, str(exc)) accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid) # --- lobby ---------------------------------------------------------------- @sio.on("create_game") async def create_game(sid, name): if sid not in accounts: return await send_error(sid, "Musíte byť prihlásený.") 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): account = accounts.get(sid) if account is None: return await send_error(sid, "Musíte byť prihlásený.") 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.") if any(p.player_id == account["player_id"] for p in game.players): return await send_error(sid, "Uz ste v tejto hre.") # 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, account["username"], order, account["player_id"]) 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: await _mark_player_offline(game, player) 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): sess = sessions.get(sid) if sess is None or sess["gid"] != gid: return await send_error(sid, "Nie ste v tejto hre.") if sess["order"] != 0: return await send_error(sid, "Iba hostitel moze spustit hru.") 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() # Persist the game with its 4 seats (ordered 0..3) so history can attribute guesses. seated = sorted(game.players, key=lambda p: p.order) await history.record_game_started(gid, game.name, [p.player_id for p in seated]) await broadcast_lobby() await send_game_status(gid) for player in game.players: await send_player_cards(gid, player.order, player.sid) @sio.on("end_game") async def end_game(sid, gid): """Host (seat 0) permanently ends a game that won't be finished. Marks it ended in the DB (so it won't be restored) and sends everyone back to the lobby.""" sess = sessions.get(sid) if sess is None or sess["gid"] != gid: return await send_error(sid, "Nie ste v tejto hre.") if sess["order"] != 0: return await send_error(sid, "Iba hostitel moze ukoncit hru.") game = games.get(gid) if game is None: return await send_error(sid, "Hra neexistuje.") await history.mark_game_ended(gid) # Notify the room first (while players are still in it), then tear it down. await sio.emit("game_ended", {"gid": gid}, room=gid) for player in game.players: if player.sid: sessions.pop(player.sid, None) await sio.leave_room(player.sid, gid) del games[gid] await broadcast_lobby() @sio.on("reconnect_to_game") async def reconnect_to_game(sid, gid, token): # Best-effort background reconnect: fail silently (no error toast). After a # server restart the old token is gone -> the user rejoins from the lobby. game = games.get(gid) if game is None: return player = game.player_by_token(token) if player is None: return old_sid = player.sid if old_sid != sid: sessions.pop(old_sid, None) 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() @sio.on("rejoin_game") async def rejoin_game(sid, gid): """Re-seat into a game via the logged-in account (used after a server restart, when per-game reconnect tokens are gone). Identity comes from the session.""" account = accounts.get(sid) if account is None: return await send_error(sid, "Musíte byť prihlásený.") 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.") player = next( (p for p in game.players if p.player_id == account["player_id"]), None ) if player is None: return await send_error(sid, "Nie ste hracom tejto hry.") old_sid = player.sid if old_sid and old_sid != sid: sessions.pop(old_sid, None) 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)) # Persist completed rounds first so the DB-backed standings in game_status are # up to date (idempotent; also marks the game ended). await history.record_completed_rounds(game.gid, core) await send_game_status(game.gid) for player in game.players: await send_player_cards(game.gid, player.order, player.sid) # --- history (read-only) -------------------------------------------------- @sio.on("get_player_history") async def get_player_history(sid, *args): account = accounts.get(sid) if account is None: return await send_error(sid, "Musíte byť prihlásený.") rows = await history.get_player_history(account["player_id"]) await sio.emit("get_player_history", {"games": rows}, to=sid) @sio.on("get_game_detail") async def get_game_detail(sid, gid): account = accounts.get(sid) if account is None: return await send_error(sid, "Musíte byť prihlásený.") detail = await history.get_game_detail(gid) if detail is None: return await send_error(sid, "Hra neexistuje.") await sio.emit("get_game_detail", detail, to=sid)