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 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() 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 --------------------------------------------------------- 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"], 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 nezacata hra, ked su vsetci hraci naraz offline. Na mobile # sa socket bezne strati uz pri zamknuti obrazovky, takze okamzite zmazanie # hry rusilo lobby, v ktorom hraci len cakali so zhasnutym telefonom. LOBBY_ABANDON_GRACE_SECONDS = 10 * 60 async def _cleanup_abandoned_lobby(gid: str): """Po grace periode zmaz nezacatu hru, ak sa medzitym nikto nevratil. Podmienka sa overuje az po uplynuti casu, takze pri navrate hraca je task neskodny no-op (netreba nic rusit).""" await asyncio.sleep(LOBBY_ABANDON_GRACE_SECONDS) game = games.get(gid) if game is not None and not game.started and not _any_human_connected(game): del games[gid] await broadcast_lobby() def _any_human_connected(game: "Game") -> bool: """Boti su 'pripojeni' stale, pre opustenost lobby sa pocitaju len ludia.""" return any(p.connected for p in game.players if not p.is_bot) async def _mark_player_offline(game: "Game", player: "Player"): """Mark player disconnected. An unstarted game with nobody left gets a delayed cleanup (mobile sockets drop on screen lock, so an immediate delete would kill lobbies where everyone is just waiting); a started game is kept in memory so it stays in the lobby and can be resumed (it's torn down only by end_game).""" player.connected = False if not _any_human_connected(game) and not game.started: asyncio.create_task(_cleanup_abandoned_lobby(game.gid)) 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 _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) # --- 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) 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": 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 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": 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("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. 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.") 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() _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()}, to=sid) info = await history.reopen_game(gid, account["player_id"]) if info is None: return await send_error(sid, "Hru sa nepodarilo obnovit.") _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)