Add Sycamore/WASM frontend and restructure into server/ + web/

Repo is now a monorepo:

- server/: the kaya backend, unchanged in behaviour, plus:
  - GET /api/me for SPA session detection
  - last_move recorded on every play and broadcast in the game state, so
    clients can show who played which card the moment they play it
  - legal_moves per hand card for the player on turn (rules stay
    server-side)
  - static catch-all route serving the compiled SPA with index.html
    fallback; Tortoise context now bound only for /api/* requests
  - configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
  lobby (create match / join by code), live game page over websocket with
  card images (CC0 woodcut napoletane deck), capture picker, move banner,
  game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
  app image serves the SPA; compose builds from the repo root with
  overridable ports/OIDC env

Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
This commit is contained in:
2026-09-16 13:20:05 +08:00
parent e9ddb82e9a
commit 96a95d74b6
104 changed files with 151484 additions and 236 deletions
+175
View File
@@ -0,0 +1,175 @@
"""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": "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.
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).
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
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, 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))
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
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 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}"))
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
if state.phase == PHASE_FINISHED:
await save_match_result(state)
await game_store.save(state)
await game_store.publish(game_id)