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
+45 -15
View File
@@ -146,7 +146,7 @@ async def send_game_status(gid: str):
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)
status["standings"], status["standings_guesses"] = await history.get_standings(gid)
await sio.emit(
"game_status",
{
@@ -180,9 +180,11 @@ async def send_error(sid: str, message: str):
async def _mark_player_offline(game: "Game", player: "Player"):
"""Mark player disconnected, delete the game if everyone left, else notify the room."""
"""Mark player disconnected. An unstarted game with nobody left is cleaned
up; 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(p.connected for p in game.players):
if not any(p.connected for p in game.players) and not game.started:
del games[game.gid]
else:
await sio.emit(
@@ -212,14 +214,21 @@ async def _restore_unfinished_games():
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
_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
game.players.append(player)
games[info["gid"]] = game
return game
# --- connection lifecycle -------------------------------------------------
@@ -382,13 +391,13 @@ async def start_game(sid, gid):
@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."""
"""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.")
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.")
@@ -471,6 +480,27 @@ async def rejoin_game(sid, gid):
await broadcast_lobby()
@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")