From 96a95d74b6612c3416d6fb2c44f55d83ce97ac27 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Wed, 16 Sep 2026 13:20:05 +0800 Subject: [PATCH] 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. --- .dockerignore | 20 +- .gitignore | 3 + Dockerfile | 60 - README.md | 186 +- docker-compose.yml | 20 +- .env.example => server/.env.example | 0 server/Dockerfile | 88 + server/README.md | 193 + conftest.py => server/conftest.py | 0 {dev => server/dev}/mockoauth/Dockerfile | 0 {dev => server/dev}/mockoauth/config.json | 0 .../models/0_20260915224544_init.py | 0 pyproject.toml => server/pyproject.toml | 0 requirements.txt => server/requirements.txt | 0 {src => server/src}/scopa/__init__.py | 0 {src => server/src}/scopa/aerich_config.py | 0 {src => server/src}/scopa/app.py | 9 +- {src => server/src}/scopa/auth.py | 0 {src => server/src}/scopa/config.py | 11 + {src => server/src}/scopa/game/__init__.py | 0 {src => server/src}/scopa/game/engine.py | 24 + {src => server/src}/scopa/game/errors.py | 0 {src => server/src}/scopa/game/state.py | 35 + {src => server/src}/scopa/http.py | 0 {src => server/src}/scopa/models.py | 0 {src => server/src}/scopa/openapi.py | 0 {src => server/src}/scopa/pagination.py | 0 {src => server/src}/scopa/routes/__init__.py | 0 {src => server/src}/scopa/routes/games.py | 0 {src => server/src}/scopa/routes/health.py | 0 server/src/scopa/routes/me.py | 25 + server/src/scopa/routes/static.py | 79 + {src => server/src}/scopa/routes/stats.py | 0 {src => server/src}/scopa/stats.py | 0 {src => server/src}/scopa/store.py | 0 {src => server/src}/scopa/tortoise_mixin.py | 5 + {src => server/src}/scopa/ws.py | 0 {tests => server/tests}/__init__.py | 0 {tests => server/tests}/helpers/__init__.py | 0 {tests => server/tests}/helpers/oidc.py | 0 {tests => server/tests}/test_engine.py | 35 + {tests => server/tests}/test_routes_games.py | 0 server/tests/test_routes_me.py | 85 + {tests => server/tests}/test_stats.py | 0 {tests => server/tests}/test_store.py | 0 {tests => server/tests}/test_websocket.py | 0 web/Cargo.lock | 697 ++ web/Cargo.toml | 21 + web/Trunk.toml | 18 + web/assets/cards/01B.svg | 3908 ++++++++ web/assets/cards/01C.svg | 4072 +++++++++ web/assets/cards/01D.svg | 7818 +++++++++++++++++ web/assets/cards/01S.svg | 3264 +++++++ web/assets/cards/02B.svg | 3421 ++++++++ web/assets/cards/02C.svg | 3819 ++++++++ web/assets/cards/02D.svg | 3449 ++++++++ web/assets/cards/02S.svg | 2514 ++++++ web/assets/cards/03B.svg | 3736 ++++++++ web/assets/cards/03C.svg | 2219 +++++ web/assets/cards/03D.svg | 2654 ++++++ web/assets/cards/03S.svg | 3140 +++++++ web/assets/cards/04B.svg | 3106 +++++++ web/assets/cards/04C.svg | 3353 +++++++ web/assets/cards/04D.svg | 2599 ++++++ web/assets/cards/04S.svg | 2148 +++++ web/assets/cards/05B.svg | 3099 +++++++ web/assets/cards/05C.svg | 3076 +++++++ web/assets/cards/05D.svg | 2883 ++++++ web/assets/cards/05S.svg | 2110 +++++ web/assets/cards/06B.svg | 3717 ++++++++ web/assets/cards/06C.svg | 3287 +++++++ web/assets/cards/06D.svg | 2390 +++++ web/assets/cards/06S.svg | 2182 +++++ web/assets/cards/07B.svg | 3933 +++++++++ web/assets/cards/07C.svg | 2771 ++++++ web/assets/cards/07D.svg | 2602 ++++++ web/assets/cards/07S.svg | 2298 +++++ web/assets/cards/08B.svg | 4001 +++++++++ web/assets/cards/08C.svg | 3505 ++++++++ web/assets/cards/08D.svg | 3640 ++++++++ web/assets/cards/08S.svg | 4530 ++++++++++ web/assets/cards/09B.svg | 4966 +++++++++++ web/assets/cards/09C.svg | 4890 +++++++++++ web/assets/cards/09D.svg | 5078 +++++++++++ web/assets/cards/09S.svg | 4966 +++++++++++ web/assets/cards/10B.svg | 5388 ++++++++++++ web/assets/cards/10C.svg | 5071 +++++++++++ web/assets/cards/10D.svg | 5107 +++++++++++ web/assets/cards/10S.svg | 5288 +++++++++++ web/assets/cards/back.svg | 2541 ++++++ web/fetch-cards.sh | 42 + web/index.html | 13 + web/src/api.rs | 90 + web/src/components/card.rs | 21 + web/src/components/mod.rs | 1 + web/src/main.rs | 57 + web/src/model.rs | 206 + web/src/pages/game.rs | 346 + web/src/pages/history.rs | 110 + web/src/pages/leaderboard.rs | 66 + web/src/pages/lobby.rs | 94 + web/src/pages/mod.rs | 4 + web/src/ws.rs | 87 + web/style.css | 430 + 104 files changed, 151484 insertions(+), 236 deletions(-) delete mode 100644 Dockerfile rename .env.example => server/.env.example (100%) create mode 100644 server/Dockerfile create mode 100644 server/README.md rename conftest.py => server/conftest.py (100%) rename {dev => server/dev}/mockoauth/Dockerfile (100%) rename {dev => server/dev}/mockoauth/config.json (100%) rename {migrations => server/migrations}/models/0_20260915224544_init.py (100%) rename pyproject.toml => server/pyproject.toml (100%) rename requirements.txt => server/requirements.txt (100%) rename {src => server/src}/scopa/__init__.py (100%) rename {src => server/src}/scopa/aerich_config.py (100%) rename {src => server/src}/scopa/app.py (89%) rename {src => server/src}/scopa/auth.py (100%) rename {src => server/src}/scopa/config.py (75%) rename {src => server/src}/scopa/game/__init__.py (100%) rename {src => server/src}/scopa/game/engine.py (93%) rename {src => server/src}/scopa/game/errors.py (100%) rename {src => server/src}/scopa/game/state.py (84%) rename {src => server/src}/scopa/http.py (100%) rename {src => server/src}/scopa/models.py (100%) rename {src => server/src}/scopa/openapi.py (100%) rename {src => server/src}/scopa/pagination.py (100%) rename {src => server/src}/scopa/routes/__init__.py (100%) rename {src => server/src}/scopa/routes/games.py (100%) rename {src => server/src}/scopa/routes/health.py (100%) create mode 100644 server/src/scopa/routes/me.py create mode 100644 server/src/scopa/routes/static.py rename {src => server/src}/scopa/routes/stats.py (100%) rename {src => server/src}/scopa/stats.py (100%) rename {src => server/src}/scopa/store.py (100%) rename {src => server/src}/scopa/tortoise_mixin.py (93%) rename {src => server/src}/scopa/ws.py (100%) rename {tests => server/tests}/__init__.py (100%) rename {tests => server/tests}/helpers/__init__.py (100%) rename {tests => server/tests}/helpers/oidc.py (100%) rename {tests => server/tests}/test_engine.py (87%) rename {tests => server/tests}/test_routes_games.py (100%) create mode 100644 server/tests/test_routes_me.py rename {tests => server/tests}/test_stats.py (100%) rename {tests => server/tests}/test_store.py (100%) rename {tests => server/tests}/test_websocket.py (100%) create mode 100644 web/Cargo.lock create mode 100644 web/Cargo.toml create mode 100644 web/Trunk.toml create mode 100644 web/assets/cards/01B.svg create mode 100644 web/assets/cards/01C.svg create mode 100644 web/assets/cards/01D.svg create mode 100644 web/assets/cards/01S.svg create mode 100644 web/assets/cards/02B.svg create mode 100644 web/assets/cards/02C.svg create mode 100644 web/assets/cards/02D.svg create mode 100644 web/assets/cards/02S.svg create mode 100644 web/assets/cards/03B.svg create mode 100644 web/assets/cards/03C.svg create mode 100644 web/assets/cards/03D.svg create mode 100644 web/assets/cards/03S.svg create mode 100644 web/assets/cards/04B.svg create mode 100644 web/assets/cards/04C.svg create mode 100644 web/assets/cards/04D.svg create mode 100644 web/assets/cards/04S.svg create mode 100644 web/assets/cards/05B.svg create mode 100644 web/assets/cards/05C.svg create mode 100644 web/assets/cards/05D.svg create mode 100644 web/assets/cards/05S.svg create mode 100644 web/assets/cards/06B.svg create mode 100644 web/assets/cards/06C.svg create mode 100644 web/assets/cards/06D.svg create mode 100644 web/assets/cards/06S.svg create mode 100644 web/assets/cards/07B.svg create mode 100644 web/assets/cards/07C.svg create mode 100644 web/assets/cards/07D.svg create mode 100644 web/assets/cards/07S.svg create mode 100644 web/assets/cards/08B.svg create mode 100644 web/assets/cards/08C.svg create mode 100644 web/assets/cards/08D.svg create mode 100644 web/assets/cards/08S.svg create mode 100644 web/assets/cards/09B.svg create mode 100644 web/assets/cards/09C.svg create mode 100644 web/assets/cards/09D.svg create mode 100644 web/assets/cards/09S.svg create mode 100644 web/assets/cards/10B.svg create mode 100644 web/assets/cards/10C.svg create mode 100644 web/assets/cards/10D.svg create mode 100644 web/assets/cards/10S.svg create mode 100644 web/assets/cards/back.svg create mode 100755 web/fetch-cards.sh create mode 100644 web/index.html create mode 100644 web/src/api.rs create mode 100644 web/src/components/card.rs create mode 100644 web/src/components/mod.rs create mode 100644 web/src/main.rs create mode 100644 web/src/model.rs create mode 100644 web/src/pages/game.rs create mode 100644 web/src/pages/history.rs create mode 100644 web/src/pages/leaderboard.rs create mode 100644 web/src/pages/lobby.rs create mode 100644 web/src/pages/mod.rs create mode 100644 web/src/ws.rs create mode 100644 web/style.css diff --git a/.dockerignore b/.dockerignore index d9e16ee..bac31ec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,21 +1,19 @@ -# Keep the build context lean: exclude the local venv, caches, tests, -# and anything not needed to build the scopa wheel. +# Build context is the repository root (see docker-compose.yml). Keep it +# lean: venvs, caches, Rust build output and tests are not needed to build +# the image. .git/ .gitignore .env .env.example -.venv/ -__pycache__/ +**/.venv/ +**/__pycache__/ *.pyc *.pyo *.egg-info/ .mypy_cache/ .pytest_cache/ -dist/ -build/ -conf/ -tests/ -Dockerfile -.dockerignore -opencode.json +web/target/ +web/dist/ +server/tests/ docker-compose.yml +opencode.json diff --git a/.gitignore b/.gitignore index aee049f..36f69be 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ build/ .mypy_cache/ .pytest_cache/ + +# Rust build output +target/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f81d9c2..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index ff8d971..bfc2582 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,39 @@ # 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. +Multiplayer **scopone scientifico** — the four-player, fixed-partnership +Italian card game — as a web application: -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. +- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya) + framework, OIDC login, live games in Redis, match statistics in Postgres. + See [server/README.md](server/README.md). +- **`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 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`. +Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`, +`dave`), the database migrator and the app all come up together. The app — +frontend and API — listens 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: ```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:` — 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:` — the 6-character join code → game id index. -- `scopa:game::lock` — a short-lived lock serializing every mutation. -- `scopa:game::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 +Backend (from `server/`): + ```sh +cd server python3 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/pip install -e '.[dev]' @@ -142,47 +41,22 @@ python3 -m venv .venv .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`: +Frontend (from `web/`), with the backend running on :8080: ```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 +cd web +trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied ``` -(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite; -adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing -baseline.) +Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see +`web/Trunk.toml`). For the login redirect to land back on the dev server, +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 +``` -``` -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 -``` +Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh` +re-downloads them if needed. diff --git a/docker-compose.yml b/docker-compose.yml index e418704..3f6dcdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: # Derived image baking dev/mockoauth/config.json in (see # dev/mockoauth/Dockerfile) — a bind mount would not work against # containerized docker daemons. - build: ./dev/mockoauth + build: ./server/dev/mockoauth environment: SERVER_PORT: "8180" LOG_LEVEL: INFO @@ -63,7 +63,9 @@ services: # One-shot: applies pending aerich migrations before the app starts # (build cache makes the second build of the same Dockerfile instant). db-migrate: - build: . + build: + context: . + dockerfile: server/Dockerfile # aerich reads [tool.aerich] from /app/pyproject.toml (default config # file) and finds migrations in ./migrations relative to working_dir. working_dir: /app @@ -75,7 +77,9 @@ services: condition: service_healthy scopa: - build: . + build: + context: . + dockerfile: server/Dockerfile depends_on: postgres: condition: service_healthy @@ -87,15 +91,17 @@ services: condition: service_healthy environment: 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. OIDC_CLIENT_ID: scopa OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret} - # Browser-facing callback: the app is published on host port 8080. - OIDC_REDIRECT_URI: http://localhost:8080/auth/callback + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback} REDIS_URL: redis://redis:6379/0 ports: - - "127.0.0.1:8080:8080" + - "127.0.0.1:${APP_PORT:-8080}:8080" volumes: pgdata: diff --git a/.env.example b/server/.env.example similarity index 100% rename from .env.example rename to server/.env.example diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..5db236f --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..5dba6c8 --- /dev/null +++ b/server/README.md @@ -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:` — 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:` — the 6-character join code → game id index. +- `scopa:game::lock` — a short-lived lock serializing every mutation. +- `scopa:game::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 +``` diff --git a/conftest.py b/server/conftest.py similarity index 100% rename from conftest.py rename to server/conftest.py diff --git a/dev/mockoauth/Dockerfile b/server/dev/mockoauth/Dockerfile similarity index 100% rename from dev/mockoauth/Dockerfile rename to server/dev/mockoauth/Dockerfile diff --git a/dev/mockoauth/config.json b/server/dev/mockoauth/config.json similarity index 100% rename from dev/mockoauth/config.json rename to server/dev/mockoauth/config.json diff --git a/migrations/models/0_20260915224544_init.py b/server/migrations/models/0_20260915224544_init.py similarity index 100% rename from migrations/models/0_20260915224544_init.py rename to server/migrations/models/0_20260915224544_init.py diff --git a/pyproject.toml b/server/pyproject.toml similarity index 100% rename from pyproject.toml rename to server/pyproject.toml diff --git a/requirements.txt b/server/requirements.txt similarity index 100% rename from requirements.txt rename to server/requirements.txt diff --git a/src/scopa/__init__.py b/server/src/scopa/__init__.py similarity index 100% rename from src/scopa/__init__.py rename to server/src/scopa/__init__.py diff --git a/src/scopa/aerich_config.py b/server/src/scopa/aerich_config.py similarity index 100% rename from src/scopa/aerich_config.py rename to server/src/scopa/aerich_config.py diff --git a/src/scopa/app.py b/server/src/scopa/app.py similarity index 89% rename from src/scopa/app.py rename to server/src/scopa/app.py index 8a67955..bfa9398 100644 --- a/src/scopa/app.py +++ b/server/src/scopa/app.py @@ -50,6 +50,8 @@ oidc_mixin = OIDCMixin( client_id=settings.oidc_client_id, client_secret=settings.oidc_client_secret, 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, ), session=session_mixin, @@ -70,6 +72,9 @@ tortoise_mixin = TortoiseMixin( app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin]) # Register routes by importing modules. Order does not matter; each module -# pulls ``app`` from here and decorates its handlers at import time. -from .routes import games, health, stats # noqa: E402,F401 +# pulls ``app`` from here and decorates its handlers at import time. The +# 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 .routes import static # noqa: E402,F401 diff --git a/src/scopa/auth.py b/server/src/scopa/auth.py similarity index 100% rename from src/scopa/auth.py rename to server/src/scopa/auth.py diff --git a/src/scopa/config.py b/server/src/scopa/config.py similarity index 75% rename from src/scopa/config.py rename to server/src/scopa/config.py index 65d0814..13ea30b 100644 --- a/src/scopa/config.py +++ b/server/src/scopa/config.py @@ -26,12 +26,20 @@ class Settings: oidc_client_id: str oidc_client_secret: Optional[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_port: int redis_url: Optional[str] # How long a live game (and its join-code index) survives in Redis # without activity, in seconds. Defaults to 24h. 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 def from_env() -> "Settings": @@ -41,6 +49,8 @@ class Settings: oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"), oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"), 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_port=int(_env("APP_PORT", "8080")), # 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_url=os.environ.get("REDIS_URL"), game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")), + static_dir=_env("STATIC_DIR", "web/dist"), ) diff --git a/src/scopa/game/__init__.py b/server/src/scopa/game/__init__.py similarity index 100% rename from src/scopa/game/__init__.py rename to server/src/scopa/game/__init__.py diff --git a/src/scopa/game/engine.py b/server/src/scopa/game/engine.py similarity index 93% rename from src/scopa/game/engine.py rename to server/src/scopa/game/engine.py index f43c8cb..53cc965 100644 --- a/src/scopa/game/engine.py +++ b/server/src/scopa/game/engine.py @@ -49,6 +49,7 @@ from .state import ( TEAM_NAMES, Card, GameState, + Move, PlayerState, parse_card, ) @@ -209,6 +210,8 @@ def play( requested = [parse_card(c) for c in (capture_codes or [])] options = legal_captures(state.table, card) + taken: List[Card] = [] + scopa = False if not options: if requested: raise IllegalMove("no capture is possible with that card") @@ -217,6 +220,7 @@ def play( chosen = _match_option(options, requested) if chosen is None: raise IllegalMove("the requested capture is not legal") + taken = chosen for captured in chosen: state.table.remove(captured) player.captured.append(captured) @@ -226,6 +230,15 @@ def play( hands_empty = all(not p.hand for p in state.players) if not state.table and not hands_empty: 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): _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], "players": players, "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: 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 diff --git a/src/scopa/game/errors.py b/server/src/scopa/game/errors.py similarity index 100% rename from src/scopa/game/errors.py rename to server/src/scopa/game/errors.py diff --git a/src/scopa/game/state.py b/server/src/scopa/game/state.py similarity index 84% rename from src/scopa/game/state.py rename to server/src/scopa/game/state.py index 5b79841..020bed7 100644 --- a/src/scopa/game/state.py +++ b/server/src/scopa/game/state.py @@ -73,6 +73,37 @@ def parse_card(code: Any) -> Card: 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 class PlayerState: sub: str @@ -129,6 +160,8 @@ class GameState: # ISO-8601 timestamps, used when the match result is written to Postgres. created_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 ---------------------------------------------------- @@ -151,6 +184,7 @@ class GameState: "stats_saved": self.stats_saved, "created_at": self.created_at, "finished_at": self.finished_at, + "last_move": self.last_move.to_json() if self.last_move else None, } @staticmethod @@ -173,6 +207,7 @@ class GameState: stats_saved=bool(data.get("stats_saved", False)), created_at=data.get("created_at"), finished_at=data.get("finished_at"), + last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None, ) # -- helpers ---------------------------------------------------------- diff --git a/src/scopa/http.py b/server/src/scopa/http.py similarity index 100% rename from src/scopa/http.py rename to server/src/scopa/http.py diff --git a/src/scopa/models.py b/server/src/scopa/models.py similarity index 100% rename from src/scopa/models.py rename to server/src/scopa/models.py diff --git a/src/scopa/openapi.py b/server/src/scopa/openapi.py similarity index 100% rename from src/scopa/openapi.py rename to server/src/scopa/openapi.py diff --git a/src/scopa/pagination.py b/server/src/scopa/pagination.py similarity index 100% rename from src/scopa/pagination.py rename to server/src/scopa/pagination.py diff --git a/src/scopa/routes/__init__.py b/server/src/scopa/routes/__init__.py similarity index 100% rename from src/scopa/routes/__init__.py rename to server/src/scopa/routes/__init__.py diff --git a/src/scopa/routes/games.py b/server/src/scopa/routes/games.py similarity index 100% rename from src/scopa/routes/games.py rename to server/src/scopa/routes/games.py diff --git a/src/scopa/routes/health.py b/server/src/scopa/routes/health.py similarity index 100% rename from src/scopa/routes/health.py rename to server/src/scopa/routes/health.py diff --git a/server/src/scopa/routes/me.py b/server/src/scopa/routes/me.py new file mode 100644 index 0000000..7ac5c21 --- /dev/null +++ b/server/src/scopa/routes/me.py @@ -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)}) diff --git a/server/src/scopa/routes/static.py b/server/src/scopa/routes/static.py new file mode 100644 index 0000000..895aee9 --- /dev/null +++ b/server/src/scopa/routes/static.py @@ -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/`` 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) diff --git a/src/scopa/routes/stats.py b/server/src/scopa/routes/stats.py similarity index 100% rename from src/scopa/routes/stats.py rename to server/src/scopa/routes/stats.py diff --git a/src/scopa/stats.py b/server/src/scopa/stats.py similarity index 100% rename from src/scopa/stats.py rename to server/src/scopa/stats.py diff --git a/src/scopa/store.py b/server/src/scopa/store.py similarity index 100% rename from src/scopa/store.py rename to server/src/scopa/store.py diff --git a/src/scopa/tortoise_mixin.py b/server/src/scopa/tortoise_mixin.py similarity index 93% rename from src/scopa/tortoise_mixin.py rename to server/src/scopa/tortoise_mixin.py index f087e59..7e72d14 100644 --- a/src/scopa/tortoise_mixin.py +++ b/server/src/scopa/tortoise_mixin.py @@ -96,6 +96,11 @@ class TortoiseMixin(KayaMixin): async def _ensure_context(self, ctx: HttpContext): if ctx.path in self._skip_paths: 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() return None diff --git a/src/scopa/ws.py b/server/src/scopa/ws.py similarity index 100% rename from src/scopa/ws.py rename to server/src/scopa/ws.py diff --git a/tests/__init__.py b/server/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to server/tests/__init__.py diff --git a/tests/helpers/__init__.py b/server/tests/helpers/__init__.py similarity index 100% rename from tests/helpers/__init__.py rename to server/tests/helpers/__init__.py diff --git a/tests/helpers/oidc.py b/server/tests/helpers/oidc.py similarity index 100% rename from tests/helpers/oidc.py rename to server/tests/helpers/oidc.py diff --git a/tests/test_engine.py b/server/tests/test_engine.py similarity index 87% rename from tests/test_engine.py rename to server/tests/test_engine.py index 815208d..93239a7 100644 --- a/tests/test_engine.py +++ b/server/tests/test_engine.py @@ -113,6 +113,22 @@ class CaptureTest(unittest.TestCase): self.assertEqual( ["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: state = make_state([["05D"], ["01C"], ["01S"], ["01B"]], @@ -255,6 +271,25 @@ class MatchFlowTest(unittest.TestCase): self.assertEqual(["07C"], view["table"]) 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: state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11) for i in range(1, 4): diff --git a/tests/test_routes_games.py b/server/tests/test_routes_games.py similarity index 100% rename from tests/test_routes_games.py rename to server/tests/test_routes_games.py diff --git a/server/tests/test_routes_me.py b/server/tests/test_routes_me.py new file mode 100644 index 0000000..29530de --- /dev/null +++ b/server/tests/test_routes_me.py @@ -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("spa") + (root / "app.js").write_text("console.log(1)") + cards = root / "assets" / "cards" + cards.mkdir(parents=True) + (cards / "07D.svg").write_text("") + + 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() diff --git a/tests/test_stats.py b/server/tests/test_stats.py similarity index 100% rename from tests/test_stats.py rename to server/tests/test_stats.py diff --git a/tests/test_store.py b/server/tests/test_store.py similarity index 100% rename from tests/test_store.py rename to server/tests/test_store.py diff --git a/tests/test_websocket.py b/server/tests/test_websocket.py similarity index 100% rename from tests/test_websocket.py rename to server/tests/test_websocket.py diff --git a/web/Cargo.lock b/web/Cargo.lock new file mode 100644 index 0000000..e843f54 --- /dev/null +++ b/web/Cargo.lock @@ -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" diff --git a/web/Cargo.toml b/web/Cargo.toml new file mode 100644 index 0000000..310773d --- /dev/null +++ b/web/Cargo.toml @@ -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 diff --git a/web/Trunk.toml b/web/Trunk.toml new file mode 100644 index 0000000..360972c --- /dev/null +++ b/web/Trunk.toml @@ -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 diff --git a/web/assets/cards/01B.svg b/web/assets/cards/01B.svg new file mode 100644 index 0000000..af106b0 --- /dev/null +++ b/web/assets/cards/01B.svg @@ -0,0 +1,3908 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Asso di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/01C.svg b/web/assets/cards/01C.svg new file mode 100644 index 0000000..c936a69 --- /dev/null +++ b/web/assets/cards/01C.svg @@ -0,0 +1,4072 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Asso di Coppe + \ No newline at end of file diff --git a/web/assets/cards/01D.svg b/web/assets/cards/01D.svg new file mode 100644 index 0000000..7f4d152 --- /dev/null +++ b/web/assets/cards/01D.svg @@ -0,0 +1,7818 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Asso di Denari + \ No newline at end of file diff --git a/web/assets/cards/01S.svg b/web/assets/cards/01S.svg new file mode 100644 index 0000000..cbac981 --- /dev/null +++ b/web/assets/cards/01S.svg @@ -0,0 +1,3264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Asso di Spade + \ No newline at end of file diff --git a/web/assets/cards/02B.svg b/web/assets/cards/02B.svg new file mode 100644 index 0000000..4d2b03b --- /dev/null +++ b/web/assets/cards/02B.svg @@ -0,0 +1,3421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Due di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/02C.svg b/web/assets/cards/02C.svg new file mode 100644 index 0000000..9ce067a --- /dev/null +++ b/web/assets/cards/02C.svg @@ -0,0 +1,3819 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Due di Coppe + \ No newline at end of file diff --git a/web/assets/cards/02D.svg b/web/assets/cards/02D.svg new file mode 100644 index 0000000..1e4d21f --- /dev/null +++ b/web/assets/cards/02D.svg @@ -0,0 +1,3449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Due di Denari + \ No newline at end of file diff --git a/web/assets/cards/02S.svg b/web/assets/cards/02S.svg new file mode 100644 index 0000000..ebc89b1 --- /dev/null +++ b/web/assets/cards/02S.svg @@ -0,0 +1,2514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Due di Spade + \ No newline at end of file diff --git a/web/assets/cards/03B.svg b/web/assets/cards/03B.svg new file mode 100644 index 0000000..11046b9 --- /dev/null +++ b/web/assets/cards/03B.svg @@ -0,0 +1,3736 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tre di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/03C.svg b/web/assets/cards/03C.svg new file mode 100644 index 0000000..cd17ceb --- /dev/null +++ b/web/assets/cards/03C.svg @@ -0,0 +1,2219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tre di Coppe + \ No newline at end of file diff --git a/web/assets/cards/03D.svg b/web/assets/cards/03D.svg new file mode 100644 index 0000000..2a41b23 --- /dev/null +++ b/web/assets/cards/03D.svg @@ -0,0 +1,2654 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tre di Denari + \ No newline at end of file diff --git a/web/assets/cards/03S.svg b/web/assets/cards/03S.svg new file mode 100644 index 0000000..2bf76fa --- /dev/null +++ b/web/assets/cards/03S.svg @@ -0,0 +1,3140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tre di Spade + \ No newline at end of file diff --git a/web/assets/cards/04B.svg b/web/assets/cards/04B.svg new file mode 100644 index 0000000..302ebdf --- /dev/null +++ b/web/assets/cards/04B.svg @@ -0,0 +1,3106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quattro di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/04C.svg b/web/assets/cards/04C.svg new file mode 100644 index 0000000..06a4e76 --- /dev/null +++ b/web/assets/cards/04C.svg @@ -0,0 +1,3353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quattro di Coppe + \ No newline at end of file diff --git a/web/assets/cards/04D.svg b/web/assets/cards/04D.svg new file mode 100644 index 0000000..cde3031 --- /dev/null +++ b/web/assets/cards/04D.svg @@ -0,0 +1,2599 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quattro di Denari + \ No newline at end of file diff --git a/web/assets/cards/04S.svg b/web/assets/cards/04S.svg new file mode 100644 index 0000000..942a309 --- /dev/null +++ b/web/assets/cards/04S.svg @@ -0,0 +1,2148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quattro di Spade + \ No newline at end of file diff --git a/web/assets/cards/05B.svg b/web/assets/cards/05B.svg new file mode 100644 index 0000000..347bcf1 --- /dev/null +++ b/web/assets/cards/05B.svg @@ -0,0 +1,3099 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cinque di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/05C.svg b/web/assets/cards/05C.svg new file mode 100644 index 0000000..f9653fa --- /dev/null +++ b/web/assets/cards/05C.svg @@ -0,0 +1,3076 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cinque di Coppe + \ No newline at end of file diff --git a/web/assets/cards/05D.svg b/web/assets/cards/05D.svg new file mode 100644 index 0000000..83c669b --- /dev/null +++ b/web/assets/cards/05D.svg @@ -0,0 +1,2883 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cinque di Denari + \ No newline at end of file diff --git a/web/assets/cards/05S.svg b/web/assets/cards/05S.svg new file mode 100644 index 0000000..ca6ad06 --- /dev/null +++ b/web/assets/cards/05S.svg @@ -0,0 +1,2110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cinque di Spade + \ No newline at end of file diff --git a/web/assets/cards/06B.svg b/web/assets/cards/06B.svg new file mode 100644 index 0000000..02982e8 --- /dev/null +++ b/web/assets/cards/06B.svg @@ -0,0 +1,3717 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sei di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/06C.svg b/web/assets/cards/06C.svg new file mode 100644 index 0000000..f8ecc86 --- /dev/null +++ b/web/assets/cards/06C.svg @@ -0,0 +1,3287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sei di Coppe + \ No newline at end of file diff --git a/web/assets/cards/06D.svg b/web/assets/cards/06D.svg new file mode 100644 index 0000000..0947253 --- /dev/null +++ b/web/assets/cards/06D.svg @@ -0,0 +1,2390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sei di Denari + \ No newline at end of file diff --git a/web/assets/cards/06S.svg b/web/assets/cards/06S.svg new file mode 100644 index 0000000..c082fb9 --- /dev/null +++ b/web/assets/cards/06S.svg @@ -0,0 +1,2182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sei di Spade + \ No newline at end of file diff --git a/web/assets/cards/07B.svg b/web/assets/cards/07B.svg new file mode 100644 index 0000000..85207d4 --- /dev/null +++ b/web/assets/cards/07B.svg @@ -0,0 +1,3933 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sette di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/07C.svg b/web/assets/cards/07C.svg new file mode 100644 index 0000000..70c8160 --- /dev/null +++ b/web/assets/cards/07C.svg @@ -0,0 +1,2771 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sette di Coppe + \ No newline at end of file diff --git a/web/assets/cards/07D.svg b/web/assets/cards/07D.svg new file mode 100644 index 0000000..de4f0ec --- /dev/null +++ b/web/assets/cards/07D.svg @@ -0,0 +1,2602 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sette di Denari + \ No newline at end of file diff --git a/web/assets/cards/07S.svg b/web/assets/cards/07S.svg new file mode 100644 index 0000000..afe46fb --- /dev/null +++ b/web/assets/cards/07S.svg @@ -0,0 +1,2298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sette di Spade + \ No newline at end of file diff --git a/web/assets/cards/08B.svg b/web/assets/cards/08B.svg new file mode 100644 index 0000000..0241808 --- /dev/null +++ b/web/assets/cards/08B.svg @@ -0,0 +1,4001 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fante di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/08C.svg b/web/assets/cards/08C.svg new file mode 100644 index 0000000..57a6787 --- /dev/null +++ b/web/assets/cards/08C.svg @@ -0,0 +1,3505 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fante di Coppe + \ No newline at end of file diff --git a/web/assets/cards/08D.svg b/web/assets/cards/08D.svg new file mode 100644 index 0000000..65fb782 --- /dev/null +++ b/web/assets/cards/08D.svg @@ -0,0 +1,3640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fante di Denari + \ No newline at end of file diff --git a/web/assets/cards/08S.svg b/web/assets/cards/08S.svg new file mode 100644 index 0000000..d761752 --- /dev/null +++ b/web/assets/cards/08S.svg @@ -0,0 +1,4530 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fante di Spade + \ No newline at end of file diff --git a/web/assets/cards/09B.svg b/web/assets/cards/09B.svg new file mode 100644 index 0000000..ed132bc --- /dev/null +++ b/web/assets/cards/09B.svg @@ -0,0 +1,4966 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cavallo di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/09C.svg b/web/assets/cards/09C.svg new file mode 100644 index 0000000..dabcfce --- /dev/null +++ b/web/assets/cards/09C.svg @@ -0,0 +1,4890 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cavallo di Coppe + \ No newline at end of file diff --git a/web/assets/cards/09D.svg b/web/assets/cards/09D.svg new file mode 100644 index 0000000..8fc2488 --- /dev/null +++ b/web/assets/cards/09D.svg @@ -0,0 +1,5078 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cavallo di Denari + \ No newline at end of file diff --git a/web/assets/cards/09S.svg b/web/assets/cards/09S.svg new file mode 100644 index 0000000..0b5bf5f --- /dev/null +++ b/web/assets/cards/09S.svg @@ -0,0 +1,4966 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cavallo di Spade + \ No newline at end of file diff --git a/web/assets/cards/10B.svg b/web/assets/cards/10B.svg new file mode 100644 index 0000000..24b4c65 --- /dev/null +++ b/web/assets/cards/10B.svg @@ -0,0 +1,5388 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Re di Bastoni + \ No newline at end of file diff --git a/web/assets/cards/10C.svg b/web/assets/cards/10C.svg new file mode 100644 index 0000000..ad1c980 --- /dev/null +++ b/web/assets/cards/10C.svg @@ -0,0 +1,5071 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Re di Coppe + \ No newline at end of file diff --git a/web/assets/cards/10D.svg b/web/assets/cards/10D.svg new file mode 100644 index 0000000..f3a2a0d --- /dev/null +++ b/web/assets/cards/10D.svg @@ -0,0 +1,5107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Re di Denari + \ No newline at end of file diff --git a/web/assets/cards/10S.svg b/web/assets/cards/10S.svg new file mode 100644 index 0000000..d91aa8c --- /dev/null +++ b/web/assets/cards/10S.svg @@ -0,0 +1,5288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Re di Spade + \ No newline at end of file diff --git a/web/assets/cards/back.svg b/web/assets/cards/back.svg new file mode 100644 index 0000000..dd2d2b9 --- /dev/null +++ b/web/assets/cards/back.svg @@ -0,0 +1,2541 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/fetch-cards.sh b/web/fetch-cards.sh new file mode 100755 index 0000000..093007c --- /dev/null +++ b/web/fetch-cards.sh @@ -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" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c7a7b4d --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + Scopone scientifico + + + + + + + diff --git a/web/src/api.rs b/web/src/api.rs new file mode 100644 index 0000000..9949de8 --- /dev/null +++ b/web/src/api.rs @@ -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, 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 { + 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 { + 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 { + 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 { + 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 { + 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()) +} diff --git a/web/src/components/card.rs b/web/src/components/card.rs new file mode 100644 index 0000000..5dc3f6d --- /dev/null +++ b/web/src/components/card.rs @@ -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") + } +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs new file mode 100644 index 0000000..b4ba26a --- /dev/null +++ b/web/src/components/mod.rs @@ -0,0 +1 @@ +pub mod card; diff --git a/web/src/main.rs b/web/src/main.rs new file mode 100644 index 0000000..0234784 --- /dev/null +++ b/web/src/main.rs @@ -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/")] + 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| { + 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" } + } + }, + }) + } + } + } + ) + } + }); +} diff --git a/web/src/model.rs b/web/src/model.rs new file mode 100644 index 0000000..5cda49e --- /dev/null +++ b/web/src/model.rs @@ -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>, +} + +#[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, + #[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, + #[serde(default)] + pub winner: Option, + #[serde(default)] + pub table: Vec, + #[serde(default)] + pub players: Vec, + #[serde(default)] + pub seats_open: Option, + #[serde(default)] + pub last_move: Option, + #[serde(default)] + pub your_turn: Option, + /// Legal captures per hand card; present only for the player on turn. + #[serde(default)] + pub legal_moves: Option>>>, +} + +#[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, + }, + Error { + #[serde(default)] + code: Option, + 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, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MatchesPage { + #[serde(default)] + pub results: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[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, +} + +/// 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")); + } +} diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs new file mode 100644 index 0000000..4022b25 --- /dev/null +++ b/web/src/pages/game.rs @@ -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>, card: String, capture: Option>) { + 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::>(); + 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::>(); + 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::::None); + let error = create_signal(Option::::None); + let capture_choice = create_signal(Option::<(String, Vec>)>::None); + let selected = create_signal(Option::::None); + let over = create_signal(Option::<(Scores, Option)>::None); + let closed = create_signal(false); + let socket = create_signal(Option::::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::>(); + 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 { + 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>, +) -> 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::>(); + 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::>(); + 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>, + socket: Signal>, + capture_choice: Signal>)>>, +) -> 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::>(); + view! { + button(class="capture-option", on:click=move |_| { + send_play(socket, played_card.clone(), Some(capture.clone())); + capture_choice.set(None); + }) { (cards) } + } + }) + .collect::>(); + 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)>, game: Option) -> 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" } + } + } + } + } + } + } +} diff --git a/web/src/pages/history.rs b/web/src/pages/history.rs new file mode 100644 index 0000000..d8d6ae1 --- /dev/null +++ b/web/src/pages/history.rs @@ -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::::None); + let error = create_signal(Option::::None); + let cursor = create_signal(Option::::None); + // Accumulated rows across "load more" clicks. + let rows = create_signal(Vec::::new()); + + let load = move |next: Option| { + 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::>() + .join(" & "); + let team_b: String = m + .players + .iter() + .filter(|p| p.team == "B") + .map(|p| p.display_name.clone()) + .collect::>() + .join(" & "); + let outcome = if m.you_won { "Won" } else { "Lost" }; + view! { + tr { + td { (m.finished_at.replace('T', " ").chars().take(16).collect::()) } + 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::>(); + 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" + } + })) + } + } + }) + } + } +} diff --git a/web/src/pages/leaderboard.rs b/web/src/pages/leaderboard.rs new file mode 100644 index 0000000..20a9368 --- /dev/null +++ b/web/src/pages/leaderboard.rs @@ -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::::None); + let error = create_signal(Option::::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::>(); + view! { + table(class="matches") { + thead { + tr { + th { "#" } + th { "Player" } + th { "Wins" } + th { "Matches" } + th { "Points" } + } + } + tbody { (rows) } + } + } + } + }) + } + } +} diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs new file mode 100644 index 0000000..07d630f --- /dev/null +++ b/web/src/pages/lobby.rs @@ -0,0 +1,94 @@ +//! Lobby page: login prompt, match creation and joining by code. +use wasm_bindgen_futures::spawn_local; +use sycamore::prelude::*; +use sycamore_router::navigate; + +use crate::api; +use crate::model::User; + +#[component] +pub fn LobbyPage() -> View { + // Outer None = still loading; Some(None) = logged out. + let user = create_signal(Option::>::None); + let error = create_signal(Option::::None); + let code = create_signal(String::new()); + + spawn_local(async move { + match api::me().await { + Ok(me) => user.set(Some(me)), + Err(e) => { + error.set(Some(e)); + user.set(Some(None)); + } + } + }); + + let on_create = move |target: i32| { + spawn_local(async move { + match api::create_game(target).await { + Ok(game) => navigate(&format!("/game/{}", game.id)), + Err(e) => error.set(Some(e)), + } + }); + }; + + let on_join = move |_| { + let value = code.get_clone().trim().to_uppercase(); + if value.is_empty() { + return; + } + spawn_local(async move { + match api::join_game(&value).await { + Ok(game) => navigate(&format!("/game/{}", game.id)), + Err(e) => error.set(Some(e)), + } + }); + }; + + view! { + div(class="lobby") { + h1 { "Scopone scientifico" } + (move || error.get_clone().map(|e| view! { div(class="toast") { (e) } })) + (move || match user.get_clone() { + None => view! { p(class="status") { "Loading…" } }, + Some(None) => view! { + div(class="panel login-panel") { + p { "Log in with your account to play." } + a(class="button primary", href="/auth/login") { "Log in" } + } + }, + Some(Some(me)) => view! { + div(class="lobby-grid") { + nav(class="top-nav") { + span(class="whoami") { "Signed in as " strong { (me.name.clone()) } } + a(href="/history") { "My matches" } + a(href="/leaderboard") { "Leaderboard" } + a(href="/auth/logout") { "Log out" } + } + div(class="panel") { + h2 { "New match" } + p { "First team to reach the target score wins." } + div(class="target-buttons") { + button(class="button", on:click=move |_| on_create(11)) { "Target 11" } + button(class="button", on:click=move |_| on_create(16)) { "Target 16" } + button(class="button", on:click=move |_| on_create(21)) { "Target 21" } + } + } + div(class="panel") { + h2 { "Join with a code" } + div(class="join-form") { + input( + r#type="text", + placeholder="6-letter code", + maxlength="6", + bind:value=code, + ) + button(class="button primary", on:click=on_join) { "Join" } + } + } + } + }, + }) + } + } +} diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs new file mode 100644 index 0000000..51efaf1 --- /dev/null +++ b/web/src/pages/mod.rs @@ -0,0 +1,4 @@ +pub mod game; +pub mod history; +pub mod leaderboard; +pub mod lobby; diff --git a/web/src/ws.rs b/web/src/ws.rs new file mode 100644 index 0000000..ec7932a --- /dev/null +++ b/web/src/ws.rs @@ -0,0 +1,87 @@ +//! WebSocket client for live play. +use std::cell::RefCell; +use std::rc::Rc; + +use futures::channel::mpsc; +use futures::{SinkExt, StreamExt}; +use gloo_net::websocket::{futures::WebSocket, Message}; +use wasm_bindgen_futures::spawn_local; + +use crate::model::ServerMessage; + +fn ws_url(game_id: &str) -> String { + let location = web_sys::window().unwrap().location(); + let protocol = location.protocol().unwrap_or_else(|_| "http:".into()); + let scheme = if protocol == "https:" { "wss" } else { "ws" }; + let host = location.host().unwrap_or_else(|_| "127.0.0.1:8080".into()); + format!("{scheme}://{host}/ws/games/{game_id}") +} + +/// A cloneable handle to the game websocket. Incoming server messages are +/// pushed into the receiver returned by [`connect`]. +#[derive(Clone)] +pub struct GameSocket { + sender: Rc>>, +} + +impl GameSocket { + /// Play a card, optionally capturing the given table cards. + pub fn play(&self, card: &str, capture: Option>) { + let mut msg = serde_json::json!({ "action": "play", "card": card }); + if let Some(capture) = capture { + msg["capture"] = serde_json::json!(capture); + } + self.send_json(msg); + } + + /// Ask the server for a fresh snapshot. + #[allow(dead_code)] + pub fn sync(&self) { + self.send_json(serde_json::json!({ "action": "state" })); + } + + fn send_json(&self, value: serde_json::Value) { + let _ = self.sender.borrow_mut().unbounded_send(value.to_string()); + } +} + +/// Open the websocket for `game_id` and forward parsed server messages to +/// `on_message`. Returns the socket handle, or `None` if the connection +/// could not be created. +pub fn connect( + game_id: &str, + on_message: impl Fn(ServerMessage) + 'static, + on_close: impl Fn() + 'static, +) -> Option { + let ws = WebSocket::open(&ws_url(game_id)).ok()?; + let (mut write, mut read) = ws.split(); + + // Outgoing channel: GameSocket::send_json queues text frames here. + let (out_tx, mut out_rx) = mpsc::unbounded::(); + spawn_local(async move { + while let Some(text) = out_rx.next().await { + if write.send(Message::Text(text)).await.is_err() { + break; + } + } + }); + + spawn_local(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + if let Ok(parsed) = serde_json::from_str::(&text) { + on_message(parsed); + } + } + Ok(Message::Bytes(_)) => {} + Err(_) => break, + } + } + on_close(); + }); + + Some(GameSocket { + sender: Rc::new(RefCell::new(out_tx)), + }) +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3b694b3 --- /dev/null +++ b/web/style.css @@ -0,0 +1,430 @@ +/* Scopone scientifico frontend — dark table-felt theme. */ + +:root { + --felt: #1d5c38; + --felt-dark: #14341f; + --panel: #223026; + --panel-light: #2e4234; + --text: #f1f0e8; + --muted: #a9b7ab; + --accent: #e8c547; + --danger: #d9534f; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--felt-dark); + color: var(--text); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ---------- shared chrome ---------- */ + +.top-nav { + display: flex; + gap: 1rem; + align-items: center; + padding: 0.75rem 1rem; + background: rgba(0, 0, 0, 0.25); +} + +.top-nav a { + color: var(--accent); + text-decoration: none; +} + +.whoami { + margin-right: auto; +} + +.panel { + background: var(--panel); + border-radius: 12px; + padding: 1.25rem 1.5rem; + margin: 1rem; +} + +.button { + display: inline-block; + background: var(--panel-light); + color: var(--text); + border: 1px solid #4c6a54; + border-radius: 8px; + padding: 0.55rem 1.1rem; + margin: 0.25rem 0.25rem 0.25rem 0; + cursor: pointer; + font-size: 1rem; + text-decoration: none; +} + +.button.primary { + background: var(--accent); + border-color: var(--accent); + color: #1d1d1d; + font-weight: 600; +} + +.button:hover { + filter: brightness(1.15); +} + +.status { + color: var(--muted); + text-align: center; + margin: 2rem; +} + +.toast { + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + background: var(--danger); + color: #fff; + padding: 0.6rem 1.2rem; + border-radius: 8px; + z-index: 30; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +/* ---------- lobby / pages ---------- */ + +.lobby h1, +.page h1 { + text-align: center; + margin-top: 2rem; +} + +.lobby-grid, +.page { + max-width: 720px; + margin: 0 auto; + width: 100%; +} + +.login-panel { + text-align: center; +} + +.target-buttons { + display: flex; + gap: 0.5rem; +} + +.join-form { + display: flex; + gap: 0.5rem; +} + +.join-form input { + flex: 1; + font-size: 1.2rem; + text-transform: uppercase; + letter-spacing: 0.3em; + padding: 0.5rem; + border-radius: 8px; + border: 1px solid #4c6a54; + background: var(--panel-light); + color: var(--text); +} + +.join-code { + font-size: 2.4rem; + font-weight: 700; + letter-spacing: 0.35em; + color: var(--accent); + text-align: center; + margin: 0.5rem 0; +} + +.status-panel { + max-width: 520px; + margin: 3rem auto; + text-align: center; +} + +.roster { + list-style: none; + padding: 0; + text-align: center; +} + +table.matches { + width: 100%; + border-collapse: collapse; + margin: 1rem; +} + +table.matches th, +table.matches td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid #3c5543; +} + +table.matches td.won { + color: #8bc34a; + font-weight: 600; +} + +table.matches td.lost { + color: var(--muted); +} + +/* ---------- game table ---------- */ + +.hud { + display: flex; + gap: 1.5rem; + align-items: center; + justify-content: center; + padding: 0.6rem 1rem; + background: rgba(0, 0, 0, 0.3); +} + +.hud a { + color: var(--accent); + text-decoration: none; +} + +.hud-scores { + font-weight: 600; +} + +.turn-note { + color: var(--muted); +} + +.turn-note.you { + color: var(--accent); + font-weight: 700; +} + +.table-wrap { + display: flex; + flex-direction: column; + flex: 1; +} + +.table-grid { + flex: 1; + display: grid; + grid-template-columns: 1fr 2.4fr 1fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + ". top ." + "left center right" + "bottom bottom bottom"; + gap: 0.5rem; + padding: 0.75rem; +} + +.seat-top { + grid-area: top; +} + +.seat-left { + grid-area: left; +} + +.seat-right { + grid-area: right; +} + +.seat-bottom { + grid-area: bottom; + text-align: center; +} + +.seat { + background: var(--panel); + border: 2px solid transparent; + border-radius: 10px; + padding: 0.5rem 0.75rem; + text-align: center; +} + +.seat.active { + border-color: var(--accent); + box-shadow: 0 0 12px rgba(232, 197, 71, 0.45); +} + +.seat-name { + font-weight: 600; + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: center; +} + +.team-badge { + font-size: 0.7rem; + background: var(--panel-light); + border-radius: 4px; + padding: 0.1rem 0.35rem; + color: var(--muted); +} + +.seat-stats { + font-size: 0.8rem; + color: var(--muted); +} + +.center { + grid-area: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.table-cards { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + justify-content: center; + min-height: 90px; + align-items: center; +} + +.table-empty { + color: var(--muted); + font-style: italic; +} + +/* ---------- cards ---------- */ + +.card-img { + border-radius: 6px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45); + background: #fff; + user-select: none; +} + +.card-img.mini { + height: 46px; +} + +.card-img.move-card { + height: 54px; + vertical-align: middle; +} + +.card-img.table-card { + height: 84px; +} + +.card-img.hand-card { + height: 120px; +} + +.hand { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; +} + +.hand-slot { + background: none; + border: none; + padding: 0; + cursor: pointer; + transition: transform 0.1s ease; +} + +.hand-slot:hover { + transform: translateY(-8px); +} + +.hand-slot.selected { + outline: 3px solid var(--accent); + border-radius: 8px; + transform: translateY(-10px); +} + +.hint { + color: var(--muted); + font-size: 0.85rem; +} + +/* ---------- move banner ---------- */ + +.move-banner { + display: flex; + align-items: center; + gap: 0.4rem; + background: rgba(0, 0, 0, 0.35); + border-radius: 999px; + padding: 0.35rem 1rem; + font-size: 0.95rem; +} + +.scopa-badge { + background: var(--accent); + color: #1d1d1d; + font-weight: 700; + border-radius: 999px; + padding: 0.15rem 0.6rem; + margin-left: 0.25rem; +} + +/* ---------- overlays ---------- */ + +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; +} + +.picker { + background: var(--panel); + border-radius: 14px; + padding: 1.5rem 2rem; + text-align: center; + min-width: 320px; +} + +.capture-options { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 1rem 0; +} + +.capture-option { + background: var(--panel-light); + border: 1px solid #4c6a54; + border-radius: 10px; + padding: 0.4rem; + cursor: pointer; + display: flex; + gap: 0.4rem; + justify-content: center; +} + +.capture-option:hover { + border-color: var(--accent); +} + +.gameover-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + margin-top: 1rem; +}