diff --git a/README.md b/README.md index bfc2582..4c6a553 100644 --- a/README.md +++ b/README.md @@ -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 ``` +## 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 Backend (from `server/`): diff --git a/docker-compose.yml b/docker-compose.yml index 3f6dcdf..dbc7109 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,6 +100,7 @@ services: OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback} REDIS_URL: redis://redis:6379/0 + HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} ports: - "127.0.0.1:${APP_PORT:-8080}:8080" diff --git a/server/.env.example b/server/.env.example index bfdb4b5..3cf5b3c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/README.md b/server/README.md index 5dba6c8..8968fa6 100644 --- a/server/README.md +++ b/server/README.md @@ -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. diff --git a/server/src/scopa/config.py b/server/src/scopa/config.py index 13ea30b..ce15be8 100644 --- a/server/src/scopa/config.py +++ b/server/src/scopa/config.py @@ -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")), ) diff --git a/server/src/scopa/game/engine.py b/server/src/scopa/game/engine.py index 53cc965..2643bb8 100644 --- a/server/src/scopa/game/engine.py +++ b/server/src/scopa/game/engine.py @@ -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 diff --git a/server/src/scopa/game/state.py b/server/src/scopa/game/state.py index 020bed7..f8c1256 100644 --- a/server/src/scopa/game/state.py +++ b/server/src/scopa/game/state.py @@ -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 ---------------------------------------------------------- diff --git a/server/src/scopa/routes/games.py b/server/src/scopa/routes/games.py index 13071f7..e12ecce 100644 --- a/server/src/scopa/routes/games.py +++ b/server/src/scopa/routes/games.py @@ -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)) diff --git a/server/src/scopa/ws.py b/server/src/scopa/ws.py index f7c87fe..4bf0aed 100644 --- a/server/src/scopa/ws.py +++ b/server/src/scopa/ws.py @@ -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) diff --git a/server/tests/test_engine.py b/server/tests/test_engine.py index 93239a7..034999f 100644 --- a/server/tests/test_engine.py +++ b/server/tests/test_engine.py @@ -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() diff --git a/server/tests/test_websocket.py b/server/tests/test_websocket.py index 48227bd..817d485 100644 --- a/server/tests/test_websocket.py +++ b/server/tests/test_websocket.py @@ -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() diff --git a/web/Cargo.lock b/web/Cargo.lock index e843f54..91526f0 100644 --- a/web/Cargo.lock +++ b/web/Cargo.lock @@ -174,6 +174,16 @@ dependencies = [ "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]] name = "gloo-utils" version = "0.2.0" @@ -366,6 +376,8 @@ dependencies = [ "console_error_panic_hook", "futures", "gloo-net", + "gloo-timers", + "js-sys", "serde", "serde_json", "sycamore", diff --git a/web/Cargo.toml b/web/Cargo.toml index 310773d..3e809a6 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -14,6 +14,8 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" futures = "0.3" web-sys = { version = "0.3", features = ["Window", "Location", "console"] } +js-sys = "0.3" +gloo-timers = "0.3" console_error_panic_hook = "0.1" [profile.release] diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index b4ba26a..a8da35a 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1 +1,2 @@ pub mod card; +pub mod summary; diff --git a/web/src/components/summary.rs b/web/src/components/summary.rs new file mode 100644 index 0000000..cc7c6c8 --- /dev/null +++ b/web/src/components/summary.rs @@ -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, + 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 = 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>, + now: Signal, +) -> 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 = 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) + } + } + } +} diff --git a/web/src/model.rs b/web/src/model.rs index 5cda49e..b6fb717 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -47,6 +47,54 @@ pub struct MoveView { 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, + #[serde(default)] + pub denara: Option, + #[serde(default)] + pub settebello: Option, + #[serde(default)] + pub primiera: Option, +} + +/// 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)] #[allow(dead_code)] pub struct GameView { @@ -74,6 +122,16 @@ pub struct GameView { pub seats_open: Option, #[serde(default)] pub last_move: Option, + /// Scoring breakdown of the most recent hand (present once a hand has + /// been completed). + #[serde(default)] + pub last_hand: Option, + /// Seats that acknowledged the hand-end summary. + #[serde(default)] + pub acknowledged: Vec, + /// ISO-8601 instant at which the next hand is dealt automatically. + #[serde(default)] + pub hand_end_deadline: Option, #[serde(default)] pub your_turn: Option, /// Legal captures per hand card; present only for the player on turn. diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 4022b25..5c5b4ca 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -2,6 +2,7 @@ use sycamore::prelude::*; 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::ws::{self, GameSocket}; @@ -77,6 +78,9 @@ pub fn GamePage(id: String) -> View { let over = create_signal(Option::<(Scores, Option)>::None); let closed = create_signal(false); let socket = create_signal(Option::::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 { @@ -140,6 +144,10 @@ pub fn GamePage(id: String) -> View { (move || capture_choice.get_clone().map(|(card, options)| { 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())) } } @@ -199,6 +207,8 @@ fn table_view( let my_turn = game.your_turn == Some(true); let turn_note = if game.phase == "finished" { "Match finished".to_string() + } else if game.phase == "hand_end" { + "Hand finished".to_string() } else if my_turn { "Your turn".to_string() } else { @@ -321,7 +331,8 @@ fn capture_picker( /// End-of-match overlay. fn game_over_view(over: Option<(Scores, Option)>, game: Option) -> View { 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)) }); match result { @@ -329,11 +340,19 @@ fn game_over_view(over: Option<(Scores, Option)>, game: Option Some((scores, winner)) => { let winner = winner.unwrap_or_else(|| "?".to_string()); 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! { div(class="overlay") { - div(class="picker") { + div(class="picker summary-panel") { h2 { "Match over" } - p { (line) } + (final_summary) + p(class="final-score") { (line) } div(class="gameover-actions") { a(class="button primary", href="/") { "Back to lobby" } a(class="button", href="/history") { "My matches" } diff --git a/web/src/ws.rs b/web/src/ws.rs index ec7932a..4cd259b 100644 --- a/web/src/ws.rs +++ b/web/src/ws.rs @@ -40,6 +40,11 @@ impl GameSocket { 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) { let _ = self.sender.borrow_mut().unbounded_send(value.to_string()); } diff --git a/web/style.css b/web/style.css index 3bb2068..8e658e4 100644 --- a/web/style.css +++ b/web/style.css @@ -9,6 +9,8 @@ --muted: #a9b7ab; --accent: #e8c547; --danger: #d9534f; + --team-a: #7db4e8; + --team-b: #e8967d; } * { @@ -448,3 +450,154 @@ table.matches td.lost { justify-content: center; 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; +}