Make an absent chiamata bidder pass instead of bidding randomly

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:
2026-10-08 22:47:32 +00:00
parent c746a88cfb
commit e0030ee94a
7 changed files with 121 additions and 38 deletions
+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
random legal card for them (choosing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
Briscola chiamata arms the same countdown for its auction bids and for
the auction winner's called-card choice (auto-bid or auto-pass, then a
random suit of the won rank).
Briscola chiamata arms the same countdown for its auction turns and for
the auction winner's called-card choice: an absent bidder passes, the
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
+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`
queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected. Briscola
chiamata uses the same timeout for its auction bids (auto-bid or
auto-pass) and for the called-card choice (a random suit of the won
rank).
chiamata uses the same timeout for its auction turns (an absent bidder
passes; the last player standing with no bid is made to bid the ace) and
for the called-card choice (a random suit of the won rank).
### 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
and partner (or the lone caller) against the other players map onto
the platform's two-team result, Elo and history.
- Auction bids, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid,
auto-called or auto-played for).
- Auction turns, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
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
+5 -3
View File
@@ -41,9 +41,11 @@ Three variants share one rules core:
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root. Briscola chiamata uses the turn timeout
for its auction bids, the called-card choice and the plays; it never
enters the between-deals acknowledgement phase (a single deal ends the
match), so the hand-ack timeout is accepted but unused.
for its auction turns, the called-card choice and the plays: an absent
bidder passes, the last player standing with no bid is made to bid the
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
@@ -797,12 +797,13 @@ def _end_deal(state: ChiamataState) -> None:
# -- server-side auto actions ---------------------------------------------------
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
"""Act for the bidder on turn: bid a random legal rank, else pass.
def auto_bid(state: ChiamataState) -> None:
"""Act for the bidder on turn: always pass, unless forced to bid.
The only player who cannot afford to pass — the last one standing
before any bid — always has every rank available, so a bid is always
possible there.
An absent player never outbids anyone: they leave the auction. Only
the last player standing before any bid cannot pass — they are made
to bid the ace, whose suit :func:`auto_call` picks at random if the
call itself times out too.
"""
if state.phase != PHASE_AUCTION:
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
raise GameNotStarted("the auction is not open")
player = _player_at(state, auction.turn)
ranks = legal_bid_ranks(state)
if ranks:
chooser = rng or _rng
bid_rank(state, player.sub, chooser.choice(ranks))
if len(auction.active) == 1 and auction.current is None:
bid_rank(state, player.sub, 1)
return
pass_auction(state, player.sub)
@@ -485,10 +485,18 @@ class BriscolaChiamataEngine(GameEngine):
state: ChiamataState = session.state
if kind == "auction":
seat = state.turn
auction = state.auction
passed_before = len(auction.passed) if auction is not None else 0
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(
"auto-bid for seat %s (auction timeout, deal %d)",
"seat %s %s (auction timeout, deal %d)",
seat,
action,
state.hand_number,
)
elif kind == "call":
@@ -240,18 +240,43 @@ class AuctionTest(unittest.TestCase):
self.assertEqual(63, view["legal_bids"]["points_min"])
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()
rules.auto_bid(state, random.Random(1))
assert state.auction is not None
self.assertIsNotNone(state.auction.current)
# The auction keeps moving and eventually concludes.
for _ in range(50):
if state.phase != PHASE_AUCTION:
break
rules.auto_bid(state, random.Random(2))
rules.auto_bid(state)
# Seat 1 passes: nobody has bid and the turn moves on.
self.assertEqual([1], state.auction.passed)
self.assertIsNone(state.auction.current)
self.assertEqual(2, state.auction.turn)
# 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.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):
@@ -543,7 +568,7 @@ class ScoringTest(unittest.TestCase):
moves = 0
while state.phase != PHASE_FINISHED and moves < 5000:
if state.phase == PHASE_AUCTION:
rules.auto_bid(state, rng)
rules.auto_bid(state)
elif state.phase == PHASE_CALLING:
rules.auto_call(state, rng)
else:
+53 -6
View File
@@ -241,7 +241,7 @@ class ChiamataTimeoutTest(unittest.TestCase):
return session.id
@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(
"chiamata-auction-timeout-1", "CH0001", lambda state: None
)
@@ -263,15 +263,62 @@ class ChiamataTimeoutTest(unittest.TestCase):
break
if (
update.get("type") == "state"
and update["game"]["auction"]["current"] is not None
and 1 in update["game"]["auction"]["passed"]
):
break
self.assertIsNotNone(update)
assert update is not None
# The absent seat 1 was auto-bid for.
current = update["game"]["auction"]["current"]
self.assertEqual(1, current["seat"])
self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2))
# The absent seat 1 passed: nobody bid and the turn
# moved to seat 2.
auction = update["game"]["auction"]
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 def test_call_and_turn_timeouts(self) -> None: