Apply velvet-table redesign, fix game lifecycle and history bugs

Frontend:
- Dark green/gold "velvet table" visual redesign across the whole app
  (Auth, Lobby, GameList, GameTable, History, GameOver, modals), with
  Playfair Display/DM Sans typography and a centralized Tailwind palette.
- Desktop game table fit-scales to fill the window; mobile gets
  overlapping hand/trick layouts and larger touch-friendly cards.
- Standings sidebar now groups completed rounds by series with a
  per-series subtotal row, struck-through tips on missed bids.
- History page rewritten into a scoreboard-style detail view (player
  totals beside names, series grouped 2-up on desktop / stacked on
  mobile) and gained game names, completed/abandoned status, and a
  button to reopen a prematurely-ended game back into the lobby.

Backend:
- Fix started games being deleted from memory (and vanishing from
  everyone's lobby) when all players disconnect; only `end_game` tears
  down a started game now.
- Fix a crash writing a timezone-aware datetime into the naive
  `ended_at` Postgres column.
- Add `reopen_game`/`restore_game` to un-end a prematurely-ended game
  from history and resume it from the lobby.
- Let any seated player end an abandoned game once the host is
  offline, not just the host, so the game isn't stuck forever.
- Expose SERIES_PER_GAME/ROUNDS_PER_SERIES as named constants on the
  engine so the persistence layer derives game-completion rules from
  bridzik.py instead of re-encoding them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tim
2026-07-01 00:11:42 +02:00
parent 30c32b7714
commit 2c2f07c2ec
28 changed files with 1472 additions and 395 deletions
+98 -33
View File
@@ -9,10 +9,20 @@ from random import shuffle
from sqlalchemy import func, or_, select
from bridzik import Bridzik, Round, Series
from bridzik import Bridzik, ROUNDS_PER_SERIES, Round, SERIES_PER_GAME, Series
from db.db import async_session
from db.models import Game, Guess, Player
# Naplno dohrana hra ma zapisanych SERIES_PER_GAME * ROUNDS_PER_SERIES
# dokoncenych kol -- tvar hry je definovany v bridzik.py, tu sa len cita.
FULL_GAME_ROUNDS = SERIES_PER_GAME * ROUNDS_PER_SERIES
def _utcnow_naive() -> datetime:
"""Naive UTC `datetime` na zapis do `ended_at`. Stlpec je TIMESTAMP WITHOUT
TIME ZONE (ako `created_at`), tz-aware hodnotu by asyncpg/Postgres odmietol."""
return datetime.now(timezone.utc).replace(tzinfo=None)
async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None:
"""Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne."""
@@ -75,19 +85,21 @@ async def record_completed_rounds(gid: str, core) -> None:
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)
game.ended_at = _utcnow_naive()
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 def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]:
"""Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend:
`[serie][kolo][sedadlo 0..3]`. Vracia dvojicu `(points, guesses)` -- tipy
su tam, aby frontend pri 0 bodoch ukazal preskrtnuty tip namiesto nuly.
Citaju sa z tych istych `Guess` riadkov, takze jeden dotaz staci.
"""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return []
return [], []
seat_of = {
game.player0_id: 0,
game.player1_id: 1,
@@ -102,18 +114,23 @@ async def get_standings(gid: str) -> list[list[list[int]]]:
)
).all()
series_map: dict[int, dict[int, list[int]]] = {}
# series_map[serie][kolo] = ([body sedadiel], [tipy sedadiel])
series_map: dict[int, dict[int, tuple[list[int], list[int]]]] = {}
for gz in rows:
rounds = series_map.setdefault(gz.series_number, {})
points = rounds.setdefault(gz.round_number, [0, 0, 0, 0])
points, tips = rounds.setdefault(gz.round_number, ([0, 0, 0, 0], [0, 0, 0, 0]))
seat = seat_of.get(gz.player_id)
if seat is not None:
points[seat] = gz.points
tips[seat] = gz.guess
return [
[series_map[s][r] for r in sorted(series_map[s])]
for s in sorted(series_map)
]
points_table: list[list[list[int]]] = []
guesses_table: list[list[list[int]]] = []
for s in sorted(series_map):
round_nums = sorted(series_map[s])
points_table.append([series_map[s][r][0] for r in round_nums])
guesses_table.append([series_map[s][r][1] for r in round_nums])
return points_table, guesses_table
async def get_player_history(player_id: int) -> list[dict]:
@@ -122,17 +139,24 @@ async def get_player_history(player_id: int) -> list[dict]:
stmt = (
select(Game)
.where(
Game.ended_at.is_not(None), # iba ukoncene hry
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()
# Pocet dokoncenych kol na hru (na rozlisenie naplno dohranej hry od
# predcasne ukoncenej) -- jeden batch dotaz pre vsetky hry hraca.
completed_rounds = await _completed_rounds_per_game(
session, [g.id for g in games]
)
result = []
for g in games:
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
@@ -145,10 +169,13 @@ async def get_player_history(player_id: int) -> list[dict]:
result.append(
{
"gid": g.id,
"name": g.name,
"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),
# True = dohrana naplno; False = predcasne ukoncena (da sa obnovit).
"completed": completed_rounds.get(g.id, 0) >= FULL_GAME_ROUNDS,
}
)
return result
@@ -173,6 +200,7 @@ async def get_game_detail(gid: str) -> dict | None:
return {
"gid": game.id,
"name": game.name,
"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": [
@@ -200,6 +228,60 @@ async def _usernames_for(session, player_ids: list[int]) -> dict[int, str]:
return {p.id: p.username for p in rows}
async def _completed_rounds_per_game(session, gids: list[str]) -> dict[str, int]:
"""Pocet dokoncenych (series, round) kol na hru. Guess sa zapisuje len za
dohrate kola, takze pocet unikatnych dvojic = pocet dokoncenych kol."""
if not gids:
return {}
rows = await session.execute(
select(Guess.game_id, Guess.series_number, Guess.round_number)
.where(Guess.game_id.in_(gids))
.distinct()
)
counts: dict[str, int] = {}
for r in rows:
counts[r.game_id] = counts.get(r.game_id, 0) + 1
return counts
async def _restore_info(session, game: Game) -> dict:
"""Postavi restore-payload pre jednu hru: gid, name, sedadla (player_id +
username podla poradia 0..3) a uz postaveny Bridzik na ulozenej pozicii.
Spolocny tvar pre `reopen_game` aj `get_unfinished_games`."""
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
usernames = await _usernames_for(session, seat_ids)
return {
"gid": game.id,
"name": game.name,
"seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids],
"core": rebuild_core(game.series, game.round),
}
async def reopen_game(gid: str, player_id: int) -> dict | None:
"""Znovu otvori predcasne ukoncenu hru: vymaze `ended_at` a vrati info na
obnovu do pamate (rovnaky tvar ako polozka z `get_unfinished_games`).
Vrati None, ak hra neexistuje, hrac v nej nie je, alebo uz bola dohrana
naplno (vtedy nie je co pokracovat).
"""
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]
if player_id not in seat_ids:
return None
counts = await _completed_rounds_per_game(session, [gid])
if counts.get(gid, 0) >= FULL_GAME_ROUNDS:
return None # naplno dohrana hra sa neobnovuje
game.ended_at = None
info = await _restore_info(session, game)
await session.commit()
return info
# --- restore ---------------------------------------------------------------
def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik:
@@ -236,31 +318,14 @@ async def mark_game_ended(gid: str) -> None:
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)
game.ended_at = _utcnow_naive()
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.
"""
"""Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte."""
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
return [await _restore_info(session, g) for g in games]