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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user