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