Fix hand-end timeouts after partial acknowledgements
This commit is contained in:
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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user