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:
Tim
2026-06-23 23:09:50 +02:00
parent beaf142ee4
commit 30c32b7714
24 changed files with 1446 additions and 87 deletions
+185 -10
View File
@@ -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)