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

This commit is contained in:
2026-09-16 21:46:06 +08:00
parent d9cdba33a1
commit 89cf0a4c96
14 changed files with 285 additions and 8 deletions
+7
View File
@@ -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/`):
+1
View File
@@ -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"
+4
View File
@@ -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
+10
View File
@@ -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
+4
View File
@@ -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")),
) )
+43
View File
@@ -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
+8
View File
@@ -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 ----------------------------------------------------------
+1
View File
@@ -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
View File
@@ -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)
+56
View File
@@ -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()
+45
View File
@@ -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()
+4
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -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;