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:
+45
-15
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user