Add hand-end scoring summary screen with acknowledgement

After each hand of an unfinished match the game now pauses in a new
hand_end phase instead of dealing immediately:

- engine: hand_points gains an 'award' map (which team won each category),
  _end_hand stops at hand_end with a deadline, new acknowledge_hand deals
  the next hand once all four players have acked; plays are rejected while
  the summary is up
- state: acked seats, hand_end_deadline and hand_ack_timeout are persisted
  and exposed in the personalized view (also on the finished state, so the
  final hand is explained before the result)
- ws: new {"action": "ack"}; a per-hand timer force-deals the next hand
  after HAND_ACK_TIMEOUT_SECONDS (new env var, default 30s) so an away
  player cannot stall the match
- web: modal explaining each category in plain language with icons (card
  images for denara/settebello/primiera), team-coloured rows, running
  totals with progress bars, an 'Understood — next hand' button that turns
  into 'Waiting for …' plus an auto-continue countdown; the final screen
  shows the last hand's breakdown too

Verified in the browser against the compose stack: hand played to
completion, summary rendered (including a carte tie), ack from all four
players dealt the next hand live, and the auto-continue path fired when
nobody acked. 60 backend tests + mypy + cargo tests green.
This commit is contained in:
2026-09-16 21:46:01 +08:00
parent b6a3a95f52
commit d9cdba33a1
19 changed files with 873 additions and 13 deletions
+9
View File
@@ -28,6 +28,15 @@ Because both the browser and the app talk to the OIDC issuer at
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
``` ```
## Between hands
When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope) with the running totals and
must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
(default 30s). The match-ending hand is explained on the final screen.
## Development ## Development
Backend (from `server/`): Backend (from `server/`):
+1
View File
@@ -100,6 +100,7 @@ services:
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
+4
View File
@@ -20,6 +20,10 @@ REDIS_URL=redis://localhost:6379/0
# How long a live game survives in Redis without activity. # How long a live game survives in Redis without activity.
GAME_TTL_SECONDS=86400 GAME_TTL_SECONDS=86400
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway.
HAND_ACK_TIMEOUT_SECONDS=30
# App server # App server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+15
View File
@@ -50,6 +50,7 @@ All configuration comes from environment variables (see `.env.example`):
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret | | `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | | `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Data model ## Data model
@@ -109,6 +110,7 @@ Client → server messages:
```json ```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"} {"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"} {"action": "state"}
``` ```
@@ -117,10 +119,23 @@ Client → server messages:
settebello). settebello).
- `capture` lists the table cards to take. When a capture is legal it is - `capture` lists the table cards to take. When a capture is legal it is
mandatory to provide one; when no capture exists it must be omitted. mandatory to provide one; when no capture exists it must be omitted.
- `ack` acknowledges the hand-end scoring summary (see below). The next
hand is dealt once all four players have acknowledged, or automatically
after `HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot. - `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players. After every accepted move the new state is broadcast to all four players.
### Hand-end summary
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), the `acknowledged` seats and a
`hand_end_deadline`. The frontend renders this as a screen every player
must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
## Rules implemented ## Rules implemented
- 40-card Italian deck, ten cards per player, empty table at hand start. - 40-card Italian deck, ten cards per player, empty table at hand start.
+4
View File
@@ -40,6 +40,9 @@ class Settings:
# Directory holding the compiled frontend (trunk's dist output), # Directory holding the compiled frontend (trunk's dist output),
# served for every path that is not under /api or /auth. # served for every path that is not under /api or /auth.
static_dir: str static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway.
hand_ack_timeout_seconds: int
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -59,6 +62,7 @@ class Settings:
redis_url=os.environ.get("REDIS_URL"), redis_url=os.environ.get("REDIS_URL"),
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")), game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
static_dir=_env("STATIC_DIR", "web/dist"), static_dir=_env("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
) )
+71 -7
View File
@@ -27,7 +27,7 @@ Rules implemented
from __future__ import annotations from __future__ import annotations
import random import random
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from itertools import combinations from itertools import combinations
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, List, Optional, Sequence, Tuple
@@ -43,6 +43,7 @@ from .errors import (
from .state import ( from .state import (
DEFAULT_TARGET_SCORE, DEFAULT_TARGET_SCORE,
PHASE_FINISHED, PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY, PHASE_LOBBY,
PHASE_PLAYING, PHASE_PLAYING,
SUITS, SUITS,
@@ -58,6 +59,11 @@ from .state import (
HAND_SIZE = 10 HAND_SIZE = 10
PLAYERS = 4 PLAYERS = 4
# Default seconds the hand-end summary waits before dealing anyway. Games
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the # Primiera card values: sevens are best, then sixes, then aces, then the
# remaining ranks in descending order. All of 8/9/10 are worth 10. # remaining ranks in descending order. All of 8/9/10 are worth 10.
PRIMIERA_VALUES: Dict[int, int] = { PRIMIERA_VALUES: Dict[int, int] = {
@@ -117,6 +123,7 @@ def create_game(
creator_sub: str, creator_sub: str,
creator_name: str, creator_name: str,
target_score: int = DEFAULT_TARGET_SCORE, target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> GameState: ) -> GameState:
"""Create a lobby game with the creator seated first.""" """Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100: if target_score < 1 or target_score > 100:
@@ -128,6 +135,7 @@ def create_game(
target_score=target_score, target_score=target_score,
phase=PHASE_LOBBY, phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
) )
@@ -193,6 +201,8 @@ def play(
""" """
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over") raise GameFinished("the match is over")
if state.phase == PHASE_HAND_END:
raise IllegalMove("the hand is over; acknowledge the summary to continue")
if state.phase != PHASE_PLAYING: if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet") raise GameNotStarted("the game has not started yet")
@@ -260,7 +270,14 @@ def _match_option(
def _end_hand(state: GameState) -> None: def _end_hand(state: GameState) -> None:
"""Sweep the table, score the hand and either deal again or finish.""" """Sweep the table and score the hand.
If the match continues, the game pauses in the ``hand_end`` phase so
every player can read the scoring summary; the next hand is dealt by
:func:`acknowledge_hand` once all four players have acknowledged (or by
the hand-end timeout in the websocket layer). If the match is over the
game goes to ``finished`` immediately.
"""
if state.table and state.last_taker is not None: if state.table and state.last_taker is not None:
taker = _player_at(state, state.last_taker) taker = _player_at(state, state.last_taker)
taker.captured.extend(state.table) taker.captured.extend(state.table)
@@ -282,8 +299,37 @@ def _end_hand(state: GameState) -> None:
state.finished_at = datetime.now(timezone.utc).isoformat() state.finished_at = datetime.now(timezone.utc).isoformat()
return return
# Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: GameState, sub: str) -> None:
"""Record that ``sub`` has read the hand-end summary.
When all four players have acknowledged, the next hand is dealt.
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
phase raises an error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no hand summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < PLAYERS:
return
state.hand_number += 1 state.hand_number += 1
state.dealer = (state.dealer + 1) % PLAYERS state.dealer = (state.dealer + 1) % PLAYERS
state.acked = []
state.hand_end_deadline = None
state.last_move = None
state.phase = PHASE_PLAYING
_deal_hand(state) _deal_hand(state)
@@ -308,26 +354,41 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
scope[player.team] += player.scope scope[player.team] += player.scope
points = [0, 0] points = [0, 0]
award: Dict[str, Optional[str]] = {}
# Carte: most captured cards. Ties award nothing. # Carte: most captured cards. Ties award nothing.
cards = [len(piles[0]), len(piles[1])] cards = [len(piles[0]), len(piles[1])]
if cards[0] != cards[1]: if cards[0] != cards[1]:
points[0 if cards[0] > cards[1] else 1] += 1 winner = 0 if cards[0] > cards[1] else 1
points[winner] += 1
award["carte"] = TEAM_NAMES[winner]
else:
award["carte"] = None
# Denara: most diamond cards. Ties award nothing. # Denara: most diamond cards. Ties award nothing.
coins = [ coins = [
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1) sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
] ]
if coins[0] != coins[1]: if coins[0] != coins[1]:
points[0 if coins[0] > coins[1] else 1] += 1 winner = 0 if coins[0] > coins[1] else 1
# Settebello: the 7 of diamonds. points[winner] += 1
award["denara"] = TEAM_NAMES[winner]
else:
award["denara"] = None
# Settebello: the 7 of diamonds always belongs to someone.
settebello = [ settebello = [
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1) any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
] ]
if settebello[0] != settebello[1]: if settebello[0] != settebello[1]:
points[0 if settebello[0] else 1] += 1 winner = 0 if settebello[0] else 1
points[winner] += 1
award["settebello"] = TEAM_NAMES[winner]
# Primiera: highest value, only if the team holds all four suits. # Primiera: highest value, only if the team holds all four suits.
primiera = [primiera_score(piles[t]) for t in (0, 1)] primiera = [primiera_score(piles[t]) for t in (0, 1)]
if primiera[0] != primiera[1]: if primiera[0] != primiera[1]:
points[0 if primiera[0] > primiera[1] else 1] += 1 winner = 0 if primiera[0] > primiera[1] else 1
points[winner] += 1
award["primiera"] = TEAM_NAMES[winner]
else:
award["primiera"] = None
# Scope: one point each. # Scope: one point each.
points[0] += scope[0] points[0] += scope[0]
points[1] += scope[1] points[1] += scope[1]
@@ -338,6 +399,7 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
"settebello": {"A": settebello[0], "B": settebello[1]}, "settebello": {"A": settebello[0], "B": settebello[1]},
"primiera": {"A": primiera[0], "B": primiera[1]}, "primiera": {"A": primiera[0], "B": primiera[1]},
"scope": {"A": scope[0], "B": scope[1]}, "scope": {"A": scope[0], "B": scope[1]},
"award": award,
} }
return points, details return points, details
@@ -380,6 +442,8 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
"players": players, "players": players,
"last_hand": state.hand_scores[-1] if state.hand_scores else None, "last_hand": state.hand_scores[-1] if state.hand_scores else None,
"last_move": state.last_move.to_json() if state.last_move else None, "last_move": state.last_move.to_json() if state.last_move else None,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
} }
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn: if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
payload["your_turn"] = True payload["your_turn"] = True
+16
View File
@@ -26,6 +26,10 @@ TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby" PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing" PHASE_PLAYING = "playing"
# Between hands of an unfinished match: scoring summary shown to every
# player; the next hand is dealt once all four acknowledge (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished" PHASE_FINISHED = "finished"
DEFAULT_TARGET_SCORE = 11 DEFAULT_TARGET_SCORE = 11
@@ -162,6 +166,12 @@ class GameState:
finished_at: Optional[str] = None finished_at: Optional[str] = None
# The most recent play in the current hand, for move announcements. # The most recent play in the current hand, for move announcements.
last_move: Optional[Move] = None last_move: Optional[Move] = None
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30
# -- serialization ---------------------------------------------------- # -- serialization ----------------------------------------------------
@@ -185,6 +195,9 @@ class GameState:
"created_at": self.created_at, "created_at": self.created_at,
"finished_at": self.finished_at, "finished_at": self.finished_at,
"last_move": self.last_move.to_json() if self.last_move else None, "last_move": self.last_move.to_json() if self.last_move else None,
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
} }
@staticmethod @staticmethod
@@ -208,6 +221,9 @@ class GameState:
created_at=data.get("created_at"), created_at=data.get("created_at"),
finished_at=data.get("finished_at"), finished_at=data.get("finished_at"),
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None, last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
) )
# -- helpers ---------------------------------------------------------- # -- helpers ----------------------------------------------------------
+2
View File
@@ -18,6 +18,7 @@ from kaya.openapi import operation
from .. import auth from .. import auth
from ..app import app, game_store, oidc_mixin from ..app import app, game_store, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..config import settings
from ..game import engine from ..game import engine
from ..game.errors import GameError from ..game.errors import GameError
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
@@ -103,6 +104,7 @@ async def create_game(ctx: HttpContext) -> None:
creator_sub=user.sub, creator_sub=user.sub,
creator_name=auth.display_name(user), creator_name=auth.display_name(user),
target_score=target_score, target_score=target_score,
hand_ack_timeout=settings.hand_ack_timeout_seconds,
) )
except GameError as exc: except GameError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
+59 -2
View File
@@ -16,11 +16,14 @@ Client -> server messages are JSON objects::
{"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"} {"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"} {"action": "state"}
``capture`` lists the table cards to take and must be a legal capture when ``capture`` lists the table cards to take and must be a legal capture when
one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted
when the played card cannot capture. when the played card cannot capture. ``ack`` acknowledges the hand-end
scoring summary; the next hand is dealt when all four players have
acknowledged or the timeout fires.
Mutations run under the per-game lock; after a successful move the new Mutations run under the per-game lock; after a successful move the new
state is saved to Redis and a change signal is published. Every connected state is saved to Redis and a change signal is published. Every connected
@@ -40,7 +43,7 @@ from . import auth
from .app import app, game_store from .app import app, game_store
from .game import engine from .game import engine
from .game.errors import GameError from .game.errors import GameError
from .game.state import PHASE_FINISHED, GameState from .game.state import PHASE_FINISHED, PHASE_HAND_END, GameState
from .stats import save_match_result from .stats import save_match_result
Send = Callable[[Dict[str, Any]], Awaitable[None]] Send = Callable[[Dict[str, Any]], Awaitable[None]]
@@ -132,6 +135,8 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
action = data.get("action") action = data.get("action")
if action == "play": if action == "play":
await _handle_play(send, game_id, sub, data) await _handle_play(send, game_id, sub, data)
elif action == "ack":
await _handle_ack(send, game_id, sub)
elif action in ("state", "sync"): elif action in ("state", "sync"):
state = await game_store.load(game_id) state = await game_store.load(game_id)
if state is not None: if state is not None:
@@ -140,6 +145,56 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
await send(_error(f"unknown action: {action!r}")) await send(_error(f"unknown action: {action!r}"))
# --- hand-end acknowledgement ------------------------------------------------
# Running auto-continue timers, keyed by (game_id, hand_number), so a hand's
# timeout is scheduled only once even when several clients are connected.
_hand_end_timers: Dict[tuple, asyncio.Task] = {}
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if state is None:
await send(_error("game not found", code="not_found"))
return
try:
engine.acknowledge_hand(state, sub)
except GameError as exc:
await send(_error(str(exc), code="illegal_move"))
return
await game_store.save(state)
await game_store.publish(game_id)
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None:
"""Deal the next hand after the acknowledgement timeout, even if not
everyone has clicked. Fizzles if the hand already advanced."""
key = (game_id, hand_number)
if key in _hand_end_timers:
return
async def _auto_advance() -> None:
try:
await asyncio.sleep(timeout)
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if (
state is None
or state.phase != engine.PHASE_HAND_END
or state.hand_number != hand_number
):
return
for player in state.players:
engine.acknowledge_hand(state, player.sub)
await game_store.save(state)
await game_store.publish(game_id)
finally:
_hand_end_timers.pop(key, None)
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
async def _handle_play( async def _handle_play(
send: Send, game_id: str, sub: str, data: Dict[str, Any] send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None: ) -> None:
@@ -171,5 +226,7 @@ async def _handle_play(
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
await save_match_result(state) await save_match_result(state)
elif state.phase == PHASE_HAND_END:
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
await game_store.save(state) await game_store.save(state)
await game_store.publish(game_id) await game_store.publish(game_id)
+77
View File
@@ -297,6 +297,10 @@ class MatchFlowTest(unittest.TestCase):
moves = 0 moves = 0
while state.phase != PHASE_FINISHED and moves < 200000: while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == "hand_end":
for p in state.players:
engine.acknowledge_hand(state, p.sub)
continue
player = next(p for p in state.players if p.seat == state.turn) player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0] played = player.hand[0]
options = engine.legal_captures(state.table, played) options = engine.legal_captures(state.table, played)
@@ -313,5 +317,78 @@ class MatchFlowTest(unittest.TestCase):
self.assertTrue(state.finished_at) self.assertTrue(state.finished_at)
class HandEndAckTest(unittest.TestCase):
def _hand_end_state(self) -> GameState:
"""Drive a game into the hand_end phase with a one-card hand."""
state = make_state([["02D"], [], [], []],
table=["02C"], target=11)
engine.play(state, "p0", "02D", ["02C"])
return state
def test_end_of_hand_pauses_for_acknowledgement(self) -> None:
state = self._hand_end_state()
self.assertEqual("hand_end", state.phase)
# Nobody has acknowledged yet, and no new hand was dealt.
self.assertEqual([], state.acked)
self.assertEqual(1, state.hand_number)
self.assertTrue(state.hand_end_deadline)
# Capture piles stay visible during the summary.
self.assertEqual(["02C", "02D"],
[c.code for c in state.players[0].captured])
# The summary carries the award map.
summary = state.hand_scores[-1]
self.assertEqual(1, summary["hand"])
self.assertIn("award", summary)
def test_play_during_hand_end_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
def test_ack_all_four_deals_next_hand(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
for i, sub in enumerate(("p0", "p1", "p2")):
engine.acknowledge_hand(state, sub)
self.assertEqual(list(range(i + 1)), state.acked)
self.assertEqual("hand_end", state.phase)
engine.acknowledge_hand(state, "p3")
self.assertEqual("playing", state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual((dealer_before + 1) % 4, state.dealer)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
self.assertIsNone(state.last_move)
for player in state.players:
self.assertEqual(10, len(player.hand))
self.assertEqual([], player.captured)
self.assertEqual((dealer_before + 2) % 4, state.turn)
def test_double_ack_is_idempotent(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
def test_ack_outside_hand_end_is_rejected(self) -> None:
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_ack_by_non_player_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(NotYourTurn):
engine.acknowledge_hand(state, "mallory")
def test_state_exposes_ack_progress(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p1")
view = engine.state_for_player(state, "p0")
self.assertEqual([1], view["acknowledged"])
self.assertTrue(view["hand_end_deadline"])
self.assertIsNotNone(view["last_hand"])
self.assertIn("award", view["last_hand"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+112 -1
View File
@@ -1,6 +1,7 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport.""" """WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations from __future__ import annotations
import asyncio
import unittest import unittest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
@@ -8,7 +9,9 @@ from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport from httpx_ws.transport import ASGIWebSocketTransport
from pwo import async_test from pwo import async_test
from scopa.app import app from scopa.app import app, game_store
from scopa.game import engine
from scopa.game.state import Card, GameState, PlayerState
from tests.helpers import make_user, oidc_user, ws_users from tests.helpers import make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave") PLAYERS = ("alice", "bob", "carol", "dave")
@@ -127,5 +130,113 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(4401, caught.exception.code) self.assertEqual(4401, caught.exception.code)
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
"""Seed a game where a single play ends the hand: p0 holds the only
card left and can capture the only table card."""
state = GameState(
id="hand-end-1",
join_code="HEND01",
creator_sub="alice",
target_score=11,
phase="playing",
turn=0,
table=[Card.parse("02C")],
)
state.players = [
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3),
]
state.hand_ack_timeout = hand_ack_timeout
await game_store.save(state)
return state.id
class HandEndWebSocketTest(unittest.TestCase):
@async_test
async def test_hand_end_ack_flow(self) -> None:
import contextlib
game_id = await _seed_last_play_state()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
async with contextlib.AsyncExitStack() as stack:
with ws_users([make_user(name) for name in PLAYERS]):
sockets = [
await stack.enter_async_context(
aconnect_ws(f"/ws/games/{game_id}", ws_client)
)
for _ in PLAYERS
]
for ws in sockets:
await ws.receive_json() # initial state
# Alice plays the last card: the hand ends and the game
# pauses for acknowledgements.
await sockets[0].send_json(
{"action": "play", "card": "02D", "capture": ["02C"]}
)
summaries = [await ws.receive_json() for ws in sockets]
for summary in summaries:
self.assertEqual("state", summary["type"])
self.assertEqual("hand_end", summary["game"]["phase"])
self.assertEqual([], summary["game"]["acknowledged"])
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
award = summary["game"]["last_hand"]["award"]
self.assertEqual("A", award["carte"])
self.assertEqual("A", award["denara"])
# Everyone acknowledges; the fourth ack deals the next hand.
for i, ws in enumerate(sockets):
await ws.send_json({"action": "ack"})
updates = [await other.receive_json() for other in sockets]
for update in updates:
if i < 3:
self.assertEqual("hand_end", update["game"]["phase"])
self.assertEqual(
list(range(i + 1)),
update["game"]["acknowledged"],
)
else:
self.assertEqual("playing", update["game"]["phase"])
self.assertEqual(2, update["game"]["hand_number"])
self.assertEqual(
10, update["game"]["players"][i]["cards_left"]
)
@async_test
async def test_hand_end_timeout_deals_next_hand(self) -> None:
game_id = await _seed_last_play_state(hand_ack_timeout=1)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
await ws.receive_json() # initial state
await ws.send_json(
{"action": "play", "card": "02D", "capture": ["02C"]}
)
summary = await ws.receive_json()
self.assertEqual("hand_end", summary["game"]["phase"])
# Nobody acks: the timer must deal the next hand.
update = None
for _ in range(20):
try:
update = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
update.get("type") == "state"
and update["game"]["phase"] == "playing"
):
break
self.assertIsNotNone(update)
assert update is not None
self.assertEqual("playing", update["game"]["phase"])
self.assertEqual(2, update["game"]["hand_number"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+12
View File
@@ -174,6 +174,16 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gloo-utils" name = "gloo-utils"
version = "0.2.0" version = "0.2.0"
@@ -366,6 +376,8 @@ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures", "futures",
"gloo-net", "gloo-net",
"gloo-timers",
"js-sys",
"serde", "serde",
"serde_json", "serde_json",
"sycamore", "sycamore",
+2
View File
@@ -14,6 +14,8 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
futures = "0.3" futures = "0.3"
web-sys = { version = "0.3", features = ["Window", "Location", "console"] } web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
js-sys = "0.3"
gloo-timers = "0.3"
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
[profile.release] [profile.release]
+1
View File
@@ -1 +1,2 @@
pub mod card; pub mod card;
pub mod summary;
+250
View File
@@ -0,0 +1,250 @@
//! The hand-end scoring summary screen.
//!
//! Shown when a hand finishes but the match continues: explains, in plain
//! language, how each scoring category played out and how the running
//! totals moved toward the target. Every player must acknowledge it before
//! the next hand is dealt (or the server-side timeout deals anyway).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{GameView, HandSummary, Scores};
use crate::ws::GameSocket;
/// One explanatory row: icon, title, plain-language sentence, points chip.
fn award_row(
icon: View,
title: &'static str,
text: String,
winner: Option<String>,
points: &'static str,
) -> View {
let cls = match winner.as_deref() {
Some("A") => "score-row team-a",
Some("B") => "score-row team-b",
_ => "score-row tie",
};
let chip = match &winner {
Some(t) => format!("Team {t} {points}"),
None => "tie".to_string(),
};
view! {
div(class=cls) {
div(class="score-icon") { (icon) }
div(class="score-body") {
div(class="score-title") { (title) }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
}
/// Winner's count first, then the loser's, for a natural sentence.
fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
match winner.map(String::as_str) {
Some("B") => (b, a),
_ => (a, b),
}
}
/// The five scoring rows of a completed hand.
pub fn summary_rows(summary: HandSummary) -> View {
let rows: Vec<View> = vec![
// Carte
award_row(
card_back("score-mini"),
"Carte",
match &summary.award.carte {
Some(t) => {
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
format!("Team {t} captured more cards ({w} vs {l})")
}
None => format!(
"Both teams captured {} cards — no point",
summary.cards.a
),
},
summary.award.carte.clone(),
"+1",
),
// Denara
award_row(
card_img("02D".to_string(), "score-mini"),
"Denara",
match &summary.award.denara {
Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
format!("Team {t} collected more denari cards ({w} vs {l})")
}
None => format!(
"Both teams collected {} denari cards — no point",
summary.denara.a
),
},
summary.award.denara.clone(),
"+1",
),
// Settebello
award_row(
card_img("07D".to_string(), "score-mini"),
"Settebello",
match &summary.award.settebello {
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
None => "Nobody captured the Settebello".to_string(),
},
summary.award.settebello.clone(),
"+1",
),
// Primiera
award_row(
card_img("10D".to_string(), "score-mini"),
"Primiera",
match &summary.award.primiera {
Some(t) => {
let (w, l) =
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
format!("Team {t} holds the strongest primiera ({w} vs {l})")
}
None => format!(
"Both primiere are worth {} — no point",
summary.primiera.a
),
},
summary.award.primiera.clone(),
"+1",
),
// Scope
{
let a = summary.scope.a;
let b = summary.scope.b;
let text = if a == 0 && b == 0 {
"No scope this hand".to_string()
} else {
format!("Team A made {a} scope · Team B made {b} scope")
};
let chip = format!("+{a} · +{b}");
view! {
div(class="score-row scope-row") {
div(class="score-icon") {
(card_back("score-mini"))
}
div(class="score-body") {
div(class="score-title") { "Scope" }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
},
];
view! {
div(class="score-rows") { (rows) }
}
}
/// Running totals with progress toward the target score, including the
/// points gained in the hand just played.
pub fn totals(game: &GameView) -> View {
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let target = game.target_score.max(1);
let pct_a = (100 * scores.a / target).min(100);
let pct_b = (100 * scores.b / target).min(100);
let (gained_a, gained_b) = game
.last_hand
.as_ref()
.map(|s| (s.team_a_points, s.team_b_points))
.unwrap_or((0, 0));
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team A" }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(scores.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
div(class="total-row team-b") {
span(class="total-label") { "Team B" }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(scores.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
}
}
}
/// The full hand-end modal: explanation + totals + acknowledgement button.
pub fn hand_summary_modal(
game: GameView,
socket: Signal<Option<GameSocket>>,
now: Signal<f64>,
) -> View {
let Some(summary) = game.last_hand.clone() else {
return view! {};
};
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat);
let acked = game.acknowledged.clone();
let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s));
let waiting: Vec<String> = game
.players
.iter()
.filter(|p| !acked.contains(&p.seat))
.map(|p| p.name.clone())
.collect();
let countdown = game.hand_end_deadline.as_ref().map(|deadline| {
// A dynamic closure so only the ticking number re-renders, not the
// whole modal (which would swap DOM nodes under the user's cursor).
let deadline_ms = js_sys::Date::parse(deadline);
view! {
p(class="hint") {
"Auto-continuing in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let rows = summary_rows(summary.clone());
let totals_view = totals(&game);
let title = format!("Hand {} — results", summary.hand);
let action = if already_acked {
let waiting_text = format!("Waiting for {}", waiting.join(", "));
view! {
button(class="button primary", disabled=true) { (waiting_text) }
}
} else {
view! {
button(class="button primary", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.ack();
}
}) { "Understood — next hand" }
}
};
view! {
div(class="overlay") {
div(class="picker summary-panel") {
h2 { (title) }
(rows)
(totals_view)
div(class="summary-actions") { (action) }
(countdown)
}
}
}
}
+58
View File
@@ -47,6 +47,54 @@ pub struct MoveView {
pub scopa: bool, pub scopa: bool,
} }
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct TeamCounts {
#[serde(rename = "A")]
pub a: i32,
#[serde(rename = "B")]
pub b: i32,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[allow(dead_code)]
pub struct TeamBools {
#[serde(rename = "A")]
pub a: bool,
#[serde(rename = "B")]
pub b: bool,
}
/// Which team (if any) won each scoring category of a hand.
#[derive(Debug, Clone, Deserialize)]
pub struct Award {
#[serde(default)]
pub carte: Option<String>,
#[serde(default)]
pub denara: Option<String>,
#[serde(default)]
pub settebello: Option<String>,
#[serde(default)]
pub primiera: Option<String>,
}
/// The scoring breakdown of one completed hand.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct HandSummary {
pub cards: TeamCounts,
pub denara: TeamCounts,
pub settebello: TeamBools,
pub primiera: TeamCounts,
pub scope: TeamCounts,
pub award: Award,
#[serde(default)]
pub hand: i32,
#[serde(default)]
pub team_a_points: i32,
#[serde(default)]
pub team_b_points: i32,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct GameView { pub struct GameView {
@@ -74,6 +122,16 @@ pub struct GameView {
pub seats_open: Option<usize>, pub seats_open: Option<usize>,
#[serde(default)] #[serde(default)]
pub last_move: Option<MoveView>, pub last_move: Option<MoveView>,
/// Scoring breakdown of the most recent hand (present once a hand has
/// been completed).
#[serde(default)]
pub last_hand: Option<HandSummary>,
/// Seats that acknowledged the hand-end summary.
#[serde(default)]
pub acknowledged: Vec<usize>,
/// ISO-8601 instant at which the next hand is dealt automatically.
#[serde(default)]
pub hand_end_deadline: Option<String>,
#[serde(default)] #[serde(default)]
pub your_turn: Option<bool>, pub your_turn: Option<bool>,
/// Legal captures per hand card; present only for the player on turn. /// Legal captures per hand card; present only for the player on turn.
+22 -3
View File
@@ -2,6 +2,7 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::components::card::{card_back, card_img}; use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage}; use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket}; use crate::ws::{self, GameSocket};
@@ -77,6 +78,9 @@ pub fn GamePage(id: String) -> View {
let over = create_signal(Option::<(Scores, Option<String>)>::None); let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false); let closed = create_signal(false);
let socket = create_signal(Option::<GameSocket>::None); let socket = create_signal(Option::<GameSocket>::None);
// Ticking clock driving the hand-end countdown display.
let now = create_signal(js_sys::Date::now());
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
{ {
let on_message = move |msg: ServerMessage| match msg { let on_message = move |msg: ServerMessage| match msg {
@@ -140,6 +144,10 @@ pub fn GamePage(id: String) -> View {
(move || capture_choice.get_clone().map(|(card, options)| { (move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice) capture_picker(card, options, socket, capture_choice)
})) }))
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone())) (move || game_over_view(over.get_clone(), game.get_clone()))
} }
} }
@@ -199,6 +207,8 @@ fn table_view(
let my_turn = game.your_turn == Some(true); let my_turn = game.your_turn == Some(true);
let turn_note = if game.phase == "finished" { let turn_note = if game.phase == "finished" {
"Match finished".to_string() "Match finished".to_string()
} else if game.phase == "hand_end" {
"Hand finished".to_string()
} else if my_turn { } else if my_turn {
"Your turn".to_string() "Your turn".to_string()
} else { } else {
@@ -321,7 +331,8 @@ fn capture_picker(
/// End-of-match overlay. /// End-of-match overlay.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View { fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let result = over.or_else(|| { let result = over.or_else(|| {
game.filter(|g| g.phase == "finished") game.clone()
.filter(|g| g.phase == "finished")
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner)) .map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
}); });
match result { match result {
@@ -329,11 +340,19 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
Some((scores, winner)) => { Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string()); let winner = winner.unwrap_or_else(|| "?".to_string());
let line = format!("Team {winner} wins {} {}", scores.a, scores.b); let line = format!("Team {winner} wins {} {}", scores.a, scores.b);
// Explain the final hand's scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
view! { view! {
div(class="overlay") { div(class="overlay") {
div(class="picker") { div(class="picker summary-panel") {
h2 { "Match over" } h2 { "Match over" }
p { (line) } (final_summary)
p(class="final-score") { (line) }
div(class="gameover-actions") { div(class="gameover-actions") {
a(class="button primary", href="/") { "Back to lobby" } a(class="button primary", href="/") { "Back to lobby" }
a(class="button", href="/history") { "My matches" } a(class="button", href="/history") { "My matches" }
+5
View File
@@ -40,6 +40,11 @@ impl GameSocket {
self.send_json(serde_json::json!({ "action": "state" })); self.send_json(serde_json::json!({ "action": "state" }));
} }
/// Acknowledge the hand-end scoring summary.
pub fn ack(&self) {
self.send_json(serde_json::json!({ "action": "ack" }));
}
fn send_json(&self, value: serde_json::Value) { fn send_json(&self, value: serde_json::Value) {
let _ = self.sender.borrow_mut().unbounded_send(value.to_string()); let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
} }
+153
View File
@@ -9,6 +9,8 @@
--muted: #a9b7ab; --muted: #a9b7ab;
--accent: #e8c547; --accent: #e8c547;
--danger: #d9534f; --danger: #d9534f;
--team-a: #7db4e8;
--team-b: #e8967d;
} }
* { * {
@@ -448,3 +450,154 @@ table.matches td.lost {
justify-content: center; justify-content: center;
margin-top: 1rem; margin-top: 1rem;
} }
/* ---------- hand-end scoring summary ---------- */
.summary-panel {
min-width: 420px;
max-width: 560px;
text-align: left;
}
.summary-panel h2 {
text-align: center;
margin-top: 0;
}
.score-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0.75rem 0;
}
.score-row {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--panel-light);
border-left: 4px solid transparent;
border-radius: 8px;
padding: 0.45rem 0.75rem;
}
.score-row.team-a {
border-left-color: var(--team-a);
}
.score-row.team-b {
border-left-color: var(--team-b);
}
.score-row.tie {
opacity: 0.65;
}
.score-icon {
width: 32px;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.card-img.score-mini {
height: 38px;
}
.score-body {
flex: 1;
}
.score-title {
font-weight: 700;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.score-text {
font-size: 0.95rem;
}
.score-points {
font-weight: 700;
white-space: nowrap;
}
.score-row.team-a .score-points {
color: var(--team-a);
}
.score-row.team-b .score-points {
color: var(--team-b);
}
.totals {
margin: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.total-row {
display: grid;
grid-template-columns: 4.5rem 1fr 4rem;
gap: 0.6rem;
align-items: center;
}
.total-label {
font-weight: 600;
}
.total-value {
font-weight: 700;
text-align: right;
}
.gained {
margin-left: 0.35rem;
font-size: 0.85rem;
opacity: 0.85;
}
.total-row.team-a .gained {
color: var(--team-a);
}
.total-row.team-b .gained {
color: var(--team-b);
}
.progress {
height: 10px;
background: rgba(0, 0, 0, 0.35);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999px;
transition: width 0.4s ease;
}
.total-row.team-a .progress-fill {
background: var(--team-a);
}
.total-row.team-b .progress-fill {
background: var(--team-b);
}
.summary-actions {
text-align: center;
margin-top: 0.5rem;
}
.final-score {
text-align: center;
font-size: 1.25rem;
font-weight: 700;
}