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.
@@ -1,21 +1,19 @@
|
|||||||
# Keep the build context lean: exclude the local venv, caches, tests,
|
# Build context is the repository root (see docker-compose.yml). Keep it
|
||||||
# and anything not needed to build the scopa wheel.
|
# lean: venvs, caches, Rust build output and tests are not needed to build
|
||||||
|
# the image.
|
||||||
.git/
|
.git/
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.env
|
||||||
.env.example
|
.env.example
|
||||||
.venv/
|
**/.venv/
|
||||||
__pycache__/
|
**/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
dist/
|
web/target/
|
||||||
build/
|
web/dist/
|
||||||
conf/
|
server/tests/
|
||||||
tests/
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
opencode.json
|
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
opencode.json
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Rust build output
|
||||||
|
target/
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
# Multi-stage production build for the scopa app on the kaya framework.
|
|
||||||
# Base: alpine:3.24 (python3 = 3.14). Deps come from the kaya Gitea registry
|
|
||||||
# (primary) with PyPI as fallback. All binary deps (asyncpg, cryptography,
|
|
||||||
# granian) ship musllinux wheels, so no compiler is strictly required;
|
|
||||||
# build-base + python3-dev are kept in the builder only as a safety net and
|
|
||||||
# are discarded in the runtime image.
|
|
||||||
#
|
|
||||||
# apk and pip both use BuildKit cache mounts (type=cache): the package
|
|
||||||
# caches persist in the builder's cache across builds instead of being
|
|
||||||
# re-downloaded, and never land in the image layers. Requires BuildKit
|
|
||||||
# (default for `docker build` / `docker buildx` on modern daemons).
|
|
||||||
|
|
||||||
# --- Builder ---------------------------------------------------------------
|
|
||||||
FROM alpine:3.24 AS builder
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
|
||||||
apk add python3 py3-pip build-base python3-dev
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
COPY pyproject.toml README.md requirements.txt ./
|
|
||||||
COPY src/ ./src/
|
|
||||||
# aerich migration files are a release artifact: the db-migrate compose
|
|
||||||
# service runs `aerich upgrade` from this image before the app starts.
|
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
||||||
python3 -m venv /opt/venv \
|
|
||||||
&& /opt/venv/bin/pip install --upgrade pip \
|
|
||||||
&& /opt/venv/bin/pip install -r requirements.txt .
|
|
||||||
|
|
||||||
# --- Runtime ---------------------------------------------------------------
|
|
||||||
FROM alpine:3.24
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
|
||||||
apk add python3 ca-certificates tzdata \
|
|
||||||
&& addgroup -S app && adduser -S -G app app
|
|
||||||
|
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
|
||||||
COPY --from=builder /build/migrations /app/migrations
|
|
||||||
# aerich reads [tool.aerich] from pyproject.toml (its default config file);
|
|
||||||
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
|
|
||||||
COPY --from=builder /build/pyproject.toml /app/pyproject.toml
|
|
||||||
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH" \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
GRANIAN_HOST=0.0.0.0 \
|
|
||||||
GRANIAN_PORT=8080 \
|
|
||||||
GRANIAN_INTERFACE=rsgi
|
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
|
||||||
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
|
||||||
|
|
||||||
CMD ["granian", "scopa.app:app"]
|
|
||||||
@@ -1,140 +1,39 @@
|
|||||||
# scopa
|
# scopa
|
||||||
|
|
||||||
A multiplayer backend for **scopone scientifico** (the four-player,
|
Multiplayer **scopone scientifico** — the four-player, fixed-partnership
|
||||||
fixed-partnership variant of the classic Italian card game), built on the
|
Italian card game — as a web application:
|
||||||
[kaya](../kaya) framework.
|
|
||||||
|
|
||||||
Players authenticate with the configured **OIDC** provider. Live game state
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
is kept in **Redis** (with real-time play over WebSocket), and completed
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
matches — every participant and the final score — are persisted to
|
See [server/README.md](server/README.md).
|
||||||
**Postgres** for history and leaderboard statistics.
|
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||||
|
WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the
|
||||||
|
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||||
|
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||||
|
|
||||||
## Quick start (Docker Compose)
|
## Run the whole stack
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The stack starts Postgres, Redis, a mock OIDC provider
|
Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`,
|
||||||
(navikt/mock-oauth2-server) with four ready-made players
|
`dave`), the database migrator and the app all come up together. The app —
|
||||||
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service,
|
frontend and API — listens on `http://127.0.0.1:8080`.
|
||||||
and the app itself on `http://127.0.0.1:8080`.
|
|
||||||
|
|
||||||
Because the browser and the app both talk to the OIDC issuer at
|
Because both the browser and the app talk to the OIDC issuer at
|
||||||
`http://mockoauth:8180/scopa`, add a host entry once:
|
`http://mockoauth:8180/scopa`, add a host entry once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
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
|
## Development
|
||||||
|
|
||||||
|
Backend (from `server/`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cd server
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/pip install -e '.[dev]'
|
.venv/bin/pip install -e '.[dev]'
|
||||||
@@ -142,47 +41,22 @@ python3 -m venv .venv
|
|||||||
.venv/bin/mypy src
|
.venv/bin/mypy src
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
|
Frontend (from `web/`), with the backend running on :8080:
|
||||||
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
|
```sh
|
||||||
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
|
cd web
|
||||||
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich upgrade
|
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
|
||||||
```
|
```
|
||||||
|
|
||||||
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
|
||||||
adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing
|
`web/Trunk.toml`). For the login redirect to land back on the dev server,
|
||||||
baseline.)
|
run the backend with:
|
||||||
|
|
||||||
## Layout
|
```sh
|
||||||
|
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||||
|
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||||
|
.venv/bin/granian --host 127.0.0.1 --port 8080 scopa.app:app
|
||||||
|
```
|
||||||
|
|
||||||
```
|
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||||
src/scopa/
|
re-downloads them if needed.
|
||||||
├── 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
# Derived image baking dev/mockoauth/config.json in (see
|
# Derived image baking dev/mockoauth/config.json in (see
|
||||||
# dev/mockoauth/Dockerfile) — a bind mount would not work against
|
# dev/mockoauth/Dockerfile) — a bind mount would not work against
|
||||||
# containerized docker daemons.
|
# containerized docker daemons.
|
||||||
build: ./dev/mockoauth
|
build: ./server/dev/mockoauth
|
||||||
environment:
|
environment:
|
||||||
SERVER_PORT: "8180"
|
SERVER_PORT: "8180"
|
||||||
LOG_LEVEL: INFO
|
LOG_LEVEL: INFO
|
||||||
@@ -63,7 +63,9 @@ services:
|
|||||||
# One-shot: applies pending aerich migrations before the app starts
|
# One-shot: applies pending aerich migrations before the app starts
|
||||||
# (build cache makes the second build of the same Dockerfile instant).
|
# (build cache makes the second build of the same Dockerfile instant).
|
||||||
db-migrate:
|
db-migrate:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
# aerich reads [tool.aerich] from /app/pyproject.toml (default config
|
# aerich reads [tool.aerich] from /app/pyproject.toml (default config
|
||||||
# file) and finds migrations in ./migrations relative to working_dir.
|
# file) and finds migrations in ./migrations relative to working_dir.
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
@@ -75,7 +77,9 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
scopa:
|
scopa:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -87,15 +91,17 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
|
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
|
||||||
OIDC_ISSUER: http://mockoauth:8180/scopa
|
# By default the app and browsers reach the mock IdP under the same
|
||||||
|
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
||||||
|
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/scopa}
|
||||||
# The mock OIDC server does not validate clients: any id/secret works.
|
# The mock OIDC server does not validate clients: any id/secret works.
|
||||||
OIDC_CLIENT_ID: scopa
|
OIDC_CLIENT_ID: scopa
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
||||||
# Browser-facing callback: the app is published on host port 8080.
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||||
OIDC_REDIRECT_URI: http://localhost:8080/auth/callback
|
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Multi-stage build for the scopa stack (kaya backend + Sycamore/WASM
|
||||||
|
# frontend). The Docker build context is the REPOSITORY ROOT (see
|
||||||
|
# docker-compose.yml) so this single image assembles both parts:
|
||||||
|
#
|
||||||
|
# web-builder rust + trunk -> compiles web/ into web/dist
|
||||||
|
# builder alpine python -> python venv with the backend + deps
|
||||||
|
# runtime alpine python + the venv + the compiled frontend
|
||||||
|
#
|
||||||
|
# apk and pip both use BuildKit cache mounts (type=cache): the package
|
||||||
|
# caches persist in the builder's cache across builds instead of being
|
||||||
|
# re-downloaded, and never land in the image layers.
|
||||||
|
|
||||||
|
# --- Web builder -------------------------------------------------------------
|
||||||
|
FROM rust:1-slim AS web-builder
|
||||||
|
|
||||||
|
ARG TRUNK_VERSION=0.21.14
|
||||||
|
# The build environment blocks plain HTTP: force the apt mirrors to HTTPS.
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
|
&& curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin \
|
||||||
|
&& rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
|
WORKDIR /web
|
||||||
|
|
||||||
|
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
||||||
|
# web/fetch-cards.sh can regenerate them.
|
||||||
|
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/Trunk.toml ./
|
||||||
|
COPY web/assets ./assets
|
||||||
|
COPY web/src ./src
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
trunk build --release
|
||||||
|
|
||||||
|
# --- Python builder ----------------------------------------------------------
|
||||||
|
FROM alpine:3.24 AS builder
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
|
apk add python3 py3-pip build-base python3-dev
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY server/pyproject.toml server/README.md server/requirements.txt ./
|
||||||
|
COPY server/src/ ./src/
|
||||||
|
# aerich migration files are a release artifact: the db-migrate compose
|
||||||
|
# service runs `aerich upgrade` from this image before the app starts.
|
||||||
|
COPY server/migrations/ ./migrations/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
python3 -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
|
&& /opt/venv/bin/pip install -r requirements.txt .
|
||||||
|
|
||||||
|
# --- Runtime ---------------------------------------------------------------
|
||||||
|
FROM alpine:3.24
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
|
apk add python3 ca-certificates tzdata \
|
||||||
|
&& addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
COPY --from=builder /build/migrations /app/migrations
|
||||||
|
# aerich reads [tool.aerich] from pyproject.toml (its default config file);
|
||||||
|
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
|
||||||
|
COPY --from=builder /build/pyproject.toml /app/pyproject.toml
|
||||||
|
# The compiled single-page application, served by the backend itself.
|
||||||
|
COPY --from=web-builder /web/dist /app/web/dist
|
||||||
|
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
GRANIAN_HOST=0.0.0.0 \
|
||||||
|
GRANIAN_PORT=8080 \
|
||||||
|
GRANIAN_INTERFACE=rsgi \
|
||||||
|
STATIC_DIR=/app/web/dist
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["granian", "scopa.app:app"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -50,6 +50,8 @@ oidc_mixin = OIDCMixin(
|
|||||||
client_id=settings.oidc_client_id,
|
client_id=settings.oidc_client_id,
|
||||||
client_secret=settings.oidc_client_secret,
|
client_secret=settings.oidc_client_secret,
|
||||||
redirect_uri=settings.oidc_redirect_uri,
|
redirect_uri=settings.oidc_redirect_uri,
|
||||||
|
post_login_redirect=settings.oidc_post_login_redirect,
|
||||||
|
post_logout_redirect=settings.oidc_post_logout_redirect,
|
||||||
fetch_userinfo=True,
|
fetch_userinfo=True,
|
||||||
),
|
),
|
||||||
session=session_mixin,
|
session=session_mixin,
|
||||||
@@ -70,6 +72,9 @@ tortoise_mixin = TortoiseMixin(
|
|||||||
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin])
|
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin])
|
||||||
|
|
||||||
# Register routes by importing modules. Order does not matter; each module
|
# Register routes by importing modules. Order does not matter; each module
|
||||||
# pulls ``app`` from here and decorates its handlers at import time.
|
# pulls ``app`` from here and decorates its handlers at import time. The
|
||||||
from .routes import games, health, stats # noqa: E402,F401
|
# static SPA catch-all is registered last and only matches paths no other
|
||||||
|
# route claimed.
|
||||||
|
from .routes import games, health, me, stats # noqa: E402,F401
|
||||||
from . import ws # noqa: E402,F401
|
from . import ws # noqa: E402,F401
|
||||||
|
from .routes import static # noqa: E402,F401
|
||||||
@@ -26,12 +26,20 @@ class Settings:
|
|||||||
oidc_client_id: str
|
oidc_client_id: str
|
||||||
oidc_client_secret: Optional[str]
|
oidc_client_secret: Optional[str]
|
||||||
oidc_redirect_uri: str
|
oidc_redirect_uri: str
|
||||||
|
# Where the browser is sent after login/logout. In production the SPA is
|
||||||
|
# served by this app ("/"); in development point these at the trunk dev
|
||||||
|
# server (e.g. "http://localhost:8000/").
|
||||||
|
oidc_post_login_redirect: str
|
||||||
|
oidc_post_logout_redirect: str
|
||||||
app_host: str
|
app_host: str
|
||||||
app_port: int
|
app_port: int
|
||||||
redis_url: Optional[str]
|
redis_url: Optional[str]
|
||||||
# How long a live game (and its join-code index) survives in Redis
|
# How long a live game (and its join-code index) survives in Redis
|
||||||
# without activity, in seconds. Defaults to 24h.
|
# without activity, in seconds. Defaults to 24h.
|
||||||
game_ttl_seconds: int
|
game_ttl_seconds: int
|
||||||
|
# Directory holding the compiled frontend (trunk's dist output),
|
||||||
|
# served for every path that is not under /api or /auth.
|
||||||
|
static_dir: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
@@ -41,6 +49,8 @@ class Settings:
|
|||||||
oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"),
|
oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"),
|
||||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||||
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
||||||
|
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
||||||
|
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
|
||||||
app_host=_env("APP_HOST", "0.0.0.0"),
|
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||||
app_port=int(_env("APP_PORT", "8080")),
|
app_port=int(_env("APP_PORT", "8080")),
|
||||||
# When unset, sessions and live games fall back to in-memory
|
# When unset, sessions and live games fall back to in-memory
|
||||||
@@ -48,6 +58,7 @@ class Settings:
|
|||||||
# redis://localhost:6379/0 to persist both in Redis.
|
# redis://localhost:6379/0 to persist both in Redis.
|
||||||
redis_url=os.environ.get("REDIS_URL"),
|
redis_url=os.environ.get("REDIS_URL"),
|
||||||
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||||
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ from .state import (
|
|||||||
TEAM_NAMES,
|
TEAM_NAMES,
|
||||||
Card,
|
Card,
|
||||||
GameState,
|
GameState,
|
||||||
|
Move,
|
||||||
PlayerState,
|
PlayerState,
|
||||||
parse_card,
|
parse_card,
|
||||||
)
|
)
|
||||||
@@ -209,6 +210,8 @@ def play(
|
|||||||
requested = [parse_card(c) for c in (capture_codes or [])]
|
requested = [parse_card(c) for c in (capture_codes or [])]
|
||||||
options = legal_captures(state.table, card)
|
options = legal_captures(state.table, card)
|
||||||
|
|
||||||
|
taken: List[Card] = []
|
||||||
|
scopa = False
|
||||||
if not options:
|
if not options:
|
||||||
if requested:
|
if requested:
|
||||||
raise IllegalMove("no capture is possible with that card")
|
raise IllegalMove("no capture is possible with that card")
|
||||||
@@ -217,6 +220,7 @@ def play(
|
|||||||
chosen = _match_option(options, requested)
|
chosen = _match_option(options, requested)
|
||||||
if chosen is None:
|
if chosen is None:
|
||||||
raise IllegalMove("the requested capture is not legal")
|
raise IllegalMove("the requested capture is not legal")
|
||||||
|
taken = chosen
|
||||||
for captured in chosen:
|
for captured in chosen:
|
||||||
state.table.remove(captured)
|
state.table.remove(captured)
|
||||||
player.captured.append(captured)
|
player.captured.append(captured)
|
||||||
@@ -226,6 +230,15 @@ def play(
|
|||||||
hands_empty = all(not p.hand for p in state.players)
|
hands_empty = all(not p.hand for p in state.players)
|
||||||
if not state.table and not hands_empty:
|
if not state.table and not hands_empty:
|
||||||
player.scope += 1
|
player.scope += 1
|
||||||
|
scopa = True
|
||||||
|
|
||||||
|
state.last_move = Move(
|
||||||
|
seat=player.seat,
|
||||||
|
name=player.name,
|
||||||
|
card=card.code,
|
||||||
|
captured=[c.code for c in taken],
|
||||||
|
scopa=scopa,
|
||||||
|
)
|
||||||
|
|
||||||
if all(not p.hand for p in state.players):
|
if all(not p.hand for p in state.players):
|
||||||
_end_hand(state)
|
_end_hand(state)
|
||||||
@@ -366,7 +379,18 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
|||||||
"table": [c.code for c in state.table],
|
"table": [c.code for c in state.table],
|
||||||
"players": players,
|
"players": players,
|
||||||
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
|
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
|
||||||
|
"last_move": state.last_move.to_json() if state.last_move else None,
|
||||||
}
|
}
|
||||||
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
||||||
payload["your_turn"] = True
|
payload["your_turn"] = True
|
||||||
|
# Only the player on turn receives their legal captures, so all the
|
||||||
|
# rule logic stays server-side.
|
||||||
|
legal_moves: Dict[str, List[List[str]]] = {}
|
||||||
|
for hand_card in viewer.hand:
|
||||||
|
options = legal_captures(state.table, hand_card)
|
||||||
|
if options:
|
||||||
|
legal_moves[hand_card.code] = [
|
||||||
|
[c.code for c in option] for option in options
|
||||||
|
]
|
||||||
|
payload["legal_moves"] = legal_moves
|
||||||
return payload
|
return payload
|
||||||
@@ -73,6 +73,37 @@ def parse_card(code: Any) -> Card:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Move:
|
||||||
|
"""Record of a single play, broadcast so every client can show who
|
||||||
|
played which card and what it captured."""
|
||||||
|
|
||||||
|
seat: int
|
||||||
|
name: str
|
||||||
|
card: str
|
||||||
|
captured: List[str] = field(default_factory=list)
|
||||||
|
scopa: bool = False
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"seat": self.seat,
|
||||||
|
"name": self.name,
|
||||||
|
"card": self.card,
|
||||||
|
"captured": list(self.captured),
|
||||||
|
"scopa": self.scopa,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "Move":
|
||||||
|
return Move(
|
||||||
|
seat=int(data["seat"]),
|
||||||
|
name=str(data["name"]),
|
||||||
|
card=str(data["card"]),
|
||||||
|
captured=[str(c) for c in data.get("captured", [])],
|
||||||
|
scopa=bool(data.get("scopa", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlayerState:
|
class PlayerState:
|
||||||
sub: str
|
sub: str
|
||||||
@@ -129,6 +160,8 @@ class GameState:
|
|||||||
# ISO-8601 timestamps, used when the match result is written to Postgres.
|
# ISO-8601 timestamps, used when the match result is written to Postgres.
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
finished_at: Optional[str] = None
|
finished_at: Optional[str] = None
|
||||||
|
# The most recent play in the current hand, for move announcements.
|
||||||
|
last_move: Optional[Move] = None
|
||||||
|
|
||||||
# -- serialization ----------------------------------------------------
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
@@ -151,6 +184,7 @@ class GameState:
|
|||||||
"stats_saved": self.stats_saved,
|
"stats_saved": self.stats_saved,
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"finished_at": self.finished_at,
|
"finished_at": self.finished_at,
|
||||||
|
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -173,6 +207,7 @@ class GameState:
|
|||||||
stats_saved=bool(data.get("stats_saved", False)),
|
stats_saved=bool(data.get("stats_saved", False)),
|
||||||
created_at=data.get("created_at"),
|
created_at=data.get("created_at"),
|
||||||
finished_at=data.get("finished_at"),
|
finished_at=data.get("finished_at"),
|
||||||
|
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Whoami endpoint: lets the single-page app detect the login state."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..app import app, oidc_mixin
|
||||||
|
from ..auth import display_name, require_auth
|
||||||
|
from ..http import send_json
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/me")
|
||||||
|
@operation(summary="Current user",
|
||||||
|
description="Returns the authenticated user's identity from the "
|
||||||
|
"session; 401 when not logged in.",
|
||||||
|
tags=["auth"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The current user"},
|
||||||
|
401: {"description": "Not logged in"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def me(ctx: HttpContext) -> None:
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Static hosting for the compiled single-page application.
|
||||||
|
|
||||||
|
In production the kaya backend itself serves the WASM frontend built into
|
||||||
|
``STATIC_DIR`` (the ``web/dist`` output of ``trunk build --release``; see
|
||||||
|
the Docker image). A glob catch-all (``/*``) handles every path that did
|
||||||
|
not match an API or auth route: real files are served with their content
|
||||||
|
type, anything else falls back to ``index.html`` so client-side routes
|
||||||
|
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||||
|
|
||||||
|
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||||
|
in the API specification.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
|
from ..app import app
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
# Explicit content types: wasm-pack/trunk outputs (.wasm, .js) are not
|
||||||
|
# consistently covered by the system mime database in slim containers.
|
||||||
|
_CONTENT_TYPES: Mapping[str, str] = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".json": "application/json",
|
||||||
|
".webmanifest": "application/manifest+json",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _static_root() -> Path:
|
||||||
|
return Path(settings.static_dir).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_path(ctx: HttpContext, target: Path) -> None:
|
||||||
|
"""Serve a static file, or 404 when it does not exist.
|
||||||
|
|
||||||
|
Uses ``send_bytes`` rather than kaya's ``send_file`` (unimplemented by
|
||||||
|
the ASGI adapter); frontend artifacts are small enough to buffer.
|
||||||
|
"""
|
||||||
|
if not target.is_file():
|
||||||
|
await ctx.send_empty(404)
|
||||||
|
return
|
||||||
|
content_type = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
|
||||||
|
body = await asyncio.to_thread(target.read_bytes)
|
||||||
|
await ctx.send_bytes(200, body, {"content-type": (content_type,)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/")
|
||||||
|
async def index(ctx: HttpContext) -> None:
|
||||||
|
"""Serve the SPA shell at the site root (the glob below cannot match
|
||||||
|
an empty path)."""
|
||||||
|
await _send_path(ctx, _static_root() / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/*", recursive=True)
|
||||||
|
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||||
|
root = _static_root()
|
||||||
|
relative = ctx.path.lstrip("/")
|
||||||
|
target = (root / relative).resolve() if relative else root
|
||||||
|
# Path-traversal guard: the resolved target must stay inside the dist
|
||||||
|
# directory.
|
||||||
|
if root != target and root not in target.parents:
|
||||||
|
await ctx.send_empty(404)
|
||||||
|
return
|
||||||
|
if not target.is_file():
|
||||||
|
# SPA fallback: unknown paths render the app shell.
|
||||||
|
target = root / "index.html"
|
||||||
|
await _send_path(ctx, target)
|
||||||
@@ -96,6 +96,11 @@ class TortoiseMixin(KayaMixin):
|
|||||||
async def _ensure_context(self, ctx: HttpContext):
|
async def _ensure_context(self, ctx: HttpContext):
|
||||||
if ctx.path in self._skip_paths:
|
if ctx.path in self._skip_paths:
|
||||||
return None
|
return None
|
||||||
|
# Only API endpoints touch the database: auth callbacks, websocket
|
||||||
|
# handshakes (handled by _ensure_ws_context) and the static SPA
|
||||||
|
# catch-all must not pay for a Tortoise context.
|
||||||
|
if not ctx.path.startswith("/api/"):
|
||||||
|
return None
|
||||||
await self._bind()
|
await self._bind()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -113,6 +113,22 @@ class CaptureTest(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["02C", "02D"], [c.code for c in state.players[0].captured]
|
["02C", "02D"], [c.code for c in state.players[0].captured]
|
||||||
)
|
)
|
||||||
|
# The move is recorded for the "who played what" announcement.
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual(0, state.last_move.seat)
|
||||||
|
self.assertEqual("p0", state.last_move.name)
|
||||||
|
self.assertEqual("02D", state.last_move.card)
|
||||||
|
self.assertEqual(["02C"], state.last_move.captured)
|
||||||
|
self.assertTrue(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_play_without_capture_records_move(self) -> None:
|
||||||
|
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
|
||||||
|
table=["09C"])
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual("02D", state.last_move.card)
|
||||||
|
self.assertEqual([], state.last_move.captured)
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
|
||||||
def test_illegal_combination_when_equal_card_present(self) -> None:
|
def test_illegal_combination_when_equal_card_present(self) -> None:
|
||||||
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
|
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
|
||||||
@@ -255,6 +271,25 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertEqual(["07C"], view["table"])
|
self.assertEqual(["07C"], view["table"])
|
||||||
self.assertTrue(view.get("your_turn"))
|
self.assertTrue(view.get("your_turn"))
|
||||||
|
|
||||||
|
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
||||||
|
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["07C"])
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
legal = view["legal_moves"]
|
||||||
|
# 02D can capture nothing; 03C has no combination either (only 07C
|
||||||
|
# on the table).
|
||||||
|
self.assertEqual({}, legal)
|
||||||
|
|
||||||
|
state = make_state([["09D"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["07C", "02S"])
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertEqual({"09D": [["07C", "02S"]]}, view["legal_moves"])
|
||||||
|
|
||||||
|
# A player who is not on turn gets no legal_moves key.
|
||||||
|
other = engine.state_for_player(state, "p1")
|
||||||
|
self.assertNotIn("legal_moves", other)
|
||||||
|
self.assertNotIn("your_turn", other)
|
||||||
|
|
||||||
def test_full_random_match_reaches_completion(self) -> None:
|
def test_full_random_match_reaches_completion(self) -> None:
|
||||||
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
|
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
|
||||||
for i in range(1, 4):
|
for i in range(1, 4):
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the whoami endpoint and the static SPA host."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from pwo import async_test
|
||||||
|
|
||||||
|
from scopa.app import app
|
||||||
|
from scopa.config import settings
|
||||||
|
from tests.helpers import oidc_user
|
||||||
|
|
||||||
|
|
||||||
|
class MeRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_me_authenticated(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
response = await client.get("/api/me")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_me_unauthenticated(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/me")
|
||||||
|
self.assertEqual(401, response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_serves_files_and_spa_fallback(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as dist:
|
||||||
|
root = Path(dist)
|
||||||
|
(root / "index.html").write_text("<html>spa</html>")
|
||||||
|
(root / "app.js").write_text("console.log(1)")
|
||||||
|
cards = root / "assets" / "cards"
|
||||||
|
cards.mkdir(parents=True)
|
||||||
|
(cards / "07D.svg").write_text("<svg/>")
|
||||||
|
|
||||||
|
patched = dataclasses.replace(settings, static_dir=dist)
|
||||||
|
with mock.patch("scopa.routes.static.settings", patched):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
index = await client.get("/")
|
||||||
|
self.assertEqual(200, index.status_code)
|
||||||
|
self.assertIn(b"spa", index.content)
|
||||||
|
|
||||||
|
js = await client.get("/app.js")
|
||||||
|
self.assertEqual(200, js.status_code)
|
||||||
|
self.assertEqual("text/javascript; charset=utf-8", js.headers["content-type"])
|
||||||
|
|
||||||
|
svg = await client.get("/assets/cards/07D.svg")
|
||||||
|
self.assertEqual(200, svg.status_code)
|
||||||
|
self.assertEqual("image/svg+xml", svg.headers["content-type"])
|
||||||
|
|
||||||
|
# Unknown client-side route falls back to the app shell.
|
||||||
|
fallback = await client.get("/game/some-id")
|
||||||
|
self.assertEqual(200, fallback.status_code)
|
||||||
|
self.assertIn(b"spa", fallback.content)
|
||||||
|
|
||||||
|
# Traversal attempts never escape the dist directory.
|
||||||
|
traversal = await client.get("/..%2F..%2Fetc%2Fpasswd")
|
||||||
|
self.assertIn(traversal.status_code, (200, 404))
|
||||||
|
if traversal.status_code == 200:
|
||||||
|
self.assertIn(b"spa", traversal.content)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_missing_dist_returns_404(self) -> None:
|
||||||
|
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||||
|
with mock.patch("scopa.routes.static.settings", patched):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/")
|
||||||
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "console_error_panic_hook"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gloo-net"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"gloo-utils",
|
||||||
|
"http",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gloo-utils"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
"allocator-api2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html-escape"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.105"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopa-web"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"console_error_panic_hook",
|
||||||
|
"futures",
|
||||||
|
"gloo-net",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sycamore",
|
||||||
|
"sycamore-router",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slotmap"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||||
|
dependencies = [
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11c735201526de5147ac6d600dad8c9be2431a70f3cb0366138b5bdb66455d20"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"indexmap",
|
||||||
|
"paste",
|
||||||
|
"sycamore-core",
|
||||||
|
"sycamore-macro",
|
||||||
|
"sycamore-reactive",
|
||||||
|
"sycamore-web",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-core"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd6339fc8b0981e0ffdf567ceeaffe8265f9eb2e21bc3d2ca2999e42e5ddcfd3"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"paste",
|
||||||
|
"sycamore-reactive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-macro"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3bb6450058019bdbe2752e94ee2c8e41b985f2e8d516a1c837f974f03a50d5a3"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rand",
|
||||||
|
"sycamore-view-parser",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-reactive"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e0b9b9e906ca671417482538e49dcdd49ac47bd874dc43dba0b38e39014af80"
|
||||||
|
dependencies = [
|
||||||
|
"paste",
|
||||||
|
"slotmap",
|
||||||
|
"smallvec",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-router"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eeded03cf2c7cf6a2873da7e4dae73075c4b31043baee2dfec69920caa7ec751"
|
||||||
|
dependencies = [
|
||||||
|
"sycamore",
|
||||||
|
"sycamore-router-macro",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-router-macro"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf7abfa0ce15ba52bd3e07f1f59b5db7578156c83c5268de9416f077cababc3a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-view-parser"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42fb4604d20af47e1cdfd4aa5287170e64c33ab5a6b03724c28a42d2d0cd7be1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sycamore-web"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ce2889120d7e8c2365afa3dfb5d5edb457c3c8f40086d633f57ff448640b9d"
|
||||||
|
dependencies = [
|
||||||
|
"html-escape",
|
||||||
|
"js-sys",
|
||||||
|
"once_cell",
|
||||||
|
"paste",
|
||||||
|
"smallvec",
|
||||||
|
"sycamore-core",
|
||||||
|
"sycamore-macro",
|
||||||
|
"sycamore-reactive",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.78"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.105"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "scopa-web"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Sycamore/WASM frontend for the scopone scientifico backend"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sycamore = "0.9"
|
||||||
|
sycamore-router = "0.9"
|
||||||
|
gloo-net = { version = "0.6", features = ["websocket"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
futures = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Development server configuration for `trunk serve`.
|
||||||
|
# The SPA runs on :8000 and proxies API, auth and websocket traffic to the
|
||||||
|
# backend on :8080 so the session cookie works without CORS.
|
||||||
|
[serve]
|
||||||
|
port = 8000
|
||||||
|
|
||||||
|
[[proxy]]
|
||||||
|
rewrite = "/api"
|
||||||
|
backend = "http://127.0.0.1:8080/api"
|
||||||
|
|
||||||
|
[[proxy]]
|
||||||
|
rewrite = "/auth"
|
||||||
|
backend = "http://127.0.0.1:8080/auth"
|
||||||
|
|
||||||
|
[[proxy]]
|
||||||
|
rewrite = "/ws"
|
||||||
|
backend = "http://127.0.0.1:8080/ws"
|
||||||
|
ws = true
|
||||||
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 182 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Download the CC0 "woodcut napoletane" card set (traced from a 1902 Naples
|
||||||
|
# deck) and rename the files to the card codes the backend uses:
|
||||||
|
# colori/denari-asso.svg -> assets/cards/01D.svg
|
||||||
|
# colori/spade-re.svg -> assets/cards/10S.svg
|
||||||
|
# colori/dorso.svg -> assets/cards/back.svg
|
||||||
|
#
|
||||||
|
# Source: https://github.com/SONDLecT/woodcut-napoletane (CC0 1.0)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BASE="https://raw.githubusercontent.com/SONDLecT/woodcut-napoletane/master/colori"
|
||||||
|
OUT="$(dirname "$0")/assets/cards"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
fetch() {
|
||||||
|
suit="$1"; rank="$2"; code="$3"
|
||||||
|
if [ ! -s "$OUT/$code.svg" ]; then
|
||||||
|
curl -sfL "$BASE/$suit-$rank.svg" -o "$OUT/$code.svg"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for suit in denari coppe spade bastoni; do
|
||||||
|
case "$suit" in
|
||||||
|
denari) letter=D ;;
|
||||||
|
coppe) letter=C ;;
|
||||||
|
spade) letter=S ;;
|
||||||
|
bastoni) letter=B ;;
|
||||||
|
esac
|
||||||
|
fetch "$suit" asso "01$letter"
|
||||||
|
for rank in 02 03 04 05 06 07; do
|
||||||
|
fetch "$suit" "$rank" "$rank$letter"
|
||||||
|
done
|
||||||
|
fetch "$suit" fante "08$letter"
|
||||||
|
fetch "$suit" cavallo "09$letter"
|
||||||
|
fetch "$suit" re "10$letter"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -s "$OUT/back.svg" ]; then
|
||||||
|
curl -sfL "$BASE/dorso.svg" -o "$OUT/back.svg"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "cards ready in $OUT"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Scopone scientifico</title>
|
||||||
|
<link data-trunk rel="rust" href="Cargo.toml">
|
||||||
|
<link data-trunk rel="css" href="style.css">
|
||||||
|
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||||
|
<link data-trunk rel="copy-dir" href="assets">
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! REST client for the scopa backend. Same-origin requests carry the
|
||||||
|
//! session cookie automatically.
|
||||||
|
use crate::model::*;
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
fn server_error(status: u16) -> String {
|
||||||
|
format!("server returned {status}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the current user; `None` when unauthenticated (401).
|
||||||
|
pub async fn me() -> Result<Option<User>, String> {
|
||||||
|
let resp = Request::get("/api/me")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == 401 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
resp.json().await.map(Some).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_game(target_score: i32) -> Result<GameView, String> {
|
||||||
|
let resp = Request::post("/api/games")
|
||||||
|
.json(&serde_json::json!({ "target_score": target_score }))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_game(code: &str) -> Result<GameView, String> {
|
||||||
|
let resp = Request::post("/api/games/join")
|
||||||
|
.json(&serde_json::json!({ "code": code }))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
|
let message = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| server_error(resp.status()));
|
||||||
|
return Err(message);
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
|
||||||
|
let resp = Request::get(&format!("/api/games/{game_id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||||
|
let url = match cursor {
|
||||||
|
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||||
|
None => "/api/me/matches?limit=10".to_string(),
|
||||||
|
};
|
||||||
|
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
||||||
|
let resp = Request::get("/api/leaderboard")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! Card rendering helpers (plain functions returning views).
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::model::{card_asset, card_label, CARD_BACK};
|
||||||
|
|
||||||
|
/// Render a card image at a given size class (`mini`, `move-card`,
|
||||||
|
/// `hand-card`, `table-card`).
|
||||||
|
pub fn card_img(code: String, class: &'static str) -> View {
|
||||||
|
let src = card_asset(&code);
|
||||||
|
let alt = card_label(&code);
|
||||||
|
view! {
|
||||||
|
img(src=src, class=format!("card-img {class}"), alt=alt, draggable="false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the back of a card (used for opponents' hidden hands).
|
||||||
|
pub fn card_back(class: &'static str) -> View {
|
||||||
|
view! {
|
||||||
|
img(src=CARD_BACK, class=format!("card-img {class}"), alt="card back", draggable="false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod card;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Application entry point.
|
||||||
|
mod api;
|
||||||
|
mod components;
|
||||||
|
mod model;
|
||||||
|
mod pages;
|
||||||
|
mod ws;
|
||||||
|
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
use sycamore_router::{HistoryIntegration, Route, Router};
|
||||||
|
|
||||||
|
use pages::game::GamePage;
|
||||||
|
use pages::history::HistoryPage;
|
||||||
|
use pages::leaderboard::LeaderboardPage;
|
||||||
|
use pages::lobby::LobbyPage;
|
||||||
|
|
||||||
|
#[derive(Route, Clone)]
|
||||||
|
enum AppRoutes {
|
||||||
|
#[to("/")]
|
||||||
|
Lobby,
|
||||||
|
#[to("/game/<id>")]
|
||||||
|
Game { id: String },
|
||||||
|
#[to("/history")]
|
||||||
|
History,
|
||||||
|
#[to("/leaderboard")]
|
||||||
|
Leaderboard,
|
||||||
|
#[not_found]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
sycamore::render(|| {
|
||||||
|
view! {
|
||||||
|
Router(
|
||||||
|
integration=HistoryIntegration::new(),
|
||||||
|
view=|route: ReadSignal<AppRoutes>| {
|
||||||
|
view! {
|
||||||
|
div(class="app") {
|
||||||
|
(match route.get_clone() {
|
||||||
|
AppRoutes::Lobby => view! { LobbyPage() },
|
||||||
|
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||||
|
AppRoutes::History => view! { HistoryPage() },
|
||||||
|
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||||
|
AppRoutes::NotFound => view! {
|
||||||
|
div(class="panel") {
|
||||||
|
h1 { "Page not found" }
|
||||||
|
a(href="/") { "Back to lobby" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! Serde types mirroring the backend API payloads.
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct User {
|
||||||
|
pub sub: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct PlayerView {
|
||||||
|
pub sub: String,
|
||||||
|
pub name: String,
|
||||||
|
pub seat: usize,
|
||||||
|
pub team: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cards_left: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub captured_count: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scope: i32,
|
||||||
|
/// Own hand only; absent for the other players.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hand: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
|
pub struct Scores {
|
||||||
|
#[serde(rename = "A")]
|
||||||
|
pub a: i32,
|
||||||
|
#[serde(rename = "B")]
|
||||||
|
pub b: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct MoveView {
|
||||||
|
pub seat: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub card: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub captured: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scopa: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct GameView {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub join_code: String,
|
||||||
|
pub phase: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_score: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hand_number: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dealer: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub turn: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scores: Option<Scores>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub winner: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub table: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub players: Vec<PlayerView>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seats_open: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_move: Option<MoveView>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub your_turn: Option<bool>,
|
||||||
|
/// Legal captures per hand card; present only for the player on turn.
|
||||||
|
#[serde(default)]
|
||||||
|
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
State { game: GameView },
|
||||||
|
GameOver {
|
||||||
|
scores: Scores,
|
||||||
|
#[serde(default)]
|
||||||
|
winner: Option<String>,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct MatchPlayer {
|
||||||
|
pub user_sub: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub seat: usize,
|
||||||
|
pub team: String,
|
||||||
|
pub won: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct MatchSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub team_a_score: i32,
|
||||||
|
pub team_b_score: i32,
|
||||||
|
pub winner_team: String,
|
||||||
|
pub target_score: i32,
|
||||||
|
pub hands_played: i32,
|
||||||
|
pub started_at: String,
|
||||||
|
pub finished_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub you_won: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub players: Vec<MatchPlayer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct MatchesPage {
|
||||||
|
#[serde(default)]
|
||||||
|
pub results: Vec<MatchSummary>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct LeaderboardEntry {
|
||||||
|
pub user_sub: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub matches: i32,
|
||||||
|
pub wins: i32,
|
||||||
|
pub points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LeaderboardPage {
|
||||||
|
#[serde(default)]
|
||||||
|
pub results: Vec<LeaderboardEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a card code (e.g. `07D`) to its asset path.
|
||||||
|
pub fn card_asset(code: &str) -> String {
|
||||||
|
format!("/assets/cards/{code}.svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CARD_BACK: &str = "/assets/cards/back.svg";
|
||||||
|
|
||||||
|
/// Human-friendly rank+suit label, e.g. `07D` -> "7 of denari".
|
||||||
|
pub fn card_label(code: &str) -> String {
|
||||||
|
if code.len() != 3 {
|
||||||
|
return code.to_string();
|
||||||
|
}
|
||||||
|
let rank = match &code[..2] {
|
||||||
|
"01" => "Asso",
|
||||||
|
"08" => "Fante",
|
||||||
|
"09" => "Cavallo",
|
||||||
|
"10" => "Re",
|
||||||
|
other => match other.trim_start_matches('0') {
|
||||||
|
"2" => "2",
|
||||||
|
"3" => "3",
|
||||||
|
"4" => "4",
|
||||||
|
"5" => "5",
|
||||||
|
"6" => "6",
|
||||||
|
"7" => "7",
|
||||||
|
_ => other,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let suit = match &code[2..] {
|
||||||
|
"D" => "denari",
|
||||||
|
"C" => "coppe",
|
||||||
|
"S" => "spade",
|
||||||
|
"B" => "bastoni",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
format!("{rank} di {suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_asset_maps_code() {
|
||||||
|
assert_eq!("/assets/cards/07D.svg", card_asset("07D"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_label_names_courts() {
|
||||||
|
assert_eq!("Asso di denari", card_label("01D"));
|
||||||
|
assert_eq!("Fante di coppe", card_label("08C"));
|
||||||
|
assert_eq!("Cavallo di spade", card_label("09S"));
|
||||||
|
assert_eq!("Re di bastoni", card_label("10B"));
|
||||||
|
assert_eq!("7 di denari", card_label("07D"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! Live game page: table view over the websocket.
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::card::{card_back, card_img};
|
||||||
|
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
||||||
|
use crate::ws::{self, GameSocket};
|
||||||
|
|
||||||
|
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||||
|
if let Some(s) = socket.get_clone() {
|
||||||
|
s.play(&card, capture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||||
|
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||||
|
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
||||||
|
return view! {};
|
||||||
|
};
|
||||||
|
let active = game.phase == "playing" && game.turn == seat;
|
||||||
|
let cls = format!(
|
||||||
|
"seat seat-{position}{}",
|
||||||
|
if active { " active" } else { "" }
|
||||||
|
);
|
||||||
|
let name = player.name.clone();
|
||||||
|
let team = player.team.clone();
|
||||||
|
let captured = player.captured_count;
|
||||||
|
let scope = player.scope;
|
||||||
|
let backs = (0..player.cards_left)
|
||||||
|
.map(|_| card_back("mini"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class=cls) {
|
||||||
|
div(class="seat-name") {
|
||||||
|
(name)
|
||||||
|
span(class="team-badge") { "Team " (team) }
|
||||||
|
}
|
||||||
|
div(class="seat-cards") { (backs) }
|
||||||
|
div(class="seat-stats") {
|
||||||
|
(captured) " captured · " (scope) " scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last-move announcement strip.
|
||||||
|
fn move_banner(mv: MoveView) -> View {
|
||||||
|
let name = mv.name.clone();
|
||||||
|
let action = if mv.captured.is_empty() {
|
||||||
|
"played"
|
||||||
|
} else {
|
||||||
|
"capturing"
|
||||||
|
};
|
||||||
|
let played = card_img(mv.card.clone(), "move-card");
|
||||||
|
let captured = mv
|
||||||
|
.captured
|
||||||
|
.iter()
|
||||||
|
.map(|c| card_img(c.clone(), "move-card"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
|
||||||
|
view! {
|
||||||
|
div(class="move-banner") {
|
||||||
|
strong { (name) }
|
||||||
|
span { " " (action) " " }
|
||||||
|
(played)
|
||||||
|
(captured)
|
||||||
|
(scopa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component(inline_props)]
|
||||||
|
pub fn GamePage(id: String) -> View {
|
||||||
|
let game = create_signal(Option::<GameView>::None);
|
||||||
|
let error = create_signal(Option::<String>::None);
|
||||||
|
let capture_choice = create_signal(Option::<(String, Vec<Vec<String>>)>::None);
|
||||||
|
let selected = create_signal(Option::<String>::None);
|
||||||
|
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||||
|
let closed = create_signal(false);
|
||||||
|
let socket = create_signal(Option::<GameSocket>::None);
|
||||||
|
|
||||||
|
{
|
||||||
|
let on_message = move |msg: ServerMessage| match msg {
|
||||||
|
ServerMessage::State { game: g } => {
|
||||||
|
capture_choice.set(None);
|
||||||
|
selected.set(None);
|
||||||
|
game.set(Some(g));
|
||||||
|
}
|
||||||
|
ServerMessage::GameOver { scores, winner } => {
|
||||||
|
over.set(Some((scores, winner)));
|
||||||
|
}
|
||||||
|
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
||||||
|
};
|
||||||
|
let on_close = move || closed.set(true);
|
||||||
|
match ws::connect(&id, on_message, on_close) {
|
||||||
|
Some(s) => socket.set(Some(s)),
|
||||||
|
None => error.set(Some("Could not connect to the game".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicking a card in the player's own hand.
|
||||||
|
let on_hand_card = move |code: String| {
|
||||||
|
let Some(g) = game.get_clone() else { return };
|
||||||
|
if g.your_turn != Some(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let options = g
|
||||||
|
.legal_moves
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get(&code))
|
||||||
|
.cloned();
|
||||||
|
match options {
|
||||||
|
None => send_play(socket, code, None),
|
||||||
|
Some(mut opts) if opts.len() == 1 => {
|
||||||
|
send_play(socket, code, Some(opts.remove(0)))
|
||||||
|
}
|
||||||
|
Some(opts) => capture_choice.set(Some((code, opts))),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
div(class="game-page") {
|
||||||
|
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||||
|
(move || match game.get_clone() {
|
||||||
|
None => {
|
||||||
|
let status = if closed.get() {
|
||||||
|
"Connection closed."
|
||||||
|
} else {
|
||||||
|
"Connecting to the game…"
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
div(class="panel status-panel") {
|
||||||
|
p { (status) }
|
||||||
|
p { a(href="/") { "Back to lobby" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||||
|
Some(g) => table_view(g, on_hand_card, selected),
|
||||||
|
})
|
||||||
|
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||||
|
capture_picker(card, options, socket, capture_choice)
|
||||||
|
}))
|
||||||
|
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lobby view while waiting for the fourth player.
|
||||||
|
fn lobby_view(game: GameView) -> View {
|
||||||
|
let seats_open = 4usize.saturating_sub(game.players.len());
|
||||||
|
let join_code = game.join_code.clone();
|
||||||
|
let players = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let name = p.name.clone();
|
||||||
|
let seat = p.seat;
|
||||||
|
let team = p.team.clone();
|
||||||
|
view! {
|
||||||
|
li {
|
||||||
|
strong { (name) }
|
||||||
|
span { " (seat " (seat) ", team " (team) ")" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class="panel status-panel") {
|
||||||
|
h2 { "Waiting for players" }
|
||||||
|
p { "Share this join code:" }
|
||||||
|
p(class="join-code") { (join_code) }
|
||||||
|
ul(class="roster") { (players) }
|
||||||
|
p { (seats_open) " seat(s) still open" }
|
||||||
|
p { a(href="/") { "Back to lobby" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||||
|
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main table view.
|
||||||
|
fn table_view(
|
||||||
|
game: GameView,
|
||||||
|
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||||
|
selected: Signal<Option<String>>,
|
||||||
|
) -> View {
|
||||||
|
// Own seat: the only player entry carrying a hand.
|
||||||
|
let viewer_seat = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.hand.is_some())
|
||||||
|
.map(|p| p.seat)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let left = seat_panel(game.clone(), (viewer_seat + 1) % 4, "left");
|
||||||
|
let top = seat_panel(game.clone(), (viewer_seat + 2) % 4, "top");
|
||||||
|
let right = seat_panel(game.clone(), (viewer_seat + 3) % 4, "right");
|
||||||
|
let my_turn = game.your_turn == Some(true);
|
||||||
|
let turn_note = if game.phase == "finished" {
|
||||||
|
"Match finished".to_string()
|
||||||
|
} else if my_turn {
|
||||||
|
"Your turn".to_string()
|
||||||
|
} else {
|
||||||
|
let name = player_for_seat(&game, game.turn)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{name}'s turn")
|
||||||
|
};
|
||||||
|
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||||||
|
|
||||||
|
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
|
let table_cards = game
|
||||||
|
.table
|
||||||
|
.iter()
|
||||||
|
.map(|c| card_img(c.clone(), "table-card"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let empty_table = game.table.is_empty().then(|| view! {
|
||||||
|
p(class="table-empty") { "Empty table" }
|
||||||
|
});
|
||||||
|
let banner = game.last_move.clone().map(move_banner);
|
||||||
|
let hand_number = game.hand_number;
|
||||||
|
let target_score = game.target_score;
|
||||||
|
|
||||||
|
let hand = player_for_seat(&game, viewer_seat)
|
||||||
|
.and_then(|p| p.hand)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let current_selection = selected.get_clone();
|
||||||
|
let hand_cards = hand
|
||||||
|
.into_iter()
|
||||||
|
.map(|code| {
|
||||||
|
let is_selected = current_selection.as_deref() == Some(code.as_str());
|
||||||
|
let cls = if is_selected {
|
||||||
|
"hand-slot selected"
|
||||||
|
} else {
|
||||||
|
"hand-slot"
|
||||||
|
};
|
||||||
|
let card_view = card_img(code.clone(), "hand-card");
|
||||||
|
view! {
|
||||||
|
button(class=cls, on:click=move |_| {
|
||||||
|
if my_turn {
|
||||||
|
on_hand_card(code.clone());
|
||||||
|
}
|
||||||
|
}) { (card_view) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let hint = my_turn.then(|| view! {
|
||||||
|
p(class="hint") {
|
||||||
|
"Click a card to play it. If it can capture in several ways you "
|
||||||
|
"will be asked to choose."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
div(class="table-wrap") {
|
||||||
|
div(class="hud") {
|
||||||
|
a(href="/") { "← Lobby" }
|
||||||
|
span { "Hand " (hand_number) }
|
||||||
|
span(class="hud-scores") {
|
||||||
|
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||||
|
(target_score) ")"
|
||||||
|
}
|
||||||
|
span(class=turn_cls) { (turn_note) }
|
||||||
|
}
|
||||||
|
div(class="table-grid") {
|
||||||
|
(top)
|
||||||
|
(left)
|
||||||
|
div(class="center") {
|
||||||
|
(banner)
|
||||||
|
div(class="table-cards") {
|
||||||
|
(table_cards)
|
||||||
|
(empty_table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(right)
|
||||||
|
div(class="seat-bottom") {
|
||||||
|
div(class="hand") { (hand_cards) }
|
||||||
|
(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Popup listing the legal captures for a selected card.
|
||||||
|
fn capture_picker(
|
||||||
|
card: String,
|
||||||
|
options: Vec<Vec<String>>,
|
||||||
|
socket: Signal<Option<GameSocket>>,
|
||||||
|
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||||
|
) -> View {
|
||||||
|
let title = format!("Capture with {}", card_label(&card));
|
||||||
|
let option_views = options
|
||||||
|
.into_iter()
|
||||||
|
.map(|capture| {
|
||||||
|
let played_card = card.clone();
|
||||||
|
let cards = capture
|
||||||
|
.iter()
|
||||||
|
.map(|c| card_img(c.clone(), "mini"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
button(class="capture-option", on:click=move |_| {
|
||||||
|
send_play(socket, played_card.clone(), Some(capture.clone()));
|
||||||
|
capture_choice.set(None);
|
||||||
|
}) { (cards) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class="overlay") {
|
||||||
|
div(class="picker") {
|
||||||
|
h3 { (title) }
|
||||||
|
div(class="capture-options") { (option_views) }
|
||||||
|
button(class="button", on:click=move |_| capture_choice.set(None)) { "Cancel" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-of-match overlay.
|
||||||
|
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||||
|
let result = over.or_else(|| {
|
||||||
|
game.filter(|g| g.phase == "finished")
|
||||||
|
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
None => view! {},
|
||||||
|
Some((scores, winner)) => {
|
||||||
|
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||||
|
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
||||||
|
view! {
|
||||||
|
div(class="overlay") {
|
||||||
|
div(class="picker") {
|
||||||
|
h2 { "Match over" }
|
||||||
|
p { (line) }
|
||||||
|
div(class="gameover-actions") {
|
||||||
|
a(class="button primary", href="/") { "Back to lobby" }
|
||||||
|
a(class="button", href="/history") { "My matches" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Match history page.
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::model::MatchesPage;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn HistoryPage() -> View {
|
||||||
|
let page = create_signal(Option::<MatchesPage>::None);
|
||||||
|
let error = create_signal(Option::<String>::None);
|
||||||
|
let cursor = create_signal(Option::<String>::None);
|
||||||
|
// Accumulated rows across "load more" clicks.
|
||||||
|
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
|
||||||
|
|
||||||
|
let load = move |next: Option<String>| {
|
||||||
|
spawn_local(async move {
|
||||||
|
match api::my_matches(next.as_deref()).await {
|
||||||
|
Ok(p) => {
|
||||||
|
cursor.set(p.next_cursor.clone());
|
||||||
|
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||||
|
page.set(Some(p));
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let load2 = load;
|
||||||
|
spawn_local(async move {
|
||||||
|
match api::my_matches(None).await {
|
||||||
|
Ok(p) => {
|
||||||
|
cursor.set(p.next_cursor.clone());
|
||||||
|
rows.set(p.results.clone());
|
||||||
|
page.set(Some(p));
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
div(class="page") {
|
||||||
|
nav(class="top-nav") {
|
||||||
|
a(href="/") { "← Lobby" }
|
||||||
|
a(href="/leaderboard") { "Leaderboard" }
|
||||||
|
}
|
||||||
|
h1 { "My matches" }
|
||||||
|
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||||
|
(move || match page.get_clone() {
|
||||||
|
None => view! { p(class="status") { "Loading…" } },
|
||||||
|
Some(_) if rows.get_clone().is_empty() => view! {
|
||||||
|
p(class="status") { "No matches played yet." }
|
||||||
|
},
|
||||||
|
Some(_) => {
|
||||||
|
let table_rows = rows
|
||||||
|
.get_clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let team_a: String = m
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.team == "A")
|
||||||
|
.map(|p| p.display_name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" & ");
|
||||||
|
let team_b: String = m
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.team == "B")
|
||||||
|
.map(|p| p.display_name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" & ");
|
||||||
|
let outcome = if m.you_won { "Won" } else { "Lost" };
|
||||||
|
view! {
|
||||||
|
tr {
|
||||||
|
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
|
||||||
|
td { (team_a) }
|
||||||
|
td { (team_b) }
|
||||||
|
td { (m.team_a_score) " – " (m.team_b_score) }
|
||||||
|
td { "Team " (m.winner_team) }
|
||||||
|
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
table(class="matches") {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Finished" }
|
||||||
|
th { "Team A" }
|
||||||
|
th { "Team B" }
|
||||||
|
th { "Score" }
|
||||||
|
th { "Winner" }
|
||||||
|
th { "You" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody { (table_rows) }
|
||||||
|
}
|
||||||
|
(cursor.get_clone().map(|c| view! {
|
||||||
|
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
|
||||||
|
"Load more"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Global leaderboard page.
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::model::LeaderboardPage;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LeaderboardPage() -> View {
|
||||||
|
let page = create_signal(Option::<LeaderboardPage>::None);
|
||||||
|
let error = create_signal(Option::<String>::None);
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
match api::leaderboard().await {
|
||||||
|
Ok(p) => page.set(Some(p)),
|
||||||
|
Err(e) => error.set(Some(e)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
div(class="page") {
|
||||||
|
nav(class="top-nav") {
|
||||||
|
a(href="/") { "← Lobby" }
|
||||||
|
a(href="/history") { "My matches" }
|
||||||
|
}
|
||||||
|
h1 { "Leaderboard" }
|
||||||
|
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||||
|
(move || match page.get_clone() {
|
||||||
|
None => view! { p(class="status") { "Loading…" } },
|
||||||
|
Some(p) => {
|
||||||
|
let rows = p
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, e)| {
|
||||||
|
view! {
|
||||||
|
tr {
|
||||||
|
td { (i + 1) }
|
||||||
|
td { (e.display_name.clone()) }
|
||||||
|
td { (e.wins) }
|
||||||
|
td { (e.matches) }
|
||||||
|
td { (e.points) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
table(class="matches") {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "#" }
|
||||||
|
th { "Player" }
|
||||||
|
th { "Wins" }
|
||||||
|
th { "Matches" }
|
||||||
|
th { "Points" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody { (rows) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||