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