import asyncio import hmac import json import os import time import uuid from collections import defaultdict from json import JSONEncoder from urllib.parse import parse_qs import socketio from bridzik import Bridzik, BridzikException, Card from db.db import init_db from api import auth as auth_module, history, leaderboard from api import bots as bots_module from api import stats as stats_module from api.auth import AuthError, RegistrationIncomplete from rl.encoding import index_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 _read_body(receive) -> bytes: body = b"" while True: message = await receive() body += message.get("body", b"") if not message.get("more_body"): break return body async def _send_text(send, status: int, body: bytes, content_type: bytes = b"text/plain"): await send({"type": "http.response.start", "status": status, "headers": [(b"content-type", content_type)]}) await send({"type": "http.response.body", "body": body}) def _admin_authorized(scope) -> bool: token = os.environ.get("ADMIN_TOKEN", "") if not token: return False headers = dict(scope.get("headers") or []) auth_header = headers.get(b"authorization", b"").decode("utf-8", "ignore") return hmac.compare_digest(auth_header, f"Bearer {token}") def _client_ip(scope) -> str: # Behind nginx (docker-compose.prod.yaml) this is the real client IP -- # the backend port is never published, so X-Forwarded-For can't be spoofed # by an external caller going around the proxy. headers = dict(scope.get("headers") or []) xff = headers.get(b"x-forwarded-for", b"").decode("utf-8", "ignore") if xff: return xff.split(",")[0].strip() client = scope.get("client") return client[0] if client else "unknown" # Lockout na neuspesne pokusy o /api/admin/stats, per client IP -- rovnaky # in-memory pattern ako login lockout v api/auth.py (jeden proces, ziadny Redis). _ADMIN_ATTEMPT_LIMIT = 5 _ADMIN_ATTEMPT_WINDOW = 300 # sekund _admin_failed_attempts: dict[str, list[float]] = defaultdict(list) def _admin_locked_out(ip: str) -> bool: cutoff = time.monotonic() - _ADMIN_ATTEMPT_WINDOW attempts = [t for t in _admin_failed_attempts.get(ip, []) if t > cutoff] if attempts: _admin_failed_attempts[ip] = attempts else: _admin_failed_attempts.pop(ip, None) return len(attempts) >= _ADMIN_ATTEMPT_LIMIT def _register_admin_failure(ip: str) -> None: _admin_failed_attempts[ip].append(time.monotonic()) async def _handle_track(scope, receive, send): try: data = json.loads((await _read_body(receive)) or b"{}") except json.JSONDecodeError: data = {} headers = dict(scope.get("headers") or []) user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300] # player_id sa sem nikdy nedava z klienta (nedovereny/spoofovatelny vstup na # neautentifikovanom endpointe) -- pripaja sa len server-side na evente # "login" (pozri handler @sio.on("login")). await stats_module.record_pageview( path=str(data.get("path", ""))[:200], referrer=str(data.get("referrer", ""))[:300], user_agent=user_agent, ip=_client_ip(scope), ) await send({"type": "http.response.start", "status": 204, "headers": []}) await send({"type": "http.response.body", "body": b""}) async def _handle_admin_stats(scope, send): ip = _client_ip(scope) if _admin_locked_out(ip): return await _send_text(send, 429, b"too many attempts") if not _admin_authorized(scope): _register_admin_failure(ip) return await _send_text(send, 403, b"forbidden") query = parse_qs((scope.get("query_string") or b"").decode("utf-8", "ignore")) logged_in_only = query.get("logged_in", ["0"])[0] == "1" data = await stats_module.get_daily_stats(logged_in_only=logged_in_only) await _send_text(send, 200, json.dumps(data).encode(), b"application/json") async def _health_app(scope, receive, send): """Minimal ASGI handler for non-socket.io HTTP routes (liveness checks, pageview tracking beacon, admin stats).""" 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": path = scope.get("path", "") method = scope.get("method", "GET") if path in ("/health", "/healthz"): return await _send_text(send, 200, b"ok") if path == "/api/track" and method == "POST": return await _handle_track(scope, receive, send) if path == "/api/admin/stats" and method == "GET": return await _handle_admin_stats(scope, send) await _send_text(send, 404, b"not found") # 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 # Serializuje tahovu slucku botov -- dva sucasne _run_bot_turns tasky # by inak mohli tahat za to iste sedadlo. self.bot_lock = asyncio.Lock() # Grace timer po odchode posledneho cloveka. Novy odchod ho zrusi a # spusti odznova, takze navrat neresetuje cudzie okno. self.abandon_task: asyncio.Task | 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 # Bot = sedadlo bez socketu; `brain` je rozhodovaci objekt s rozhranim # guess(rnd, seat) / play(rnd, seat) z rl/players.py. self.is_bot = False self.brain = None 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. A game that finished naturally (all 4 series played out) stays in the `games` dict for reconnect purposes (e.g. a reload while still on the GameOver screen), but it has nothing left to offer the lobby — drop it here rather than have it linger forever as a "started"/resumable entry. """ 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, "is_bot": p.is_bot, } for p in g.players ], } for g in games.values() if g.bridzik_core is None or not g.bridzik_core.is_completed() ] # --- emit helpers --------------------------------------------------------- def _online_player_ids() -> set[int]: """Distinct logged-in players currently connected. Counting *players* (by account id), not raw sockets, is what makes the lobby counters stable and meaningful: * one person with several tabs/devices counts once; * a not-yet-logged-in visitor (still on the auth screen) is not a player and is not counted; * reconnect churn and phantom sockets lingering until the engine.io ping timeout no longer inflate the number, because the identity is counted, not the transient connection. """ return {acc["player_id"] for acc in accounts.values()} def _in_game_player_ids() -> set[int]: """Players who currently occupy a seat in a game (any of their sockets is seated) -- 'busy', i.e. not available to start/join another game.""" ids: set[int] = set() for sid in sessions: acc = accounts.get(sid) if acc is not None: ids.add(acc["player_id"]) return ids def online_count() -> int: """Number of distinct logged-in players online (in the lobby or in a game).""" return len(_online_player_ids()) def available_count() -> int: """Online players not currently seated in any game (free to join/create).""" return len(_online_player_ids() - _in_game_player_ids()) async def broadcast_lobby(): await sio.emit( "get_games", { "games": public_games(), "online_count": online_count(), "available_count": available_count(), }, 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"], status["standings_guesses"] = 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, "is_bot": p.is_bot} 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) # Ako dlho prezije prazdna hra. Na mobile sa socket bezne strati uz pri # zamknuti obrazovky, takze okamzite zmazanie rusilo lobby aj rozohrate partie. LOBBY_ABANDON_GRACE_SECONDS = 10 * 60 GAME_ABANDON_GRACE_SECONDS = 30 * 60 def _any_human_connected(game: "Game") -> bool: """Boti su 'pripojeni' stale, pre opustenost sa pocitaju len ludia.""" return any(p.connected for p in game.players if not p.is_bot) def _arm_abandon(game: "Game") -> None: """Spusti grace odznova. Predosly timer sa zrusi, aby navrat + novy odchod nedal staremu tasku zmazat hru hned.""" if game.abandon_task is not None: game.abandon_task.cancel() delay = GAME_ABANDON_GRACE_SECONDS if game.started else LOBBY_ABANDON_GRACE_SECONDS game.abandon_task = asyncio.create_task(_abandon_if_empty(game.gid, delay)) async def _abandon_if_empty(gid: str, delay: float) -> None: await asyncio.sleep(delay) game = games.get(gid) # Porovnanie s current_task odfiltruje aj hru s tym istym gid, ktoru medzitym # niekto ukoncil a obnovil (ma vlastny timer). if game is None or game.abandon_task is not asyncio.current_task() or _any_human_connected(game): return if not game.started: del games[gid] await broadcast_lobby() elif not game.bridzik_core.is_completed(): await _finish_game(game) async def _finish_game(game: "Game") -> None: if games.pop(game.gid, None) is not game: return try: await history.mark_game_ended(game.gid) except Exception: games[game.gid] = game raise await sio.emit("game_ended", {"gid": game.gid}, room=game.gid) for player in game.players: if player.sid: sessions.pop(player.sid, None) await sio.leave_room(player.sid, game.gid) await broadcast_lobby() async def _mark_player_offline(game: "Game", player: "Player"): """Oznac hraca offline. Ked nezostal ziadny clovek, grace timer hru neskor zmaze (nezacatu z lobby, rozohratu aj z DB).""" if games.get(game.gid) is not game: return player.connected = False if not _any_human_connected(game): _arm_abandon(game) 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 _arm_abandon(_load_game_into_memory(info)) def _load_game_into_memory(info: dict) -> "Game": """Postav in-memory Game z restore-info (gid/name/seats/core), hraci offline, a vlozi ju do `games`. Pouzite pri starte aj pri obnove hry z historie.""" 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 # Boti sa rozpoznaju konvenciou mena a dostanu novy mozog -- ozivi ich # prvy _kick_bots (napr. ked sa clovek vrati cez rejoin_game). if bots_module.is_bot_username(uname): player.is_bot = True player.brain = bots_module.make_brain(uname) player.connected = True game.players.append(player) games[info["gid"]] = game return game # --- bot turns -------------------------------------------------------------- # Pauza medzi tahmi bota, nech ludia stihaju sledovat hru (0 = okamzite). BOT_MOVE_DELAY_SECONDS = float(os.environ.get("BOT_MOVE_DELAY_SECONDS", "0.8")) # Kopka na stole sa po dohrati este chvilu zmieta smerom k vitazovi (SETTLE_MS + # COLLECT_MS vo frontend/src/pages/GameTable.tsx, spolu 1650ms) -- kym tato # animacia nedobehne vsetkym hracom, prvy bot na tahu nesmie zahodit kartu do # novej kopky, inak by mu karta "vyletela" uprostred zmetania predoslej. TRICK_SWEEP_SECONDS = 1.7 def _kick_bots(gid: str) -> None: """Ak je v hre bot, spusti (na pozadi) dohratie vsetkych botich tahov. Vola sa po kazdej akcii, ktora mohla posunut tah na botie sedadlo.""" game = games.get(gid) if game is not None and game.started and any(p.is_bot for p in game.players): asyncio.create_task(_run_bot_turns(gid)) async def _run_bot_turns(gid: str): """Kym je na tahu botie sedadlo, vykonavaj jeho tahy tym istym internym postupom ako handlery add_guess/play_card (engine validuje, historia sa zapisuje, room dostava game_status). MC vypocet bezi v executori, aby nedrzal event loop ostatnych hier.""" game = games.get(gid) if game is None or not game.started or game.bridzik_core is None: return async with game.bot_lock: core = game.bridzik_core loop = asyncio.get_running_loop() while not core.is_completed(): rnd = core.series[-1].get_last_round() seat = rnd.get_active_player() bot = game.player_by_order(seat) if bot is None or not bot.is_bot: return # na tahu je clovek delay = BOT_MOVE_DELAY_SECONDS if rnd.is_guessing_completed() and not rnd.get_last_stash().get_cards() \ and core.get_previous_stash() is not None: # Bot vedie novu kopku a este bezi zmetanie tej predoslej. delay = max(delay, TRICK_SWEEP_SECONDS) if delay > 0: await asyncio.sleep(delay) if games.get(gid) is not game: return # hru medzitym niekto ukoncil (end_game) played_card = False try: if not rnd.is_guessing_completed(): guess = await loop.run_in_executor(None, bot.brain.guess, rnd, seat) core.add_player_guess(seat, guess) else: action = await loop.run_in_executor(None, bot.brain.play, rnd, seat) core.play_card(seat, index_card(action)) await history.record_completed_rounds(gid, core) played_card = True except BridzikException as exc: # Nemalo by nastat (bot hra len legalne tahy) -- nezacykli sa, # slucku znovu spusti dalsia akcia cloveka. await send_error_room(gid, str(exc)) return # game_status musi ist PRED player_cards -- klient podla neho (novy # round_number + previous_stash) pozna, ze prave zacalo nove kolo, a # dovtedy si drzi starych karty na obrazovke (pozri "displayedHand" vo # frontend/src/pages/GameTable.tsx), kym nedobehne animacia zmetenia # poslednej kopky. Opacne poradie by novu ruku odhalilo predcasne. await send_game_status(gid) if played_card: for player in game.players: if player.sid: await send_player_cards(gid, player.order, player.sid) async def send_error_room(gid: str, message: str): await sio.emit("error", {"error": message}, room=gid) def _public_identity(account: dict) -> dict: """The client only needs `player_id` to compare against game rosters (see `isMember` in frontend/src/pages/GameList.tsx) -- `username` stays purely server-side (used to name the seat on register_player/rejoin_game etc.).""" return {"player_id": account["player_id"]} # --- 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": _public_identity(identity)}, to=sid) # Broadcast (not just emit to sid) so everyone's online-players count stays live. await broadcast_lobby() @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) # accounts/sessions already updated above, so the lobby counters are correct. 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) async def _record_login_event(sid: str, player_id: int) -> None: scope = (sio.get_environ(sid) or {}).get("asgi.scope", {}) headers = dict(scope.get("headers") or []) await stats_module.record_pageview( path="login", referrer="", user_agent=headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300], ip=_client_ip(scope), player_id=player_id, ) @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 _record_login_event(sid, identity["player_id"]) await sio.emit( "login", {"player": _public_identity(accounts[sid]), "token": identity["token"]}, to=sid ) # A new player just came online -> refresh everyone's lobby counters. await broadcast_lobby() @sio.on("login") async def login(sid, username, code): try: identity = await auth_module.login(username, code) except RegistrationIncomplete as exc: # Nedokoncena registracia -> vydame novy QR kod, klient sa prepne # na registracny tab a pouzivatel ju moze dokoncit. try: data = await auth_module.register_account(username) except AuthError as exc2: return await send_error(sid, str(exc2)) await sio.emit("register_account", data, to=sid) return await send_error(sid, str(exc)) except AuthError as exc: return await send_error(sid, str(exc)) accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} await _record_login_event(sid, identity["player_id"]) await sio.emit( "login", {"player": _public_identity(accounts[sid]), "token": identity["token"]}, to=sid ) # A new player just came online -> refresh everyone's lobby counters. await broadcast_lobby() @sio.on("logout") async def logout(sid): """De-authenticate this connection without touching the transport. The previous approach (client calling `socket.disconnect()` then immediately `socket.connect()`) was racy: python-socketio's disconnect isn't instantaneous (the old sid can briefly linger past its ping-timeout while a brand new sid is already connecting), so for that window BOTH sids were counted in the lobby online/available totals -- exactly the transient over-count reported. Simply dropping the account association server-side avoids any connect/disconnect churn entirely. """ accounts.pop(sid, None) # A player just went offline -> refresh everyone's lobby counters. await broadcast_lobby() # --- 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(), "online_count": online_count(), "available_count": available_count(), }, 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("add_bot") async def add_bot(sid, gid, kind=None): """Hostitel prida bota na najnizsie volne sedadlo nezacatej hry.""" 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 pridavat botov.") 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 kind == "neural" and not bots_module.neural_available(): return await send_error(sid, "AI bot nie je na tomto serveri dostupny.") if kind not in bots_module.BOT_KINDS: kind = bots_module.DEFAULT_KIND account = await bots_module.ensure_bot_account( kind, {p.player_id for p in game.players} ) used = {p.order for p in game.players} order = next(o for o in range(4) if o not in used) player = Player(None, account["username"], order, account["player_id"]) player.is_bot = True player.brain = bots_module.make_brain(account["username"]) game.players.append(player) await broadcast_lobby() @sio.on("remove_bot") async def remove_bot(sid, gid, order): """Hostitel odoberie bota z nezacatej hry (sedadlo sa uvolni).""" 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 odoberat botov.") 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.") try: seat = int(order) except (TypeError, ValueError): return await send_error(sid, "Neplatne sedadlo.") player = game.player_by_order(seat) if player is None or not player.is_bot: return await send_error(sid, "Na tomto sedadle nie je bot.") game.players.remove(player) 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: # sid None = bot alebo offline sedadlo -- emit s to=None by karty # broadcastol VSETKYM klientom, preto sa preskakuje. if player.sid: await send_player_cards(gid, player.order, player.sid) _kick_bots(gid) @sio.on("end_game") async def end_game(sid, gid): """Any seated player can permanently end a game that won't be finished -- not just the host, so the other players aren't stuck forever if the host abandons the game.""" sess = sessions.get(sid) if sess is None or sess["gid"] != gid: return await send_error(sid, "Nie ste v tejto hre.") game = games.get(gid) if game is None: return await send_error(sid, "Hra neexistuje.") await _finish_game(game) @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() _kick_bots(gid) @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() _kick_bots(gid) @sio.on("restore_game") async def restore_game(sid, gid): """Obnov predcasne ukoncenu hru z historie spat do lobby. Smie ju vyvolat iba hrac danej hry; v lobby sa potom objavi ako rozohrata a clenovia sa pripoja cez `rejoin_game`.""" account = accounts.get(sid) if account is None: return await send_error(sid, "Musíte byť prihlásený.") if gid in games: # Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta. await sio.emit("game_restored", {"gid": gid}, to=sid) return await sio.emit( "get_games", { "games": public_games(), "online_count": online_count(), "available_count": available_count(), }, to=sid, ) info = await history.reopen_game(gid, account["player_id"]) if info is None: return await send_error(sid, "Hru sa nepodarilo obnovit.") _arm_abandon(_load_game_into_memory(info)) await sio.emit("game_restored", {"gid": gid}, to=sid) 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) _kick_bots(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: if player.sid: # None (bot/offline) by broadcastoval karty vsetkym await send_player_cards(game.gid, player.order, player.sid) # A naturally-finished game (all 4 series played out) has nothing left to # offer the lobby -- refresh the list so it drops out immediately instead # of lingering as "started"/resumable until someone happens to leave it. if core.is_completed(): await broadcast_lobby() _kick_bots(game.gid) # --- 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) # --- leaderboard (read-only) ---------------------------------------------- @sio.on("get_leaderboard") async def get_leaderboard(sid, *args): account = accounts.get(sid) if account is None: return await send_error(sid, "Musíte byť prihlásený.") data = await leaderboard.get_leaderboard(account["player_id"]) await sio.emit("get_leaderboard", data, to=sid)