Make an absent chiamata bidder pass instead of bidding randomly
CI / Build and push docker image (push) Successful in 1m37s

The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
This commit is contained in:
woggioni committed 2026-10-09 06:51:43 +08:00
1 parent 322ca27bf4
commit 0f9fb9b6bb
7 files changed
+121 -38

No files matched your search

+4 -3
View File
@@ -56,9 +56,10 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (choosing among the legal captures when the random legal card for them (choosing among the legal captures when the
rules require a capture), so one absent player cannot stall the table. rules require a capture), so one absent player cannot stall the table.
Briscola chiamata arms the same countdown for its auction bids and for Briscola chiamata arms the same countdown for its auction turns and for
the auction winner's called-card choice (auto-bid or auto-pass, then a the auction winner's called-card choice: an absent bidder passes, the
random suit of the won rank). last player standing with no bid is made to bid the ace, and a timed-out
call names a random suit of the won rank.
## Watching ## Watching
+7 -6
View File
@@ -375,9 +375,9 @@ timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines` broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue (see above), not from timers tied to client connections, so the queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected. Briscola match keeps progressing even with every player disconnected. Briscola
chiamata uses the same timeout for its auction bids (auto-bid or chiamata uses the same timeout for its auction turns (an absent bidder
auto-pass) and for the called-card choice (a random suit of the won passes; the last player standing with no bid is made to bid the ace) and
rank). for the called-card choice (a random suit of the won rank).
### Hand-end summary ### Hand-end summary
@@ -460,9 +460,10 @@ player must dismiss. A play attempted in this phase is rejected with an
replayed with the next dealer. A match is a single deal, so the caller replayed with the next dealer. A match is a single deal, so the caller
and partner (or the lone caller) against the other players map onto and partner (or the lone caller) against the other players map onto
the platform's two-team result, Elo and history. the platform's two-team result, Elo and history.
- Auction bids, the called-card choice and the plays all have their own - Auction turns, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid, `TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
auto-called or auto-played for). player standing with no bid is made to bid the ace, an absent winner's
call names a random suit of the won rank, and plays are auto-played.
## Development ## Development
+5 -3
View File
@@ -41,9 +41,11 @@ Three variants share one rules core:
Timeouts are constructor arguments (`turn_timeout_seconds`, Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the `hand_ack_timeout_seconds`), wired from the environment by the
application composition root. Briscola chiamata uses the turn timeout application composition root. Briscola chiamata uses the turn timeout
for its auction bids, the called-card choice and the plays; it never for its auction turns, the called-card choice and the plays: an absent
enters the between-deals acknowledgement phase (a single deal ends the bidder passes, the last player standing with no bid is made to bid the
match), so the hand-ack timeout is accepted but unused. ace, and an absent winner's call names a random suit of the won rank. It
never enters the between-deals acknowledgement phase (a single deal ends
the match), so the hand-ack timeout is accepted but unused.
## Rules ## Rules
@@ -797,12 +797,13 @@ def _end_deal(state: ChiamataState) -> None:
# -- server-side auto actions --------------------------------------------------- # -- server-side auto actions ---------------------------------------------------
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None: def auto_bid(state: ChiamataState) -> None:
"""Act for the bidder on turn: bid a random legal rank, else pass. """Act for the bidder on turn: always pass, unless forced to bid.
The only player who cannot afford to pass — the last one standing An absent player never outbids anyone: they leave the auction. Only
before any bid — always has every rank available, so a bid is always the last player standing before any bid cannot pass — they are made
possible there. to bid the ace, whose suit :func:`auto_call` picks at random if the
call itself times out too.
""" """
if state.phase != PHASE_AUCTION: if state.phase != PHASE_AUCTION:
raise GameNotStarted("the auction is not open") raise GameNotStarted("the auction is not open")
@@ -810,10 +811,8 @@ def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
if auction is None: # pragma: no cover - guarded by phase if auction is None: # pragma: no cover - guarded by phase
raise GameNotStarted("the auction is not open") raise GameNotStarted("the auction is not open")
player = _player_at(state, auction.turn) player = _player_at(state, auction.turn)
ranks = legal_bid_ranks(state) if len(auction.active) == 1 and auction.current is None:
if ranks: bid_rank(state, player.sub, 1)
chooser = rng or _rng
bid_rank(state, player.sub, chooser.choice(ranks))
return return
pass_auction(state, player.sub) pass_auction(state, player.sub)
@@ -485,10 +485,18 @@ class BriscolaChiamataEngine(GameEngine):
state: ChiamataState = session.state state: ChiamataState = session.state
if kind == "auction": if kind == "auction":
seat = state.turn seat = state.turn
auction = state.auction
passed_before = len(auction.passed) if auction is not None else 0
chiamata.auto_bid(state) chiamata.auto_bid(state)
action = (
"auto-passed"
if auction is not None and len(auction.passed) > passed_before
else "forced to bid the ace"
)
log.info( log.info(
"auto-bid for seat %s (auction timeout, deal %d)", "seat %s %s (auction timeout, deal %d)",
seat, seat,
action,
state.hand_number, state.hand_number,
) )
elif kind == "call": elif kind == "call":
@@ -240,18 +240,43 @@ class AuctionTest(unittest.TestCase):
self.assertEqual(63, view["legal_bids"]["points_min"]) self.assertEqual(63, view["legal_bids"]["points_min"])
self.assertEqual(120, view["legal_bids"]["points_max"]) self.assertEqual(120, view["legal_bids"]["points_max"])
def test_auto_bid_bids_or_passes(self) -> None: def test_auto_bid_always_passes_when_legal(self) -> None:
state = started_state() state = started_state()
rules.auto_bid(state, random.Random(1))
assert state.auction is not None assert state.auction is not None
self.assertIsNotNone(state.auction.current) rules.auto_bid(state)
# The auction keeps moving and eventually concludes. # Seat 1 passes: nobody has bid and the turn moves on.
for _ in range(50): self.assertEqual([1], state.auction.passed)
if state.phase != PHASE_AUCTION: self.assertIsNone(state.auction.current)
break self.assertEqual(2, state.auction.turn)
rules.auto_bid(state, random.Random(2)) # An absent bidder still passes when there is a standing bid.
rules.bid_rank(state, "p2", 10)
rules.auto_bid(state)
self.assertEqual([1, 3], state.auction.passed)
assert state.auction.current is not None
self.assertEqual(10, state.auction.current.rank)
def test_auto_bid_passes_at_the_point_stage(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 2)
rules.bid_points(state, "p2", 62)
rules.auto_bid(state) # seat 3
assert state.auction is not None
self.assertIn(3, state.auction.passed)
assert state.auction.current is not None
self.assertEqual(62, state.auction.current.points)
def test_auto_bid_forced_to_call_the_ace(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
# Seat 0 is the last one standing and nobody has bid yet: the
# forced auto-bid calls the ace.
rules.auto_bid(state)
self.assertEqual(PHASE_CALLING, state.phase) self.assertEqual(PHASE_CALLING, state.phase)
self.assertIsNotNone(state.caller) self.assertEqual(0, state.caller)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(1, state.auction.current.rank)
self.assertEqual(61, state.declared_target)
class CallTest(unittest.TestCase): class CallTest(unittest.TestCase):
@@ -543,7 +568,7 @@ class ScoringTest(unittest.TestCase):
moves = 0 moves = 0
while state.phase != PHASE_FINISHED and moves < 5000: while state.phase != PHASE_FINISHED and moves < 5000:
if state.phase == PHASE_AUCTION: if state.phase == PHASE_AUCTION:
rules.auto_bid(state, rng) rules.auto_bid(state)
elif state.phase == PHASE_CALLING: elif state.phase == PHASE_CALLING:
rules.auto_call(state, rng) rules.auto_call(state, rng)
else: else:
+53 -6
View File
@@ -241,7 +241,7 @@ class ChiamataTimeoutTest(unittest.TestCase):
return session.id return session.id
@async_test @async_test
async def test_auction_timeout_auto_bids(self) -> None: async def test_auction_timeout_auto_passes(self) -> None:
game_id = await self._seed( game_id = await self._seed(
"chiamata-auction-timeout-1", "CH0001", lambda state: None "chiamata-auction-timeout-1", "CH0001", lambda state: None
) )
@@ -263,15 +263,62 @@ class ChiamataTimeoutTest(unittest.TestCase):
break break
if ( if (
update.get("type") == "state" update.get("type") == "state"
and update["game"]["auction"]["current"] is not None and 1 in update["game"]["auction"]["passed"]
): ):
break break
self.assertIsNotNone(update) self.assertIsNotNone(update)
assert update is not None assert update is not None
# The absent seat 1 was auto-bid for. # The absent seat 1 passed: nobody bid and the turn
current = update["game"]["auction"]["current"] # moved to seat 2.
self.assertEqual(1, current["seat"]) auction = update["game"]["auction"]
self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)) self.assertIn(1, auction["passed"])
self.assertIsNone(auction["current"])
self.assertEqual(2, auction["turn"])
@async_test
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
def seed(state) -> None:
state.phase = PHASE_AUCTION
state.auction = Auction(
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
)
state.turn = 0
state.auction_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
state.call_deadline = None
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
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:
first = await ws.receive_json()
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
# The auction times out: seat 0 is forced to bid the
# ace; its call then times out too and a random ace
# suit is named.
played = None
for _ in range(20):
try:
played = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
played.get("type") == "state"
and played["game"]["phase"] == PHASE_PLAYING
):
break
self.assertIsNotNone(played)
assert played is not None
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
self.assertEqual(0, played["game"]["caller"])
card = played["game"]["called_card"]
self.assertIsNotNone(card)
self.assertTrue(card.startswith("01"))
@async_test @async_test
async def test_call_and_turn_timeouts(self) -> None: async def test_call_and_turn_timeouts(self) -> None: