diff --git a/README.md b/README.md index 4c6a553..154714b 100644 --- a/README.md +++ b/README.md @@ -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` (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 Backend (from `server/`): diff --git a/docker-compose.yml b/docker-compose.yml index dbc7109..e830ee6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,6 +101,7 @@ services: OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback} REDIS_URL: redis://redis:6379/0 HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} + TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30} ports: - "127.0.0.1:${APP_PORT:-8080}:8080" diff --git a/server/.env.example b/server/.env.example index 3cf5b3c..f9a5bf6 100644 --- a/server/.env.example +++ b/server/.env.example @@ -24,6 +24,10 @@ GAME_TTL_SECONDS=86400 # before dealing the next hand anyway. 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_HOST=0.0.0.0 APP_PORT=8080 diff --git a/server/README.md b/server/README.md index 8968fa6..21d13d8 100644 --- a/server/README.md +++ b/server/README.md @@ -51,6 +51,7 @@ All configuration comes from environment variables (see `.env.example`): | `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | | `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 | +| `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 | ## Data model @@ -126,6 +127,15 @@ Client → server messages: 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 When a hand finishes but the match continues, the game enters the diff --git a/server/src/scopa/config.py b/server/src/scopa/config.py index ce15be8..7ae8e36 100644 --- a/server/src/scopa/config.py +++ b/server/src/scopa/config.py @@ -43,6 +43,9 @@ class Settings: # Seconds the between-hands scoring summary waits for acknowledgements # before dealing the next hand anyway. 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 def from_env() -> "Settings": @@ -63,6 +66,7 @@ class Settings: game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")), static_dir=_env("STATIC_DIR", "web/dist"), hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")), + turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")), ) diff --git a/server/src/scopa/game/engine.py b/server/src/scopa/game/engine.py index 2643bb8..3e2aaeb 100644 --- a/server/src/scopa/game/engine.py +++ b/server/src/scopa/game/engine.py @@ -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 diff --git a/server/src/scopa/game/state.py b/server/src/scopa/game/state.py index f8c1256..bc67988 100644 --- a/server/src/scopa/game/state.py +++ b/server/src/scopa/game/state.py @@ -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 ---------------------------------------------------------- diff --git a/server/src/scopa/routes/games.py b/server/src/scopa/routes/games.py index e12ecce..0a7de88 100644 --- a/server/src/scopa/routes/games.py +++ b/server/src/scopa/routes/games.py @@ -105,6 +105,7 @@ async def create_game(ctx: HttpContext) -> None: creator_name=auth.display_name(user), target_score=target_score, hand_ack_timeout=settings.hand_ack_timeout_seconds, + turn_timeout=settings.turn_timeout_seconds, ) except GameError as exc: await send_error(ctx, 400, str(exc)) diff --git a/server/src/scopa/ws.py b/server/src/scopa/ws.py index 4bf0aed..d6aa2fc 100644 --- a/server/src/scopa/ws.py +++ b/server/src/scopa/ws.py @@ -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 websocket is subscribed to that signal and re-renders the state, so all 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 import asyncio import json from contextlib import suppress +from datetime import datetime, timezone from typing import Any, Awaitable, Callable, Dict, Optional from kaya.core import WebSocket @@ -43,7 +50,7 @@ from . import auth from .app import app, game_store from .game import engine 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 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 send(_state_message(state, user.sub)) + schedule_turn_timer(game_id, state) async with game_store.subscribe(game_id) as events: forward = asyncio.create_task( @@ -110,6 +118,7 @@ async def _forward( state = await game_store.load(game_id) if state is None: return + schedule_turn_timer(game_id, state) await send(_state_message(state, sub)) if state.phase == PHASE_FINISHED: 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()) +# --- 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( send: Send, game_id: str, sub: str, data: Dict[str, Any] ) -> None: @@ -224,9 +303,4 @@ async def _handle_play( await send(_error("invalid card code", code="illegal_move")) return - 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) + await _after_play(state, game_id) diff --git a/server/tests/test_engine.py b/server/tests/test_engine.py index 034999f..7bf5a6e 100644 --- a/server/tests/test_engine.py +++ b/server/tests/test_engine.py @@ -1,12 +1,14 @@ """Rule engine tests: captures, scope, scoring and full-match simulation.""" from __future__ import annotations +import random import unittest from scopa.game import engine from scopa.game.errors import ( CardNotInHand, GameFinished, + GameNotStarted, IllegalMove, NotYourTurn, ) @@ -390,5 +392,59 @@ class HandEndAckTest(unittest.TestCase): 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__": unittest.main() diff --git a/server/tests/test_websocket.py b/server/tests/test_websocket.py index 817d485..2178a58 100644 --- a/server/tests/test_websocket.py +++ b/server/tests/test_websocket.py @@ -238,5 +238,50 @@ class HandEndWebSocketTest(unittest.TestCase): 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__": unittest.main() diff --git a/web/src/model.rs b/web/src/model.rs index b6fb717..4f6ebbe 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -132,6 +132,10 @@ pub struct GameView { /// ISO-8601 instant at which the next hand is dealt automatically. #[serde(default)] pub hand_end_deadline: Option, + /// ISO-8601 instant at which the server plays a random legal card for + /// the player on turn. + #[serde(default)] + pub turn_deadline: Option, #[serde(default)] pub your_turn: Option, /// Legal captures per hand card; present only for the player on turn. diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 5c5b4ca..3450007 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -139,7 +139,7 @@ pub fn GamePage(id: String) -> View { } } 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)| { capture_picker(card, options, socket, capture_choice) @@ -193,6 +193,7 @@ fn table_view( game: GameView, on_hand_card: impl Fn(String) + Copy + 'static, selected: Signal>, + now: Signal, ) -> View { // Own seat: the only player entry carrying a hand. let viewer_seat = game @@ -218,6 +219,19 @@ fn table_view( format!("{name}'s turn") }; 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 table_cards = game @@ -272,6 +286,7 @@ fn table_view( (target_score) ")" } span(class=turn_cls) { (turn_note) } + (countdown) } div(class="table-grid") { (top) diff --git a/web/style.css b/web/style.css index 8e658e4..a71b72f 100644 --- a/web/style.css +++ b/web/style.css @@ -212,6 +212,11 @@ table.matches td.lost { font-weight: 700; } +.turn-timer { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + .table-wrap { display: flex; flex-direction: column;