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
+131
View File
@@ -0,0 +1,131 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from pwo import async_test
from scopa.app import app
from tests.helpers import make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave")
class WebSocketTest(unittest.TestCase):
async def _started_game(self, client: AsyncClient) -> dict:
"""Create a game and seat four players; return the playing state."""
with oidc_user("alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
response = created
for player in PLAYERS[1:]:
with oidc_user(player):
response = await client.post("/api/games/join", json={"code": code})
return response.json()
async def _bob_view(self, client: AsyncClient, game_id: str) -> dict:
with oidc_user("bob"):
return (await client.get(f"/api/games/{game_id}")).json()
@async_test
async def test_move_updates_all_connections(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client)
game_id = state["id"]
bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob"), make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
self.assertEqual(1, first["game"]["turn"])
self.assertTrue(first["game"].get("your_turn"))
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
self.assertEqual("state", alice_first["type"])
self.assertEqual(
"alice", alice_first["game"]["players"][0]["sub"]
)
self.assertNotIn("hand", alice_first["game"]["players"][1])
await bob_ws.send_json(
{"action": "play", "card": bob_hand[0]}
)
bob_update = await bob_ws.receive_json()
alice_update = await alice_ws.receive_json()
for update in (bob_update, alice_update):
self.assertEqual("state", update["type"])
self.assertEqual(2, update["game"]["turn"])
self.assertEqual(1, len(update["game"]["table"]))
@async_test
async def test_illegal_move_returns_error(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client)
game_id = state["id"]
bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json(
{"action": "play", "card": bob_hand[0]}
)
await bob_ws.receive_json() # the resulting state
# Bob cannot play twice in a row.
await bob_ws.send_json(
{"action": "play", "card": bob_hand[1]}
)
error = await bob_ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("illegal_move", error["code"])
@async_test
async def test_unknown_game_is_closed(self) -> None:
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(4404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(4403, caught.exception.code)
@async_test
async def test_unauthenticated_is_closed(self) -> None:
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(4401, caught.exception.code)
if __name__ == "__main__":
unittest.main()