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
+4
View File
@@ -20,6 +20,10 @@ REDIS_URL=redis://localhost:6379/0
# How long a live game survives in Redis without activity.
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_HOST=0.0.0.0
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_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `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 |
## Data model
@@ -109,6 +110,7 @@ Client → server messages:
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"}
```
@@ -117,10 +119,23 @@ Client → server messages:
settebello).
- `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.
- `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.
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
- 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),
# served for every path that is not under /api or /auth.
static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway.
hand_ack_timeout_seconds: int
@staticmethod
def from_env() -> "Settings":
@@ -59,6 +62,7 @@ class Settings:
redis_url=os.environ.get("REDIS_URL"),
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
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
import random
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from itertools import combinations
from typing import Dict, List, Optional, Sequence, Tuple
@@ -43,6 +43,7 @@ from .errors import (
from .state import (
DEFAULT_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
SUITS,
@@ -58,6 +59,11 @@ from .state import (
HAND_SIZE = 10
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
# remaining ranks in descending order. All of 8/9/10 are worth 10.
PRIMIERA_VALUES: Dict[int, int] = {
@@ -117,6 +123,7 @@ def create_game(
creator_sub: str,
creator_name: str,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> GameState:
"""Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100:
@@ -128,6 +135,7 @@ def create_game(
target_score=target_score,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
created_at=datetime.now(timezone.utc).isoformat(),
)
@@ -193,6 +201,8 @@ def play(
"""
if state.phase == PHASE_FINISHED:
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:
raise GameNotStarted("the game has not started yet")
@@ -260,7 +270,14 @@ def _match_option(
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:
taker = _player_at(state, state.last_taker)
taker.captured.extend(state.table)
@@ -282,8 +299,37 @@ def _end_hand(state: GameState) -> None:
state.finished_at = datetime.now(timezone.utc).isoformat()
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.dealer = (state.dealer + 1) % PLAYERS
state.acked = []
state.hand_end_deadline = None
state.last_move = None
state.phase = PHASE_PLAYING
_deal_hand(state)
@@ -308,26 +354,41 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
scope[player.team] += player.scope
points = [0, 0]
award: Dict[str, Optional[str]] = {}
# Carte: most captured cards. Ties award nothing.
cards = [len(piles[0]), len(piles[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.
coins = [
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
]
if coins[0] != coins[1]:
points[0 if coins[0] > coins[1] else 1] += 1
# Settebello: the 7 of diamonds.
winner = 0 if coins[0] > coins[1] else 1
points[winner] += 1
award["denara"] = TEAM_NAMES[winner]
else:
award["denara"] = None
# Settebello: the 7 of diamonds always belongs to someone.
settebello = [
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 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 = [primiera_score(piles[t]) for t in (0, 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.
points[0] += scope[0]
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]},
"primiera": {"A": primiera[0], "B": primiera[1]},
"scope": {"A": scope[0], "B": scope[1]},
"award": award,
}
return points, details
@@ -380,6 +442,8 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
"players": players,
"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,
"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:
payload["your_turn"] = True
+16
View File
@@ -26,6 +26,10 @@ TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
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"
DEFAULT_TARGET_SCORE = 11
@@ -162,6 +166,12 @@ class GameState:
finished_at: Optional[str] = None
# The most recent play in the current hand, for move announcements.
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 ----------------------------------------------------
@@ -185,6 +195,9 @@ class GameState:
"created_at": self.created_at,
"finished_at": self.finished_at,
"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
@@ -208,6 +221,9 @@ class GameState:
created_at=data.get("created_at"),
finished_at=data.get("finished_at"),
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 ----------------------------------------------------------
+2
View File
@@ -18,6 +18,7 @@ from kaya.openapi import operation
from .. import auth
from ..app import app, game_store, oidc_mixin
from ..auth import require_auth
from ..config import settings
from ..game import engine
from ..game.errors import GameError
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_name=auth.display_name(user),
target_score=target_score,
hand_ack_timeout=settings.hand_ack_timeout_seconds,
)
except GameError as 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"}
{"action": "ack"}
{"action": "state"}
``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
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
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 .game import engine
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
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")
if action == "play":
await _handle_play(send, game_id, sub, data)
elif action == "ack":
await _handle_ack(send, game_id, sub)
elif action in ("state", "sync"):
state = await game_store.load(game_id)
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}"))
# --- 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(
send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None:
@@ -171,5 +226,7 @@ async def _handle_play(
if state.phase == PHASE_FINISHED:
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.publish(game_id)
+77
View File
@@ -297,6 +297,10 @@ class MatchFlowTest(unittest.TestCase):
moves = 0
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)
played = player.hand[0]
options = engine.legal_captures(state.table, played)
@@ -313,5 +317,78 @@ class MatchFlowTest(unittest.TestCase):
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__":
unittest.main()
+112 -1
View File
@@ -1,6 +1,7 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations
import asyncio
import unittest
from httpx import ASGITransport, AsyncClient
@@ -8,7 +9,9 @@ from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
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
PLAYERS = ("alice", "bob", "carol", "dave")
@@ -127,5 +130,113 @@ class WebSocketTest(unittest.TestCase):
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__":
unittest.main()