Bot doteraz vedel zahodit prvu kartu novej kopky uz po BOT_MOVE_DELAY_SECONDS (0.8s), zatial co frontend zmetaciu animaciu predoslej kopky prehraval 1650ms -- karta tak "vyletela" uprostred zmetania. _run_bot_turns teraz pri vedeni novej kopky caka TRICK_SWEEP_SECONDS (1.7s), zhodne s SETTLE_MS+ COLLECT_MS v GameTable.tsx. _run_bot_turns tiez posielal player_cards PRED game_status (opacne ako human play_card handler) -- pri prechode do noveho kola tak klient dostal novu ruku skor, nez vedel, ze zacalo nove kolo, a fixne ju hned zobrazil. Poradie broadcastov je teraz zhodne s human handlerom. GameTable/Hand: ruka noveho kola sa zobrazi az po dobehnuti zmetacej animacie poslednej kopky predchadzajuceho kola (displayedHand + freeze efekt), a Hand.tsx rezervuje fixny priestor pre karty aj ked je ruka docasne prazdna, aby layout neposkakoval. tests/test_bots.py: nulovanie TRICK_SWEEP_SECONDS v setUpClass, aby testy zostali rychle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
904 lines
34 KiB
Python
904 lines
34 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
|
|
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)
|