From 565e927a9b3930061791225d502a04f90cebd708 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Sun, 25 Oct 2026 13:22:17 +0000 Subject: [PATCH] Fix hand-end timeouts after partial acknowledgements --- .../src/tavolo/briscola/plugin.py | 2 + .../tavolo-scopa/src/tavolo/scopa/plugin.py | 2 + .../src/tavolo/scopone/plugin.py | 2 + server/tests/test_deadlines.py | 91 ++++++++++++++++++- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py index 35ba973..6b5483b 100644 --- a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py @@ -220,6 +220,8 @@ class _BriscolaBase(GameEngine): ) elif kind == "hand_end": for player in state.players: + if state.phase != PHASE_HAND_END: + break engine.acknowledge_hand(state, player.sub) log.info( "deal %d auto-advanced after the acknowledgement timeout", diff --git a/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py b/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py index edd94cc..6c0336c 100644 --- a/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py +++ b/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py @@ -224,6 +224,8 @@ class _ScopaBase(GameEngine): ) elif kind == "hand_end": for player in state.players: + if state.phase != PHASE_HAND_END: + break engine.acknowledge_hand(state, player.sub) log.info( "hand %d auto-advanced after the acknowledgement timeout", diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py index 6ab9c1e..5377ce8 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py @@ -234,6 +234,8 @@ class ScoponeEngine(GameEngine): ) elif kind == "hand_end": for player in state.players: + if state.phase != PHASE_HAND_END: + break engine.acknowledge_hand(state, player.sub) log.info( "hand %d auto-advanced after the acknowledgement timeout", diff --git a/server/tests/test_deadlines.py b/server/tests/test_deadlines.py index d37bdc0..f96d9a7 100644 --- a/server/tests/test_deadlines.py +++ b/server/tests/test_deadlines.py @@ -1,4 +1,4 @@ -"""Deadline-queue timeout tests (scopone game, full platform stack). +"""Deadline-queue timeout tests (full platform stack). Timeouts must be driven by the persisted deadlines and the shared queue, not by connected sockets: these tests seed sessions, queue their @@ -10,12 +10,15 @@ from __future__ import annotations import asyncio import unittest from datetime import datetime, timedelta, timezone +from itertools import permutations from typing import Optional from tavolo.app import game_store, platform, scheduler, tortoise_mixin from tavolo.platform import GameSession, Seat -from tavolo.platform.deadlines import encode +from tavolo.platform.deadlines import DeadlineScheduler, encode from tavolo.platform.models import Match +from tavolo.platform.registry import GameRegistry +from tavolo.platform.store import InMemoryGameStore from tavolo.scopone.state import Card, PlayerState, ScoponeState from tests.helpers import async_test @@ -27,8 +30,9 @@ def _started_session( code: str, turn_timeout: int = 3600, hand_ack_timeout: int = 3600, + game_type: str = "scopone_scientifico", ) -> GameSession: - engine = platform.registry.require("scopone_scientifico") + engine = platform.registry.require(game_type) session = GameSession( id=game_id, game_type=engine.id, @@ -39,7 +43,7 @@ def _started_session( engine.create(session, {}) session.state.hand_ack_timeout = hand_ack_timeout session.state.turn_timeout = turn_timeout - for name in PLAYERS[1:]: + for name in PLAYERS[1:engine.max_players]: engine.join(session, name, name.capitalize()) return session @@ -171,6 +175,85 @@ async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]: return session if session is not None and session.state.phase == phase else None +class PartialHandEndAckTest(unittest.TestCase): + @async_test + async def test_every_partial_ack_ordering_advances_persisted_game(self) -> None: + # Use an isolated store so the exhaustive matrix cannot affect other + # integration tests through the app's ongoing-game list or queue. + registry = GameRegistry(platform.registry.all()) + store = InMemoryGameStore(registry) + local_scheduler = DeadlineScheduler(store, registry, heartbeat_ms=60_000) + loop = asyncio.get_running_loop() + case_count = 0 + try: + for game_type in ( + "scopa", + "scopa_a_quattro", + "scopone_scientifico", + "briscola", + "briscola_a_quattro", + ): + engine = registry.require(game_type) + for ack_count in range(1, engine.max_players): + for ack_order in permutations( + range(engine.max_players), ack_count + ): + case_count += 1 + with self.subTest(game_type=game_type, ack_order=ack_order): + session = _started_session( + f"dl-partial-{case_count}", + f"DLP{case_count:03d}", + game_type=game_type, + ) + state = session.state + state.phase = "hand_end" + state.turn_deadline = None + state.hand_end_deadline = ( + datetime.now(timezone.utc) - timedelta(seconds=1) + ).isoformat() + for seat in ack_order: + engine.handle_action( + session, state.players[seat].sub, "ack", {} + ) + self.assertEqual(list(ack_order), state.acked) + + await store.save(session) + current = engine.next_deadline(session) + assert current is not None + member = encode({ + "game_id": session.id, + "kind": current.kind, + "token": current.token, + }) + # Avoid racing the background consumer with this + # direct, deterministic process_due call. + await store.add_deadline( + member, + due_at=datetime.now(timezone.utc).timestamp() + 3600, + ) + + await local_scheduler.process_due(member) + + result = await store.load(session.id) + assert result is not None + self.assertEqual("playing", result.state.phase) + self.assertEqual(2, result.state.hand_number) + self.assertEqual([], result.state.acked) + self.assertIsNone(result.state.hand_end_deadline) + following = engine.next_deadline(result) + assert following is not None + self.assertEqual("turn", following.kind) + self.assertNotIn( + member, + await store.due_deadlines( + float("inf"), limit=1000 + ), + ) + finally: + local_scheduler.stop_consumer(loop) + self.assertEqual(124, case_count) + + class ProcessDueTest(unittest.TestCase): """Direct ``process_due`` behaviour: engine revalidation and idempotency."""