Add persistence layer: TOTP auth, game history, restore
- db/ package: async SQLAlchemy engine + Player/Game/Guess models - api/auth.py: passwordless TOTP login (pyotp), session token via socket auth - api/history.py: record guesses/points, DB-backed standings, restore unfinished games on startup, host-only end_game - api/__init__.py: auth-gated handlers, accounts map, rejoin via account - frontend: Auth (QR + code) and History pages, resume/end-game in lobby/table - docker-compose: real PostgreSQL service wired via DATABASE_URL - tests_history.py for the persistence/auth layer; refresh CLAUDE.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+185
-10
@@ -6,6 +6,9 @@ from json import JSONEncoder
|
||||
import socketio
|
||||
|
||||
from bridzik import Bridzik, BridzikException, Card
|
||||
from db.db import init_db
|
||||
from api import auth as auth_module, history
|
||||
from api.auth import AuthError
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -36,6 +39,8 @@ async def _health_app(scope, receive, send):
|
||||
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"})
|
||||
@@ -60,6 +65,10 @@ games: dict[str, "Game"] = {}
|
||||
# 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:
|
||||
@@ -85,10 +94,11 @@ class Game:
|
||||
|
||||
|
||||
class Player:
|
||||
def __init__(self, sid: str, name: str, order: int):
|
||||
def __init__(self, sid: str, name: str, order: int, player_id: int):
|
||||
self.sid = sid
|
||||
self.name = name
|
||||
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
|
||||
|
||||
@@ -110,7 +120,12 @@ def public_games() -> list:
|
||||
"name": g.name,
|
||||
"started": g.started,
|
||||
"players": [
|
||||
{"order": p.order, "name": p.name, "connected": p.connected}
|
||||
{
|
||||
"order": p.order,
|
||||
"name": p.name,
|
||||
"connected": p.connected,
|
||||
"player_id": p.player_id,
|
||||
}
|
||||
for p in g.players
|
||||
],
|
||||
}
|
||||
@@ -128,6 +143,10 @@ async def send_game_status(gid: str):
|
||||
game = games[gid]
|
||||
core = game.bridzik_core
|
||||
last_round = core.series[-1].get_last_round()
|
||||
status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
|
||||
# Use DB-backed standings so the score is correct even after a server restart
|
||||
# (the engine only knows rounds completed since restart).
|
||||
status["standings"] = await history.get_standings(gid)
|
||||
await sio.emit(
|
||||
"game_status",
|
||||
{
|
||||
@@ -141,7 +160,7 @@ async def send_game_status(gid: str):
|
||||
"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": json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)),
|
||||
"status": status,
|
||||
},
|
||||
room=gid,
|
||||
)
|
||||
@@ -184,16 +203,42 @@ def _active_game(sid: str) -> "tuple[Game, dict] | None":
|
||||
return game, sess
|
||||
|
||||
|
||||
async def _restore_unfinished_games():
|
||||
"""Po starte servera obnov rozohrate hry z DB do pamate (hraci offline).
|
||||
|
||||
Karty su rozdane nanovo (pozicia z `series`/`round`); hraci sa vratia cez
|
||||
`rejoin_game` podla svojej trvalej identity (per-hra tokeny restart neprezili).
|
||||
"""
|
||||
for info in await history.get_unfinished_games():
|
||||
if info["gid"] in games:
|
||||
continue
|
||||
game = Game(info["gid"], info["name"])
|
||||
game.bridzik_core = info["core"]
|
||||
game.started = True
|
||||
for seat, (pid, uname) in enumerate(info["seats"]):
|
||||
player = Player(None, uname, seat, pid)
|
||||
player.connected = False
|
||||
game.players.append(player)
|
||||
games[info["gid"]] = game
|
||||
|
||||
|
||||
# --- connection lifecycle -------------------------------------------------
|
||||
|
||||
@sio.event
|
||||
async def connect(sid, environ, auth=None):
|
||||
await sio.enter_room(sid, LOBBY)
|
||||
# Auto-login via the session token the client stored after a previous login.
|
||||
token = auth.get("token") if isinstance(auth, dict) else None
|
||||
identity = await auth_module.player_by_token(token)
|
||||
if identity is not None:
|
||||
accounts[sid] = identity
|
||||
await sio.emit("login", {"player": identity}, to=sid)
|
||||
await sio.emit("get_games", {"games": public_games()}, to=sid)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid):
|
||||
accounts.pop(sid, None)
|
||||
sess = sessions.pop(sid, None)
|
||||
game = games.get(sess["gid"]) if sess else None
|
||||
if game is not None:
|
||||
@@ -203,10 +248,44 @@ async def disconnect(sid):
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
# --- authentication (TOTP) ------------------------------------------------
|
||||
|
||||
@sio.on("register_account")
|
||||
async def register_account(sid, username):
|
||||
try:
|
||||
data = await auth_module.register_account(username)
|
||||
except AuthError as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
# otpauth_uri -> the client renders it as a QR code to scan into the app.
|
||||
await sio.emit("register_account", data, to=sid)
|
||||
|
||||
|
||||
@sio.on("confirm_account")
|
||||
async def confirm_account(sid, username, code):
|
||||
try:
|
||||
identity = await auth_module.confirm_account(username, code)
|
||||
except AuthError as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||
|
||||
|
||||
@sio.on("login")
|
||||
async def login(sid, username, code):
|
||||
try:
|
||||
identity = await auth_module.login(username, code)
|
||||
except AuthError as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||
|
||||
|
||||
# --- lobby ----------------------------------------------------------------
|
||||
|
||||
@sio.on("create_game")
|
||||
async def create_game(sid, name):
|
||||
if sid not in accounts:
|
||||
return await send_error(sid, "Musíte byť prihlásený.")
|
||||
gid = str(uuid.uuid4())
|
||||
games[gid] = Game(gid, name)
|
||||
await sio.emit("create_game", {"gid": gid}, to=sid)
|
||||
@@ -219,7 +298,10 @@ async def get_games(sid, *args):
|
||||
|
||||
|
||||
@sio.on("register_player")
|
||||
async def register_player(sid, gid, player_name):
|
||||
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)
|
||||
@@ -229,18 +311,20 @@ async def register_player(sid, gid, player_name):
|
||||
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, player_name, order)
|
||||
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},
|
||||
{"player": {"order": order, "name": player.name}, "token": player.token},
|
||||
to=sid,
|
||||
)
|
||||
await broadcast_lobby()
|
||||
@@ -287,20 +371,49 @@ async def start_game(sid, gid):
|
||||
return await send_error(sid, "Nedostatocny pocet hracov.")
|
||||
|
||||
game.start()
|
||||
# Persist the game with its 4 seats (ordered 0..3) so history can attribute guesses.
|
||||
seated = sorted(game.players, key=lambda p: p.order)
|
||||
await history.record_game_started(gid, game.name, [p.player_id for p in seated])
|
||||
await broadcast_lobby()
|
||||
await send_game_status(gid)
|
||||
for player in game.players:
|
||||
await send_player_cards(gid, player.order, player.sid)
|
||||
|
||||
|
||||
@sio.on("reconnect_to_game")
|
||||
async def reconnect_to_game(sid, gid, token):
|
||||
@sio.on("end_game")
|
||||
async def end_game(sid, gid):
|
||||
"""Host (seat 0) permanently ends a game that won't be finished. Marks it
|
||||
ended in the DB (so it won't be restored) and sends everyone back to the lobby."""
|
||||
sess = sessions.get(sid)
|
||||
if sess is None or sess["gid"] != gid:
|
||||
return await send_error(sid, "Nie ste v tejto hre.")
|
||||
if sess["order"] != 0:
|
||||
return await send_error(sid, "Iba hostitel moze ukoncit hru.")
|
||||
game = games.get(gid)
|
||||
if game is None:
|
||||
return await send_error(sid, "Hra neexistuje.")
|
||||
|
||||
await history.mark_game_ended(gid)
|
||||
# Notify the room first (while players are still in it), then tear it down.
|
||||
await sio.emit("game_ended", {"gid": gid}, room=gid)
|
||||
for player in game.players:
|
||||
if player.sid:
|
||||
sessions.pop(player.sid, None)
|
||||
await sio.leave_room(player.sid, gid)
|
||||
del games[gid]
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("reconnect_to_game")
|
||||
async def reconnect_to_game(sid, gid, token):
|
||||
# Best-effort background reconnect: fail silently (no error toast). After a
|
||||
# server restart the old token is gone -> the user rejoins from the lobby.
|
||||
game = games.get(gid)
|
||||
if game is None:
|
||||
return
|
||||
player = game.player_by_token(token)
|
||||
if player is None:
|
||||
return await send_error(sid, "Neplatny token pre pripojenie.")
|
||||
return
|
||||
|
||||
old_sid = player.sid
|
||||
if old_sid != sid:
|
||||
@@ -321,6 +434,43 @@ async def reconnect_to_game(sid, gid, token):
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("rejoin_game")
|
||||
async def rejoin_game(sid, gid):
|
||||
"""Re-seat into a game via the logged-in account (used after a server restart,
|
||||
when per-game reconnect tokens are gone). Identity comes from the session."""
|
||||
account = accounts.get(sid)
|
||||
if account is None:
|
||||
return await send_error(sid, "Musíte byť prihlásený.")
|
||||
if sid in sessions:
|
||||
return await send_error(sid, "Uz ste v hre.")
|
||||
game = games.get(gid)
|
||||
if game is None:
|
||||
return await send_error(sid, "Hra neexistuje.")
|
||||
player = next(
|
||||
(p for p in game.players if p.player_id == account["player_id"]), None
|
||||
)
|
||||
if player is None:
|
||||
return await send_error(sid, "Nie ste hracom tejto hry.")
|
||||
|
||||
old_sid = player.sid
|
||||
if old_sid and old_sid != sid:
|
||||
sessions.pop(old_sid, None)
|
||||
player.sid = sid
|
||||
player.connected = True
|
||||
sessions[sid] = {"gid": gid, "order": player.order}
|
||||
await sio.enter_room(sid, gid)
|
||||
await sio.emit(
|
||||
"register_player",
|
||||
{"player": {"order": player.order, "name": player.name}, "token": player.token},
|
||||
to=sid,
|
||||
)
|
||||
if game.started:
|
||||
await send_game_status(gid)
|
||||
await send_player_cards(gid, player.order, sid)
|
||||
await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid)
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
# --- in-game actions (seat derived from the connection, never the client) -
|
||||
|
||||
@sio.on("game_status")
|
||||
@@ -376,6 +526,31 @@ async def play_card(sid, card_key):
|
||||
core.play_card(sess["order"], hand[key])
|
||||
except BridzikException as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
# Persist completed rounds first so the DB-backed standings in game_status are
|
||||
# up to date (idempotent; also marks the game ended).
|
||||
await history.record_completed_rounds(game.gid, core)
|
||||
await send_game_status(game.gid)
|
||||
for player in game.players:
|
||||
await send_player_cards(game.gid, player.order, player.sid)
|
||||
|
||||
|
||||
# --- history (read-only) --------------------------------------------------
|
||||
|
||||
@sio.on("get_player_history")
|
||||
async def get_player_history(sid, *args):
|
||||
account = accounts.get(sid)
|
||||
if account is None:
|
||||
return await send_error(sid, "Musíte byť prihlásený.")
|
||||
rows = await history.get_player_history(account["player_id"])
|
||||
await sio.emit("get_player_history", {"games": rows}, to=sid)
|
||||
|
||||
|
||||
@sio.on("get_game_detail")
|
||||
async def get_game_detail(sid, gid):
|
||||
account = accounts.get(sid)
|
||||
if account is None:
|
||||
return await send_error(sid, "Musíte byť prihlásený.")
|
||||
detail = await history.get_game_detail(gid)
|
||||
if detail is None:
|
||||
return await send_error(sid, "Hra neexistuje.")
|
||||
await sio.emit("get_game_detail", detail, to=sid)
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""Bezheslova autentifikacia cez TOTP (Google Authenticator a pod.).
|
||||
|
||||
Domenova logika nad `db/` -- tenke socket handlery v api/__init__.py ju volaju.
|
||||
Hodnoty sa overuju cez pyotp; replay sa bloku pomocou Player.totp_last_step.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.db import async_session
|
||||
from db.models import Player
|
||||
|
||||
ISSUER = "Bridžik"
|
||||
TOTP_PERIOD = 30 # sekund -- default pyotp
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def _current_step() -> int:
|
||||
return int(time.time()) // TOTP_PERIOD
|
||||
|
||||
|
||||
def _verify_code(player: Player, code: str) -> None:
|
||||
"""Overi TOTP kod a posunie totp_last_step. Pri neuspechu vyhodi AuthError.
|
||||
|
||||
Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok.
|
||||
"""
|
||||
totp = pyotp.TOTP(player.totp_secret)
|
||||
current = _current_step()
|
||||
for step in (current - 1, current, current + 1):
|
||||
if step <= player.totp_last_step:
|
||||
continue
|
||||
if totp.verify(code, for_time=step * TOTP_PERIOD):
|
||||
player.totp_last_step = step
|
||||
return
|
||||
raise AuthError("Nesprávny alebo už použitý kód.")
|
||||
|
||||
|
||||
async def register_account(username: str) -> dict:
|
||||
"""Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR."""
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
raise AuthError("Zadajte meno.")
|
||||
secret = pyotp.random_base32()
|
||||
async with async_session() as session:
|
||||
existing = await session.scalar(
|
||||
select(Player).where(Player.username == username)
|
||||
)
|
||||
if existing is not None:
|
||||
raise AuthError("Toto meno je už obsadené.")
|
||||
session.add(Player(username=username, totp_secret=secret))
|
||||
await session.commit()
|
||||
otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
|
||||
return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri}
|
||||
|
||||
|
||||
async def confirm_account(username: str, code: str) -> dict:
|
||||
"""Potvrdi registraciu prvym kodom z aplikacie a vrati session token."""
|
||||
return await _verify_and_issue_token(username, code)
|
||||
|
||||
|
||||
async def login(username: str, code: str) -> dict:
|
||||
"""Prihlasi existujuci ucet a vrati session token."""
|
||||
return await _verify_and_issue_token(username, code)
|
||||
|
||||
|
||||
async def _verify_and_issue_token(username: str, code: str) -> dict:
|
||||
async with async_session() as session:
|
||||
player = await session.scalar(
|
||||
select(Player).where(Player.username == (username or "").strip())
|
||||
)
|
||||
if player is None:
|
||||
raise AuthError("Účet neexistuje.")
|
||||
_verify_code(player, (code or "").strip())
|
||||
token = _new_token()
|
||||
player.auth_token = token
|
||||
await session.commit()
|
||||
return {"player_id": player.id, "username": player.username, "token": token}
|
||||
|
||||
|
||||
async def player_by_token(token: str) -> dict | None:
|
||||
"""Overi session token (z Socket.IO `auth`). Vrati identitu alebo None."""
|
||||
if not token:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
player = await session.scalar(
|
||||
select(Player).where(Player.auth_token == token)
|
||||
)
|
||||
if player is None:
|
||||
return None
|
||||
return {"player_id": player.id, "username": player.username}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
"""Zapis a citanie historie hier nad `db/`.
|
||||
|
||||
Cita hodnoty z ciste-Python enginu (bridzik.Bridzik) a uklada ich do DB.
|
||||
Engine sa neupravuje -- pouzivame len jeho existujuce metody.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from random import shuffle
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from bridzik import Bridzik, Round, Series
|
||||
from db.db import async_session
|
||||
from db.models import Game, Guess, Player
|
||||
|
||||
|
||||
async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None:
|
||||
"""Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne."""
|
||||
async with async_session() as session:
|
||||
if await session.get(Game, gid) is not None:
|
||||
return
|
||||
session.add(
|
||||
Game(
|
||||
id=gid,
|
||||
name=name,
|
||||
player0_id=player_ids[0],
|
||||
player1_id=player_ids[1],
|
||||
player2_id=player_ids[2],
|
||||
player3_id=player_ids[3],
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def record_completed_rounds(gid: str, core) -> None:
|
||||
"""Zapise 4 Guess-y za kazde nove dohrate kolo; po dohrani hry vyplni ended_at.
|
||||
|
||||
Idempotentne: kola uz zapisane v DB sa preskakuju.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
game = await session.get(Game, gid)
|
||||
if game is None:
|
||||
return
|
||||
|
||||
rows = await session.execute(
|
||||
select(Guess.series_number, Guess.round_number)
|
||||
.where(Guess.game_id == gid)
|
||||
.distinct()
|
||||
)
|
||||
already = {(r.series_number, r.round_number) for r in rows}
|
||||
|
||||
for series in core.series:
|
||||
for rnd in series.rounds:
|
||||
if not rnd.is_completed():
|
||||
continue
|
||||
key = (series.series_number, rnd.round_number)
|
||||
if key in already:
|
||||
continue
|
||||
points = rnd.get_points_summary() # list[4], body za kolo
|
||||
for seat in range(4):
|
||||
session.add(
|
||||
Guess(
|
||||
game_id=gid,
|
||||
player_id=game.player_id_for_seat(seat),
|
||||
series_number=series.series_number,
|
||||
round_number=rnd.round_number,
|
||||
guess=rnd.guesses[seat],
|
||||
points=points[seat],
|
||||
)
|
||||
)
|
||||
|
||||
# Priebezne uloz aktualnu poziciu hry (na restore).
|
||||
last_series = core.series[-1]
|
||||
game.series = last_series.series_number
|
||||
game.round = last_series.get_last_round().round_number
|
||||
|
||||
if core.is_completed() and game.ended_at is None:
|
||||
game.ended_at = datetime.now(timezone.utc)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_standings(gid: str) -> list[list[list[int]]]:
|
||||
"""Body po seriach/kolach z DB v tvare, ktory caka frontend: standings[serie][kolo]
|
||||
= [body sedadiel 0..3]. Pouziva sa v game_status, aby skore sedelo aj po restarte.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
game = await session.get(Game, gid)
|
||||
if game is None:
|
||||
return []
|
||||
seat_of = {
|
||||
game.player0_id: 0,
|
||||
game.player1_id: 1,
|
||||
game.player2_id: 2,
|
||||
game.player3_id: 3,
|
||||
}
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(Guess)
|
||||
.where(Guess.game_id == gid)
|
||||
.order_by(Guess.series_number, Guess.round_number)
|
||||
)
|
||||
).all()
|
||||
|
||||
series_map: dict[int, dict[int, list[int]]] = {}
|
||||
for gz in rows:
|
||||
rounds = series_map.setdefault(gz.series_number, {})
|
||||
points = rounds.setdefault(gz.round_number, [0, 0, 0, 0])
|
||||
seat = seat_of.get(gz.player_id)
|
||||
if seat is not None:
|
||||
points[seat] = gz.points
|
||||
|
||||
return [
|
||||
[series_map[s][r] for r in sorted(series_map[s])]
|
||||
for s in sorted(series_map)
|
||||
]
|
||||
|
||||
|
||||
async def get_player_history(player_id: int) -> list[dict]:
|
||||
"""Zoznam hier daneho hraca (najnovsie prve) so sumarom jeho bodov."""
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Game)
|
||||
.where(
|
||||
or_(
|
||||
Game.player0_id == player_id,
|
||||
Game.player1_id == player_id,
|
||||
Game.player2_id == player_id,
|
||||
Game.player3_id == player_id,
|
||||
)
|
||||
)
|
||||
.order_by(Game.created_at.desc())
|
||||
)
|
||||
games = (await session.scalars(stmt)).all()
|
||||
|
||||
result = []
|
||||
for g in games:
|
||||
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
|
||||
usernames = await _usernames_for(session, seat_ids)
|
||||
total = await session.scalar(
|
||||
select(func.coalesce(func.sum(Guess.points), 0)).where(
|
||||
Guess.game_id == g.id, Guess.player_id == player_id
|
||||
)
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"gid": g.id,
|
||||
"created_at": g.created_at.isoformat() if g.created_at else None,
|
||||
"ended_at": g.ended_at.isoformat() if g.ended_at else None,
|
||||
"players": [usernames[pid] for pid in seat_ids],
|
||||
"my_points": int(total or 0),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def get_game_detail(gid: str) -> dict | None:
|
||||
"""Detail hry: tipy a body po kolach (won = points > 0)."""
|
||||
async with async_session() as session:
|
||||
game = await session.get(Game, gid)
|
||||
if game is None:
|
||||
return None
|
||||
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
|
||||
usernames = await _usernames_for(session, seat_ids)
|
||||
|
||||
guesses = (
|
||||
await session.scalars(
|
||||
select(Guess)
|
||||
.where(Guess.game_id == gid)
|
||||
.order_by(Guess.series_number, Guess.round_number, Guess.player_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"gid": game.id,
|
||||
"created_at": game.created_at.isoformat() if game.created_at else None,
|
||||
"ended_at": game.ended_at.isoformat() if game.ended_at else None,
|
||||
"players": [
|
||||
{"player_id": pid, "username": usernames[pid]} for pid in seat_ids
|
||||
],
|
||||
"rounds": [
|
||||
{
|
||||
"series_number": gz.series_number,
|
||||
"round_number": gz.round_number,
|
||||
"player_id": gz.player_id,
|
||||
"username": usernames.get(gz.player_id),
|
||||
"guess": gz.guess,
|
||||
"points": gz.points,
|
||||
"won": gz.points > 0,
|
||||
}
|
||||
for gz in guesses
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _usernames_for(session, player_ids: list[int]) -> dict[int, str]:
|
||||
rows = await session.scalars(
|
||||
select(Player).where(Player.id.in_(set(player_ids)))
|
||||
)
|
||||
return {p.id: p.username for p in rows}
|
||||
|
||||
|
||||
# --- restore ---------------------------------------------------------------
|
||||
|
||||
def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik:
|
||||
"""Postavi Bridzik na danu poziciu (zaciatok kola), karty rozda nanovo.
|
||||
|
||||
Vsetko ostatne (rotacia first_player, pocet kariet) je deterministicke z
|
||||
dvojice (series_number, round_number), takze tieto dve cisla staci na obnovu
|
||||
hracej kostry. Tipy a rozohrate kopky aktualneho kola sa NEobnovuju -- kolo
|
||||
sa zacne odznova; historicke body si vola get_game_detail z tabulky Guess.
|
||||
"""
|
||||
core = Bridzik(shuffler=shuffler)
|
||||
# Doplnaj serie az po cielovu (kazda nova zacne svojim kolom 0).
|
||||
while core.series[-1].series_number < series_number:
|
||||
core.series.append(Series(len(core.series), shuffler=shuffler))
|
||||
# V poslednej serii doplnaj kola az po cielove (Round rozda karty nanovo).
|
||||
last = core.series[-1]
|
||||
while last.get_last_round().round_number < round_number:
|
||||
rn = len(last.rounds)
|
||||
last.rounds.append(Round(rn, (last.first_player + rn) % 4, shuffler=shuffler))
|
||||
return core
|
||||
|
||||
|
||||
async def restore_game_core(gid: str, shuffler=shuffle) -> Bridzik | None:
|
||||
"""Nacita poziciu hry z DB a vrati obnoveny Bridzik (alebo None)."""
|
||||
async with async_session() as session:
|
||||
game = await session.get(Game, gid)
|
||||
if game is None:
|
||||
return None
|
||||
return rebuild_core(game.series, game.round, shuffler=shuffler)
|
||||
|
||||
|
||||
async def mark_game_ended(gid: str) -> None:
|
||||
"""Natrvalo ukonci hru (host ju zrusil, ked sa nedohra). Uz sa neobnovi."""
|
||||
async with async_session() as session:
|
||||
game = await session.get(Game, gid)
|
||||
if game is not None and game.ended_at is None:
|
||||
game.ended_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_unfinished_games() -> list[dict]:
|
||||
"""Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte.
|
||||
|
||||
Vracia per hru: gid, name, sedadla (player_id + username podla poradia 0..3)
|
||||
a uz postaveny Bridzik na ulozenej pozicii.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
games = (
|
||||
await session.scalars(select(Game).where(Game.ended_at.is_(None)))
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for g in games:
|
||||
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
|
||||
usernames = await _usernames_for(session, seat_ids)
|
||||
result.append(
|
||||
{
|
||||
"gid": g.id,
|
||||
"name": g.name,
|
||||
"seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids],
|
||||
"core": rebuild_core(g.series, g.round),
|
||||
}
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user