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:
+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