Files
bridzik/api/__init__.py
T
timandClaude Sonnet 5.5 04846aee26 pridanie rebricka (leaderboard)
Nova polozka menu Rebricek a stranka /leaderboard: top 10 podla najlepsej hry (hry len s ludmi / s aspon jednym botom) a podla hodin v hre (hry do 3 h). Backend api/leaderboard.py + socket udalost get_leaderboard, testy v tests/test_history.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YzEAACE9VgnLtXPuWGjwy
2026-10-26 22:06:50 +01:00

1030 lines
38 KiB
Python

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)