Fix hand-end timeouts after partial acknowledgements

This commit is contained in:
woggioni committed 2026-10-25 13:22:17 +00:00
1 parent 71493f720b
commit 565e927a9b
4 files changed
+93 -4

No files matched your search

@@ -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",
@@ -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",
@@ -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",
+87 -4
View File
@@ -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."""