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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user