diff --git a/api/__init__.py b/api/__init__.py
index 2f95570..9ba14f1 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -214,7 +214,13 @@ class CardStatusEncoder(JSONEncoder):
def public_games() -> list:
- """Public lobby view — no sids, no reconnect tokens."""
+ """Public lobby view — no sids, no reconnect tokens.
+
+ A game that finished naturally (all 4 series played out) stays in the
+ `games` dict for reconnect purposes (e.g. a reload while still on the
+ GameOver screen), but it has nothing left to offer the lobby — drop it
+ here rather than have it linger forever as a "started"/resumable entry.
+ """
return [
{
"gid": g.gid,
@@ -231,6 +237,7 @@ def public_games() -> list:
],
}
for g in games.values()
+ if g.bridzik_core is None or not g.bridzik_core.is_completed()
]
@@ -704,6 +711,11 @@ async def play_card(sid, card_key):
await send_game_status(game.gid)
for player in game.players:
await send_player_cards(game.gid, player.order, player.sid)
+ # A naturally-finished game (all 4 series played out) has nothing left to
+ # offer the lobby -- refresh the list so it drops out immediately instead
+ # of lingering as "started"/resumable until someone happens to leave it.
+ if core.is_completed():
+ await broadcast_lobby()
# --- history (read-only) --------------------------------------------------
diff --git a/frontend/src/components/PlayerCircle.tsx b/frontend/src/components/PlayerCircle.tsx
index 0924ee0..da82c35 100644
--- a/frontend/src/components/PlayerCircle.tsx
+++ b/frontend/src/components/PlayerCircle.tsx
@@ -12,25 +12,34 @@ interface Props {
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
const nameFont = Math.max(9, Math.round(size * 0.17));
const valueFont = Math.round(size * 0.32);
- // Oval: width = size, height a touch shorter so it reads as an ellipse.
+ // Oval: height a touch shorter than size so it reads as an ellipse. Width
+ // starts at `size` (a circle for short names) but grows with the name via
+ // fit-content + padding, up to a cap beyond which the name is ellipsised
+ // rather than wrapping (wrapping would break the fixed height/oval shape).
const height = Math.round(size * 0.78);
+ const hPad = Math.round(size * 0.16);
+ const maxWidth = Math.round(size * 2);
return (
{
+ if (booted.current || !gameStatus) return;
+ booted.current = true;
+ if (previousStashKey) setDismissedKey(previousStashKey);
+ }, [gameStatus, previousStashKey]);
+
useEffect(() => {
if (!previousStashKey) return;
setCollecting(false);
@@ -83,13 +94,15 @@ export default function GameTable() {
const myTurnToPlay = isPlayPhase && active_player === myOrder;
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
- // Once a player leads, that live trick always wins the centre. Otherwise, if a
- // just-completed trick hasn't been swept away yet, keep it face-up. Reading
- // `previousStash` synchronously (rather than a state set in an effect) means
- // the pile never blinks to empty on the frame the 4th card lands — the last
- // card simply joins the three already there, then the whole pile is collected.
- const finishing =
- previousStashKey !== null && previousStashKey !== dismissedKey && activeCards === 0;
+ // A just-completed trick that hasn't been swept away yet always wins the centre
+ // — even once the winner has already led the next trick. The engine reveals that
+ // lead card (and, at a round boundary, the next bidding phase) the instant the
+ // 4th card lands, so without holding the pile here the sweep would be cut off
+ // after every trick. Reading `previousStash` synchronously (rather than a state
+ // set in an effect) also means the pile never blinks to empty on the frame the
+ // 4th card lands — the last card simply joins the three already there, then the
+ // whole pile is collected before the next trick takes over.
+ const finishing = previousStashKey !== null && previousStashKey !== dismissedKey;
const displayedStash: StashData | null = finishing
? previousStash
: activeCards > 0 && active_stash
@@ -102,7 +115,11 @@ export default function GameTable() {
? COLLECT_BY_OFFSET[(stashWinner(previousStash) - myOrder + 4) % 4]
: null;
- const playableKeys = myTurnToPlay && active_stash
+ // Block play until the previous trick's sweep animation has finished — otherwise
+ // the winner could lead the next card while the pile is still visibly clearing.
+ const canPlayNow = myTurnToPlay && !finishing;
+
+ const playableKeys = canPlayNow && active_stash
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
: undefined;
@@ -185,7 +202,11 @@ export default function GameTable() {
);
// Center of the oval: trick during play, guess controls during bidding.
- const ovalContent = isPlayPhase ? (
+ // `finishing` also keeps the trick on screen while the round's *last* stash is
+ // swept away: the engine advances to the next round's bidding the instant the
+ // 4th card lands, so `isPlayPhase` flips to false immediately — without this,
+ // that final trick would vanish straight into the guess controls with no sweep.
+ const ovalContent = isPlayPhase || finishing ? (
@@ -222,7 +243,7 @@ export default function GameTable() {
);
const handArea = (
-
+
);
// ── DESKTOP LAYOUT ───────────────────────────────────────────────