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
+193
View File
@@ -0,0 +1,193 @@
# scopa
A multiplayer backend for **scopone scientifico** (the four-player,
fixed-partnership variant of the classic Italian card game), built on the
[kaya](../kaya) framework.
Players authenticate with the configured **OIDC** provider. Live game state
is kept in **Redis** (with real-time play over WebSocket), and completed
matches — every participant and the final score — are persisted to
**Postgres** for history and leaderboard statistics.
## Quick start (Docker Compose)
```sh
# From the repository root (docker-compose.yml lives there):
docker compose up --build
```
The stack starts Postgres, Redis, a mock OIDC provider
(navikt/mock-oauth2-server) with four ready-made players
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service,
and the app itself on `http://127.0.0.1:8080`. The same container also
serves the Sycamore/WASM frontend (built from `../web/` by the Docker
image), so the UI is available at that address.
Because the browser and the app both talk to the OIDC issuer at
`http://mockoauth:8180/scopa`, add a host entry once:
```sh
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
```
Then log in: `GET http://localhost:8080/auth/login`, type any username
(e.g. `alice`) in the interactive login, and you are redirected back with a
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
## Configuration
All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `postgres://scopa:scopa@localhost:5432/scopa` | Postgres DSN for match statistics |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_ISSUER` | `http://localhost:8180/scopa` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `scopa` | OIDC client id |
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Data model
### Redis (live games)
- `scopa:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
scores, phase (`lobby``playing``finished`). Sliding TTL
(`GAME_TTL_SECONDS`).
- `scopa:code:<JOINCODE>` — the 6-character join code → game id index.
- `scopa:game:<uuid>:lock` — a short-lived lock serializing every mutation.
- `scopa:game:<uuid>:events` — a pub/sub channel carrying "state changed"
signals; every open WebSocket reloads the state and pushes the
personalized view to its player.
### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: both teams' final scores, winner,
target score, hands played, start/finish timestamps.
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team and whether they won. Unique per `(match, user_sub)`.
When a match ends, the result is written transactionally to Postgres
(once, guarded by a flag on the Redis state); the finished state stays in
Redis until its TTL expires so clients can still fetch the final board.
## REST API
All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json`
require authentication.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. Returns `{id, join_code}` |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player |
## WebSocket protocol
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; unauthenticated, unknown-game and non-player
connections are closed with codes `4401`, `4404` and `4403` respectively.
Server → client messages are JSON objects with a `type`:
- `{"type": "state", "game": {...}}` — personalized game view. Your own
hand is included; other players expose only their card counts.
`your_turn: true` is present when it is your move.
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages:
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "state"}
```
- `card` is the card you play, rendered as `RRSUIT` (`01A`..`10D`; suits
`D` denari, `C` coppe, `S` spade, `B` bastoni — e.g. `07D` is the
settebello).
- `capture` lists the table cards to take. When a capture is legal it is
mandatory to provide one; when no capture exists it must be omitted.
- `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
## Rules implemented
- 40-card Italian deck, ten cards per player, empty table at hand start.
- A card captures a **single card of equal rank** (mandatory when present)
or a **combination of table cards whose ranks sum to its own**.
- Emptying the table scores a *scopa* (+1), except on the last play of a
hand. Remaining table cards go to the last player who captured.
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
required), plus one point per scopa. Ties award nothing.
- The match ends when a team reaches the target score (default 11,
configurable per game) with a clear lead; a tie at or above the target is
broken by another hand.
## Development
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e '.[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/mypy src
```
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
`httpx-ws` ASGI transports for HTTP and WebSocket coverage.
### Migrations
The Postgres schema is owned by aerich migrations in `migrations/`. The
`db-migrate` compose service runs `aerich upgrade` before the app starts.
To add a migration after changing `src/scopa/models.py`:
```sh
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich upgrade
```
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing
baseline.)
## Layout
Everything lives under `server/`:
```
src/scopa/
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
├── config.py # env -> frozen Settings
├── auth.py # auth helpers (HTTP + WebSocket)
├── http.py # JSON request/response helpers
├── pagination.py # keyset (cursor) pagination
├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
├── aerich_config.py # aerich CLI configuration
├── models.py # Match, MatchPlayer (Postgres)
├── stats.py # finished match -> Postgres persistence
├── store.py # Redis / in-memory live-game store
├── ws.py # WebSocket live-play endpoint
├── game/
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
│ ├── engine.py # pure scopone scientifico rules
│ └── errors.py # typed rule violations
└── routes/
├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot
└── stats.py # match history + leaderboard
```