From e0030ee94a724a7da4586fda54ac106c7a91a98b Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 8 Oct 2026 22:47:32 +0000 Subject: [PATCH] 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. --- README.md | 7 ++- server/README.md | 13 ++-- server/packages/tavolo-briscola/README.md | 8 ++- .../src/tavolo/briscola/chiamata.py | 17 +++--- .../src/tavolo/briscola/plugin.py | 10 +++- .../tavolo-briscola/tests/test_chiamata.py | 45 ++++++++++---- server/tests/test_briscola_chiamata.py | 59 +++++++++++++++++-- 7 files changed, 121 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 5aaaef8..70c4227 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/README.md b/server/README.md index 09d2c08..347e6fc 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/packages/tavolo-briscola/README.md b/server/packages/tavolo-briscola/README.md index d87dfd2..0b05cce 100644 --- a/server/packages/tavolo-briscola/README.md +++ b/server/packages/tavolo-briscola/README.md @@ -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 diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py b/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py index d99c914..9f06fb5 100644 --- a/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py @@ -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) diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py index 802a5e1..35ba973 100644 --- a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py @@ -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": diff --git a/server/packages/tavolo-briscola/tests/test_chiamata.py b/server/packages/tavolo-briscola/tests/test_chiamata.py index e5855f7..4f118db 100644 --- a/server/packages/tavolo-briscola/tests/test_chiamata.py +++ b/server/packages/tavolo-briscola/tests/test_chiamata.py @@ -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: diff --git a/server/tests/test_briscola_chiamata.py b/server/tests/test_briscola_chiamata.py index e1510bd..9be273e 100644 --- a/server/tests/test_briscola_chiamata.py +++ b/server/tests/test_briscola_chiamata.py @@ -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: