Auto-play a random legal card when the turn timeout expires

This commit is contained in:
2026-09-16 11:31:06 +00:00
parent 047f43fa21
commit 550bd3aefd
14 changed files with 285 additions and 8 deletions
+43
View File
@@ -64,6 +64,11 @@ PLAYERS = 4
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# legal card for them. Games carry their own copy in
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS).
DEFAULT_TURN_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the
# remaining ranks in descending order. All of 8/9/10 are worth 10.
PRIMIERA_VALUES: Dict[int, int] = {
@@ -124,6 +129,7 @@ def create_game(
creator_name: str,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> GameState:
"""Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100:
@@ -136,6 +142,7 @@ def create_game(
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
created_at=datetime.now(timezone.utc).isoformat(),
)
@@ -162,6 +169,12 @@ def start_game(state: GameState) -> None:
_deal_hand(state)
def _set_turn_deadline(state: GameState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _deal_hand(state: GameState) -> None:
deck = shuffled_deck()
for player in state.players:
@@ -173,6 +186,7 @@ def _deal_hand(state: GameState) -> None:
# Dealer rotates each hand; the first card is played by the player to
# the dealer's left.
state.turn = (state.dealer + 1) % PLAYERS
_set_turn_deadline(state)
for offset in range(HAND_SIZE):
for seat in range(PLAYERS):
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
@@ -254,6 +268,7 @@ def play(
_end_hand(state)
else:
state.turn = (state.turn + 1) % PLAYERS
_set_turn_deadline(state)
def _match_option(
@@ -269,6 +284,32 @@ def _match_option(
return None
def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
"""Play a random legal move for the player currently on turn.
A card is drawn at random from that player's hand; if it can capture,
one of the legal captures is chosen at random (the rules require a
capture when one exists). Delegates to :func:`play`, so the move is
fully validated and can end the hand or the match. Pass ``rng`` for
deterministic tests.
"""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
options = legal_captures(state.table, card)
capture = chooser.choice(options) if options else None
play(
state,
player.sub,
card.code,
[c.code for c in capture] if capture else None,
)
def _end_hand(state: GameState) -> None:
"""Sweep the table and score the hand.
@@ -278,6 +319,7 @@ def _end_hand(state: GameState) -> None:
the hand-end timeout in the websocket layer). If the match is over the
game goes to ``finished`` immediately.
"""
state.turn_deadline = None
if state.table and state.last_taker is not None:
taker = _player_at(state, state.last_taker)
taker.captured.extend(state.table)
@@ -444,6 +486,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
"last_move": state.last_move.to_json() if state.last_move else None,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
}
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
payload["your_turn"] = True
+8
View File
@@ -172,6 +172,10 @@ class GameState:
hand_end_deadline: Optional[str] = None
# Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30
# While phase == "playing": when the server plays a random legal card
# for the player on turn. Copied from settings at creation.
turn_deadline: Optional[str] = None
turn_timeout: int = 30
# -- serialization ----------------------------------------------------
@@ -198,6 +202,8 @@ class GameState:
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
@@ -224,6 +230,8 @@ class GameState:
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(data.get("turn_timeout", 30)),
)
# -- helpers ----------------------------------------------------------