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")
|
||||
|
||||
+98
-33
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user