Make an absent chiamata bidder pass instead of bidding randomly
CI / Build and push docker image (push) Successful in 1m37s
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:
7 files changed
+121
-38
No files matched your search
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user