"""WebSocket endpoint for live play. Clients connect to ``/ws/games/{game_id}`` using their session cookie (the OIDC login stores the user in the session, which the session mixin loads onto the websocket). Only seated players are accepted. Protocol -------- Server -> client messages are JSON objects with a ``type``: * ``state`` — the personalized game view (own hand visible, others hidden). * ``game_over`` — sent once when the match ends, with the final scores. * ``error`` — a rejected action or malformed message. Client -> server messages are JSON objects:: {"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D"} {"action": "ack"} {"action": "state"} ``capture`` lists the table cards to take and must be a legal capture when one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted when the played card cannot capture. ``ack`` acknowledges the hand-end scoring summary; the next hand is dealt when all four players have acknowledged or the timeout fires. 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 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, PHASE_PLAYING, GameState from .stats import save_match_result Send = Callable[[Dict[str, Any]], Awaitable[None]] def _error(message: str, code: str = "invalid") -> Dict[str, Any]: return {"type": "error", "code": code, "message": message} def _state_message(state: GameState, sub: str) -> Dict[str, Any]: return {"type": "state", "game": engine.state_for_player(state, sub)} @app.websocket("/ws/games/${game_id}") async def game_socket(ws: WebSocket, game_id: str) -> None: user = auth.get_ws_user(ws) if user is None: await ws.close(4401) return state = await game_store.load(game_id) if state is None: await ws.close(4404) return if not state.seated(user.sub): await ws.close(4403) return await ws.accept() send_lock = asyncio.Lock() async def send(payload: Dict[str, Any]) -> None: async with send_lock: 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( _forward(events, game_id, user.sub, send) ) try: async for message in ws: if message.kind == "close": break if message.kind != "text" or not isinstance(message.data, str): await send(_error("expected a text frame with a JSON object")) continue await _handle_message(send, game_id, user.sub, message.data) finally: forward.cancel() with suppress(asyncio.CancelledError): await forward async def _forward( events, game_id: str, sub: str, send: Send, ) -> None: async for _ in events: 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( { "type": "game_over", "scores": {"A": state.scores[0], "B": state.scores[1]}, "winner": "A" if state.winner == 0 else "B", } ) return async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None: try: data = json.loads(raw) except (ValueError, TypeError): await send(_error("invalid JSON")) return if not isinstance(data, dict): await send(_error("message must be a JSON object")) return action = data.get("action") if action == "play": await _handle_play(send, game_id, sub, data) elif action == "ack": await _handle_ack(send, game_id, sub) elif action in ("state", "sync"): state = await game_store.load(game_id) if state is not None: await send(_state_message(state, sub)) else: await send(_error(f"unknown action: {action!r}")) # --- hand-end acknowledgement ------------------------------------------------ # Running auto-continue timers, keyed by (game_id, hand_number), so a hand's # timeout is scheduled only once even when several clients are connected. _hand_end_timers: Dict[tuple, asyncio.Task] = {} async def _handle_ack(send: Send, game_id: str, sub: str) -> None: async with game_store.lock(game_id): state = await game_store.load(game_id) if state is None: await send(_error("game not found", code="not_found")) return try: engine.acknowledge_hand(state, sub) except GameError as exc: await send(_error(str(exc), code="illegal_move")) return await game_store.save(state) await game_store.publish(game_id) def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None: """Deal the next hand after the acknowledgement timeout, even if not everyone has clicked. Fizzles if the hand already advanced.""" key = (game_id, hand_number) if key in _hand_end_timers: return async def _auto_advance() -> None: try: await asyncio.sleep(timeout) async with game_store.lock(game_id): state = await game_store.load(game_id) if ( state is None or state.phase != engine.PHASE_HAND_END or state.hand_number != hand_number ): return for player in state.players: engine.acknowledge_hand(state, player.sub) await game_store.save(state) await game_store.publish(game_id) finally: _hand_end_timers.pop(key, None) _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: card = data.get("card") capture = data.get("capture") if not isinstance(card, str): await send(_error("'card' must be a card code string")) return if capture is not None and ( not isinstance(capture, list) or any(not isinstance(item, str) for item in capture) ): await send(_error("'capture' must be a list of card codes")) return async with game_store.lock(game_id): state = await game_store.load(game_id) if state is None: await send(_error("game not found", code="not_found")) return try: engine.play(state, sub, card, capture) except GameError as exc: await send(_error(str(exc), code="illegal_move")) return except ValueError: await send(_error("invalid card code", code="illegal_move")) return await _after_play(state, game_id)