Auto-play a random legal card when the turn timeout expires
This commit is contained in:
@@ -37,6 +37,13 @@ must click "Understood" before the next hand is dealt. If someone is away
|
|||||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||||
(default 30s). The match-ending hand is explained on the final screen.
|
(default 30s). The match-ending hand is explained on the final screen.
|
||||||
|
|
||||||
|
## On your turn
|
||||||
|
|
||||||
|
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 (randomizing among the legal captures when the
|
||||||
|
rules require a capture), so one absent player cannot stall the table.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`):
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ services:
|
|||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||||
|
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ GAME_TTL_SECONDS=86400
|
|||||||
# before dealing the next hand anyway.
|
# before dealing the next hand anyway.
|
||||||
HAND_ACK_TIMEOUT_SECONDS=30
|
HAND_ACK_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
# Seconds a player has to play before the server plays a random legal card
|
||||||
|
# for them (covers disconnects and idle players).
|
||||||
|
TURN_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
# App server
|
# App server
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
||||||
|
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
||||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
@@ -126,6 +127,15 @@ Client → server messages:
|
|||||||
|
|
||||||
After every accepted move the new state is broadcast to all four players.
|
After every accepted move the new state is broadcast to all four players.
|
||||||
|
|
||||||
|
### Turn timeout
|
||||||
|
|
||||||
|
The state carries a `turn_deadline` while a hand is being played. If the
|
||||||
|
player on turn does not move before it, the server plays a random legal
|
||||||
|
card for them (picking one of the legal captures at random when a capture
|
||||||
|
is required), so a disconnected or idle player cannot stall the match. The
|
||||||
|
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||||
|
broadcast like any other.
|
||||||
|
|
||||||
### Hand-end summary
|
### Hand-end summary
|
||||||
|
|
||||||
When a hand finishes but the match continues, the game enters the
|
When a hand finishes but the match continues, the game enters the
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ class Settings:
|
|||||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
# before dealing the next hand anyway.
|
# before dealing the next hand anyway.
|
||||||
hand_ack_timeout_seconds: int
|
hand_ack_timeout_seconds: int
|
||||||
|
# Seconds a player has to play before the server plays a random legal
|
||||||
|
# card for them (covering disconnects and idle players).
|
||||||
|
turn_timeout_seconds: int
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
@@ -63,6 +66,7 @@ class Settings:
|
|||||||
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||||
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ PLAYERS = 4
|
|||||||
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
||||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
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
|
# 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.
|
# remaining ranks in descending order. All of 8/9/10 are worth 10.
|
||||||
PRIMIERA_VALUES: Dict[int, int] = {
|
PRIMIERA_VALUES: Dict[int, int] = {
|
||||||
@@ -124,6 +129,7 @@ def create_game(
|
|||||||
creator_name: str,
|
creator_name: str,
|
||||||
target_score: int = DEFAULT_TARGET_SCORE,
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
) -> GameState:
|
) -> GameState:
|
||||||
"""Create a lobby game with the creator seated first."""
|
"""Create a lobby game with the creator seated first."""
|
||||||
if target_score < 1 or target_score > 100:
|
if target_score < 1 or target_score > 100:
|
||||||
@@ -136,6 +142,7 @@ def create_game(
|
|||||||
phase=PHASE_LOBBY,
|
phase=PHASE_LOBBY,
|
||||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
hand_ack_timeout=hand_ack_timeout,
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
|
turn_timeout=turn_timeout,
|
||||||
created_at=datetime.now(timezone.utc).isoformat(),
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +169,12 @@ def start_game(state: GameState) -> None:
|
|||||||
_deal_hand(state)
|
_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:
|
def _deal_hand(state: GameState) -> None:
|
||||||
deck = shuffled_deck()
|
deck = shuffled_deck()
|
||||||
for player in state.players:
|
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
|
# Dealer rotates each hand; the first card is played by the player to
|
||||||
# the dealer's left.
|
# the dealer's left.
|
||||||
state.turn = (state.dealer + 1) % PLAYERS
|
state.turn = (state.dealer + 1) % PLAYERS
|
||||||
|
_set_turn_deadline(state)
|
||||||
for offset in range(HAND_SIZE):
|
for offset in range(HAND_SIZE):
|
||||||
for seat in range(PLAYERS):
|
for seat in range(PLAYERS):
|
||||||
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
||||||
@@ -254,6 +268,7 @@ def play(
|
|||||||
_end_hand(state)
|
_end_hand(state)
|
||||||
else:
|
else:
|
||||||
state.turn = (state.turn + 1) % PLAYERS
|
state.turn = (state.turn + 1) % PLAYERS
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
def _match_option(
|
def _match_option(
|
||||||
@@ -269,6 +284,32 @@ def _match_option(
|
|||||||
return None
|
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:
|
def _end_hand(state: GameState) -> None:
|
||||||
"""Sweep the table and score the hand.
|
"""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
|
the hand-end timeout in the websocket layer). If the match is over the
|
||||||
game goes to ``finished`` immediately.
|
game goes to ``finished`` immediately.
|
||||||
"""
|
"""
|
||||||
|
state.turn_deadline = None
|
||||||
if state.table and state.last_taker is not None:
|
if state.table and state.last_taker is not None:
|
||||||
taker = _player_at(state, state.last_taker)
|
taker = _player_at(state, state.last_taker)
|
||||||
taker.captured.extend(state.table)
|
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,
|
"last_move": state.last_move.to_json() if state.last_move else None,
|
||||||
"acknowledged": list(state.acked),
|
"acknowledged": list(state.acked),
|
||||||
"hand_end_deadline": state.hand_end_deadline,
|
"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:
|
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
||||||
payload["your_turn"] = True
|
payload["your_turn"] = True
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ class GameState:
|
|||||||
hand_end_deadline: Optional[str] = None
|
hand_end_deadline: Optional[str] = None
|
||||||
# Seconds the hand-end summary waits before dealing anyway.
|
# Seconds the hand-end summary waits before dealing anyway.
|
||||||
hand_ack_timeout: int = 30
|
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 ----------------------------------------------------
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
@@ -198,6 +202,8 @@ class GameState:
|
|||||||
"acked": list(self.acked),
|
"acked": list(self.acked),
|
||||||
"hand_end_deadline": self.hand_end_deadline,
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
"hand_ack_timeout": self.hand_ack_timeout,
|
"hand_ack_timeout": self.hand_ack_timeout,
|
||||||
|
"turn_deadline": self.turn_deadline,
|
||||||
|
"turn_timeout": self.turn_timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -224,6 +230,8 @@ class GameState:
|
|||||||
acked=[int(s) for s in data.get("acked", [])],
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
hand_end_deadline=data.get("hand_end_deadline"),
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
|
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 ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ async def create_game(ctx: HttpContext) -> None:
|
|||||||
creator_name=auth.display_name(user),
|
creator_name=auth.display_name(user),
|
||||||
target_score=target_score,
|
target_score=target_score,
|
||||||
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||||
|
turn_timeout=settings.turn_timeout_seconds,
|
||||||
)
|
)
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
await send_error(ctx, 400, str(exc))
|
await send_error(ctx, 400, str(exc))
|
||||||
|
|||||||
+81
-7
@@ -29,12 +29,19 @@ Mutations run under the per-game lock; after a successful move the new
|
|||||||
state is saved to Redis and a change signal is published. Every connected
|
state is saved to Redis and a change signal is published. Every connected
|
||||||
websocket is subscribed to that signal and re-renders the state, so all
|
websocket is subscribed to that signal and re-renders the state, so all
|
||||||
players see the move immediately (and consistently across workers).
|
players see the move immediately (and consistently across workers).
|
||||||
|
|
||||||
|
If a player does not move before the per-game ``turn_timeout``, the server
|
||||||
|
plays a random card (with a random legal capture when one is required) for
|
||||||
|
them, so a disconnected or idle player cannot stall the match. The timer is
|
||||||
|
re-armed by every client connection and state broadcast, and fires
|
||||||
|
immediately when a reconnect finds the deadline already past.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
from kaya.core import WebSocket
|
from kaya.core import WebSocket
|
||||||
@@ -43,7 +50,7 @@ from . import auth
|
|||||||
from .app import app, game_store
|
from .app import app, game_store
|
||||||
from .game import engine
|
from .game import engine
|
||||||
from .game.errors import GameError
|
from .game.errors import GameError
|
||||||
from .game.state import PHASE_FINISHED, PHASE_HAND_END, GameState
|
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
||||||
from .stats import save_match_result
|
from .stats import save_match_result
|
||||||
|
|
||||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||||
@@ -81,6 +88,7 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
|
|||||||
await ws.send_text(json.dumps(payload))
|
await ws.send_text(json.dumps(payload))
|
||||||
|
|
||||||
await send(_state_message(state, user.sub))
|
await send(_state_message(state, user.sub))
|
||||||
|
schedule_turn_timer(game_id, state)
|
||||||
|
|
||||||
async with game_store.subscribe(game_id) as events:
|
async with game_store.subscribe(game_id) as events:
|
||||||
forward = asyncio.create_task(
|
forward = asyncio.create_task(
|
||||||
@@ -110,6 +118,7 @@ async def _forward(
|
|||||||
state = await game_store.load(game_id)
|
state = await game_store.load(game_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
|
schedule_turn_timer(game_id, state)
|
||||||
await send(_state_message(state, sub))
|
await send(_state_message(state, sub))
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
await send(
|
await send(
|
||||||
@@ -195,6 +204,76 @@ def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> Non
|
|||||||
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
|
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
|
||||||
|
|
||||||
|
|
||||||
|
# --- auto-play on turn timeout ------------------------------------------------
|
||||||
|
|
||||||
|
# Running turn timers, keyed by (game_id, hand_number, turn, deadline), so a
|
||||||
|
# turn's timeout is scheduled only once even when several clients are
|
||||||
|
# connected. Including the deadline means a re-arm after a reconnect cannot
|
||||||
|
# duplicate a timer for a turn that was already auto-played.
|
||||||
|
_turn_timers: Dict[tuple, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_turn_timer(game_id: str, state: GameState) -> None:
|
||||||
|
"""Auto-play a random legal card if the player on turn misses the
|
||||||
|
deadline. Fizzles if the turn already advanced."""
|
||||||
|
if state.phase != PHASE_PLAYING or not state.turn_deadline:
|
||||||
|
return
|
||||||
|
key = (game_id, state.hand_number, state.turn, state.turn_deadline)
|
||||||
|
if key in _turn_timers:
|
||||||
|
return
|
||||||
|
|
||||||
|
hand_number = state.hand_number
|
||||||
|
turn = state.turn
|
||||||
|
deadline_raw = state.turn_deadline
|
||||||
|
try:
|
||||||
|
deadline = datetime.fromisoformat(deadline_raw)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _auto_play() -> None:
|
||||||
|
try:
|
||||||
|
delay = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||||
|
await asyncio.sleep(max(delay, 0))
|
||||||
|
async with game_store.lock(game_id):
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if (
|
||||||
|
state is None
|
||||||
|
or state.phase != PHASE_PLAYING
|
||||||
|
or state.hand_number != hand_number
|
||||||
|
or state.turn != turn
|
||||||
|
or state.turn_deadline != deadline_raw
|
||||||
|
):
|
||||||
|
# The turn moved on (or the game ended) without this
|
||||||
|
# timer firing: make sure the current turn is armed.
|
||||||
|
if state is not None:
|
||||||
|
schedule_turn_timer(game_id, state)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
engine.auto_play(state)
|
||||||
|
except GameError:
|
||||||
|
return
|
||||||
|
await _after_play(state, game_id)
|
||||||
|
finally:
|
||||||
|
_turn_timers.pop(key, None)
|
||||||
|
|
||||||
|
_turn_timers[key] = asyncio.create_task(_auto_play())
|
||||||
|
|
||||||
|
|
||||||
|
async def _after_play(state: GameState, game_id: str) -> None:
|
||||||
|
"""Persist a successful move and notify every connected player.
|
||||||
|
|
||||||
|
Callers must hold the per-game lock. Handles the two terminal
|
||||||
|
transitions: the match result is written to Postgres once, and a
|
||||||
|
hand-end summary schedules the auto-continue timeout.
|
||||||
|
"""
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
await save_match_result(state)
|
||||||
|
elif state.phase == PHASE_HAND_END:
|
||||||
|
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
||||||
|
await game_store.save(state)
|
||||||
|
await game_store.publish(game_id)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_play(
|
async def _handle_play(
|
||||||
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -224,9 +303,4 @@ async def _handle_play(
|
|||||||
await send(_error("invalid card code", code="illegal_move"))
|
await send(_error("invalid card code", code="illegal_move"))
|
||||||
return
|
return
|
||||||
|
|
||||||
if state.phase == PHASE_FINISHED:
|
await _after_play(state, game_id)
|
||||||
await save_match_result(state)
|
|
||||||
elif state.phase == PHASE_HAND_END:
|
|
||||||
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
|
||||||
await game_store.save(state)
|
|
||||||
await game_store.publish(game_id)
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from scopa.game import engine
|
from scopa.game import engine
|
||||||
from scopa.game.errors import (
|
from scopa.game.errors import (
|
||||||
CardNotInHand,
|
CardNotInHand,
|
||||||
GameFinished,
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
IllegalMove,
|
IllegalMove,
|
||||||
NotYourTurn,
|
NotYourTurn,
|
||||||
)
|
)
|
||||||
@@ -390,5 +392,59 @@ class HandEndAckTest(unittest.TestCase):
|
|||||||
self.assertIn("award", view["last_hand"])
|
self.assertIn("award", view["last_hand"])
|
||||||
|
|
||||||
|
|
||||||
|
class AutoPlayTest(unittest.TestCase):
|
||||||
|
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
|
||||||
|
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["09B"])
|
||||||
|
state.turn_deadline = "2000-01-01T00:00:00+00:00"
|
||||||
|
engine.auto_play(state, random.Random(7))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertEqual(1, len(state.players[0].hand))
|
||||||
|
# The played card could not capture the nine, so the table grew.
|
||||||
|
self.assertEqual(2, len(state.table))
|
||||||
|
self.assertIsNotNone(state.last_move)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual(0, state.last_move.seat)
|
||||||
|
self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline)
|
||||||
|
|
||||||
|
def test_auto_play_takes_a_mandatory_capture(self) -> None:
|
||||||
|
# p0 holds only the five of denari, which must capture the equal
|
||||||
|
# five of coppe instead of the unrelated nine on the table.
|
||||||
|
state = make_state([["05D"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["05C", "09B"])
|
||||||
|
engine.auto_play(state)
|
||||||
|
self.assertIsNotNone(state.last_move)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual("05D", state.last_move.card)
|
||||||
|
self.assertEqual(["05C"], state.last_move.captured)
|
||||||
|
self.assertEqual(["09B"], [c.code for c in state.table])
|
||||||
|
self.assertEqual(["05C", "05D"],
|
||||||
|
[c.code for c in state.players[0].captured])
|
||||||
|
|
||||||
|
def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None:
|
||||||
|
state = make_state([["02D"], [], [], []], table=["02C"])
|
||||||
|
state.turn_deadline = "2000-01-01T00:00:00+00:00"
|
||||||
|
engine.auto_play(state)
|
||||||
|
self.assertEqual("hand_end", state.phase)
|
||||||
|
self.assertIsNone(state.turn_deadline)
|
||||||
|
self.assertTrue(state.hand_end_deadline)
|
||||||
|
|
||||||
|
def test_auto_play_requires_playing_phase(self) -> None:
|
||||||
|
state = make_state([["02D"], ["04D"], ["05D"], ["06D"]], table=[])
|
||||||
|
state.phase = "hand_end"
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.auto_play(state)
|
||||||
|
|
||||||
|
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
|
||||||
|
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
|
||||||
|
self.assertEqual(7, state.turn_timeout)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertTrue(state.turn_deadline)
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertTrue(view["turn_deadline"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -238,5 +238,50 @@ class HandEndWebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(2, update["game"]["hand_number"])
|
self.assertEqual(2, update["game"]["hand_number"])
|
||||||
|
|
||||||
|
|
||||||
|
class TurnTimeoutWebSocketTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||||
|
state = engine.create_game(
|
||||||
|
"turn-timeout-1", "TT0001", "alice", "Alice",
|
||||||
|
target_score=11, turn_timeout=1,
|
||||||
|
)
|
||||||
|
for name in PLAYERS[1:]:
|
||||||
|
engine.join_game(state, name, name.capitalize())
|
||||||
|
await game_store.save(state)
|
||||||
|
|
||||||
|
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/{state.id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
# Bob (seat 1) is first to act and never connects.
|
||||||
|
self.assertEqual(1, first["game"]["turn"])
|
||||||
|
deadline = first["game"]["turn_deadline"]
|
||||||
|
self.assertIsNotNone(deadline)
|
||||||
|
|
||||||
|
# Nobody plays: the timer must play a random card for Bob.
|
||||||
|
update = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
update = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
update.get("type") == "state"
|
||||||
|
and update["game"]["turn"] == 2
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(update)
|
||||||
|
assert update is not None
|
||||||
|
self.assertEqual(2, update["game"]["turn"])
|
||||||
|
self.assertEqual(1, update["game"]["last_move"]["seat"])
|
||||||
|
self.assertEqual(
|
||||||
|
9, update["game"]["players"][1]["cards_left"]
|
||||||
|
)
|
||||||
|
self.assertNotEqual(deadline, update["game"]["turn_deadline"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ pub struct GameView {
|
|||||||
/// ISO-8601 instant at which the next hand is dealt automatically.
|
/// ISO-8601 instant at which the next hand is dealt automatically.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hand_end_deadline: Option<String>,
|
pub hand_end_deadline: Option<String>,
|
||||||
|
/// ISO-8601 instant at which the server plays a random legal card for
|
||||||
|
/// the player on turn.
|
||||||
|
#[serde(default)]
|
||||||
|
pub turn_deadline: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub your_turn: Option<bool>,
|
pub your_turn: Option<bool>,
|
||||||
/// Legal captures per hand card; present only for the player on turn.
|
/// Legal captures per hand card; present only for the player on turn.
|
||||||
|
|||||||
+16
-1
@@ -139,7 +139,7 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||||
Some(g) => table_view(g, on_hand_card, selected),
|
Some(g) => table_view(g, on_hand_card, selected, now),
|
||||||
})
|
})
|
||||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||||
capture_picker(card, options, socket, capture_choice)
|
capture_picker(card, options, socket, capture_choice)
|
||||||
@@ -193,6 +193,7 @@ fn table_view(
|
|||||||
game: GameView,
|
game: GameView,
|
||||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||||
selected: Signal<Option<String>>,
|
selected: Signal<Option<String>>,
|
||||||
|
now: Signal<f64>,
|
||||||
) -> View {
|
) -> View {
|
||||||
// Own seat: the only player entry carrying a hand.
|
// Own seat: the only player entry carrying a hand.
|
||||||
let viewer_seat = game
|
let viewer_seat = game
|
||||||
@@ -218,6 +219,19 @@ fn table_view(
|
|||||||
format!("{name}'s turn")
|
format!("{name}'s turn")
|
||||||
};
|
};
|
||||||
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||||||
|
let countdown = game.turn_deadline.as_ref().map(|deadline| {
|
||||||
|
// A dynamic closure so only the ticking number re-renders.
|
||||||
|
let deadline_ms = js_sys::Date::parse(deadline);
|
||||||
|
view! {
|
||||||
|
span(class="turn-timer") {
|
||||||
|
"Auto-play in "
|
||||||
|
(move || {
|
||||||
|
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||||||
|
})
|
||||||
|
"s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
let table_cards = game
|
let table_cards = game
|
||||||
@@ -272,6 +286,7 @@ fn table_view(
|
|||||||
(target_score) ")"
|
(target_score) ")"
|
||||||
}
|
}
|
||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
|
(countdown)
|
||||||
}
|
}
|
||||||
div(class="table-grid") {
|
div(class="table-grid") {
|
||||||
(top)
|
(top)
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ table.matches td.lost {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.turn-timer {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user