Compare commits

..
Author SHA1 Message Date
woggioni 03a571a6a5 Notify when another player plays a card
CI / Build and push docker image (push) Successful in 1m42s
Add an opt-in bell to the game HUD: the first click asks for the
browser's notification permission, and the choice is remembered per
device. A play by any other seat then raises a desktop notification
with the player's name, the card and the next player, while the page
is in the background and the viewer is actually seated in the game.

Detect the newest play across the game families: scopone and scopa
report it in last_move, while briscola and briscola chiamata only
expose the trick, which mid-trick or once completed ends with the
card just played. The first snapshot after every (re)connection is a
resync baseline, so reconnects and the initial load never replay old
moves; so are the viewer's own plays and the server's auto-plays.

Deliver through the service worker registration (installed PWAs on
Android reject page-context notifications), falling back to a page
notification when no worker is active, under a per-game tag that
replaces the previous move. sw.js now focuses the game window when a
notification is clicked.
2026-10-15 21:46:00 +08:00
woggioni 517ac6fb46 Make the frontend an installable PWA
CI / Build and push docker image (push) Successful in 1m54s
Add a web app manifest and an install-only service worker (no offline
caching: live state always comes from the server and hashed asset names
change every build), registered from the WASM bundle. The worker must
live at the site root for its scope to cover the app, and Granian only
serves /static and /assets, so tavolo/static.py answers
/manifest.webmanifest and /sw.js from STATIC_DIR with the content types
browsers require; index.html gains the manifest link, icons, theme-color
and Apple standalone meta tags.

Icons are committed under web/assets/icons/ with vector sources and a
make-icons.sh regeneration script, mirroring the card art workflow.
2026-10-15 06:16:11 +08:00
woggioni 46f10f145a Show the OIDC profile picture with an identicon fallback
CI / Build and push docker image (push) Successful in 2m9s
GET /api/me now carries the profile picture URL when the provider
supplies an absolute http(s) one; the SPA shows it in the lobby navbar
and draws a deterministic identicon from the sub otherwise, falling back
to the identicon when the image fails to load.

The identicon is a 5x5 mirrored grid seeded by an inline FNV-1a hash so
it stays stable across Rust versions and is unit-testable on the host.
The dev mock IdP hands alice and bob a picture claim.
2026-10-15 00:14:17 +08:00
woggioni a9eb97e6e1 Stop requesting the user email from the OIDC provider
CI / Build and push docker image (push) Successful in 1m11s
OIDCConfig now asks for the openid and profile scopes only: kaya-oidc's
default would also request email, which the platform never stores or
exposes. The display-name fallback drops the email claim too
(preferred_username, name, then the sub).

Tests pin the configured scopes and cover an IdP that still returns an
email claim: the claim is ignored and absent from /api/me.
2026-10-14 23:58:40 +08:00
woggioni e98d43e59a Document every API response body in the OpenAPI spec
CI / Build and push docker image (push) Successful in 6m2s
kaya-openapi 0.0.8 supports named component schemas, so the routes'
description-only responses become $refs: schemas for the core payloads
(auth providers, lobby, game view, ongoing games, match history,
leaderboard, ratings) live in tavolo.platform.openapi and are registered
through the mixin's components. Engine-defined parts (options echo, game
views, match results) stay open objects on purpose. Error responses now
carry the error envelope schema, and a new suite sweeps the document to
keep every /api response typed.
2026-10-14 22:07:38 +08:00
woggioni 1ab748a34f Paginate the leaderboard from materialized player ratings
CI / Build and push docker image (push) Successful in 1m19s
Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
2026-10-09 16:42:47 +08:00
woggioni ae7ab5ce60 Set scopa targets to their traditional defaults
CI / Build and push docker image (push) Successful in 2m6s
Partnership games go to 21: scopone scientifico (the tournament target)
and scopa a quattro, while two-player scopa keeps its usual 11. The scopa
package resolves the default from the player count and exposes a per
variant options_schema default, so the lobby creation form shows the
right value without frontend changes.
2026-10-09 14:45:30 +08:00
woggioni 68200bbe62 Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 14:45:20 +08:00
75 changed files with 6112 additions and 386 deletions
+60 -21
View File
@@ -1,23 +1,27 @@
# tavolo
A platform for multiplayer card games. Three games are included:
A platform for multiplayer card games. Four games are included:
**scopone scientifico** — the four-player, fixed-partnership Italian card
game — **briscola**, in its classic two-player and four-player
partnership (*briscola a quattro*) variants, and **briscola chiamata** —
the five-player variant with a calling auction and a secret partner:
game — **scopa**, in its classic two-player and four-player partnership
(*scopa a quattro*) variants, with the *asso pigliatutto* option turning
either into **scopa d'assi** — **briscola**, in its classic two-player
and four-player partnership (*briscola a quattro*) variants, and
**briscola chiamata** — the five-player variant with a calling auction
and a secret partner:
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres.
A monorepo of four distributions sharing the `tavolo` namespace: the
game-independent `tavolo-platform`, the `tavolo-scopone` and
`tavolo-briscola` games, and the `tavolo-app` composition root wiring
them together. See [server/README.md](server/README.md).
A monorepo of five distributions sharing the `tavolo` namespace: the
game-independent `tavolo-platform`, the `tavolo-scopone`,
`tavolo-scopa` and `tavolo-briscola` games, and the `tavolo-app`
composition root wiring them together. See
[server/README.md](server/README.md).
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
`web/vendor/` so builds work offline) plus the custom felt theme in
`web/theme.css`. Card images are the
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
`web/theme.css`. Also an installable PWA — see [PWA](#pwa). Card images
are the CC0 *woodcut napoletane* deck traced from a 1902 Naples print
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
## Run the whole stack
@@ -40,15 +44,16 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
## Between hands
When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: scopone players see how each category was won
(carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled), briscola players see the deal's points and any
*patta*; both see the running totals and must click "Understood" before
the next hand is dealt. If someone is away the next hand is dealt
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
match-ending hand is explained on the final screen. Briscola chiamata
plays a single deal per match, so it goes straight from the last trick to
the final scoring screen; a 60–60 *patta* is replayed immediately.
**scoring summary screen**: scopone and scopa players see how each
category was won (carte, denara, settebello, primiera, scope — plus
napola and asso pigliatutto when enabled), briscola players see the
deal's points and any *patta*; both see the running totals and must click
"Understood" before the next hand is dealt. If someone is away the next
hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` (default
30s). The match-ending hand is explained on the final screen. Briscola
chiamata plays a single deal per match, so it goes straight from the last
trick to the final scoring screen; a 60–60 *patta* is replayed
immediately.
## On your turn
@@ -72,6 +77,37 @@ the called card is played, so the secret partner stays secret. The
the URL to share. A spectator who opens the URL before the match starts
sees a "game not started yet" waiting room.
## PWA
The frontend is an installable PWA: `web/manifest.webmanifest` describes
the app (standalone display, felt theme colors, PNG icons), and `web/sw.js`
is a caching-free service worker — it deliberately caches nothing, since
live state always comes from the server and hashed asset names change every
build — that also handles clicks on move notifications. Browsers offer to
install Tavolo over HTTPS (or on localhost in development).
Both files must be served from the site root, or the worker's scope and the
manifest's `start_url` could not cover the app: Granian only serves
`/static` and `/assets`, so `tavolo/static.py` answers
`/manifest.webmanifest` and `/sw.js` (revalidated on every load) from the
same build directory as the shell. Icons are committed under
`web/assets/icons/`; `web/make-icons.sh` regenerates them with
`rsvg-convert` after editing the vector sources.
## Move notifications
Players can opt into desktop notifications for the other players' card
plays with the bell in the game HUD (the first click asks for the browser
permission; the preference is stored per device and applies to every
game). Notifications only appear while the game tab is in the background
or its window is unfocused — never while the player is watching the table
— and only for games the viewer is seated in, never for their own plays.
They are delivered through the service worker (so installed PWAs get them
too) under a per-game tag, which replaces the previous move instead of
stacking one notification per play; clicking one focuses the game tab.
The game page must stay open to receive them, since that is where the
live-play websocket lives.
## Development
Backend (from `server/`):
@@ -80,10 +116,11 @@ Backend (from `server/`):
cd server
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
```
@@ -109,4 +146,6 @@ OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
re-downloads them if needed.
re-downloads them if needed. PWA icons are committed under
`web/assets/icons/`; `web/make-icons.sh` regenerates them from the SVG
sources.
+5 -2
View File
@@ -30,6 +30,9 @@ 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/theme.css web/Trunk.toml ./
# PWA manifest and service worker: trunk's copy-file entries need them at
# the build root, and the runtime serves both from the site root (static.py).
COPY web/manifest.webmanifest web/sw.js ./
COPY web/assets ./assets
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
# image builds and runs without network access.
@@ -58,13 +61,13 @@ RUN --mount=type=cache,target=/root/.cache/pip \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt
# The four sibling distributions, installed from the local tree (PEP 420
# The five sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency — including the tavolo-app `otel` extra — is
# already pinned in requirements.txt.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-briscola ./packages/tavolo-app
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-scopa ./packages/tavolo-briscola ./packages/tavolo-app
# server/pyproject.toml only carries the [tool.aerich] section (the
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
# next to ./migrations).
+77 -25
View File
@@ -1,10 +1,11 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. It is a monorepo of four distributions
[kaya](../kaya) framework. It is a monorepo of five distributions
sharing the `tavolo` namespace (see
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md),
[`packages/tavolo-scopa/README.md`](packages/tavolo-scopa/README.md)
and
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
@@ -15,6 +16,10 @@ and
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
scientifico, the four-player, fixed-partnership variant of the classic
Italian card game: pure rules plus the platform adapter.
- **`tavolo-scopa`** (`packages/tavolo-scopa/`) — scopa, the classic
Italian fishing game, in its two-player (`scopa`) and four-player
fixed-partnership (`scopa_a_quattro`) variants, with the optional asso
pigliatutto rule (scopa d'assi): pure rules plus the platform adapters.
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
Italian trick-taking game, in its classic two-player (`briscola`),
four-player partnership (`briscola_a_quattro`) and five-player
@@ -56,7 +61,9 @@ alias that redirects to the first configured provider), type any username
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`); every
endpoint documents its request body and its response bodies as named
component schemas.
## Configuration
@@ -236,16 +243,20 @@ aggregate with `max`, not `sum`.
scoped per game), start/finish timestamps, and the game-specific outcome
as reported by the engine (`result` JSON — for scopone: both teams'
final scores, winner, target score, hands played and the per-hand
audit; for briscola: the final scores, winner, target score, deals
played and the per-deal audit; for briscola chiamata: the calling side's
points against the defenders', the declared target, the called card and
audit; for scopa: both sides' final scores, winner, target score, the
asso pigliatutto flag, hands played and the per-hand audit; for
briscola: the final scores, winner, target score, deals played and the
per-deal audit; for briscola chiamata: the calling side's points
against the defenders', the declared target, the called card and
whether the caller played alone).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
the number of rated matches played.
- `player_rating` — current Elo rating and leaderboard totals per
`(user_sub, game_type)`: the rating, matches played, wins, accumulated
points and the most recent display name. Kept in sync with the match
transaction, so the leaderboard is a single indexed table query.
When a match ends, the engine's result is written transactionally to
Postgres (once, guarded by a flag on the session); the finished session
@@ -259,10 +270,10 @@ Players carry a chess-style Elo rating per game type
mean of its members, and the standard formula
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
many points the match result moves — the same delta for every member of
a team, zero-sum between the two teams. Ratings update in the same
transaction as the match result. To recompute every rating from the
recorded match history (e.g. to backfill matches recorded before ratings
existed):
a team, zero-sum between the two teams. Ratings and the leaderboard
aggregates update in the same transaction as the match result. To
rebuild every rating and aggregate from the recorded match history (e.g.
to backfill matches recorded before ratings existed):
```sh
.venv/bin/python -m tavolo.platform.backfill_elo \
@@ -278,14 +289,14 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 21, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` (target default 11) or `{"game_type": "scopa_a_quattro"}` (target default 21) with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
| `GET` | `/api/leaderboard` | Elo rating, wins / matches / points per rated player, sorted by Elo (`?game_type=`, `?page=`, `?limit=`, `?focus=me`). The response carries the requested page, the page count and — when authenticated — the caller's own rank and page; `focus=me` returns the caller's page directly |
## WebSocket protocol
@@ -312,7 +323,7 @@ platform handles `state` (resend the snapshot) itself; every other action
is dispatched to the session's game engine with the rest of the message
as its payload. Spectators may only send `state`/`sync`: any other
action is answered with an `error` whose `code` is `forbidden`. For
scopone scientifico:
scopone scientifico and scopa (both variants):
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -384,11 +395,11 @@ for the called-card choice (a random suit of the won rank).
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries the
scoring breakdown of the hand just played (`last_hand` with an `award`
map naming the team that won each scopone category; `last_deal` with the
deal's points and running totals for briscola), the `acknowledged` seats
and a `hand_end_deadline`. The frontend renders this as a screen every
player must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
map naming the side that won each scopone/scopa category; `last_deal`
with the deal's points and running totals for briscola), the
`acknowledged` seats and a `hand_end_deadline`. The frontend renders this
as a screen every player must dismiss. A play attempted in this phase is
rejected with an `illegal_move` error.
## Rules implemented
@@ -413,9 +424,42 @@ player must dismiss. A play attempted in this phase is rejected with an
empty. The capture never scores a scopa, but it counts as the last
capture, and the mandatory equal-rank capture still applies when another
ace is on the table.
- The match ends when a team reaches the target score (default 11,
configurable per game via the `target_score` creation option) with a
clear lead; a tie at or above the target is broken by another hand.
- The match ends when a team reaches the target score (default 21,
the traditional partnership target, configurable per game via the
`target_score` creation option) with a clear lead; a tie at or above
the target is broken by another hand.
### Scopa
- 40-card Italian deck; three cards per player and four cards face up on
the table at the start of every hand. When every hand is empty the
dealer deals three more cards to each player (no new table cards) until
the deck is exhausted: two players play six rounds per hand, four
players three.
- A card captures a **single card of equal rank** (mandatory when present)
or a **combination of table cards whose ranks sum to its own**; when any
capture exists it must be taken. Remaining table cards at the end of the
hand go to the player who made the last capture.
- Emptying the table scores a *scopa* (+1), except on the last play of the
hand (a sweep on the last play of a round still scores when the deck has
cards left).
- Hand points: `carte`, `denara`, `settebello` and `primiera` (same values
and all-four-suits requirement as scopone), plus one point per scopa.
Ties award nothing. There is no napola.
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
option, default off; scopa d'assi): an ace played when no equal rank is
on the table sweeps every card on the table — or captures itself when
the table is empty. The capture never scores a scopa, but it counts as
the last capture, and the mandatory equal-rank capture still applies
when another ace is on the table.
- The match ends when a side reaches the target score (default 11 points
for two-player scopa and 21 for scopa a quattro, the traditional
partnership target; configurable per game via the `target_score`
creation option) with a clear lead; a tie at or above the target is
broken by another hand.
- `scopa` is the classic two-player game; `scopa_a_quattro` seats four
players in two fixed partnerships (seats 0/2 vs 1/3, partners
opposite).
### Briscola
@@ -470,16 +514,18 @@ player must dismiss. A play attempted in this phase is rejected with an
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
# per-package suites (each package is self-contained):
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
# type checks (see packages/tavolo-app/README.md for the app modules):
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
.venv/bin/python -m mypy -p tavolo.scopa
.venv/bin/python -m mypy -p tavolo.briscola
```
@@ -518,7 +564,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # MatchResult -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── backfill_elo.py # recompute all ratings and aggregates from match history
├── store.py # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint (generic envelope)
@@ -538,6 +584,12 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
├── engine.py # pure scopone scientifico rules
├── errors.py # scopone-specific rule violations
└── plugin.py # ScoponeEngine: the GameEngine adapter
packages/tavolo-scopa/ # scopa game (own pyproject + tests)
src/tavolo/scopa/
├── state.py # ScopaState / PlayerState / Card / Move, JSON (de)serialization
├── engine.py # pure scopa rules (two- and four-player)
├── errors.py # scopa-specific rule violations
└── plugin.py # ScopaEngine + ScopaQuattroEngine adapters
packages/tavolo-briscola/ # briscola game (own pyproject + tests)
src/tavolo/briscola/
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
+4 -2
View File
@@ -11,7 +11,8 @@
"claims": {
"sub": "alice",
"preferred_username": "alice",
"name": "Alice"
"name": "Alice",
"picture": "https://i.pravatar.cc/128?u=alice"
}
},
{
@@ -20,7 +21,8 @@
"claims": {
"sub": "bob",
"preferred_username": "bob",
"name": "Bob"
"name": "Bob",
"picture": "https://i.pravatar.cc/128?u=bob"
}
},
{
@@ -0,0 +1,79 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_player_rati_game_ty_3326d1";
ALTER TABLE "player_rating" ADD "points" DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE "player_rating" ADD "display_name" VARCHAR(200) NOT NULL DEFAULT '';
ALTER TABLE "player_rating" ADD "wins" INT NOT NULL DEFAULT 0;
-- Backfill the leaderboard aggregates from the match history:
-- existing rows keep their rating and matches, while players from
-- matches recorded before ratings existed are inserted at the
-- initial rating (1500) so they stay on the board.
INSERT INTO "player_rating" (
"id", "user_sub", "game_type", "rating", "matches_played",
"wins", "points", "display_name", "updated_at"
)
SELECT gen_random_uuid(), agg.user_sub, agg.game_type, 1500,
agg.matches, agg.wins, agg.points, agg.display_name, NOW()
FROM (
SELECT mp.user_sub AS user_sub,
m.game_type AS game_type,
COUNT(*) AS matches,
SUM(CASE WHEN mp.won THEN 1 ELSE 0 END) AS wins,
SUM(mp.score) AS points,
(ARRAY_AGG(mp.display_name ORDER BY m.finished_at DESC, m.id DESC))[1]
AS display_name
FROM "match_player" mp
JOIN "match" m ON m.id = mp.match_id
GROUP BY mp.user_sub, m.game_type
) agg
ON CONFLICT ("user_sub", "game_type") DO UPDATE
SET "wins" = EXCLUDED."wins",
"points" = EXCLUDED."points",
"display_name" = EXCLUDED."display_name";
CREATE INDEX "idx_player_rati_game_ty_abcb75" ON "player_rating" ("game_type", "rating", "wins", "points", "matches_played", "user_sub");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_player_rati_game_ty_abcb75";
ALTER TABLE "player_rating" DROP COLUMN "points";
ALTER TABLE "player_rating" DROP COLUMN "display_name";
ALTER TABLE "player_rating" DROP COLUMN "wins";
CREATE INDEX "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");"""
MODELS_STATE = (
"eJztmm1v4jgQgP9KlE89qVcBfdnV6XRSoFTLLZQK6N1pq1VkEkOtGjubOMeiXv/72c67k/"
"DSEgoVH7ZLxh7HeSYez4zzrM+oDbF31gPMetR/0551AmaQ/8g2nGo6cJxELAQMjHHQM+4y"
"9pgLLMaFE4A9yEU29CwXOQxRIroamkVnDoYM2ppU0+hEowSK/9gj1Fw4RR6DLm+e8nlobO"
"FA70yMbVOLD47I9G3D+AT98KHJ6BTyji4f7OE7FyNiw5/Qiy6dJ3OCILYzQJAtBpByUwwo"
"ZPf3nesb2VNMcWxaFPszkvR2FuyRkri77yP7TOiItikk0AX8EVK4iI9xiDUSBTPmAub6MJ"
"6qnQhsOAE+FtD13yc+sQRrTd5J/Ln4I5xaqptp3vZH5rA9Mk09ZyMxBYV3KLIoEfZFhAlQ"
"zy/BuAkQKdXFDVpfjMHJ+dUvEgH12NSVjRKX/iIVAQOBqoSeUBb2CnjlYLcegVsMO6OkMO"
"dTfg3tSLAEd0SqArZ8Tf00MSRTJhbleWMJ67+MQYC7IXFTvgCDZXkbtjRkk6CeUPYYcDkL"
"E7A85mtOiaEZLEad1VRY26HqWfRjO+QTZ7ID9EtQjzq99nBk9O7E8DPP+4ElL2PUFi0NKV"
"0o0pMrxSzxINrfndEXTVxq3/q3bXWtxP1G33QxJ+AzahI6N4GdZhKJI1HGzBNEkPf4Kjsr"
"qkdD77Wh+VCCTc7Gfw77t8X2TTRU0yKLaf9pmG+gVRk1tU+NfYQZIt6ZuO17bFWCUMbKkd"
"s86Rn/qB611e03VfOJAZrcvYq4YfKU2tOEYAyspzlwbTPTktjNwWABXS9vuGaoePN1ADGQ"
"z5s3VTpEu5MjHe4yTKThViuB0gYtI5pvmjVmqgQQMJWPJO4t7lSArCzoTYiuCH1NJ+m5Mg"
"K+49snspAjbRqFrb4HXQ0R+VsOmQ95N9AriHEfkiBd6JieP9a/HwPffQp8Y7tsEPemdT5e"
"2Nu4vFwj7uW9SgNf2ZaNfG3kieVqyusNUKt6VeHeqe9VeNdq6/Cu1cp5izYl04BFsedwBj"
"DuEFaSY8DCoFPsDQfIeCom8et549PVZ94q5yguPi1hPewZ3W7ndqSwZBDMNnlno/6velfD"
"db83GCvOiOdBhKVEYZRiCEgx3XlhTDbmKgf4li7B2ez3u5kIudkZKVzve8324KQucfNOiE"
"lx/gX2LOoWeN0bTEGZK4g0FMwToVIV59ruIV/375vdtnY3aLc6w06YksQppmzMwh20ja5C"
"F2LK54wZ2NTfZhRf5XT3zVlsz+fakAGECzK08tQ6pXLMrbeSW6cNEqRcm+UkaZ1tZib767"
"RXJCK5YoXCt8BHcz+MpuQrXEjEHT4RQKwi16weHB0m1Fw9gotdMI/T48w7xZ+ePzMMPHPL"
"GLaM67b+Ul4AqrK0EZQtBoAFD5mrbWTaT5cVN4KyhukmXVdWN1q+60LCtDamWqCnAWJrPI"
"iyoTum/OE0RhnXjSoYwT00h/8Thzj5usdWRiysiKST5+QASa2KPGQPlxIYc+5Zg9Ul35zT"
"8I2AXlAOsvO1lmNx5Vhc2Z8UqpLqyv6e3n6oZDXxQlnGpRF+orC7mkr9slZVIhXG9436xa"
"eLz+dXF3GQH0uWRfr5ID/vudfkmlfcHd9DgRttlGsijffVI0gFZBJprF0+SVSO9ZOV9ZPD"
"OxrQq4rCdnAw4Dvi25HXfJqS1fyAX6boLs9u+gQv9PAovBz+nn+pEh/kr/llRJWJsQFdVP"
"yNa9hyuiwZBkmfVVlwuZ23/PVp6R5amCYW7KBhrve2/LD6Ou4bd9DytPBf6Hqo6OCn3Nmn"
"VD5gqlJJUigW1QaEw+4fkG59rX20vmQfref3UX5HBslG3/6lVHZ/QPE+9fD3+Mxv+5vZy/"
"+g2+dS"
)
+5 -3
View File
@@ -2,15 +2,17 @@
The deployable tavolo application: the composition root wiring
[`tavolo-platform`](../tavolo-platform/README.md) to the
[`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-scopone`](../tavolo-scopone/README.md),
[`tavolo-scopa`](../tavolo-scopa/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
## Contents
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
mixins plus `PlatformMixin` and the deadline scheduler), registers the
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
their configured timeouts, and exposes `tavolo.app:app` for granian.
`ScoponeEngine`, `ScopaEngine`, `ScopaQuattroEngine`, `BriscolaEngine`,
`BriscolaQuattroEngine` and `BriscolaChiamataEngine` with their
configured timeouts, and exposes `tavolo.app:app` for granian.
- `config.py` — environment-driven frozen `Settings`.
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
meter/tracer/logger providers, the
@@ -11,6 +11,7 @@ requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
"tavolo-scopone",
"tavolo-scopa",
"tavolo-briscola",
"kaya-core",
"kaya-cors",
@@ -46,8 +46,10 @@ from tavolo.briscola import (
)
from tavolo.platform import GameRegistry, Platform, PlatformMixin
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
from tavolo.scopone import ScoponeEngine
from .config import Settings, settings
@@ -136,6 +138,18 @@ registry.register(
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
ScopaEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
ScopaQuattroEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
@@ -189,6 +203,9 @@ oidc = OIDCProviders(
redirect_uri=provider.redirect_uri,
post_login_redirect=provider.post_login_redirect,
post_logout_redirect=provider.post_logout_redirect,
# kaya-oidc defaults to openid+email+profile; the app neither
# needs nor stores the email claim (profile covers the names).
scopes=("openid", "profile"),
fetch_userinfo=True,
)
for provider in settings.oidc_providers
@@ -205,6 +222,7 @@ openapi_mixin = OpenAPIMixin(
description="Multiplayer card-game platform API",
spec_path="/api/openapi.json",
docs_path="/api/docs",
components={"schemas": SCHEMAS},
)
tortoise_mixin = TortoiseMixin(
database_url=settings.database_url,
+57 -14
View File
@@ -1,19 +1,27 @@
"""SPA shell hosting for the compiled single-page application.
Static assets (wasm, js, css, card images) are served by Granian itself
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
Dockerfile) and never reach Python. This module only serves ``index.html``
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes.
Static assets (wasm, js, css, card images, PWA icons) are served by
Granian itself under the ``/static`` and ``/assets`` prefixes
(``GRANIAN_STATIC_PATH_*`` env vars; see the Dockerfile) and never reach
Python. This module serves the few files that must live at the site root
from ``STATIC_DIR``:
kaya-openapi deliberately skips glob routes, so this handler never appears
in the API specification.
- ``index.html`` at the site root and — via the glob catch-all — for
every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes;
- the web app manifest, whose ``start_url``/``scope`` cover the app;
- the service worker, whose default scope is its own directory: served
from ``/static`` it could only control ``/static``, so it is served at
``/sw.js`` instead.
kaya-openapi deliberately skips glob routes, so the fallback handler
never appears in the API specification.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
from kaya.core import HttpContext
@@ -21,14 +29,30 @@ from .app import app
from .config import settings
async def _send_shell(ctx: HttpContext) -> None:
"""Serve the SPA shell, or 404 when the frontend build is missing."""
index = Path(settings.static_dir) / "index.html"
if not index.is_file():
async def _send_from_dist(ctx: HttpContext,
filename: str,
content_type: str,
cache_control: Optional[str] = None) -> None:
"""Serve one file from ``STATIC_DIR``, or 404 when the build lacks it.
Unlike the SPA fallback, a missing file is not silently replaced by
the app shell: a manifest or worker answer with the wrong content
type would only confuse the browser.
"""
file = Path(settings.static_dir) / filename
if not file.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(index.read_bytes)
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
body = await asyncio.to_thread(file.read_bytes)
headers = {"content-type": (content_type,)}
if cache_control is not None:
headers["cache-control"] = (cache_control,)
await ctx.send_bytes(200, body, headers)
async def _send_shell(ctx: HttpContext) -> None:
"""Serve the SPA shell, or 404 when the frontend build is missing."""
await _send_from_dist(ctx, "index.html", "text/html; charset=utf-8")
@app.GET("/")
@@ -38,6 +62,25 @@ async def index(ctx: HttpContext) -> None:
await _send_shell(ctx)
@app.GET("/manifest.webmanifest")
async def manifest(ctx: HttpContext) -> None:
"""Serve the web app manifest (linked from index.html)."""
await _send_from_dist(ctx, "manifest.webmanifest", "application/manifest+json")
@app.GET("/sw.js")
async def service_worker(ctx: HttpContext) -> None:
"""Serve the service worker from the site root so its scope is the
whole app. The script is revalidated on every load (no-cache) so a new
build's worker takes over promptly."""
await _send_from_dist(
ctx,
"sw.js",
"text/javascript; charset=utf-8",
cache_control="no-cache",
)
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
"""SPA fallback: any path that matched no other route renders the app
+11 -7
View File
@@ -33,22 +33,26 @@ itself ships no game.
revalidate the token, so stale or duplicate deliveries are harmless.
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON
`details`) and `PlayerRating` (Elo per `(user_sub, game_type)`).
`details`) and `PlayerRating` (Elo and leaderboard aggregates per
`(user_sub, game_type)`).
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
transactionally, with Elo) and `apply_elo`.
transactionally, with Elo and the leaderboard aggregates) and
`apply_match_stats`.
- `elo.py` — chess-style Elo math generalized to two-team matches.
- `backfill_elo.py` — rebuild every rating from the match history:
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
- `backfill_elo.py` — rebuild every rating and aggregate from the match
history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
page-number pagination, shared OpenAPI fragments and response schemas,
the TortoiseORM lifecycle mixin.
## Adding a game
Implement `GameEngine` (see `engine.py` for the full contract), register
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
`KayaApp` — see the composition root in `tavolo.app`. The canonical
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
examples are [`tavolo-scopone`](../tavolo-scopone/README.md),
[`tavolo-scopa`](../tavolo-scopa/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md).
## Development (from `server/`)
@@ -51,15 +51,28 @@ def display_name(user: OIDCUser) -> str:
``preferred_username`` is preferred: it is the account's stable handle,
while ``name`` is a free-form display name a user may change (and two
accounts can share). ``email`` is a last resort before the ``sub``.
accounts can share). The ``sub`` is the last resort.
"""
for key in ("preferred_username", "name", "email"):
for key in ("preferred_username", "name"):
value = user.get(key)
if isinstance(value, str) and value:
return value
return user.sub
def picture(user: OIDCUser) -> Optional[str]:
"""The user's avatar URL, when the provider supplies a usable one.
Only absolute ``http(s)`` URLs are exported: the claim ends up in an
``<img src>`` in the SPA, where relative or script-ish schemes have no
business.
"""
value = user.picture
if isinstance(value, str) and value.startswith(("http://", "https://")):
return value
return None
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
"""Return the authenticated user of an HTTP request, if any.
@@ -1,9 +1,9 @@
"""Recompute every Elo rating from the recorded match history.
"""Recompute every Elo rating and leaderboard aggregate from the history.
Ratings are deterministic given the finished matches, so this replays all
matches in chronological order and rewrites the ``player_rating`` table
and each ``match_player.elo_delta`` from scratch. Run any time ratings
need to be rebuilt::
Ratings and aggregates are deterministic given the finished matches, so
this replays all matches in chronological order and rewrites the
``player_rating`` table and each ``match_player.elo_delta`` from scratch.
Run any time the leaderboard state needs to be rebuilt::
python -m tavolo.platform.backfill_elo --database-url postgres://...
"""
@@ -17,14 +17,14 @@ from typing import Dict, List
from tortoise.transactions import in_transaction
from .stats import apply_elo
from .stats import PlayerStats, apply_match_stats
from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__)
async def backfill_elo() -> int:
"""Rebuild all ratings; returns the number of matches replayed."""
"""Rebuild all ratings and aggregates; returns the matches replayed."""
from .models import Match, MatchPlayer, PlayerRating
replayed = 0
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
),
0,
)
deltas = await apply_elo(match.game_type, teams, winner_index)
deltas = await apply_match_stats(
match.game_type,
teams,
winner_index,
[
PlayerStats(
user_sub=p.user_sub,
won=p.won,
score=p.score,
display_name=p.display_name,
)
for p in players
],
)
for player in players:
player.elo_delta = deltas[player.user_sub]
await player.save()
@@ -9,9 +9,10 @@ completed matches are written here. The schema is game-independent:
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat, an optional team label, whether they won, the
points they scored and their Elo delta.
* :class:`PlayerRating` — current chess-style Elo rating of a player for
one game type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo`).
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
totals (wins, points, latest display name) of a player for one game
type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
"""
from __future__ import annotations
@@ -69,7 +70,7 @@ class MatchPlayer(Model):
class PlayerRating(Model):
"""Current Elo rating of one player for one game type."""
"""Current Elo rating and leaderboard totals of one player per game."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
@@ -78,9 +79,17 @@ class PlayerRating(Model):
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
# Leaderboard aggregates, updated with every finished match.
wins = fields.IntField(default=0)
points = fields.FloatField(default=0)
# Most recent display name seen for the player.
display_name = fields.CharField(max_length=200, default="")
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),)
# Composite index matching the leaderboard ordering within a game.
indexes = (
("game_type", "rating", "wins", "points", "matches_played", "user_sub"),
)
@@ -1,7 +1,23 @@
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``."""
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.
Two kinds of fragment live here:
* parameter lists (``PAGINATION_PARAMETERS``, ``PAGE_PARAMETERS``), used
directly in the route decorators, and
* reusable response schemas, registered as ``components.schemas`` by the
application (and the test fixtures) through ``SCHEMAS`` and referenced
from the routes with :func:`kaya.openapi.schema_ref`.
Engine-defined payload parts (lobby options echo, game views, match
results) are intentionally typed as open objects: the platform is
game-agnostic, so their exact shape belongs to each engine, not to this
document.
"""
from __future__ import annotations
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional, Sequence
from kaya.openapi import schema_ref
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
{
@@ -19,3 +35,341 @@ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
"description": "Opaque pagination cursor from a previous response's next_cursor.",
},
]
# Page-number pagination (used by the leaderboard, which supports jumping
# to a specific page).
PAGE_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "page",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "default": 1},
"description": "1-based page number (clamped to the last page).",
},
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
]
# ---------------------------------------------------------------------------
# Schema builders
# ---------------------------------------------------------------------------
def _object(
properties: Dict[str, Any],
required: Sequence[str] = (),
*,
description: Optional[str] = None,
additional_properties: bool = False,
) -> Dict[str, Any]:
"""An object schema with the given properties (JSON Schema 2020-12)."""
schema: Dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = list(required)
if description is not None:
schema["description"] = description
if additional_properties:
schema["additionalProperties"] = True
return schema
def _free_object(description: Optional[str] = None) -> Dict[str, Any]:
"""An open object: used for engine-specific payload parts."""
schema: Dict[str, Any] = {"type": "object", "additionalProperties": True}
if description is not None:
schema["description"] = description
return schema
def _array(items: Dict[str, Any]) -> Dict[str, Any]:
return {"type": "array", "items": items}
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
nullable = dict(schema)
kind = nullable.get("type")
if isinstance(kind, str):
nullable["type"] = [kind, "null"]
return nullable
def json_response(description: str, schema: Dict[str, Any]) -> Dict[str, Any]:
"""A JSON response object carrying ``schema``, for ``@operation``."""
return {
"description": description,
"content": {"application/json": {"schema": schema}},
}
def error_response(description: str) -> Dict[str, Any]:
"""The standard JSON error envelope (``{"error": <message>}``)."""
return json_response(description, schema_ref("Error"))
# ---------------------------------------------------------------------------
# Component schemas
# ---------------------------------------------------------------------------
ERROR_SCHEMA = _object({"error": {"type": "string"}}, ("error",))
HEALTH_SCHEMA = _object(
{"status": {"type": "string", "enum": ["ok"]}}, ("status",)
)
ME_SCHEMA = _object(
{
"sub": {"type": "string"},
"name": {"type": "string"},
"picture": _nullable({"type": "string"}),
},
("sub", "name", "picture"),
)
AUTH_PROVIDER_SCHEMA = _object(
{
"name": {"type": "string"},
"login_url": {"type": "string"},
},
("name", "login_url"),
)
AUTH_PROVIDERS_PAGE_SCHEMA = _object(
{"providers": _array(schema_ref("AuthProvider"))}, ("providers",)
)
GAME_TYPE_SCHEMA = _object(
{
"id": {"type": "string"},
"name": {"type": "string"},
"description": {"type": "string"},
"min_players": {"type": "integer"},
"max_players": {"type": "integer"},
"options_schema": _free_object(
"JSON-schema fragment describing the game-specific creation "
"options accepted by POST /api/games."
),
},
("id", "name", "description", "min_players", "max_players", "options_schema"),
)
GAME_TYPES_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("GameType"))}, ("results",)
)
LOBBY_PLAYER_SCHEMA = _object(
{
"sub": {"type": "string"},
"name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({
"type": "string",
"description": "Game-defined team label; null for games without fixed teams.",
}),
},
("sub", "name", "seat", "team"),
)
_LOBBY_PROPERTIES: Dict[str, Any] = {
"id": {"type": "string"},
"join_code": {"type": "string"},
"game_type": {"type": "string"},
"players": _array(schema_ref("LobbyPlayer")),
"seats_open": {"type": "integer"},
}
_LOBBY_REQUIRED = ("id", "join_code", "game_type", "players", "seats_open")
LOBBY_SCHEMA = _object(
dict(_LOBBY_PROPERTIES),
_LOBBY_REQUIRED,
description="The lobby payload: the platform envelope plus the engine's "
"lobby_view fields (the creation-options echo and the phase).",
additional_properties=True,
)
GAME_PREVIEW_SCHEMA = _object(
{
**_LOBBY_PROPERTIES,
"game_name": {"type": "string"},
"game_description": {"type": "string"},
},
(*_LOBBY_REQUIRED, "game_name", "game_description"),
description="The join-code preview: the lobby payload plus the game's "
"display name and description.",
additional_properties=True,
)
GAME_VIEW_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"spectator": {"type": "boolean"},
"seats_open": {"type": "integer"},
"join_code": {
"type": "string",
"description": "The join code is a credential: present only in a "
"seated player's view, never to spectators.",
},
},
("id", "game_type", "spectator", "seats_open"),
description="A game snapshot: the platform envelope plus the engine's "
"view_for fields. Seated players get their personalized view "
"(own hand, join code); everyone else the read-only spectator "
"view (no hands, no join code).",
additional_properties=True,
)
ONGOING_GAME_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"players": _array(schema_ref("LobbyPlayer")),
"seats_open": {"type": "integer"},
"game_name": {"type": "string"},
"game_description": {"type": "string"},
"your_seat": _nullable({"type": "integer"}),
"created_at": _nullable({"type": "string", "format": "date-time"}),
},
(
"id",
"game_type",
"players",
"seats_open",
"game_name",
"game_description",
"your_seat",
"created_at",
),
description="One entry of the caller's ongoing-games list: the lobby "
"payload without the join code, plus the game name and the "
"caller's seat.",
additional_properties=True,
)
ONGOING_GAMES_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("OngoingGame"))}, ("results",)
)
MATCH_PLAYER_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"display_name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({"type": "string"}),
"won": {"type": "boolean"},
"score": {"type": "number"},
"elo_delta": _nullable({"type": "integer"}),
"details": _nullable(_free_object("Game-specific extras reported by the engine.")),
},
(
"user_sub",
"display_name",
"seat",
"team",
"won",
"score",
"elo_delta",
"details",
),
)
MATCH_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"result": _nullable(_free_object(
"The game-specific outcome reported by the engine."
)),
"started_at": {"type": "string", "format": "date-time"},
"finished_at": {"type": "string", "format": "date-time"},
"you_won": {"type": "boolean"},
"your_elo_delta": _nullable({"type": "integer"}),
"players": _array(schema_ref("MatchPlayer")),
},
(
"id",
"game_type",
"result",
"started_at",
"finished_at",
"you_won",
"your_elo_delta",
"players",
),
)
MATCHES_PAGE_SCHEMA = _object(
{
"results": _array(schema_ref("Match")),
"next_cursor": _nullable({"type": "string"}),
},
("results", "next_cursor"),
)
LEADERBOARD_ENTRY_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"display_name": {"type": "string"},
"elo": {"type": "integer"},
"matches": {"type": "integer"},
"wins": {"type": "integer"},
"points": {"type": "number"},
},
("user_sub", "display_name", "elo", "matches", "wins", "points"),
)
LEADERBOARD_PAGE_SCHEMA = _object(
{
"results": _array(schema_ref("LeaderboardEntry")),
"page": {"type": "integer"},
"pages": {"type": "integer"},
"total": {"type": "integer"},
"your_rank": _nullable({"type": "integer"}),
"your_page": _nullable({"type": "integer"}),
},
("results", "page", "pages", "total", "your_rank", "your_page"),
)
RATING_SCHEMA = _object(
{
"game_type": {"type": "string"},
"rating": {"type": "integer"},
"matches_played": {"type": "integer"},
},
("game_type", "rating", "matches_played"),
)
RATINGS_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("Rating"))}, ("results",)
)
#: Component name -> schema, passed to
#: ``OpenAPIMixin(components={"schemas": SCHEMAS})``.
SCHEMAS: Dict[str, Dict[str, Any]] = {
"Error": ERROR_SCHEMA,
"Health": HEALTH_SCHEMA,
"Me": ME_SCHEMA,
"AuthProvider": AUTH_PROVIDER_SCHEMA,
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
"GameType": GAME_TYPE_SCHEMA,
"GameTypesPage": GAME_TYPES_PAGE_SCHEMA,
"LobbyPlayer": LOBBY_PLAYER_SCHEMA,
"Lobby": LOBBY_SCHEMA,
"GamePreview": GAME_PREVIEW_SCHEMA,
"GameView": GAME_VIEW_SCHEMA,
"OngoingGame": ONGOING_GAME_SCHEMA,
"OngoingGamesPage": ONGOING_GAMES_PAGE_SCHEMA,
"MatchPlayer": MATCH_PLAYER_SCHEMA,
"Match": MATCH_SCHEMA,
"MatchesPage": MATCHES_PAGE_SCHEMA,
"LeaderboardEntry": LEADERBOARD_ENTRY_SCHEMA,
"LeaderboardPage": LEADERBOARD_PAGE_SCHEMA,
"Rating": RATING_SCHEMA,
"RatingsPage": RATINGS_PAGE_SCHEMA,
}
@@ -30,12 +30,17 @@ MIN_LIMIT = 1
CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit"
PAGE_PARAM = "page"
class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded."""
class PageParamsError(ValueError):
"""Raised when the ``page``/``limit`` query params are invalid."""
def encode_cursor(values: Dict[str, Any]) -> str:
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
# datetime-typed sort fields (e.g. finished_at for match history).
@@ -79,6 +84,36 @@ def parse_cursor_params(query_string: str) -> Cursor:
return Cursor(limit=limit, after=after)
def parse_page_params(query_string: str) -> Tuple[int, int]:
"""Parse the ``page``/``limit`` params: a 1-based page and a page size.
``limit`` is clamped to [1, 100] (default 20); ``page`` must be a
positive integer (default 1). Clamping ``page`` to the last existing
page is left to the endpoint, which knows how many pages there are.
"""
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise PageParamsError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
page_raw = params.get(PAGE_PARAM)
if page_raw:
try:
page = int(page_raw[0])
except ValueError as exc:
raise PageParamsError("invalid page") from exc
else:
page = 1
if page < 1:
raise PageParamsError("invalid page")
return page, limit
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]]
@@ -16,9 +16,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kaya.openapi import operation, schema_ref
from ..http import send_json
from ..openapi import json_response
if TYPE_CHECKING:
from ..mixin import Platform
@@ -36,7 +37,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
description="Names and login URLs of the configured OIDC "
"providers, in configuration order.",
tags=["auth"],
responses={200: {"description": "The configured providers"}})
responses={200: json_response("The configured providers",
schema_ref("AuthProvidersPage"))})
async def auth_providers(ctx: HttpContext) -> None:
await send_json(
ctx,
@@ -19,7 +19,7 @@ from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kaya.openapi import operation, schema_ref
from ..auth import current_user, display_name, require_auth
from ..engine import GameSession, Seat
@@ -32,6 +32,7 @@ from ..http import (
send_error,
send_json,
)
from ..openapi import error_response, json_response
if TYPE_CHECKING:
from ..mixin import Platform
@@ -144,7 +145,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
"describes the per-game creation options accepted "
"by POST /api/games.",
tags=["games"],
responses={200: {"description": "The available game types"}})
responses={200: json_response("The available game types",
schema_ref("GameTypesPage"))})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
@@ -194,9 +196,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
},
},
responses={
201: {"description": "The created lobby"},
400: {"description": "Invalid game_type, options or body"},
401: {"description": "Authentication required"},
201: json_response("The created lobby", schema_ref("Lobby")),
400: error_response("Invalid game_type, options or body"),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def create_game(ctx: HttpContext) -> None:
@@ -264,11 +266,15 @@ def register(app: KayaApp, platform: "Platform") -> None:
},
},
responses={
200: {"description": "Seated; game state (may be playing)"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
409: {"description": "Already joined or lobby full"},
200: json_response(
"Seated: the lobby, or the personalized game state "
"when the join started the match",
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]},
),
400: error_response("Missing code"),
401: error_response("Authentication required"),
404: error_response("Unknown join code"),
409: error_response("Already joined or lobby full"),
})
@require_auth(platform.oidc)
async def join_game(ctx: HttpContext) -> None:
@@ -328,10 +334,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
"its engine.",
tags=["games"],
responses={
200: {"description": "The lobby preview"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
200: json_response("The lobby preview",
schema_ref("GamePreview")),
400: error_response("Missing code"),
401: error_response("Authentication required"),
404: error_response("Unknown join code"),
})
@require_auth(platform.oidc)
async def preview_game(ctx: HttpContext) -> None:
@@ -356,9 +363,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
"join code, just what is public at the table.",
tags=["games"],
responses={
200: {"description": "The personalized (player) or "
"read-only (spectator) game state"},
404: {"description": "Game not found"},
200: json_response(
"The personalized (player) or read-only (spectator) game state",
schema_ref("GameView"),
),
404: error_response("Game not found"),
})
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
@@ -378,8 +387,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
"list. Finished matches are not included.",
tags=["games"],
responses={
200: {"description": "The caller's ongoing games"},
401: {"description": "Authentication required"},
200: json_response("The caller's ongoing games",
schema_ref("OngoingGamesPage")),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def my_games(ctx: HttpContext) -> None:
@@ -4,7 +4,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kaya.openapi import operation, schema_ref
from ..openapi import json_response
if TYPE_CHECKING:
from ..mixin import Platform
@@ -14,7 +16,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/health")
@operation(summary="Health check",
tags=["health"],
responses={200: {"description": "The service is up"}})
responses={200: json_response("The service is up", schema_ref("Health"))})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(
200,
@@ -4,10 +4,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kaya.openapi import operation, schema_ref
from ..auth import current_user, display_name, require_auth
from ..auth import current_user, display_name, picture, require_auth
from ..http import send_json
from ..openapi import error_response, json_response
if TYPE_CHECKING:
from ..mixin import Platform
@@ -17,14 +18,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me")
@operation(summary="Current user",
description="Returns the authenticated user's identity from the "
"session; 401 when not logged in.",
"session, including the profile picture URL when "
"the provider supplies one; 401 when not logged "
"in.",
tags=["auth"],
responses={
200: {"description": "The current user"},
401: {"description": "Not logged in"},
200: json_response("The current user", schema_ref("Me")),
401: error_response("Not logged in"),
})
@require_auth(platform.oidc)
async def me(ctx: HttpContext) -> None:
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
await send_json(
ctx,
200,
{
"sub": user.sub,
"name": display_name(user),
"picture": picture(user),
},
)
@@ -13,14 +13,25 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from uuid import UUID
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kaya.openapi import operation, schema_ref
from ..auth import current_user, require_auth
from ..elo import INITIAL_RATING
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating
from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
from ..openapi import (
PAGE_PARAMETERS,
PAGINATION_PARAMETERS,
error_response,
json_response,
)
from ..pagination import (
CursorDecodeError,
PageParamsError,
paginate,
parse_cursor_params,
parse_page_params,
)
from tortoise.expressions import Q
if TYPE_CHECKING:
from ..mixin import Platform
@@ -33,6 +44,15 @@ GAME_TYPE_PARAMETER: Dict[str, Any] = {
"description": "Only count matches of this game (id from GET /api/game-types).",
}
LEADERBOARD_FOCUS_PARAMETER: Dict[str, Any] = {
"name": "focus",
"in": "query",
"required": False,
"schema": {"type": "string", "enum": ["me"]},
"description": "Set to 'me' to return the page containing the caller's "
"own ranking (page 1 for unrated or anonymous callers).",
}
def _parse_game_type(
platform: "Platform", query_string: str
@@ -50,6 +70,33 @@ def _parse_game_type(
return game_type, None
def _ranks_before(rating: PlayerRating) -> Q:
"""Keyset condition selecting the rows ranked above ``rating``.
Mirrors the leaderboard ordering ``-rating, -wins, -points,
matches_played, user_sub``: exactly the rows sorting before the given
one.
"""
return (
Q(rating__gt=rating.rating)
| Q(rating=rating.rating, wins__gt=rating.wins)
| Q(rating=rating.rating, wins=rating.wins, points__gt=rating.points)
| Q(
rating=rating.rating,
wins=rating.wins,
points=rating.points,
matches_played__lt=rating.matches_played,
)
| Q(
rating=rating.rating,
wins=rating.wins,
points=rating.points,
matches_played=rating.matches_played,
user_sub__lt=rating.user_sub,
)
)
def _serialize_match(
match: Match, viewer: str, participants: List[MatchPlayer]
) -> Dict[str, Any]:
@@ -89,9 +136,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={
200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"},
200: json_response("A page of matches",
schema_ref("MatchesPage")),
400: error_response("Invalid pagination cursor or game_type"),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def my_matches(ctx: HttpContext) -> None:
@@ -131,56 +179,88 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/leaderboard")
@operation(summary="Global leaderboard",
description="Elo rating, aggregated wins, matches played and "
"points for every player with at least one "
"finished match. Sorted by Elo rating (the rating "
"for the requested game_type, or the default game "
"when the filter is absent).",
description="Elo rating, wins, matches played and points for "
"every rated player of a game, sorted by Elo (the "
"requested game_type, or the default game when "
"the filter is absent). Page-number paginated; "
"the response carries the caller's own rank when "
"authenticated.",
tags=["stats"],
parameters=[GAME_TYPE_PARAMETER],
parameters=[
GAME_TYPE_PARAMETER,
*PAGE_PARAMETERS,
LEADERBOARD_FOCUS_PARAMETER,
],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
200: json_response("A page of the leaderboard",
schema_ref("LeaderboardPage")),
400: error_response("Unknown game_type or invalid parameters"),
})
async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset
# Ratings are per game type; without a filter show the default
# game's.
try:
page, limit = parse_page_params(ctx.query_string)
except PageParamsError as exc:
await send_error(ctx, 400, str(exc))
return
focus_values = extract_query_params(ctx.query_string).get("focus")
focus = focus_values[0] if focus_values else None
if focus is not None and focus != "me":
await send_error(ctx, 400, "invalid focus")
return
# The board is the requested game's, or the default game's when the
# filter is absent (ratings are per game type).
rating_game = game_type or platform.registry.default.id
rating_rows = await PlayerRating.filter(game_type=rating_game)
ratings = {row.user_sub: row.rating for row in rating_rows}
aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows:
entry = aggregate.setdefault(
row.user_sub,
board = PlayerRating.filter(game_type=rating_game)
total = await board.count()
pages = (total + limit - 1) // limit
current = min(page, pages) if pages else 1
# The caller's own ranking, when authenticated and rated: ranks
# above theirs plus one. `focus=me` jumps to their page.
user = current_user(platform.oidc, ctx)
yours: Optional[PlayerRating] = None
if user is not None:
yours = await PlayerRating.get_or_none(
user_sub=user.sub, game_type=rating_game
)
your_rank: Optional[int] = None
your_page: Optional[int] = None
if yours is not None:
your_rank = 1 + await board.filter(_ranks_before(yours)).count()
your_page = (your_rank - 1) // limit + 1
if focus == "me":
current = your_page
rows = await (
board.order_by(
"-rating", "-wins", "-points", "matches_played", "user_sub"
)
.offset((current - 1) * limit)
.limit(limit)
)
await send_json(ctx, 200, {
"results": [
{
"user_sub": row.user_sub,
"display_name": row.display_name,
"matches": 0,
"wins": 0,
"points": 0,
"elo": ratings.get(row.user_sub, INITIAL_RATING),
},
)
entry["matches"] += 1
entry["wins"] += 1 if row.won else 0
entry["points"] += row.score
# Keep the most recent display name seen.
entry["display_name"] = row.display_name
ranking: List[Dict[str, Any]] = sorted(
aggregate.values(),
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
reverse=True,
)
await send_json(ctx, 200, {"results": ranking})
"display_name": row.display_name or row.user_sub,
"matches": row.matches_played,
"wins": row.wins,
"points": row.points,
"elo": row.rating,
}
for row in rows
],
"page": current,
"pages": pages,
"total": total,
"your_rank": your_rank,
"your_page": your_page,
})
@app.GET("/api/me/ratings")
@operation(summary="My Elo ratings",
@@ -188,8 +268,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
"type they have played.",
tags=["stats"],
responses={
200: {"description": "The caller's ratings"},
401: {"description": "Authentication required"},
200: json_response("The caller's ratings",
schema_ref("RatingsPage")),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def my_ratings(ctx: HttpContext) -> None:
@@ -3,8 +3,8 @@
Called once when a session reaches the finished state (guarded by the
``stats_saved`` flag on the session). The write is transactional so a
match never appears with only some of its players. The same transaction
also updates the participants' Elo ratings (see
:mod:`tavolo.platform.elo`).
also updates the participants' Elo ratings and leaderboard aggregates
(see :mod:`tavolo.platform.elo`).
This module is game-independent: everything it persists comes from the
engine's :class:`~tavolo.platform.engine.MatchResult`.
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from logging import getLogger
from typing import Dict, List
from typing import Dict, List, Sequence
from tortoise.transactions import in_transaction
@@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession
log = getLogger(__name__)
async def apply_elo(
game_type: str, teams: List[List[str]], winner_team: int
@dataclass(frozen=True)
class PlayerStats:
"""One participant's data feeding a ``player_rating`` row update."""
user_sub: str
won: bool
score: float
display_name: str
async def apply_match_stats(
game_type: str,
teams: List[List[str]],
winner_team: int,
players: Sequence[PlayerStats],
) -> Dict[str, int]:
"""Update the Elo ratings of ``teams`` for ``game_type``.
"""Update the ratings and leaderboard aggregates of ``teams``.
``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Returns the per-player delta.
Must be called inside a transaction.
players start at the initial rating. Alongside the Elo delta the
transaction records each participant's wins, accumulated points and
latest display name, so the leaderboard is a single-table query.
Returns the per-player delta. Must be called inside a transaction.
"""
if len(teams) != 2:
raise ValueError("Elo rating requires exactly two teams")
from .models import PlayerRating
stats = {player.user_sub: player for player in players}
ratings: Dict[str, "PlayerRating"] = {}
for subs in teams:
for sub in subs:
@@ -63,6 +80,13 @@ async def apply_elo(
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
player = stats.get(sub)
if player is not None:
if player.won:
rating.wins += 1
rating.points += player.score
if player.display_name:
rating.display_name = player.display_name
await rating.save()
return deltas
@@ -78,6 +102,15 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
started_at = session.created_at or datetime.now(timezone.utc)
finished_at = session.finished_at or datetime.now(timezone.utc)
display_names = {seat.user_sub: seat.display_name for seat in session.players}
player_stats = [
PlayerStats(
user_sub=player.user_sub,
won=player.won,
score=player.score,
display_name=display_names.get(player.user_sub, player.user_sub),
)
for player in result.players
]
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
@@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
finished_at=finished_at,
result=result.summary,
)
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
deltas = await apply_match_stats(
session.game_type, result.teams, result.winner_team, player_stats
)
for player in result.players:
await MatchPlayer.create(
id=uuid.uuid4(),
@@ -45,6 +45,7 @@ from tavolo.platform import (
)
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin
@@ -253,6 +254,7 @@ def make_platform(
description="test app",
spec_path="/api/openapi.json",
docs_path="/api/docs",
components={"schemas": SCHEMAS},
)
scheduler = DeadlineScheduler(
game_store,
@@ -19,6 +19,7 @@ from tavolo.platform.auth import (
current_user,
display_name,
get_ws_user,
picture,
require_auth,
session_user,
)
@@ -106,9 +107,11 @@ class DisplayNameTest(unittest.TestCase):
user = OIDCUser({"sub": "alice", "name": "Alice"})
self.assertEqual("Alice", display_name(user))
def test_falls_back_to_email(self) -> None:
def test_ignores_email(self) -> None:
# The app requests only the openid and profile scopes; an email
# claim the provider still returns must not leak into the name.
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
self.assertEqual("alice@example.com", display_name(user))
self.assertEqual("alice", display_name(user))
def test_falls_back_to_sub(self) -> None:
user = OIDCUser({"sub": "alice"})
@@ -119,6 +122,23 @@ class DisplayNameTest(unittest.TestCase):
self.assertEqual("alice", display_name(user))
class PictureTest(unittest.TestCase):
"""Only absolute http(s) picture claims are exported to the SPA."""
def test_uses_an_absolute_http_url(self) -> None:
user = OIDCUser({"sub": "alice", "picture": "https://cdn.example.com/a.png"})
self.assertEqual("https://cdn.example.com/a.png", picture(user))
def test_rejects_relative_and_script_schemes(self) -> None:
for value in ("/relative.png", "javascript:alert(1)", "data:image/png;base64,x"):
with self.subTest(picture=value):
user = OIDCUser({"sub": "alice", "picture": value})
self.assertIsNone(picture(user))
def test_missing_claim_is_none(self) -> None:
self.assertIsNone(picture(OIDCUser({"sub": "alice"})))
class CurrentUserTest(unittest.TestCase):
def test_anonymous_session_has_no_user(self) -> None:
self.assertIsNone(current_user(_providers("default"), _ctx({})))
@@ -14,7 +14,14 @@ from datetime import datetime, timezone
from typing import Any, List
from tavolo.platform.models import Match
from tavolo.platform.pagination import Cursor, decode_cursor, encode_cursor, paginate
from tavolo.platform.pagination import (
Cursor,
PageParamsError,
decode_cursor,
encode_cursor,
paginate,
parse_page_params,
)
from helpers import async_test
@@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase):
self.assertEqual(match_id, id_values[0])
class PageParamsTest(unittest.TestCase):
def test_defaults(self) -> None:
self.assertEqual((1, 20), parse_page_params(""))
def test_page_and_limit(self) -> None:
self.assertEqual((3, 5), parse_page_params("page=3&limit=5"))
def test_limit_is_clamped(self) -> None:
self.assertEqual((1, 100), parse_page_params("limit=500"))
self.assertEqual((1, 1), parse_page_params("limit=0"))
def test_invalid_values_raise(self) -> None:
for query in ("page=0", "page=-2", "page=abc", "limit=abc"):
with self.assertRaises(PageParamsError, msg=query):
parse_page_params(query)
if __name__ == "__main__":
unittest.main()
@@ -310,7 +310,10 @@ class MeRouteTest(unittest.TestCase):
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
response.json(),
)
@async_test
async def test_me_unauthenticated(self) -> None:
@@ -4,6 +4,7 @@ from __future__ import annotations
import unittest
import uuid
from datetime import datetime, timezone
from typing import Any, Dict
from unittest import mock
from httpx import ASGITransport, AsyncClient
@@ -78,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase):
self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played)
# The leaderboard aggregates ride along in the same transaction.
self.assertEqual(1, ratings["alice"].wins)
self.assertEqual(0, ratings["bob"].wins)
self.assertEqual(2.0, ratings["alice"].points)
self.assertEqual(0.0, ratings["bob"].points)
self.assertEqual("alice", ratings["alice"].display_name)
# The per-match delta is recorded on each participation row.
deltas = {
@@ -136,6 +143,9 @@ async def _seed_two_matches(
) -> None:
ctx = await use_db(tortoise_mixin)
with ctx:
# The leaderboard reads the player_rating aggregates, so the seeded
# participations are mirrored there (as save_match_result would).
aggregates: Dict[tuple, Dict[str, Any]] = {}
for index, (winner, finished) in enumerate(
[
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
@@ -150,6 +160,7 @@ async def _seed_two_matches(
result={"winner": winner, "plays": 3 + index},
)
for seat, sub in enumerate(("alice", "bob")):
score = 2.0 + index if sub == winner else 1.0
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
@@ -158,8 +169,26 @@ async def _seed_two_matches(
seat=seat,
team="A" if seat == 0 else "B",
won=(sub == winner),
score=2.0 + index if sub == winner else 1.0,
score=score,
)
aggregate = aggregates.setdefault(
(sub, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0},
)
aggregate["matches"] += 1
aggregate["wins"] += 1 if sub == winner else 0
aggregate["points"] += score
for (sub, game_type), aggregate in aggregates.items():
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type=game_type,
rating=INITIAL_RATING,
matches_played=aggregate["matches"],
wins=aggregate["wins"],
points=aggregate["points"],
display_name=sub,
)
class StatsRouteTest(unittest.TestCase):
@@ -252,13 +281,9 @@ class StatsRouteTest(unittest.TestCase):
ctx = await use_db(tortoise_mixin)
with ctx:
# Alice outranks everyone despite Bob leading on points.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="dummy",
rating=1600,
matches_played=2,
)
await PlayerRating.filter(
user_sub="alice", game_type="dummy"
).update(rating=1600)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
@@ -266,7 +291,6 @@ class StatsRouteTest(unittest.TestCase):
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"])
# Players without a rating row report the initial rating.
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
# Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"])
@@ -279,13 +303,9 @@ class StatsRouteTest(unittest.TestCase):
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="dummy",
rating=1516,
matches_played=1,
)
await PlayerRating.filter(
user_sub="bob", game_type="dummy"
).update(rating=1516)
# Bob's rating in another game must not leak into the
# dummy leaderboard.
await PlayerRating.create(
@@ -305,6 +325,97 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual("second", platform.registry.all()[1].id)
@async_test
async def test_leaderboard_pagination(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type="dummy",
rating=1600 + index,
matches_played=1,
wins=1,
points=2.0,
display_name=sub,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
first = await client.get("/api/leaderboard?limit=2")
second = await client.get("/api/leaderboard?limit=2&page=2")
third = await client.get("/api/leaderboard?limit=2&page=3")
beyond = await client.get("/api/leaderboard?limit=2&page=99")
body = first.json()
self.assertEqual(1, body["page"])
self.assertEqual(3, body["pages"])
self.assertEqual(6, body["total"])
self.assertEqual(2, len(body["results"]))
self.assertEqual(
["frank", "erin"], [row["user_sub"] for row in body["results"]]
)
self.assertEqual(
["dave", "carol"],
[row["user_sub"] for row in second.json()["results"]],
)
self.assertEqual(
["bob", "alice"],
[row["user_sub"] for row in third.json()["results"]],
)
# Out-of-range pages clamp to the last page.
self.assertEqual(3, beyond.json()["page"])
self.assertEqual(
["bob", "alice"],
[row["user_sub"] for row in beyond.json()["results"]],
)
@async_test
async def test_leaderboard_focus_me(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
for index, sub in enumerate(("carol", "dave")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type="dummy",
rating=1600 + index,
matches_played=1,
wins=1,
points=2.0,
display_name=sub,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
mine = await client.get("/api/leaderboard?limit=1&focus=me")
anonymous = await client.get("/api/leaderboard?limit=1&focus=me")
# dave, carol, bob, alice: the caller lands on their own page.
body = mine.json()
self.assertEqual(4, body["page"])
self.assertEqual(4, body["your_rank"])
self.assertEqual(4, body["your_page"])
self.assertEqual("alice", body["results"][0]["user_sub"])
anonymous_body = anonymous.json()
self.assertEqual(1, anonymous_body["page"])
self.assertIsNone(anonymous_body["your_rank"])
self.assertIsNone(anonymous_body["your_page"])
@async_test
async def test_leaderboard_rejects_bad_page_params(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
for query in ("page=0", "page=abc", "focus=elsewhere"):
response = await client.get(f"/api/leaderboard?{query}")
self.assertEqual(400, response.status_code, query)
# The limit is clamped, not rejected.
clamped = await client.get("/api/leaderboard?limit=500")
self.assertEqual(200, clamped.status_code)
@async_test
async def test_my_matches_include_elo_delta(self) -> None:
app, platform, tortoise_mixin = make_platform()
+44
View File
@@ -0,0 +1,44 @@
# tavolo-scopa
Scopa — the classic Italian fishing game — in its two-player form and in
the four-player fixed-partnership variant (*scopa a quattro*), as
[`tavolo-platform`](../tavolo-platform/README.md) game implementations.
The optional *asso pigliatutto* creation option turns either variant into
**scopa d'assi**.
## Contents
- `state.py` — `ScopaState` (pure game data: phases, players, hands,
table, the deck persisting between rounds, scores, deadlines),
`PlayerState`, `Card`, `Move`, with JSON (de)serialization. No session
envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, round re-dealing, hand scoring (carte, denara, settebello,
primiera, scope), the optional asso pigliatutto capture, hand-end
acknowledgements. Deterministic and I/O-free apart from logging, so the
whole rule set is unit-testable.
- `errors.py` — scopa-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `ScopaEngine` (two players) and `ScopaQuattroEngine`
(four players in fixed partnerships): the platform-facing adapters.
They translate create/join/websocket actions/deadlines into
rules-engine calls and back, validate the
`target_score`/`asso_pigliatutto` creation options (the match target
defaults to the traditional 11 points for two-player scopa and 21 for
scopa a quattro), and extract the `MatchResult` (teams, winner,
per-player scores and the match summary persisted as the match's JSON
`result`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m mypy -p tavolo.scopa
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-scopa"
version = "0.1.0"
description = "Scopa game implementation for the tavolo platform"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -0,0 +1,15 @@
"""Scopa: the classic Italian fishing game.
The pure rules (:mod:`tavolo.scopa.engine`, :mod:`tavolo.scopa.state`)
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopa.plugin`
adapts them to the platform's
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them. Two variants are provided:
:class:`ScopaEngine` (two players) and :class:`ScopaQuattroEngine` (four
players in fixed partnerships).
"""
from __future__ import annotations
from .plugin import ScopaEngine, ScopaQuattroEngine
__all__ = ["ScopaEngine", "ScopaQuattroEngine"]
@@ -0,0 +1,595 @@
"""Pure rules engine for scopa.
Every function here is deterministic and I/O-free (the only side effect is
debug logging): it mutates (or reads) :class:`~tavolo.scopa.state.ScopaState`
and raises :class:`~tavolo.platform.errors.GameError` subclasses on rule
violations. This makes the whole rule set unit-testable without Redis,
Postgres or HTTP. The platform-facing adapter is
:class:`~tavolo.scopa.plugin.ScopaEngine`.
Rules implemented
-----------------
* 40-card Italian deck; three cards per player and four cards face up on
the table at the start of every hand; the rest of the deck stays out of
play until the next round of the hand.
* When every hand is empty the dealer deals three more cards to each
player (no new table cards) and play resumes; this repeats until the
deck is exhausted. Two players play six rounds per hand, four players
three.
* A card captures either a **single card of equal rank** or a **combination
of cards whose ranks sum to its own**. When an equal-ranked card is on the
table that capture is mandatory; the player may not take an alternative
combination instead. When any capture exists it must be taken.
* Emptying the table with a capture is a **scopa** (+1), except on the very
last play of a hand — a sweep on the last play of a round still scores
when the deck has cards left.
* At the end of a hand the remaining table cards go to the player who made
the last capture.
* Hand points: ``carte`` (most captured cards), ``denara`` (most diamond
cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best
seven/five/four/three card of each suit, all four suits required), plus
one point per ``scopa``. Ties on carte/denara/primiera award nothing.
With four players the partners' piles and scope are pooled.
* Optional ``asso_pigliatutto`` rule (disabled by default; scopa d'assi):
an ace played when no equal rank is on the table sweeps the whole table
— or captures itself, leaving the table empty, when the table is bare.
The sweep never scores a scopa, even when cards remain in hand or in the
deck, but it counts as a capture for the last-taker rule. When another
ace is on the table the mandatory equal-rank capture still applies.
* The match ends when a side reaches the target score with a clear lead; a
tie at or above the target is broken by playing another hand.
"""
from __future__ import annotations
import random
from datetime import datetime, timedelta, timezone
from itertools import combinations
from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple
from tavolo.platform.errors import (
AlreadyJoined,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .errors import CardNotInHand
from .state import (
DEFAULT_PLAYER_COUNT,
DEFAULT_TARGET_SCORES,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
PLAYER_COUNTS,
SUITS,
TEAM_NAMES,
Card,
Move,
PlayerState,
ScopaState,
parse_card,
)
log = getLogger(__name__)
# Number of cards dealt to each player at a time.
HAND_SIZE = 3
# Cards dealt face up on the table at the start of every hand.
TABLE_CARDS = 4
# Default seconds the hand-end summary waits before dealing anyway. Games
# carry their own copy in ``ScopaState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# legal card for them. Games carry their own copy in
# ``ScopaState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the
# remaining ranks in descending order. All of 8/9/10 are worth 10.
PRIMIERA_VALUES: Dict[int, int] = {
7: 21,
6: 18,
1: 16,
5: 15,
4: 14,
3: 13,
2: 12,
8: 10,
9: 10,
10: 10,
}
_rng = random.SystemRandom()
def full_deck() -> List[Card]:
"""Return the 40 cards of the Italian deck in canonical order."""
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
deck = full_deck()
(rng or _rng).shuffle(deck)
return deck
def legal_captures(
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
) -> List[List[Card]]:
"""Return every legal capture (a list of card sets) for ``card``.
If an equal-ranked card is on the table, only those single-card
captures are returned (the rule forbids taking a combination instead).
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
the only capture is the whole table — or an empty capture when the
table is bare, the ace taking itself. Without the option every subset
of the table whose ranks sum to ``card.rank`` is returned.
"""
equal = [c for c in table if c.rank == card.rank]
if equal:
return [[c] for c in equal]
if asso_pigliatutto and card.rank == 1:
return [list(table)]
candidates = [c for c in table if c.rank <= card.rank]
captures: List[List[Card]] = []
# A sum-equal capture needs at least two cards (single non-equal cards
# cannot sum to the played card).
for size in range(2, len(candidates) + 1):
for combo in combinations(candidates, size):
if sum(c.rank for c in combo) == card.rank:
captures.append(list(combo))
return captures
def create_game(
creator_sub: str,
creator_name: str,
player_count: int = DEFAULT_PLAYER_COUNT,
target_score: Optional[int] = None,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
asso_pigliatutto: bool = False,
) -> ScopaState:
"""Create a lobby game with the creator seated first.
``target_score`` defaults to the variant's traditional target: 11
points for two-player scopa, 21 for the four-player partnership game.
"""
if player_count not in PLAYER_COUNTS:
raise IllegalMove(
"player_count must be one of: "
+ ", ".join(str(count) for count in PLAYER_COUNTS)
)
if target_score is None:
target_score = DEFAULT_TARGET_SCORES[player_count]
if target_score < 1 or target_score > 100:
raise IllegalMove("target_score must be between 1 and 100")
return ScopaState(
player_count=player_count,
target_score=target_score,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
)
def join_game(state: ScopaState, sub: str, name: str) -> None:
"""Seat ``sub`` in the next free chair, starting the match when full."""
if state.phase != PHASE_LOBBY:
raise GameNotStarted("game has already started")
if state.seated(sub):
raise AlreadyJoined("already joined this game")
if len(state.players) >= state.player_count:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
if len(state.players) == state.player_count:
start_game(state)
def start_game(state: ScopaState) -> None:
"""Deal the first hand and switch the game to playing."""
if len(state.players) != state.player_count:
raise GameNotStarted(
f"need exactly {state.player_count} players to start"
)
state.phase = PHASE_PLAYING
_deal_hand(state)
def _set_turn_deadline(state: ScopaState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _player_at(state: ScopaState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def _deal_hand(state: ScopaState) -> None:
"""Start a new hand: shuffle, lay the table, deal the first round."""
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
player.scope = 0
state.table = []
state.last_taker = None
for _ in range(TABLE_CARDS):
state.table.append(deck.pop())
state.deck = deck
_deal_round(state)
log.debug(
"hand %d dealt (dealer seat %d, %d players, %d on the table)",
state.hand_number,
state.dealer,
state.player_count,
len(state.table),
)
def _deal_round(state: ScopaState) -> None:
"""Deal three cards to every player and give the lead to the player
after the dealer."""
for player in state.players:
player.hand = []
for _ in range(HAND_SIZE):
for offset in range(state.player_count):
player = _player_at(state, (state.dealer + 1 + offset) % state.player_count)
player.hand.append(state.deck.pop())
state.turn = (state.dealer + 1) % state.player_count
_set_turn_deadline(state)
def play(
state: ScopaState,
sub: str,
card_code: str,
capture_codes: Optional[Sequence[str]] = None,
) -> None:
"""Apply one move by the player identified by ``sub``.
``capture_codes`` selects which table cards to capture; it must be a
legal capture (see :func:`legal_captures`) when one exists and empty
otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass
on any violation.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase == PHASE_HAND_END:
raise IllegalMove("the hand is over; acknowledge the summary to continue")
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = state.player_for(sub)
if player is None or player.seat != state.turn:
raise NotYourTurn("it is not your turn")
card = parse_card(card_code)
if card not in player.hand:
raise CardNotInHand(f"card {card.code} is not in your hand")
# Remove the card now so the scopa check below can tell whether this
# was the last play of the hand.
player.hand.remove(card)
requested = [parse_card(c) for c in (capture_codes or [])]
table_before = list(state.table)
options = legal_captures(
table_before, card, asso_pigliatutto=state.asso_pigliatutto
)
# An asso pigliatutto capture: the ace takes the whole table because no
# equal rank was on it, or takes itself when the table is bare. Such a
# capture never scores a scopa.
pigliatutto = (
state.asso_pigliatutto
and card.rank == 1
and all(c.rank != card.rank for c in table_before)
)
taken: List[Card] = []
scopa = False
if not options:
if requested:
raise IllegalMove("no capture is possible with that card")
state.table.append(card)
else:
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)
player.captured.append(card)
state.last_taker = player.seat
# A scopa scores only if cards remain to be played this hand: a
# sweep on the last play of a round still scores when the deck has
# cards left to deal.
cards_left = bool(state.deck) or any(p.hand for p in state.players)
if not state.table and cards_left and not pigliatutto:
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,
pigliatutto=pigliatutto,
)
if all(not p.hand for p in state.players):
if state.deck:
# Same hand continues with a fresh round of three cards each.
_deal_round(state)
else:
_end_hand(state)
else:
state.turn = (state.turn + 1) % state.player_count
_set_turn_deadline(state)
def _match_option(
options: Sequence[Sequence[Card]], requested: Sequence[Card]
) -> Optional[List[Card]]:
"""Return the option matching ``requested`` exactly, if any.
An empty request matches only an empty option — the asso pigliatutto
self-capture — so requesting nothing never satisfies a real capture.
"""
wanted = sorted(c.code for c in requested)
for option in options:
if sorted(c.code for c in option) == wanted:
return list(option)
return None
def auto_play(state: ScopaState, rng: Optional[random.Random] = None) -> None:
"""Play a random legal move for the player currently on turn.
A card is drawn at random from that player's hand; if it can capture,
one of the legal captures is chosen at random (the rules require a
capture when one exists). Delegates to :func:`play`, so the move is
fully validated and can end the hand or the match. Pass ``rng`` for
deterministic tests.
"""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
options = legal_captures(
state.table, card, asso_pigliatutto=state.asso_pigliatutto
)
capture = chooser.choice(options) if options else None
play(
state,
player.sub,
card.code,
[c.code for c in capture] if capture is not None else None,
)
def _end_hand(state: ScopaState) -> None:
"""Sweep the table and score the hand.
If the match continues, the game pauses in the ``hand_end`` phase so
every player can read the scoring summary; the next hand is dealt by
:func:`acknowledge_hand` once every player has acknowledged (or by the
hand-end timeout fired by the platform's deadline scheduler). If the
match is over the game goes to ``finished`` immediately.
"""
state.turn_deadline = None
if state.table and state.last_taker is not None:
taker = _player_at(state, state.last_taker)
taker.captured.extend(state.table)
state.table = []
points, details = hand_points(state)
for team in (0, 1):
state.scores[team] += points[team]
details["hand"] = state.hand_number
details["team_a_points"] = points[0]
details["team_b_points"] = points[1]
state.hand_scores.append(details)
a, b = state.scores
log.debug(
"hand %d scored A+%d B+%d (totals %d-%d)",
state.hand_number,
points[0],
points[1],
a,
b,
)
reached = max(a, b) >= state.target_score
if reached and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
log.debug("match ended, team %s wins", TEAM_NAMES[state.winner])
return
# Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: ScopaState, sub: str) -> None:
"""Record that ``sub`` has read the hand-end summary.
When every player has acknowledged, the next hand is dealt. Acknowledging
twice is a no-op; acknowledging outside the ``hand_end`` phase raises an
error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no hand summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < state.player_count:
return
state.hand_number += 1
state.dealer = (state.dealer + 1) % state.player_count
state.acked = []
state.hand_end_deadline = None
state.last_move = None
state.phase = PHASE_PLAYING
_deal_hand(state)
def primiera_score(captured: Sequence[Card]) -> int:
"""Return the primiera value of a capture pile (0 if a suit is absent)."""
best: Dict[str, int] = {}
for card in captured:
value = PRIMIERA_VALUES[card.rank]
if card.suit not in best or value > best[card.suit]:
best[card.suit] = value
if len(best) < len(SUITS):
return 0
return sum(best.values())
def hand_points(state: ScopaState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the hand points for both sides (index 0 = team A).
With four players the partners' piles and scopa counts are pooled; with
two each player is their own side.
"""
piles: List[List[Card]] = [[], []]
scope: List[int] = [0, 0]
for player in state.players:
piles[player.team].extend(player.captured)
scope[player.team] += player.scope
points = [0, 0]
award: Dict[str, Optional[str]] = {}
# Carte: most captured cards. Ties award nothing.
cards = [len(piles[0]), len(piles[1])]
if cards[0] != cards[1]:
winner = 0 if cards[0] > cards[1] else 1
points[winner] += 1
award["carte"] = TEAM_NAMES[winner]
else:
award["carte"] = None
# Denara: most diamond cards. Ties award nothing.
coins = [
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
]
if coins[0] != coins[1]:
winner = 0 if coins[0] > coins[1] else 1
points[winner] += 1
award["denara"] = TEAM_NAMES[winner]
else:
award["denara"] = None
# Settebello: the 7 of diamonds always belongs to someone.
settebello = [
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
]
if settebello[0] != settebello[1]:
winner = 0 if settebello[0] else 1
points[winner] += 1
award["settebello"] = TEAM_NAMES[winner]
# Primiera: highest value, only if the side holds all four suits.
primiera = [primiera_score(piles[t]) for t in (0, 1)]
if primiera[0] != primiera[1]:
winner = 0 if primiera[0] > primiera[1] else 1
points[winner] += 1
award["primiera"] = TEAM_NAMES[winner]
else:
award["primiera"] = None
# Scope: one point each.
points[0] += scope[0]
points[1] += scope[1]
return points, {
"cards": {"A": cards[0], "B": cards[1]},
"denara": {"A": coins[0], "B": coins[1]},
"settebello": {"A": settebello[0], "B": settebello[1]},
"primiera": {"A": primiera[0], "B": primiera[1]},
"scope": {"A": scope[0], "B": scope[1]},
"award": award,
}
def state_for_player(state: ScopaState, sub: Optional[str]) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
for player in state.players:
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": TEAM_NAMES[player.team],
"cards_left": len(player.hand),
"captured_count": len(player.captured),
"scope": player.scope,
}
if viewer is not None and viewer.seat == player.seat:
view["hand"] = [c.code for c in player.hand]
players.append(view)
payload: Dict[str, object] = {
"phase": state.phase,
"player_count": state.player_count,
"target_score": state.target_score,
"asso_pigliatutto": state.asso_pigliatutto,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
"deck_count": len(state.deck),
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
"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,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
}
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, asso_pigliatutto=state.asso_pigliatutto
)
if options:
legal_moves[hand_card.code] = [
[c.code for c in option] for option in options
]
payload["legal_moves"] = legal_moves
return payload
@@ -0,0 +1,34 @@
"""Scopa errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely scopa-specific errors
are defined here.
"""
from __future__ import annotations
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
__all__ = [
"AlreadyJoined",
"CardNotInHand",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"IllegalMove",
"LobbyFull",
"NotYourTurn",
]
class CardNotInHand(GameError):
"""The played card is not held by the player."""
@@ -0,0 +1,305 @@
"""The platform-facing adapter for scopa.
:class:`ScopaEngine` (classic two-player) and
:class:`ScopaQuattroEngine` (four-player fixed partnerships) implement
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
:mod:`tavolo.scopa.engine`: they translate platform calls (create, join,
websocket actions, deadlines) into rules-engine calls and back, keeping
all scopa knowledge inside this package. Nothing outside ``tavolo.scopa``
needs to know about phases, turns or hand scoring.
"""
from __future__ import annotations
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Mapping, Optional
from tavolo.platform.engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from .state import (
DEFAULT_TARGET_SCORE,
DEFAULT_TARGET_SCORE_QUATTRO,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
ScopaState,
)
log = getLogger(__name__)
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
def _options_schema(default_target: int) -> Dict[str, Any]:
"""The creation options schema with this variant's target default."""
return {
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": default_target,
"description": "Match points the winning side must reach.",
},
"asso_pigliatutto": {
"type": "boolean",
"default": False,
"description": "Scopa d'assi: an ace sweeps the whole "
"table (no scopa)",
},
},
}
class _ScopaBase(GameEngine):
"""Shared platform adapter for the two-player and four-player games."""
player_count: int
#: Traditional match target of the variant; also the schema default.
default_target_score: int
def __init__(
self,
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target_score = options.get("target_score", self.default_target_score)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
asso_pigliatutto = options.get("asso_pigliatutto", False)
if not isinstance(asso_pigliatutto, bool):
raise IllegalMove("asso_pigliatutto must be a boolean")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
player_count=self.player_count,
target_score=target_score,
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
asso_pigliatutto=asso_pigliatutto,
)
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team=TEAM_NAMES[0],
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
seat = len(session.players)
engine.join_game(session.state, user_sub, display_name)
# join_game raises before appending on any violation, so the seat
# list stays in sync with the engine's players.
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team=TEAM_NAMES[seat % 2],
)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: ScopaState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
"asso_pigliatutto": state.asso_pigliatutto,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: ScopaState = session.state
if action == "play":
card = payload.get("card")
capture = payload.get("capture")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
raise IllegalMove("'capture' must be a list of card codes")
try:
engine.play(state, user_sub, card, capture)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "ack":
engine.acknowledge_hand(state, user_sub)
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return engine.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state: ScopaState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, ScopaState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return ScopaState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: ScopaState = session.state
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
if due_ms is None:
return None
return Deadline(
kind="turn",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
if due_ms is None:
return None
return Deadline(
kind="hand_end",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"hand_end:{state.hand_number}:{due_ms}",
)
return None
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
# Overtaken by events (a play landed in time, the hand was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: ScopaState = session.state
if kind == "turn":
engine.auto_play(state)
actor = state.last_move.seat if state.last_move is not None else None
log.info(
"auto-played for seat %s (turn timeout, hand %d)",
actor,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"hand %d auto-advanced after the acknowledgement timeout",
state.hand_number,
)
else: # pragma: no cover - next_deadline never emits other kinds
raise GameError(f"unknown deadline kind: {kind!r}")
# -- results -------------------------------------------------------------
def result(self, session: GameSession) -> MatchResult:
state: ScopaState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
teams = [
[p.sub for p in state.players if p.team == 0],
[p.sub for p in state.players if p.team == 1],
]
return MatchResult(
teams=teams,
winner_team=state.winner,
players=[
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=player.team == state.winner,
team=TEAM_NAMES[player.team],
score=float(state.scores[player.team]),
details={
"scope": player.scope,
"captured_cards": len(player.captured),
},
)
for player in state.players
],
summary={
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[state.winner],
"target_score": state.target_score,
"asso_pigliatutto": state.asso_pigliatutto,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
},
)
class ScopaEngine(_ScopaBase):
"""Classic two-player scopa as a platform game engine."""
id = "scopa"
name = "Scopa"
description = (
"The classic two-player Italian fishing game: three cards each, "
"four on the table, capture by rank or sum and sweep for scope. "
"First to the target score wins."
)
min_players = 2
max_players = 2
player_count = 2
default_target_score = DEFAULT_TARGET_SCORE
options_schema = _options_schema(DEFAULT_TARGET_SCORE)
class ScopaQuattroEngine(_ScopaBase):
"""Four-player partnership scopa as a platform game engine."""
id = "scopa_a_quattro"
name = "Scopa a quattro"
description = (
"Scopa for four players in fixed partnerships: three cards each, "
"four on the table, capture by rank or sum and sweep for scope. "
"First side to the target score wins."
)
min_players = 4
max_players = 4
player_count = 4
default_target_score = DEFAULT_TARGET_SCORE_QUATTRO
options_schema = _options_schema(DEFAULT_TARGET_SCORE_QUATTRO)
@@ -0,0 +1,265 @@
"""In-memory representation of a scopa game.
The whole mutable game lives in :class:`ScopaState`, which is serialized
to and from plain JSON for storage inside the platform's session envelope
(see :mod:`tavolo.platform.store`). Keeping the representation JSON-native
means the store needs no custom codecs and the state is inspectable with
``redis-cli``.
The same state serves the classic two-player game and the four-player
partnership variant (``player_count``): with two players the two seats
are individual sides, with four they are fixed partnerships (0/2 vs 1/3),
and either way the side is the seat's parity. Within a hand the deck is
dealt in rounds of three cards per player and persists across rounds in
:attr:`ScopaState.deck`.
The state contains only game data: the platform owns the session envelope
(id, join code, seats, timestamps, stats persistence) — see
:class:`tavolo.platform.engine.GameSession`.
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
SUITS = ("D", "C", "S", "B")
RANKS = tuple(range(1, 11))
# Teams are derived from the seat parity: seats 0 and 2 form team A
# (index 0), seats 1 and 3 form team B (index 1). In the two-player game
# that makes the two players their own sides (seats 0 and 1); in the
# four-player game partners sit opposite each other.
TEAM_A = 0
TEAM_B = 1
TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing"
# Between hands of an unfinished match: scoring summary shown to every
# player; the next hand is dealt once everyone acknowledges (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
# Traditional match targets: two-player scopa is played to 11 points,
# while the four-player partnership game (like scopone) goes to 21.
DEFAULT_TARGET_SCORE = 11
DEFAULT_TARGET_SCORE_QUATTRO = 21
DEFAULT_TARGET_SCORES = {2: DEFAULT_TARGET_SCORE, 4: DEFAULT_TARGET_SCORE_QUATTRO}
DEFAULT_PLAYER_COUNT = 2
PLAYER_COUNTS = (2, 4)
def team_of(seat: int) -> int:
return seat % 2
@dataclass(frozen=True)
class Card:
rank: int
suit: str
def __post_init__(self) -> None:
if self.suit not in SUITS:
raise ValueError(f"invalid suit: {self.suit!r}")
if self.rank not in RANKS:
raise ValueError(f"invalid rank: {self.rank!r}")
@property
def code(self) -> str:
return f"{self.rank:02d}{self.suit}"
@staticmethod
def parse(code: str) -> "Card":
code = str(code).upper()
if len(code) != 3 or not code[:2].isdigit():
raise ValueError(f"invalid card code: {code!r}")
return Card(rank=int(code[:2]), suit=code[2])
def to_json(self) -> str:
return self.code
@staticmethod
def from_json(value: Any) -> "Card":
return Card.parse(str(value))
def parse_card(code: Any) -> Card:
"""Parse a card code, raising :class:`ValueError` on malformed input."""
return Card.parse(str(code))
@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
# The play was an asso pigliatutto capture (the whole table, or the
# ace itself when the table was empty).
pigliatutto: 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,
"pigliatutto": self.pigliatutto,
}
@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)),
pigliatutto=bool(data.get("pigliatutto", False)),
)
@dataclass
class PlayerState:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
scope: int = 0
@property
def team(self) -> int:
return team_of(self.seat)
def to_json(self) -> Dict[str, Any]:
return {
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": [c.to_json() for c in self.hand],
"captured": [c.to_json() for c in self.captured],
"scope": self.scope,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "PlayerState":
return PlayerState(
sub=str(data["sub"]),
name=str(data["name"]),
seat=int(data["seat"]),
hand=[Card.from_json(c) for c in data.get("hand", [])],
captured=[Card.from_json(c) for c in data.get("captured", [])],
scope=int(data.get("scope", 0)),
)
@dataclass
class ScopaState:
# 2 (individual) or 4 (fixed partnerships). Fixed for the session.
player_count: int = DEFAULT_PLAYER_COUNT
target_score: int = DEFAULT_TARGET_SCORE
# Whether the asso pigliatutto rule is scored (scopa d'assi: an ace
# sweeps the whole table when no equal rank is on it, or captures
# itself when the table is empty; the capture never scores a scopa).
# Default off, also for states serialized before the option existed.
asso_pigliatutto: bool = False
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list)
# Cards of the current hand not dealt yet: scopa deals three cards per
# player at a time, and re-deals when every hand is empty until the
# deck is exhausted.
deck: List[Card] = field(default_factory=list)
dealer: int = 0
turn: int = 0
hand_number: int = 1
scores: List[int] = field(default_factory=lambda: [0, 0])
winner: Optional[int] = None
last_taker: Optional[int] = None
# Per-hand points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# The most recent play in the current hand, for move announcements.
last_move: Optional[Move] = None
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30
# While phase == "playing": when the server plays a random legal card
# for the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = 30
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"player_count": self.player_count,
"target_score": self.target_score,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table],
"deck": [c.to_json() for c in self.deck],
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"scores": list(self.scores),
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": list(self.hand_scores),
"last_move": self.last_move.to_json() if self.last_move else None,
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "ScopaState":
return ScopaState(
player_count=int(data.get("player_count", DEFAULT_PLAYER_COUNT)),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])],
deck=[Card.from_json(c) for c in data.get("deck", [])],
dealer=int(data.get("dealer", 0)),
turn=int(data.get("turn", 0)),
hand_number=int(data.get("hand_number", 1)),
scores=[int(x) for x in data.get("scores", [0, 0])],
winner=data.get("winner"),
last_taker=data.get("last_taker"),
hand_scores=list(data.get("hand_scores", [])),
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(data.get("turn_timeout", 30)),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
return None
def seated(self, sub: str) -> bool:
return self.player_for(sub) is not None
@@ -0,0 +1,625 @@
"""Rule engine tests: deals, captures, scope, scoring and full matches."""
from __future__ import annotations
import random
import unittest
from tavolo.scopa import engine
from tavolo.scopa.errors import (
AlreadyJoined,
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
Card,
PlayerState,
ScopaState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
table,
turn: int = 0,
*,
dealer: int = 0,
captured=None,
scope=None,
target: int = 11,
last_taker=None,
deck=None,
asso_pigliatutto: bool = False,
) -> ScopaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = ScopaState(
player_count=len(hands),
target_score=target,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_PLAYING,
dealer=dealer,
turn=turn,
last_taker=last_taker,
)
for seat, hand in enumerate(hands):
state.players.append(
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand])
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
if scope is not None:
for player, value in zip(state.players, scope):
player.scope = value
state.table = [card(c) for c in table]
state.deck = [card(c) for c in (deck or [])]
return state
class DeckTest(unittest.TestCase):
def test_full_deck_has_40_unique_cards(self) -> None:
deck = engine.full_deck()
self.assertEqual(40, len(deck))
self.assertEqual(40, len({c.code for c in deck}))
self.assertEqual(4, len({c.suit for c in deck}))
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
def test_shuffled_deck_is_permutation(self) -> None:
deck = engine.shuffled_deck()
self.assertEqual(
sorted(c.code for c in engine.full_deck()),
sorted(c.code for c in deck),
)
class CaptureTest(unittest.TestCase):
def test_equal_card_is_mandatory(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
def test_sum_combination(self) -> None:
table = [card("01C"), card("03C"), card("02S")]
options = engine.legal_captures(table, card("04D"))
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
table = [card("05C"), card("05S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual(
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
)
def test_no_capture(self) -> None:
table = [card("09C"), card("08S")]
self.assertEqual([], engine.legal_captures(table, card("02D")))
def test_play_without_capture_places_card_on_table(self) -> None:
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
engine.play(state, "p0", "02D")
self.assertIn("02D", [c.code for c in state.table])
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
self.assertEqual(1, state.turn)
def test_play_capture_and_scopa(self) -> None:
state = make_state([["02D", "09C"], ["01C"]], table=["02C"])
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(1, state.players[0].scope)
self.assertEqual([], state.table)
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"]], 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"]],
table=["05C", "02D", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D", ["02D", "03S"])
def test_illegal_capture_rejected(self) -> None:
state = make_state([["04D"], ["01C"]], table=["02C", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "04D", ["02C"])
def test_no_capture_requested_when_capture_possible(self) -> None:
state = make_state([["05D"], ["01C"]], table=["05C"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D")
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_game_rejects_moves(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
class DealTest(unittest.TestCase):
def test_create_game_defaults(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(2, state.player_count)
# Two-player scopa is traditionally played to 11 points.
self.assertEqual(11, state.target_score)
self.assertFalse(state.asso_pigliatutto)
self.assertEqual("p0", state.players[0].sub)
self.assertEqual("lobby", state.phase)
def test_create_game_four_player_default_target(self) -> None:
# The four-player partnership game (like scopone) goes to 21.
state = engine.create_game("p0", "p0", player_count=4)
self.assertEqual(21, state.target_score)
# An explicit target still wins over the default.
explicit = engine.create_game("p0", "p0", player_count=4, target_score=16)
self.assertEqual(16, explicit.target_score)
def test_create_game_player_count_validation(self) -> None:
for bad in (1, 3, 5, 0):
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=bad)
def test_join_starts_two_player_game(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
self.assertEqual(4, len({c.code for c in state.table}))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 6 in hand.
self.assertEqual(30, len(state.deck))
self.assertEqual(1, state.turn)
def test_join_starts_four_player_game(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 12 in hand.
self.assertEqual(24, len(state.deck))
self.assertEqual(1, state.turn)
for seat in range(4):
self.assertEqual(seat % 2, engine._player_at(state, seat).team)
def test_join_errors(self) -> None:
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
# The lobby filled up and the match started.
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_start_requires_full_table(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.start_game(state)
class RoundTest(unittest.TestCase):
def test_redeal_when_hands_empty_and_deck_has_cards(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=["09C"],
deck=deck, turn=0, dealer=0)
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
# The last card of the round: the next three cards are dealt.
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(3, len(player.hand))
self.assertEqual([], state.deck)
# The table is never topped up.
self.assertEqual(
{"09C", "02D", "03D"}, {c.code for c in state.table}
)
# The player after the dealer leads every round again.
self.assertEqual(1, state.turn)
def test_redeal_gives_the_lead_back_to_the_non_dealer(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=[], deck=deck,
turn=0, dealer=1)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
# Dealer seat 1: the lead is seat 0 again.
self.assertEqual(0, state.turn)
def test_hand_ends_when_deck_is_exhausted(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], deck=[], target=100)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_HAND_END, state.phase)
class ScopaTest(unittest.TestCase):
def test_scopa_on_the_last_play_of_a_round_with_deck_left(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], []], table=["02C"], deck=deck, turn=0)
engine.play(state, "p0", "02D", ["02C"])
# Cards remain in the deck: the sweep is a scopa, and the round is
# re-dealt immediately.
self.assertEqual(1, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.scopa)
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_no_scopa_on_the_last_play_of_the_hand(self) -> None:
state = make_state([["02D"], []], table=["02C"], deck=[],
turn=0, target=100)
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertFalse(state.last_move.scopa)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# p1 still holds cards, so emptying the table would normally score
# a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"]],
table=["05C", "02D", "03S"],
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
self.assertEqual([], state.table)
self.assertEqual(
{"05C", "02D", "03S", "01D"},
{c.code for c in state.players[0].captured},
)
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertFalse(state.last_move.scopa)
def test_legal_captures_offer_only_the_whole_table(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(
table, card("01D"), asso_pigliatutto=True
)
self.assertEqual(
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
)
# A bare table leaves only the self-capture option; without the
# rule the ace stays on the table.
self.assertEqual(
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
)
self.assertEqual(
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
)
# Non-ace cards keep the normal rules.
self.assertEqual(
[],
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
)
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
asso_pigliatutto=True,
)
options = engine.legal_captures(
state.table, card("01D"), asso_pigliatutto=True
)
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "01D", ["01C", "02D"])
# A rejected play consumes the card, so retry on a fresh state.
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D", ["01C"])
self.assertEqual(["02D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_disabled_by_default(self) -> None:
state = make_state([["01D", "09C"], ["04D"]], table=["05C", "02D"])
engine.play(state, "p0", "01D")
# Without the rule an ace captures nothing here and stays on the
# table.
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
# ... and on a bare table it stays there too.
state = make_state([["01D", "09C"], ["04D"]], table=[])
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
def test_ace_on_empty_table_captures_itself(self) -> None:
# The self-capture makes p0 the last taker and scores no scopa.
state = make_state(
[["01D", "09C"], ["04D"]],
table=[],
last_taker=1,
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D")
self.assertEqual([], state.table)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual(0, state.players[0].scope)
self.assertEqual(0, state.last_taker)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_non_ace_on_empty_table_stays(self) -> None:
state = make_state(
[["02D", "09C"], ["04D"]], table=[], asso_pigliatutto=True
)
engine.play(state, "p0", "02D")
self.assertEqual(["02D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
class HandEndTest(unittest.TestCase):
def test_table_swept_to_last_taker(self) -> None:
state = make_state([["02D"], []], table=["05C", "04D"],
last_taker=1, deck=[], target=100)
engine.play(state, "p0", "02D")
captured = {c.code for c in state.players[1].captured}
self.assertEqual({"05C", "04D", "02D"}, captured)
self.assertEqual(PHASE_HAND_END, state.phase)
def test_match_ends_at_target_with_clear_lead(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], turn=0,
target=1, captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
def test_tie_at_target_plays_another_hand(self) -> None:
state = make_state(
[["02D"], []],
table=[],
deck=[],
target=1,
captured=[["01D", "02C"], ["02D", "01C"]],
)
state.scores = [1, 1]
engine.play(state, "p0", "02D")
# A 0-0 hand at the target is not a win: another hand is played.
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.winner)
def test_acknowledge_deals_next_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
state.phase = PHASE_HAND_END
engine.acknowledge_hand(state, "p0")
self.assertEqual(PHASE_HAND_END, state.phase)
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual(1, state.dealer)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_acknowledge_rejects_outside_hand_end(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_hand_summary_has_no_napola(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], target=1,
captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertNotIn("napola", state.hand_scores[-1])
class ScoringTest(unittest.TestCase):
def test_primiera_values_and_all_suits_requirement(self) -> None:
self.assertEqual(70, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
self.assertEqual(0, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S"]]))
self.assertEqual(40, engine.primiera_score(
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
def test_two_player_hand_points(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"],
["03D", "04C", "07S", "02B"],
],
scope=[1, 0],
)
points, details = engine.hand_points(state)
# Carte and denara tie; A takes settebello and primiera, plus one
# scopa: A+3.
self.assertEqual({"A": 4, "B": 4}, details["cards"])
self.assertEqual({"A": 1, "B": 1}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 0}, details["scope"])
self.assertEqual([3, 0], points)
def test_ties_award_nothing(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["06C", "01S", "05B", "02D"],
["06S", "01B", "05D", "02C"],
],
scope=[0, 0],
)
points, _ = engine.hand_points(state)
# Equal cards, equal denara, equal primiera and no settebello:
# everything ties, so no points at all.
self.assertEqual([0, 0], points)
def test_four_player_pools_the_partners(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"], # seat 0, team A
["03D", "04C", "07S", "02B"], # seat 1, team B
["02D"], # seat 2, team A
["10D", "10C", "10S", "10B"], # seat 3, team B
],
scope=[1, 0, 0, 2],
)
points, details = engine.hand_points(state)
self.assertEqual([3, 3], points)
self.assertEqual({"A": 5, "B": 8}, details["cards"])
self.assertEqual({"A": 2, "B": 2}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 2}, details["scope"])
self.assertNotIn("napola", details)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game(
"p0", "p0", player_count=4, target_score=16,
asso_pigliatutto=True,
)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
restored = ScopaState.from_json(state.to_json())
self.assertEqual(4, restored.player_count)
self.assertEqual(16, restored.target_score)
self.assertTrue(restored.asso_pigliatutto)
self.assertEqual(
[c.code for c in state.deck], [c.code for c in restored.deck]
)
self.assertEqual(
[c.code for c in state.table], [c.code for c in restored.table]
)
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
def test_asso_pigliatutto_defaults_off(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScopaState.from_json(data).asso_pigliatutto)
def test_player_count_defaults_to_two(self) -> None:
data = ScopaState().to_json()
del data["player_count"]
self.assertEqual(2, ScopaState.from_json(data).player_count)
class AutoPlayTest(unittest.TestCase):
def test_auto_play_applies_a_legal_move(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(7)
player = engine._player_at(state, state.turn)
hand_size = len(player.hand)
engine.auto_play(state, rng)
self.assertEqual(hand_size - 1, len(player.hand))
assert state.last_move is not None
self.assertEqual(player.seat, state.last_move.seat)
class FullMatchTest(unittest.TestCase):
def _play_out(self, player_count: int, target: int) -> ScopaState:
state = engine.create_game("p0", "p0", player_count=player_count,
target_score=target)
for seat in range(1, player_count):
engine.join_game(state, f"p{seat}", f"p{seat}")
rng = random.Random(1234 + player_count)
steps = 0
while state.phase != PHASE_FINISHED and steps < 100_000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
engine.auto_play(state, rng)
steps += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIsNotNone(state.winner)
self.assertGreaterEqual(max(state.scores), target)
self.assertNotEqual(state.scores[0], state.scores[1])
return state
def test_random_two_player_match_reaches_a_winner(self) -> None:
state = self._play_out(2, 5)
for hand in state.hand_scores:
self.assertIn("cards", hand)
self.assertIn("scope", hand)
def test_random_four_player_match_reaches_a_winner(self) -> None:
state = self._play_out(4, 5)
self.assertEqual(4, len(state.players))
def test_all_cards_are_captured_by_the_end_of_a_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(99)
# Finish exactly one hand (target 100 keeps the match alive).
state.target_score = 100
while state.phase != PHASE_HAND_END:
engine.auto_play(state, rng)
self.assertIsNotNone(state.last_taker)
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,345 @@
"""Adapter tests: the platform contract over the pure scopa rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotStarted,
IllegalMove,
)
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
from tavolo.scopa import engine as rules
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
ScopaState,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="s1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for seat in range(1, engine.max_players):
engine.join(session, f"p{seat}", f"P{seat}")
return session
def _drive_to_end(engine, session: GameSession) -> None:
"""Play the match out with the first legal move each turn."""
moves = 0
while not engine.is_finished(session) and moves < 200_000:
state = session.state
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(
state.table, played, asso_pigliatutto=state.asso_pigliatutto
)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_side_a(self) -> None:
for engine_type, target in ((ScopaEngine, 11), (ScopaQuattroEngine, 21)):
engine = engine_type()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, ScopaState)
# Two-player scopa is played to 11, the partnership game to 21.
self.assertEqual(target, session.state.target_score)
self.assertFalse(session.state.asso_pigliatutto)
def test_schema_defaults_follow_the_variant(self) -> None:
self.assertEqual(
11, ScopaEngine().options_schema["properties"]["target_score"]["default"]
)
self.assertEqual(
21,
ScopaQuattroEngine().options_schema["properties"]["target_score"][
"default"
],
)
def test_create_options(self) -> None:
engine = ScopaEngine()
session = _session(engine, target_score=21, asso_pigliatutto=True)
self.assertEqual(21, session.state.target_score)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None:
engine = ScopaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=0)
with self.assertRaises(IllegalMove):
_session(engine, target_score=101)
with self.assertRaises(IllegalMove):
_session(engine, target_score="eleven")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None:
engine = ScopaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_join_starts_two_player_game(self) -> None:
engine = ScopaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(["A", "B"], [p.team for p in session.players])
def test_join_starts_four_player_game_with_teams(self) -> None:
engine = ScopaQuattroEngine()
session = _session(engine)
for seat, team in ((1, "B"), (2, "A"), (3, "B")):
engine.join(session, f"p{seat}", f"P{seat}")
self.assertEqual(team, session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = ScopaEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
engine.join(session, "bob", "Bob")
with self.assertRaises(GameNotStarted):
engine.join(session, "carol", "Carol")
with self.assertRaises(GameNotStarted):
engine.join(session, "alice", "Alice")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(
session, "p1", "play", {"card": "01D", "capture": "02C"}
)
def test_invalid_card_code_is_illegal_move(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": "nope"})
def test_finished_match_rejects_actions(self) -> None:
engine = ScopaEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
class ViewTest(unittest.TestCase):
def test_view_hides_other_hands_and_reports_the_deck(self) -> None:
engine = ScopaEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
self.assertEqual(2, view["player_count"])
self.assertEqual(30, view["deck_count"])
self.assertEqual(4, len(view["table"]))
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual(4, view["player_count"])
def test_lobby_view(self) -> None:
engine = ScopaEngine()
session = _session(engine, target_score=16, asso_pigliatutto=True)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ScopaState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
self.assertEqual(
[c.code for c in session.state.deck],
[c.code for c in restored.deck],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = ScopaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = ScopaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
# A forged token is stale.
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
engine = ScopaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = ScopaEngine()
session = _started(engine)
state = session.state
# Force the hand-end phase with an imminent deadline.
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_two_player_match(self) -> None:
engine = ScopaEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual(1, len(result.teams[0]))
self.assertEqual(1, len(result.teams[1]))
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(2, len(result.players))
for player in result.players:
self.assertEqual(
player.won,
player.team == ("A" if result.winner_team == 0 else "B"),
)
self.assertIn("team_a_score", result.summary)
self.assertIn("hands_played", result.summary)
self.assertIn("asso_pigliatutto", result.summary)
def test_result_of_finished_four_player_match(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
result = engine.result(session)
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertEqual(4, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = ScopaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = ScopaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [3, 11]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 3, "B": 11}, over["scores"])
self.assertEqual("B", over["winner"])
def test_registry_metadata(self) -> None:
two = ScopaEngine()
self.assertEqual("scopa", two.id)
self.assertEqual("Scopa", two.name)
self.assertEqual(2, two.min_players)
self.assertEqual(2, two.max_players)
four = ScopaQuattroEngine()
self.assertEqual("scopa_a_quattro", four.id)
self.assertEqual("Scopa a quattro", four.name)
self.assertEqual(4, four.min_players)
self.assertEqual(4, four.max_players)
for engine in (two, four):
self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn(
"asso_pigliatutto", engine.options_schema["properties"]
)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -19,7 +19,8 @@ implementation.
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
It translates create/join/websocket actions/deadlines into rules-engine
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
creation options, and extracts the `MatchResult` (teams, winner,
creation options (the match target defaults to the traditional 21
points), and extracts the `MatchResult` (teams, winner,
per-player scores and the match summary persisted as the match's JSON
`result`).
@@ -37,7 +37,9 @@ PHASE_PLAYING = "playing"
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
DEFAULT_TARGET_SCORE = 11
# The traditional match target for scopone scientifico: the partnership
# game is played to 21 points.
DEFAULT_TARGET_SCORE = 21
def team_of(seat: int) -> int:
@@ -35,7 +35,8 @@ class CreateTest(unittest.TestCase):
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, ScoponeState)
self.assertEqual(11, session.state.target_score)
# The traditional partnership target.
self.assertEqual(21, session.state.target_score)
self.assertTrue(session.state.napola)
def test_create_options(self) -> None:
+6 -4
View File
@@ -4,10 +4,12 @@
#
# pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml
#
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-briscola and tavolo-app
# sibling packages are NOT pinned here: they are installed from ./packages in
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-scopa, tavolo-briscola
# and tavolo-app sibling packages are NOT pinned here: they are installed
# from ./packages in
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
# -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola
# -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa
# -e ./packages/tavolo-briscola
# -e ./packages/tavolo-app). All of their
# third-party dependencies are direct dependencies of tavolo-app, so this
# lock covers the full closure. The otel extra (kaya-otel and its
@@ -77,7 +79,7 @@ kaya-cors==0.0.7
# via tavolo-app (pyproject.toml)
kaya-oidc==0.0.7
# via tavolo-app (pyproject.toml)
kaya-openapi==0.0.7
kaya-openapi==0.0.8
# via tavolo-app (pyproject.toml)
kaya-otel==0.0.7
# via tavolo-app (pyproject.toml)
+26 -5
View File
@@ -23,14 +23,35 @@ from tavolo.platform import auth
_PROVIDER = next(iter(oidc.providers))
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
def make_user(
sub: str,
name: Optional[str] = None,
email: Optional[str] = None,
picture: Optional[str] = None,
) -> OIDCUser:
claims = {"sub": sub, "preferred_username": name or sub}
if email:
claims["email"] = email
if picture:
claims["picture"] = picture
return OIDCUser(claims)
@contextlib.contextmanager
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user."""
user = make_user(sub, name)
def oidc_user(
sub: str,
name: Optional[str] = None,
email: Optional[str] = None,
picture: Optional[str] = None,
) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user.
``email`` is optional so tests can simulate an IdP that still returns
the claim: the app requests only the ``openid`` and ``profile`` scopes
and ignores email where present. ``picture`` simulates the profile
scope's avatar claim.
"""
user = make_user(sub, name, email, picture)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
patcher.start()
try:
+139
View File
@@ -0,0 +1,139 @@
"""OpenAPI document tests: components, response schemas and Swagger UI."""
from __future__ import annotations
import unittest
from typing import Any, Dict, Tuple
from httpx import ASGITransport, AsyncClient
from kaya.openapi import schema_ref
from tavolo.app import app
from tavolo.platform.openapi import SCHEMAS
from tests.helpers import async_test
#: Methods documented for the application's own routes.
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
class OpenApiDocumentTest(unittest.TestCase):
async def _get(self, path: str) -> Any:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get(path)
self.assertEqual(200, response.status_code)
return response
async def _spec(self) -> Dict[str, Any]:
response = await self._get("/api/openapi.json")
body: Dict[str, Any] = response.json()
return body
async def _schema(
self, spec: Dict[str, Any], path: str, method: str, status: str
) -> Dict[str, Any]:
schema: Dict[str, Any] = spec["paths"][path][method]["responses"][status][
"content"
]["application/json"]["schema"]
return schema
@async_test
async def test_components_registered(self) -> None:
spec = await self._spec()
self.assertEqual(SCHEMAS, spec["components"]["schemas"])
@async_test
async def test_every_api_response_documents_a_schema(self) -> None:
spec = await self._spec()
checked = 0
for path, item in spec["paths"].items():
if not path.startswith("/api/"):
continue
for method, operation in item.items():
if method not in HTTP_METHODS:
continue
for status, response in operation["responses"].items():
with self.subTest(path=path, method=method, status=status):
if status == "204":
# No content, so no schema either.
self.assertNotIn("content", response)
continue
self.assertIn("application/json", response["content"])
self.assertTrue(
response["content"]["application/json"]["schema"]
)
checked += 1
self.assertGreater(checked, 0)
@async_test
async def test_response_schemas_reference_components(self) -> None:
spec = await self._spec()
cases: Tuple[Tuple[str, str, str, Dict[str, Any]], ...] = (
("/api/health", "get", "200", schema_ref("Health")),
("/api/me", "get", "200", schema_ref("Me")),
("/api/me", "get", "401", schema_ref("Error")),
("/api/auth/providers", "get", "200", schema_ref("AuthProvidersPage")),
("/api/game-types", "get", "200", schema_ref("GameTypesPage")),
("/api/games", "post", "201", schema_ref("Lobby")),
("/api/games/preview", "get", "200", schema_ref("GamePreview")),
("/api/games/{game_id}", "get", "200", schema_ref("GameView")),
("/api/games/{game_id}", "get", "404", schema_ref("Error")),
("/api/me/games", "get", "200", schema_ref("OngoingGamesPage")),
("/api/me/matches", "get", "200", schema_ref("MatchesPage")),
("/api/leaderboard", "get", "200", schema_ref("LeaderboardPage")),
("/api/me/ratings", "get", "200", schema_ref("RatingsPage")),
)
for path, method, status, expected in cases:
with self.subTest(path=path, method=method, status=status):
self.assertEqual(
expected, await self._schema(spec, path, method, status)
)
@async_test
async def test_join_response_is_lobby_or_game_view(self) -> None:
spec = await self._spec()
schema = await self._schema(spec, "/api/games/join", "post", "200")
self.assertEqual(
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]}, schema
)
@async_test
async def test_component_nesting_and_nullability(self) -> None:
spec = await self._spec()
schemas = spec["components"]["schemas"]
matches = schemas["MatchesPage"]["properties"]
self.assertEqual(schema_ref("Match"), matches["results"]["items"])
self.assertEqual(["string", "null"], matches["next_cursor"]["type"])
match = schemas["Match"]["properties"]
self.assertEqual(schema_ref("MatchPlayer"), match["players"]["items"])
self.assertEqual(["integer", "null"], match["your_elo_delta"]["type"])
lobby = schemas["Lobby"]
self.assertEqual(schema_ref("LobbyPlayer"), lobby["properties"]["players"]["items"])
self.assertIs(True, lobby["additionalProperties"])
self.assertEqual(
["string", "null"],
schemas["LobbyPlayer"]["properties"]["team"]["type"],
)
game_view = schemas["GameView"]
self.assertEqual("boolean", game_view["properties"]["spectator"]["type"])
self.assertIs(True, game_view["additionalProperties"])
# The join code is optional (players only), so it is not required.
self.assertNotIn("join_code", game_view["required"])
board = schemas["LeaderboardPage"]
self.assertEqual(["integer", "null"], board["properties"]["your_rank"]["type"])
self.assertEqual(
["integer", "null"], board["properties"]["your_page"]["type"]
)
self.assertEqual(schema_ref("LeaderboardEntry"),
board["properties"]["results"]["items"])
@async_test
async def test_docs_serve_swagger_ui(self) -> None:
response = await self._get("/api/docs")
self.assertIn("text/html", response.headers["content-type"])
self.assertIn("swagger-ui", response.text)
+8 -1
View File
@@ -10,7 +10,7 @@ import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tavolo.app import app, oidc
from tests.helpers import async_test
@@ -33,3 +33,10 @@ class AuthRoutesTest(unittest.TestCase):
response = await client.get("/auth/login", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/default/login", response.headers["location"])
def test_authorization_scopes_exclude_email(self) -> None:
# Only the scopes the app uses are requested: openid (sub) and
# profile (preferred_username/name). kaya-oidc's default would also
# request email, which the app neither needs nor stores.
mixin = oidc.provider("default")
self.assertEqual(("openid", "profile"), tuple(mixin._config.scopes))
+38 -3
View File
@@ -302,6 +302,8 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(
[
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
@@ -314,23 +316,56 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(4, scopone["min_players"])
self.assertEqual(4, scopone["max_players"])
self.assertIn("target_score", scopone["options_schema"]["properties"])
self.assertEqual(
21,
scopone["options_schema"]["properties"]["target_score"]["default"],
)
self.assertIn("napola", scopone["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", scopone["options_schema"]["properties"]
)
briscola = results[1]
scopa = results[1]
self.assertEqual("Scopa", scopa["name"])
self.assertTrue(scopa["description"])
self.assertEqual(2, scopa["min_players"])
self.assertEqual(2, scopa["max_players"])
self.assertIn("target_score", scopa["options_schema"]["properties"])
self.assertEqual(
11, scopa["options_schema"]["properties"]["target_score"]["default"]
)
self.assertIn(
"asso_pigliatutto", scopa["options_schema"]["properties"]
)
scopa_quattro = results[2]
self.assertEqual("Scopa a quattro", scopa_quattro["name"])
self.assertTrue(scopa_quattro["description"])
self.assertEqual(4, scopa_quattro["min_players"])
self.assertEqual(4, scopa_quattro["max_players"])
self.assertIn(
"target_score", scopa_quattro["options_schema"]["properties"]
)
self.assertEqual(
21,
scopa_quattro["options_schema"]["properties"]["target_score"][
"default"
],
)
self.assertIn(
"asso_pigliatutto", scopa_quattro["options_schema"]["properties"]
)
briscola = results[3]
self.assertEqual("Briscola", briscola["name"])
self.assertTrue(briscola["description"])
self.assertEqual(2, briscola["min_players"])
self.assertEqual(2, briscola["max_players"])
self.assertIn("target_score", briscola["options_schema"]["properties"])
quattro = results[2]
quattro = results[4]
self.assertEqual("Briscola a quattro", quattro["name"])
self.assertTrue(quattro["description"])
self.assertEqual(4, quattro["min_players"])
self.assertEqual(4, quattro["max_players"])
self.assertIn("target_score", quattro["options_schema"]["properties"])
chiamata = results[3]
chiamata = results[5]
self.assertEqual("Briscola chiamata", chiamata["name"])
self.assertTrue(chiamata["description"])
self.assertEqual(5, chiamata["min_players"])
+68 -2
View File
@@ -19,10 +19,36 @@ class MeRouteTest(unittest.TestCase):
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"):
with oidc_user("alice", email="alice@example.com"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
# The email claim the IdP returns is neither stored nor exposed;
# no picture claim in this request.
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
response.json(),
)
@async_test
async def test_me_includes_the_profile_picture(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", picture="https://cdn.example.com/alice.png"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual(
"https://cdn.example.com/alice.png",
response.json()["picture"],
)
@async_test
async def test_me_rejects_non_http_picture_claims(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", picture="javascript:alert(1)"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertIsNone(response.json()["picture"])
@async_test
async def test_me_unauthenticated(self) -> None:
@@ -55,6 +81,46 @@ class StaticRouteTest(unittest.TestCase):
self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content)
@async_test
async def test_serves_pwa_manifest_and_worker(self) -> None:
"""The manifest and the service worker are served from the site
root, with the content types browsers require, instead of falling
through to the app shell."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
(Path(dist) / "manifest.webmanifest").write_text('{"name": "Tavolo"}')
(Path(dist) / "sw.js").write_text("self.addEventListener('fetch', () => {});")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
manifest = await client.get("/manifest.webmanifest")
self.assertEqual(200, manifest.status_code)
self.assertEqual("application/manifest+json", manifest.headers["content-type"])
self.assertEqual('{"name": "Tavolo"}', manifest.text)
worker = await client.get("/sw.js")
self.assertEqual(200, worker.status_code)
self.assertEqual("text/javascript; charset=utf-8", worker.headers["content-type"])
# The worker script itself must never be cached stale.
self.assertEqual("no-cache", worker.headers["cache-control"])
self.assertIn(b"addEventListener", worker.content)
@async_test
async def test_missing_pwa_files_return_404(self) -> None:
"""A build without the PWA files must 404 those exact paths rather
than answer them with the shell (the wrong content type)."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
self.assertEqual(404, (await client.get("/manifest.webmanifest")).status_code)
self.assertEqual(404, (await client.get("/sw.js")).status_code)
@async_test
async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
+419
View File
@@ -0,0 +1,419 @@
"""Scopa integration tests: lobby, websocket play, timeouts and summaries.
The per-package suites cover the rules and the engine contract; these
tests wire both scopa variants through the full platform stack (REST,
WebSocket, deadline scheduler).
"""
from __future__ import annotations
import asyncio
import contextlib
import unittest
from httpx import ASGITransport, AsyncClient
from httpx_ws import aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from tavolo.app import app, game_store, platform
from tavolo.scopa.state import (
PHASE_HAND_END,
PHASE_PLAYING,
Card,
PlayerState,
ScopaState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
class ScopaRoutesTest(unittest.TestCase):
@async_test
async def test_create_and_start_two_player_game(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games",
json={
"game_type": "scopa",
"options": {"target_score": 21, "asso_pigliatutto": True},
},
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopa", body["game_type"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(1, body["seats_open"])
self.assertEqual(21, body["target_score"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual("A", body["players"][0]["team"])
code = body["join_code"]
with oidc_user("bob"):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
self.assertEqual("playing", state["phase"])
self.assertEqual(2, len(state["players"]))
self.assertEqual(["A", "B"], [p["team"] for p in state["players"]])
for participant in state["players"]:
self.assertEqual(3, participant["cards_left"])
self.assertEqual(4, len(state["table"]))
self.assertEqual(30, state["deck_count"])
self.assertEqual(1, state["turn"])
self.assertTrue(state["asso_pigliatutto"])
# Bob's view carries his own hand only.
self.assertIn("hand", state["players"][1])
self.assertNotIn("hand", state["players"][0])
# A non-player watches without a hand or the join code.
watched = await client.get(f"/api/games/{state['id']}")
self.assertEqual(200, watched.status_code)
spectator = watched.json()
self.assertTrue(spectator["spectator"])
self.assertNotIn("join_code", spectator)
for participant in spectator["players"]:
self.assertNotIn("hand", participant)
@async_test
async def test_create_and_start_four_player_game(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "scopa_a_quattro"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopa_a_quattro", body["game_type"])
self.assertEqual(3, body["seats_open"])
code = body["join_code"]
for player in ("bob", "carol"):
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
self.assertEqual("lobby", joined.json()["phase"])
with oidc_user("dave"):
started = await client.post("/api/games/join", json={"code": code})
state = started.json()
self.assertEqual("playing", state["phase"])
self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]])
# Partnership scopa defaults to the traditional 21 points.
self.assertEqual(21, state["target_score"])
for participant in state["players"]:
self.assertEqual(3, participant["cards_left"])
self.assertEqual(4, len(state["table"]))
self.assertEqual(24, state["deck_count"])
@async_test
async def test_preview_shows_scopa_options(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games",
json={
"game_type": "scopa",
"options": {"target_score": 21, "asso_pigliatutto": True},
},
)
code = created.json()["join_code"]
with oidc_user("bob"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("scopa", body["game_type"])
self.assertEqual("Scopa", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual(21, body["target_score"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual(1, body["seats_open"])
@async_test
async def test_create_rejects_bad_options(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
for bad in (0, 101, "eleven", True):
with oidc_user("alice"):
response = await client.post(
"/api/games",
json={"game_type": "scopa", "options": {"target_score": bad}},
)
self.assertEqual(400, response.status_code, bad)
with oidc_user("alice"):
response = await client.post(
"/api/games",
json={"game_type": "scopa", "options": {"asso_pigliatutto": "yes"}},
)
self.assertEqual(400, response.status_code)
class ScopaWebSocketTest(unittest.TestCase):
async def _started_two_player(self, client: AsyncClient) -> dict:
with oidc_user("alice"):
created = await client.post("/api/games", json={"game_type": "scopa"})
code = created.json()["join_code"]
with oidc_user("bob"):
started = await client.post("/api/games/join", json={"code": code})
return started.json()
@async_test
async def test_play_trails_or_captures(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_two_player(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
game = first["game"]
self.assertEqual(1, game["turn"])
self.assertTrue(game.get("your_turn"))
self.assertEqual(4, len(game["table"]))
card = game["players"][1]["hand"][0]
options = (game.get("legal_moves") or {}).get(card)
payload = {"action": "play", "card": card}
if options:
payload["capture"] = options[0]
await bob_ws.send_json(payload)
update = await bob_ws.receive_json()
self.assertEqual("state", update["type"])
game = update["game"]
self.assertEqual(0, game["turn"])
self.assertIsNotNone(game["last_move"])
self.assertEqual(1, game["last_move"]["seat"])
self.assertEqual(card, game["last_move"]["card"])
if options:
# The pile grows by the captured table cards
# plus the card just played.
self.assertEqual(
len(options[0]) + 1,
game["players"][1]["captured_count"],
)
else:
self.assertEqual(5, len(game["table"]))
@async_test
async def test_illegal_move_returns_error(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_two_player(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
# Bob (seat 1) is on turn, not Alice.
await alice_ws.send_json({"action": "play", "card": "01D"})
error = await alice_ws.receive_json()
self.assertEqual("error", error["type"])
@async_test
async def test_spectator_cannot_act(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_two_player(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
for player in first["game"]["players"]:
self.assertNotIn("hand", player)
await ws.send_json({"action": "play", "card": "01D"})
error = await ws.receive_json()
self.assertEqual("forbidden", error["code"])
@async_test
async def test_asso_pigliatutto_sweeps_the_table(self) -> None:
# A controlled scopa d'assi position: Alice's ace sweeps the whole
# table, scores no scopa and counts as the last capture.
state = ScopaState(
player_count=2,
target_score=100,
asso_pigliatutto=True,
phase=PHASE_PLAYING,
dealer=0,
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
hand=[Card.parse("01D"), Card.parse("09C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
hand=[Card.parse("04D")],
),
],
table=[Card.parse("05C"), Card.parse("02D")],
)
engine = platform.registry.require("scopa")
session = GameSession(
id="scopa-asso-pigliatutto-1",
game_type="scopa",
join_code="SA0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
],
)
session.state = state
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(0, first["game"]["turn"])
self.assertTrue(first["game"].get("your_turn"))
await ws.send_json(
{"action": "play", "card": "01D", "capture": ["05C", "02D"]}
)
update = await ws.receive_json()
game = update["game"]
self.assertEqual([], game["table"])
self.assertEqual(3, game["players"][0]["captured_count"])
self.assertEqual(0, game["players"][0]["scope"])
self.assertEqual(0, game["scores"]["A"])
self.assertTrue(game["last_move"]["pigliatutto"])
self.assertFalse(game["last_move"]["scopa"])
@async_test
async def test_hand_end_ack_flow(self) -> None:
# A one-play hand with target 100 so the match continues to the
# scoring summary.
state = ScopaState(
player_count=2,
target_score=100,
phase=PHASE_PLAYING,
dealer=0,
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
hand=[Card.parse("02D")],
),
PlayerState(sub="bob", name="Bob", seat=1),
],
table=[Card.parse("02C")],
)
session = GameSession(
id="scopa-hand-end-1",
game_type="scopa",
join_code="SH0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
],
)
session.state = state
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
async with contextlib.AsyncExitStack() as stack:
with ws_users([make_user("alice"), make_user("bob")]):
sockets = [
await stack.enter_async_context(
aconnect_ws(f"/ws/games/{session.id}", ws_client)
)
for _ in range(2)
]
for ws in sockets:
await ws.receive_json()
# Alice captures the last table card: the hand ends.
await sockets[0].send_json(
{"action": "play", "card": "02D", "capture": ["02C"]}
)
summaries = [await ws.receive_json() for ws in sockets]
for summary in summaries:
self.assertEqual("state", summary["type"])
self.assertEqual(PHASE_HAND_END, summary["game"]["phase"])
self.assertEqual([], summary["game"]["acknowledged"])
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
hand = summary["game"]["last_hand"]
self.assertEqual({"A": 2, "B": 0}, hand["cards"])
self.assertEqual("A", hand["award"]["carte"])
# Both acknowledge; the second ack deals the next hand.
await sockets[0].send_json({"action": "ack"})
updates = [await ws.receive_json() for ws in sockets]
for update in updates:
self.assertEqual(PHASE_HAND_END, update["game"]["phase"])
self.assertEqual([0], update["game"]["acknowledged"])
await sockets[1].send_json({"action": "ack"})
updates = [await ws.receive_json() for ws in sockets]
for update in updates:
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
self.assertEqual(2, update["game"]["hand_number"])
for player in update["game"]["players"]:
self.assertEqual(3, player["cards_left"])
class ScopaTurnTimeoutTest(unittest.TestCase):
@async_test
async def test_turn_timeout_auto_plays_a_card(self) -> None:
engine = platform.registry.require("scopa")
session = GameSession(
id="scopa-turn-timeout-1",
game_type=engine.id,
join_code="ST0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
first = await ws.receive_json()
# Bob (seat 1) is first to act and never connects.
self.assertEqual(1, first["game"]["turn"])
self.assertIsNotNone(first["game"]["turn_deadline"])
update = None
for _ in range(20):
try:
update = await asyncio.wait_for(ws.receive_json(), timeout=2)
except asyncio.TimeoutError:
break
if (
update.get("type") == "state"
and update["game"]["last_move"] is not None
):
break
self.assertIsNotNone(update)
assert update is not None
# The absent player was auto-played: the move is on the
# board and the turn advanced.
self.assertEqual(1, update["game"]["last_move"]["seat"])
self.assertEqual(0, update["game"]["turn"])
players = {p["seat"]: p for p in update["game"]["players"]}
self.assertEqual(2, players[1]["cards_left"])
self.assertEqual(3, players[0]["cards_left"])
if __name__ == "__main__":
unittest.main()
+144 -2
View File
@@ -19,6 +19,7 @@ from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.errors import IllegalMove
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from tavolo.scopa import engine as scopa_rules
from tavolo.scopone import engine as rules
from tests.helpers import async_test, oidc_user
@@ -132,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase):
self.assertIn("your_elo_delta", results[0])
self.assertEqual(4, len(results[0]["players"]))
board = await client.get("/api/leaderboard")
with oidc_user("alice"):
board = await client.get("/api/leaderboard")
self.assertEqual(200, board.status_code)
by_sub = {r["user_sub"]: r for r in board.json()["results"]}
body = board.json()
by_sub = {r["user_sub"]: r for r in body["results"]}
self.assertEqual(4, len(by_sub))
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
self.assertEqual(1, body["page"])
self.assertEqual(1, body["pages"])
self.assertEqual(4, body["total"])
# The caller's own rank rides along on the response.
self.assertIsNotNone(body["your_rank"])
self.assertEqual(1, body["your_page"])
paged = await client.get("/api/leaderboard?limit=1&page=2")
self.assertEqual(200, paged.status_code)
self.assertEqual(4, paged.json()["pages"])
self.assertEqual(2, paged.json()["page"])
self.assertEqual(1, len(paged.json()["results"]))
async def _finished_briscola_session() -> GameSession:
@@ -318,5 +333,132 @@ class ChiamataStatsTest(unittest.TestCase):
)
class ScopaStatsTest(unittest.TestCase):
async def _finished_session(
self, player_count: int = 2, asso_pigliatutto: bool = False
) -> GameSession:
engine = platform.registry.require(
"scopa" if player_count == 2 else "scopa_a_quattro"
)
session = GameSession(
id=f"stats-scopa-{player_count}",
game_type=engine.id,
join_code="SC0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(
session,
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
)
for name in PLAYERS[1:player_count]:
engine.join(session, name, name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == "hand_end":
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = scopa_rules.legal_captures(
state.table, played,
asso_pigliatutto=state.asso_pigliatutto,
)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
assert engine.is_finished(session)
return session
@async_test
async def test_finished_two_player_match_persisted_with_summary(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopa")
session = await self._finished_session(2, asso_pigliatutto=True)
with ctx:
await save_match_result(session, engine)
self.assertEqual(1, await Match.all().count())
self.assertEqual(2, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual("scopa", match.game_type)
summary = match.result
for key in (
"team_a_score",
"team_b_score",
"winner_team",
"target_score",
"asso_pigliatutto",
"hands_played",
"hand_scores",
):
self.assertIn(key, summary)
self.assertTrue(summary["asso_pigliatutto"])
winner_team = summary["winner_team"]
players = {p.user_sub: p for p in await MatchPlayer.all()}
for row in players.values():
self.assertEqual(row.won, row.team == winner_team)
# A two-player match moves Elo by 16 points each.
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
self.assertEqual(
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
)
@async_test
async def test_finished_four_player_match_persisted_with_teams(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopa_a_quattro")
session = await self._finished_session(4)
with ctx:
await save_match_result(session, engine)
self.assertEqual(4, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual("scopa_a_quattro", match.game_type)
players = await MatchPlayer.all()
self.assertEqual({"A", "B"}, {p.team for p in players})
# Partnership Elo: the winning side gains 16 each.
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
winners = {p.user_sub for p in players if p.won}
self.assertEqual(
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
)
self.assertEqual(
{INITIAL_RATING - 16},
{ratings[sub] for sub in ratings if sub not in winners},
)
@async_test
async def test_history_exposes_scopa_result(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopa")
session = await self._finished_session(2)
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
history = await client.get("/api/me/matches")
self.assertEqual(200, history.status_code)
results = history.json()["results"]
self.assertEqual(1, len(results))
self.assertEqual("scopa", results[0]["game_type"])
self.assertIn("team_a_score", results[0]["result"])
self.assertIn("your_elo_delta", results[0])
self.assertEqual(2, len(results[0]["players"]))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -13,7 +13,7 @@ serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
futures = "0.3"
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage", "ServiceWorkerContainer", "ServiceWorkerRegistration", "Notification", "NotificationPermission", "NotificationOptions", "Document"] }
js-sys = "0.3"
gloo-timers = "0.3"
console_error_panic_hook = "0.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- Maskable variant: the felt background bleeds to the edges and the
spade shrinks inside the 80% safe zone, so platform masks (circles,
squircles, rounded squares) never clip the glyph. Also rasterized
into the 180x180 apple-touch-icon, where iOS applies its own mask. -->
<defs>
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
<stop offset="0" stop-color="#1d5c38"/>
<stop offset="1" stop-color="#14341f"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#felt)"/>
<path fill="#e8c547" transform="translate(128 134) scale(16)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- Tavolo app icon: the gold spade from the navbar on dark felt.
The spade glyph is Bootstrap Icons' "suit-spade-fill" (MIT), the
same license as the vendored Bootstrap CSS. Rasterized into the
PNG sizes by make-icons.sh (rsvg-convert); not used directly at
runtime except as the SVG favicon. -->
<defs>
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
<stop offset="0" stop-color="#1d5c38"/>
<stop offset="1" stop-color="#14341f"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#felt)"/>
<path fill="#e8c547" transform="translate(96 104) scale(20)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

+16 -1
View File
@@ -4,12 +4,27 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tavolo</title>
<!-- PWA: installable app shell. The manifest and the (install-only)
service worker are served from the site root, where the worker's
default scope covers the whole app; the Python host routes them in
production, trunk serve serves them in development. -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#14341f">
<link rel="icon" href="/assets/icons/icon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/icons/icon-192.png" sizes="192x192" type="image/png">
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Tavolo">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link data-trunk rel="rust" href="Cargo.toml">
<link data-trunk rel="copy-file" href="manifest.webmanifest">
<link data-trunk rel="copy-file" href="sw.js">
<!-- Bootstrap 5.3.3 (MIT), vendored so builds work offline. -->
<link data-trunk rel="css" href="vendor/bootstrap.min.css">
<link data-trunk rel="css" href="theme.css">
<link data-trunk rel="css" href="style.css">
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
<!-- Card images (CC0 woodcut napoletane deck) and the PWA icons
(generated by make-icons.sh) copied verbatim into dist. -->
<link data-trunk rel="copy-dir" href="assets">
</head>
<body></body>
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Regenerate the PWA icon PNGs from the vector sources in assets/icons/.
# The PNGs are committed (like the card art) because the Docker builder has
# no SVG rasterizer; run this only after editing icon.svg or
# icon-maskable.svg. Requires rsvg-convert (librsvg).
set -eu
ICONS="$(dirname "$0")/assets/icons"
rsvg-convert -w 192 -h 192 "$ICONS/icon.svg" -o "$ICONS/icon-192.png"
rsvg-convert -w 512 -h 512 "$ICONS/icon.svg" -o "$ICONS/icon-512.png"
rsvg-convert -w 512 -h 512 "$ICONS/icon-maskable.svg" -o "$ICONS/icon-maskable-512.png"
# iOS masks the home-screen icon itself; a full-bleed square is expected.
rsvg-convert -w 180 -h 180 "$ICONS/icon-maskable.svg" -o "$ICONS/apple-touch-icon.png"
echo "icons ready in $ICONS"
+31
View File
@@ -0,0 +1,31 @@
{
"name": "Tavolo",
"short_name": "Tavolo",
"description": "Multiplayer card games: scopone, scopa and briscola.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#14341f",
"theme_color": "#14341f",
"icons": [
{
"src": "/assets/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+18 -7
View File
@@ -175,13 +175,24 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
resp.json().await.map_err(|e| e.to_string())
}
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
/// are all scoped to that game.
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
.send()
.await
.map_err(|e| e.to_string())?;
/// Fetch one page of the leaderboard for a game. With `focus_me` the
/// server returns the page holding the caller's own ranking (page 1 for
/// anonymous or unrated callers).
pub async fn leaderboard(
game_type: &str,
page: Option<u32>,
focus_me: bool,
) -> Result<LeaderboardPage, String> {
let mut url = format!(
"/api/leaderboard?game_type={game_type}&limit={LEADERBOARD_PAGE_SIZE}"
);
if focus_me {
url.push_str("&focus=me");
}
if let Some(page) = page {
url.push_str(&format!("&page={page}"));
}
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
+183
View File
@@ -0,0 +1,183 @@
//! User avatar: the provider's `picture` claim when available, a
//! deterministic identicon generated from the OIDC `sub` otherwise.
//!
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
//! FNV-1a hash. The hash is implemented here (instead of
//! `DefaultHasher`) so the picture is stable across Rust versions and
//! the helper functions stay unit-testable on the host, with no DOM.
use sycamore::prelude::*;
/// FNV-1a 64-bit: a stable hash for identicon seeds.
fn fnv1a(input: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in input.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// The identicon hue (0..360) for a seed.
fn hue_for(seed: &str) -> u32 {
(fnv1a(seed) % 360) as u32
}
/// The identicon cell grid as 5 columns x 5 rows. Only 15 bits (3
/// columns) are derived from the hash; the left half is mirrored onto
/// the right so the icon is symmetric. The center cell is forced on so
/// an unlucky hash can never produce a blank icon.
fn cells_for(seed: &str) -> [[bool; 5]; 5] {
let hash = fnv1a(seed);
let mut half = [false; 15];
for (index, cell) in half.iter_mut().enumerate() {
*cell = (hash >> index) & 1 == 1;
}
if !half.iter().any(|set| *set) {
half[8] = true; // center of the half grid: row 2, column 2
}
let mut cells = [[false; 5]; 5];
for row in 0..5 {
for column in 0..3 {
let set = half[row * 3 + column];
cells[row][column] = set;
if column < 2 {
cells[row][4 - column] = set;
}
}
}
cells
}
/// Only absolute http(s) picture URLs are rendered. The backend filters
/// the claim the same way; this is the client-side half of the rule.
fn safe_picture_url(url: &str) -> Option<String> {
let trimmed = url.trim();
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Some(trimmed.to_string())
} else {
None
}
}
/// The user's avatar: their profile picture, or an identicon generated
/// from the `sub`. A picture that fails to load falls back to the
/// identicon as well.
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
let Some(url) = picture.and_then(safe_picture_url) else {
return identicon(sub);
};
let seed = sub.to_string();
let failed = create_signal(false);
view! {
(move || {
if failed.get() {
identicon(&seed)
} else {
let src = url.clone();
view! {
img(class="avatar", src=src, alt="",
on:error=move |_| failed.set(true))
}
}
})
}
}
/// The generated fallback avatar.
fn identicon(seed: &str) -> View {
let hue = hue_for(seed);
let background = format!("hsl({hue}, 45%, 22%)");
let foreground = format!("hsl({hue}, 65%, 62%)");
let cells = cells_for(seed);
let mut squares: Vec<View> = Vec::new();
for (row, line) in cells.iter().enumerate() {
for (column, set) in line.iter().enumerate() {
if *set {
let x = column.to_string();
let y = row.to_string();
let fill = foreground.clone();
squares.push(view! {
rect(x=x, y=y, width="1", height="1", fill=fill)
});
}
}
}
view! {
svg(class="avatar", viewBox="0 0 5 5", "aria-hidden"="true") {
rect(x="0", y="0", width="5", height="5", fill=background)
(squares)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_is_deterministic_and_seed_dependent() {
assert_eq!(fnv1a("alice"), fnv1a("alice"));
assert_ne!(fnv1a("alice"), fnv1a("bob"));
// Pinned vectors: guard against accidental changes to the hash
// (it must stay stable for the identicons users see).
assert_eq!(149_266_183, fnv1a("alice") % 1_000_000_000);
assert_eq!(23, hue_for("alice"));
}
#[test]
fn hue_stays_in_range() {
for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] {
assert!(hue_for(seed) < 360);
}
}
#[test]
fn pattern_is_symmetric_and_never_blank() {
for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] {
let cells = cells_for(seed);
assert!(
cells.iter().flatten().any(|cell| *cell),
"blank for {seed:?}"
);
for row in 0..5 {
for column in 0..5 {
assert_eq!(
cells[row][column],
cells[row][4 - column],
"asymmetric for {seed:?} at ({row}, {column})"
);
}
}
}
}
#[test]
fn different_seeds_draw_different_patterns() {
assert_ne!(cells_for("alice"), cells_for("bob"));
}
#[test]
fn only_absolute_http_picture_urls_are_rendered() {
assert_eq!(
Some("https://cdn.example.com/a.png".to_string()),
safe_picture_url("https://cdn.example.com/a.png")
);
assert_eq!(
Some("https://cdn.example.com/a.png".to_string()),
safe_picture_url(" https://cdn.example.com/a.png ")
);
assert_eq!(
Some("HTTPS://cdn.example.com/a.png".to_string()),
safe_picture_url("HTTPS://cdn.example.com/a.png")
);
assert_eq!(None, safe_picture_url("httpz://x"));
assert_eq!(None, safe_picture_url("/relative.png"));
assert_eq!(None, safe_picture_url("javascript:alert(1)"));
assert_eq!(
None,
safe_picture_url("data:image/svg+xml;base64,PHN2ZyB4bW")
);
}
}
+2
View File
@@ -1,7 +1,9 @@
pub mod avatar;
pub mod briscola;
pub mod card;
pub mod copy;
pub mod login;
pub mod nav;
pub mod notify;
pub mod summary;
pub mod toast;
+80
View File
@@ -0,0 +1,80 @@
//! HUD bell for desktop notifications about the other players' moves.
use std::cell::Cell;
use std::rc::Rc;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::notify::{self, Permission};
/// Notification state shared by the game page and its table views: the
/// opt-in flag and the browser permission, both page-scoped so a
/// permission prompt that resolves after a table re-render still lands
/// somewhere safe. `alive` mirrors the game page's unmount flag.
#[derive(Clone)]
pub struct NotifyCtx {
pub enabled: Signal<bool>,
pub permission: Signal<Permission>,
pub alive: Rc<Cell<bool>>,
}
/// The caption for the current (enabled, permission) state, and whether
/// the button is a dead end (the user must change browser settings).
fn face(enabled: bool, permission: Permission) -> (&'static str, bool) {
match (enabled, permission) {
(true, _) => ("🔔 Notifications on", false),
(false, Permission::Granted | Permission::Default) => ("🔔 Notify me", false),
(false, Permission::Denied | Permission::Unsupported) => {
("🔔 Notifications blocked", true)
}
}
}
/// The move-notification bell, rendered in the HUD of seated players.
///
/// The preference is global (all games) and persisted per device. The
/// permission prompt fires on the first click: browsers require a user
/// gesture, and asking on page load would be ignored.
pub fn notify_toggle(ctx: NotifyCtx) -> View {
let NotifyCtx { enabled, permission, alive } = ctx;
let on_click = move |_| {
if enabled.get() {
enabled.set(false);
notify::set_enabled(false);
return;
}
match permission.get() {
Permission::Default => {
let alive = alive.clone();
spawn_local(async move {
let granted = notify::request_permission().await;
if !alive.get() {
return;
}
permission.set(granted);
if granted == Permission::Granted {
enabled.set(true);
notify::set_enabled(true);
}
})
}
Permission::Granted => {
enabled.set(true);
notify::set_enabled(true);
}
Permission::Denied | Permission::Unsupported => {}
}
};
view! {
button(
class="btn btn-outline-light",
disabled=move || face(enabled.get(), permission.get()).1,
title="Desktop notification when another player plays a card, \
while this tab is in the background",
on:click=on_click,
) {
(move || face(enabled.get(), permission.get()).0)
}
}
}
+130 -57
View File
@@ -4,27 +4,56 @@
//! language, how each scoring category played out and how the running
//! totals moved toward the target. Every player must acknowledge it before
//! the next hand is dealt (or the server-side timeout deals anyway).
//!
//! The sides are named from the game: the players' names for individual
//! games (classic two-player scopa), "Team A"/"Team B" for partnership
//! games (scopone scientifico, scopa a quattro).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{GameView, HandSummary, Scores};
use crate::model::{is_individual_game, GameView, HandSummary, Scores};
use crate::ws::GameSocket;
/// Names of the two sides: the players' names for games without
/// partnerships, "Team A"/"Team B" otherwise.
fn side_names(game: &GameView) -> (String, String) {
if is_individual_game(&game.game_type) {
(
game.player_name_for_team("A"),
game.player_name_for_team("B"),
)
} else {
("Team A".to_string(), "Team B".to_string())
}
}
/// The display label for the side that won a category ("A"/"B").
fn side_label(game: &GameView, team: &str) -> String {
let (a, b) = side_names(game);
match team {
"A" => a,
"B" => b,
other => other.to_string(),
}
}
/// One explanatory row: icon, title, plain-language sentence, points chip.
/// `team` is the winning side's letter, or `None` for a tie.
fn award_row(
icon: View,
title: &'static str,
text: String,
winner: Option<String>,
team: Option<&str>,
name: &str,
points: &'static str,
) -> View {
let cls = match winner.as_deref() {
let cls = match team {
Some("A") => "score-row team-a",
Some("B") => "score-row team-b",
_ => "score-row tie",
};
let chip = match &winner {
Some(t) => format!("Team {t} {points}"),
let chip = match team {
Some(_) => format!("{name} {points}"),
None => "tie".to_string(),
};
view! {
@@ -48,69 +77,109 @@ fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
}
/// The five scoring rows of a completed hand.
pub fn summary_rows(summary: HandSummary) -> View {
pub fn summary_rows(game: &GameView, summary: HandSummary) -> View {
// Carte
let carte = summary.award.carte.clone();
let carte_name = carte
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let carte_text = match &carte {
Some(t) => {
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
format!(
"{} captured more cards ({w} vs {l})",
side_label(game, t)
)
}
None => format!(
"Both sides captured {} cards — no point",
summary.cards.a
),
};
// Denara
let denara = summary.award.denara.clone();
let denara_name = denara
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let denara_text = match &denara {
Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
format!(
"{} collected more denari cards ({w} vs {l})",
side_label(game, t)
)
}
None => format!(
"Both sides collected {} denari cards — no point",
summary.denara.a
),
};
// Settebello
let settebello = summary.award.settebello.clone();
let settebello_name = settebello
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let settebello_text = match &settebello {
Some(t) => format!(
"{} captured the 7 of denari — the Settebello",
side_label(game, t)
),
None => "Nobody captured the Settebello".to_string(),
};
// Primiera
let primiera = summary.award.primiera.clone();
let primiera_name = primiera
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let primiera_text = match &primiera {
Some(t) => {
let (w, l) = winner_first(summary.primiera.a, summary.primiera.b, Some(t));
format!(
"{} holds the strongest primiera ({w} vs {l})",
side_label(game, t)
)
}
None => format!(
"Both primiere are worth {} — no point",
summary.primiera.a
),
};
let rows: Vec<View> = vec![
// Carte
award_row(
card_back("score-mini"),
"Carte",
match &summary.award.carte {
Some(t) => {
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
format!("Team {t} captured more cards ({w} vs {l})")
}
None => format!(
"Both teams captured {} cards — no point",
summary.cards.a
),
},
summary.award.carte.clone(),
carte_text,
carte.as_deref(),
&carte_name,
"+1",
),
// Denara
award_row(
card_img("02D".to_string(), "score-mini"),
"Denari",
match &summary.award.denara {
Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
format!("Team {t} collected more denari cards ({w} vs {l})")
}
None => format!(
"Both teams collected {} denari cards — no point",
summary.denara.a
),
},
summary.award.denara.clone(),
"Denara",
denara_text,
denara.as_deref(),
&denara_name,
"+1",
),
// Settebello
award_row(
card_img("07D".to_string(), "score-mini"),
"Settebello",
match &summary.award.settebello {
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
None => "Nobody captured the Settebello".to_string(),
},
summary.award.settebello.clone(),
settebello_text,
settebello.as_deref(),
&settebello_name,
"+1",
),
// Primiera
award_row(
card_img("10D".to_string(), "score-mini"),
"Primiera",
match &summary.award.primiera {
Some(t) => {
let (w, l) =
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
format!("Team {t} holds the strongest primiera ({w} vs {l})")
}
None => format!(
"Both primiere are worth {} — no point",
summary.primiera.a
),
},
summary.award.primiera.clone(),
primiera_text,
primiera.as_deref(),
&primiera_name,
"+1",
),
// Napola (only when the rule is enabled for this match)
@@ -124,15 +193,17 @@ pub fn summary_rows(summary: HandSummary) -> View {
};
let text = match (&summary.award.napola, n) {
(Some(t), 10) => format!(
"Team {t} swept the whole denari suit — napola! Instant match win"
"{} swept the whole denari suit — napola! Instant match win",
side_label(game, t)
),
(Some(t), n) => format!(
"Team {t} captured {n} consecutive denari from the ace"
"{} captured {n} consecutive denari from the ace",
side_label(game, t)
),
(None, _) => "No napola this hand".to_string(),
};
let chip = match &summary.award.napola {
Some(t) => format!("Team {t} +{n}"),
Some(t) => format!("{} +{n}", side_label(game, t)),
None => "tie".to_string(),
};
let cls = match summary.award.napola.as_deref() {
@@ -154,12 +225,13 @@ pub fn summary_rows(summary: HandSummary) -> View {
},
// Scope
{
let (name_a, name_b) = side_names(game);
let a = summary.scope.a;
let b = summary.scope.b;
let text = if a == 0 && b == 0 {
"No scope this hand".to_string()
} else {
format!("Team A made {a} scope · Team B made {b} scope")
format!("{name_a} made {a} scope · {name_b} made {b} scope")
};
let chip = format!("+{a} · +{b}");
view! {
@@ -193,10 +265,11 @@ pub fn totals(game: &GameView) -> View {
.as_ref()
.map(|s| (s.team_a_points, s.team_b_points))
.unwrap_or((0, 0));
let (name_a, name_b) = side_names(game);
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team A" }
span(class="total-label") { (name_a) }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
@@ -206,7 +279,7 @@ pub fn totals(game: &GameView) -> View {
}
}
div(class="total-row team-b") {
span(class="total-label") { "Team B" }
span(class="total-label") { (name_b) }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
@@ -256,7 +329,7 @@ pub fn hand_summary_modal(
}
});
let rows = summary_rows(summary.clone());
let rows = summary_rows(&game, summary.clone());
let totals_view = totals(&game);
let title = format!("Hand {} — results", summary.hand);
+3
View File
@@ -3,7 +3,9 @@ mod api;
mod auth;
mod components;
mod model;
mod notify;
mod pages;
mod pwa;
mod ws;
use sycamore::prelude::*;
@@ -91,5 +93,6 @@ fn main() {
)
}
});
pwa::register();
restore_page_after_login();
}
+182 -8
View File
@@ -11,6 +11,8 @@ fn default_true() -> bool {
pub struct User {
pub sub: String,
pub name: String,
#[serde(default)]
pub picture: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -193,6 +195,9 @@ pub struct GameView {
pub winner: Option<String>,
#[serde(default)]
pub table: Vec<String>,
/// Scopa: face-down cards left in the deck for the current hand.
#[serde(default)]
pub deck_count: usize,
#[serde(default)]
pub players: Vec<PlayerView>,
#[serde(default)]
@@ -463,6 +468,10 @@ fn default_elo() -> i32 {
1500
}
fn default_page() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardEntry {
@@ -509,10 +518,30 @@ pub struct RatingsPage {
pub results: Vec<PlayerRating>,
}
/// Page size requested by the leaderboard page. Also used to turn a rank
/// into the row number on a page (and back).
pub const LEADERBOARD_PAGE_SIZE: u32 = 20;
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardPage {
#[serde(default)]
pub results: Vec<LeaderboardEntry>,
/// 1-based page actually returned (clamped to the last page).
#[serde(default = "default_page")]
pub page: u32,
/// Total number of pages; 0 when the board has no rated players.
#[serde(default)]
pub pages: u32,
/// Total number of rated players.
#[serde(default)]
pub total: u32,
/// The viewer's own rank, when authenticated and rated for this game.
#[serde(default)]
pub your_rank: Option<u32>,
/// The page holding the viewer's row.
#[serde(default)]
pub your_page: Option<u32>,
}
/// A card game the platform can host (GET /api/game-types).
@@ -553,6 +582,22 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "scopa".to_string(),
name: "Scopa".to_string(),
description: String::new(),
min_players: 2,
max_players: 2,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "scopa_a_quattro".to_string(),
name: "Scopa a quattro".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola".to_string(),
name: "Briscola".to_string(),
@@ -863,6 +908,12 @@ pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro"
}
/// Whether a game type is one of the scopa variants (classic two-player
/// scopa and four-player partnership scopa a quattro).
pub fn is_scopa(game_type: &str) -> bool {
game_type == "scopa" || game_type == "scopa_a_quattro"
}
/// Whether a game type is five-player briscola chiamata, which has its
/// own table view (auction, secret partner, five seats).
pub fn is_chiamata(game_type: &str) -> bool {
@@ -888,10 +939,10 @@ pub fn rank_name(rank: i32) -> String {
}
/// Whether a game is played by two individuals rather than partnerships
/// (classic two-player briscola): team labels and team messages are
/// replaced by the players' names.
/// (classic two-player briscola, classic two-player scopa): team labels
/// and team messages are replaced by the players' names.
pub fn is_individual_game(game_type: &str) -> bool {
game_type == "briscola"
game_type == "briscola" || game_type == "scopa"
}
/// Human-readable suit name for a one-letter code (`D` -> "denari").
@@ -1088,6 +1139,31 @@ mod tests {
assert_eq!(1500, e.elo);
}
#[test]
fn leaderboard_page_parses_pagination_fields() {
let p: LeaderboardPage = serde_json::from_value(json!({
"results": [],
"page": 3,
"pages": 12,
"total": 234,
"your_rank": 47,
"your_page": 3,
}))
.unwrap();
assert_eq!(3, p.page);
assert_eq!(12, p.pages);
assert_eq!(234, p.total);
assert_eq!(Some(47), p.your_rank);
assert_eq!(Some(3), p.your_page);
// The pagination fields are optional for older payloads.
let legacy: LeaderboardPage =
serde_json::from_value(json!({"results": []})).unwrap();
assert_eq!(1, legacy.page);
assert_eq!(0, legacy.pages);
assert_eq!(None, legacy.your_rank);
assert_eq!(None, legacy.your_page);
}
fn scopone_game_type() -> GameTypeInfo {
serde_json::from_value(json!({
"id": "scopone_scientifico",
@@ -1100,7 +1176,7 @@ mod tests {
"properties": {
"target_score": {
"type": "integer", "minimum": 1, "maximum": 100,
"default": 11,
"default": 21,
},
"napola": {"type": "boolean", "default": true},
"asso_pigliatutto": {"type": "boolean", "default": false},
@@ -1121,7 +1197,7 @@ mod tests {
target.kind,
OptionKind::Integer { min: Some(1), max: Some(100) }
));
assert_eq!(json!(11), target.default);
assert_eq!(json!(21), target.default);
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
assert_eq!(json!(true), by_key["napola"].default);
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
@@ -1155,7 +1231,7 @@ mod tests {
#[test]
fn default_options_collect_schema_defaults() {
let opts = scopone_game_type().default_options();
assert_eq!(Some(&json!(11)), opts.get("target_score"));
assert_eq!(Some(&json!(21)), opts.get("target_score"));
assert_eq!(Some(&json!(true)), opts.get("napola"));
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
}
@@ -1408,6 +1484,14 @@ mod tests {
assert_eq!("?", suit_name("?"));
}
#[test]
fn is_scopa_matches_both_variants() {
assert!(is_scopa("scopa"));
assert!(is_scopa("scopa_a_quattro"));
assert!(!is_scopa("scopone_scientifico"));
assert!(!is_scopa("briscola"));
}
#[test]
fn rank_names_cover_the_ladder() {
assert_eq!("Asso", rank_name(1));
@@ -1419,9 +1503,11 @@ mod tests {
}
#[test]
fn is_individual_game_only_matches_two_player_briscola() {
fn is_individual_game_matches_two_player_games() {
assert!(is_individual_game("briscola"));
assert!(is_individual_game("scopa"));
assert!(!is_individual_game("briscola_a_quattro"));
assert!(!is_individual_game("scopa_a_quattro"));
assert!(!is_individual_game("scopone_scientifico"));
}
@@ -1485,6 +1571,8 @@ mod tests {
assert_eq!(
vec![
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
@@ -1493,7 +1581,7 @@ mod tests {
);
// The lobby's first game (the creation default) must exist.
assert_eq!("scopone_scientifico", types[0].id);
assert_eq!(5, types[3].max_players);
assert_eq!(5, types[5].max_players);
}
#[test]
@@ -1724,4 +1812,90 @@ mod tests {
assert_eq!("Difensori", b);
assert_eq!(Some("Chiamante".to_string()), g.winner);
}
#[test]
fn game_view_parses_scopa_payload() {
let g: GameView = serde_json::from_value(json!({
"id": "s1",
"join_code": "ABC123",
"game_type": "scopa",
"phase": "playing",
"target_score": 11,
"asso_pigliatutto": true,
"player_count": 2,
"hand_number": 1,
"dealer": 0,
"turn": 1,
"deck_count": 24,
"scores": {"A": 0, "B": 0},
"table": ["05C", "02D", "03S", "10B"],
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 3, "captured_count": 0, "scope": 0},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
"cards_left": 3, "captured_count": 0, "scope": 0,
"hand": ["01D", "06C", "09S"]},
],
"last_hand": null,
"last_move": {"seat": 0, "name": "Alice", "card": "04D",
"captured": ["01C", "03C"], "scopa": true,
"pigliatutto": false},
"acknowledged": [],
"hand_end_deadline": null,
"turn_deadline": "2026-01-01T00:00:30+00:00",
"your_turn": true,
"legal_moves": {"01D": [["01C"]]},
}))
.unwrap();
assert!(is_scopa(&g.game_type));
assert!(is_individual_game(&g.game_type));
assert!(g.asso_pigliatutto);
assert_eq!(2, g.player_count);
assert_eq!(24, g.deck_count);
assert_eq!(4, g.table.len());
assert_eq!(Some(vec!["01D".to_string(), "06C".to_string(), "09S".to_string()]),
g.players[1].hand.clone());
let mv = g.last_move.unwrap();
assert!(mv.scopa);
assert_eq!(2, mv.captured.len());
}
#[test]
fn game_view_parses_scopa_a_quattro_hand_summary() {
let g: GameView = serde_json::from_value(json!({
"id": "s2",
"game_type": "scopa_a_quattro",
"phase": "hand_end",
"player_count": 4,
"scores": {"A": 3, "B": 0},
"players": [
{"sub": "a", "name": "Alice", "seat": 0, "team": "A"},
{"sub": "b", "name": "Bob", "seat": 1, "team": "B"},
{"sub": "c", "name": "Carol", "seat": 2, "team": "A"},
{"sub": "d", "name": "Dave", "seat": 3, "team": "B"},
],
"last_hand": {
"cards": {"A": 21, "B": 19},
"denara": {"A": 6, "B": 4},
"settebello": {"A": true, "B": false},
"primiera": {"A": 70, "B": 60},
"scope": {"A": 1, "B": 0},
"award": {
"carte": "A", "denara": "A", "settebello": "A",
"primiera": "A",
},
"hand": 1,
"team_a_points": 3,
"team_b_points": 0,
},
}))
.unwrap();
assert!(is_scopa(&g.game_type));
assert!(!is_individual_game(&g.game_type));
let hand = g.last_hand.unwrap();
assert_eq!(21, hand.cards.a);
assert_eq!(Some("A".to_string()), hand.award.carte);
assert!(hand.napola.is_none());
assert_eq!(3, hand.team_a_points);
}
}
+464
View File
@@ -0,0 +1,464 @@
//! Desktop notifications for the other players' card plays.
//!
//! Move notifications are opt-in (the bell in the game HUD), fire only
//! while the game page is in the background, and only for games the viewer
//! is seated in. They are delivered through the service worker —
//! page-context notifications are rejected on some platforms (Android in
//! particular) — with a page-context fallback when no worker is active.
//!
//! The interesting logic is split into pure functions ([`last_play`],
//! [`opponent_play`], [`message`]) so it is unit-testable on the host;
//! everything else talks to the browser.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::{spawn_local, JsFuture};
use crate::model::{card_label, is_briscola, is_chiamata, GameView};
/// localStorage key holding whether the user wants move notifications.
const ENABLED_KEY: &str = "tavolo.notify.moves";
/// The most recent card played, in a form comparable across snapshots.
#[derive(Debug, Clone, PartialEq)]
pub struct LastPlay {
pub seat: usize,
pub card: String,
}
/// The browser's notification permission.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Permission {
Default,
Granted,
Denied,
/// The browser has no Notification API at all.
Unsupported,
}
/// Whether the user has opted into move notifications. The preference is
/// global (all games) and per device; browsers without `localStorage`
/// (rare) just never remember it.
pub fn enabled() -> bool {
storage()
.and_then(|s| s.get_item(ENABLED_KEY).ok().flatten())
.as_deref()
== Some("1")
}
/// Persist the opt-in state.
pub fn set_enabled(on: bool) {
if let Some(storage) = storage() {
let _ = if on {
storage.set_item(ENABLED_KEY, "1")
} else {
storage.remove_item(ENABLED_KEY)
};
}
}
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok().flatten()
}
fn has_notification_api() -> bool {
js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("Notification"))
.map(|value| !value.is_undefined())
.unwrap_or(false)
}
/// The browser's current notification permission.
pub fn permission() -> Permission {
if !has_notification_api() {
return Permission::Unsupported;
}
match web_sys::Notification::permission() {
web_sys::NotificationPermission::Granted => Permission::Granted,
web_sys::NotificationPermission::Denied => Permission::Denied,
_ => Permission::Default,
}
}
/// Ask the browser for the notification permission.
///
/// Must be called from a user gesture (a click): Chrome rejects
/// permission prompts without one. Returns the resulting permission.
pub async fn request_permission() -> Permission {
if !has_notification_api() {
return Permission::Unsupported;
}
match web_sys::Notification::request_permission() {
Ok(promise) => {
let _ = JsFuture::from(promise).await;
// Re-read the permission: the promise resolves to the same
// enum, but re-reading also covers browsers that settled it
// through their own UI.
permission()
}
Err(_) => permission(),
}
}
/// Whether the page is not in the foreground: the tab is hidden, or its
/// window lost focus. Notifications are only useful — and only shown —
/// when the viewer cannot see the table.
pub fn page_in_background() -> bool {
let Some(window) = web_sys::window() else {
// No window object: headless contexts cannot show notifications
// anyway; treat the page as visible and stay quiet.
return false;
};
let Some(document) = window.document() else {
return false;
};
document.hidden() || !document.has_focus().unwrap_or(true)
}
/// The viewer's seat, or `None` for spectators. The viewer is the only
/// player whose hand is present in the snapshot.
pub fn viewer_seat(game: &GameView) -> Option<usize> {
if game.spectator {
return None;
}
game.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat)
}
/// The most recent card played in the snapshot, across all game families:
///
/// - scopone and scopa report every play in `last_move`;
/// - briscola and briscola chiamata don't: a play either extends `trick`
/// or, when it completes the trick, moves `trick` into `last_trick` —
/// in both cases the latest play is the last card of the trick.
pub fn last_play(game: &GameView) -> Option<LastPlay> {
if let Some(mv) = &game.last_move {
return Some(LastPlay { seat: mv.seat, card: mv.card.clone() });
}
if let Some(card) = game.trick.last() {
return Some(LastPlay { seat: card.seat, card: card.card.clone() });
}
game.last_trick
.as_ref()
.and_then(|trick| trick.cards.last())
.map(|card| LastPlay { seat: card.seat, card: card.card.clone() })
}
/// The play to announce between two snapshots.
///
/// `primed` is false for the first snapshot after a (re)connection: the
/// snapshot is a resync, not a play, and only establishes the baseline.
/// Only plays by *other* seats are reported; the viewer's own plays (and
/// the server's auto-plays for them) are already visible.
pub fn opponent_play(primed: bool, prev: Option<&LastPlay>, next: &GameView) -> Option<LastPlay> {
let cur = last_play(next)?;
if !primed || Some(&cur) == prev {
return None;
}
// Spectators (and snapshots without a visible hand) have no seat to
// compare against: stay quiet rather than announce every play.
let seat = viewer_seat(next)?;
if cur.seat == seat {
return None;
}
Some(cur)
}
/// Track the notification baseline across game snapshots. The baseline
/// must be reset for every (re)connection; see [`opponent_play`].
#[derive(Clone)]
pub struct Baseline {
prev: Rc<RefCell<Option<LastPlay>>>,
primed: Rc<Cell<bool>>,
}
impl Baseline {
pub fn new() -> Self {
Baseline {
prev: Rc::new(RefCell::new(None)),
primed: Rc::new(Cell::new(false)),
}
}
/// Forget the previous snapshot: the next one is a resync baseline.
pub fn reset(&self) {
*self.prev.borrow_mut() = None;
self.primed.set(false);
}
/// Record `game` and return the opponent's play to notify about, if
/// any. Must be called for every snapshot, whether or not the
/// preference is on, so that enabling it mid-game never replays an
/// old move.
pub fn observe(&self, game: &GameView) -> Option<LastPlay> {
let play = opponent_play(self.primed.get(), self.prev.borrow().as_ref(), game);
*self.prev.borrow_mut() = last_play(game);
self.primed.set(true);
play
}
}
/// The notification title and body for `play` in `game`.
pub fn message(game: &GameView, play: &LastPlay) -> (String, String) {
let title = format!(
"{} played {}",
game.player_name_for_seat(play.seat),
card_label(&play.card)
);
let mut parts = vec![if is_briscola(&game.game_type) || is_chiamata(&game.game_type) {
format!("Deal {}", game.hand_number)
} else {
format!("Hand {}", game.hand_number)
}];
if game.your_turn == Some(true) {
parts.push("Your turn".to_string());
} else {
parts.push(format!("{} to play", game.player_name_for_seat(game.turn)));
}
if let Some(mv) = &game.last_move {
if mv.scopa {
parts.push("Scopa!".to_string());
}
if mv.pigliatutto {
parts.push("Asso pigliatutto!".to_string());
}
}
(title, parts.join(" · "))
}
/// Show a desktop notification for `play`.
///
/// Best-effort: failures (no permission, no worker, platform rejection)
/// are silently ignored — the table itself is the source of truth.
pub fn notify_move(game: &GameView, play: &LastPlay) {
let (title, body) = message(game, play);
let url = format!("/game/{}", game.id);
let tag = format!("tavolo-{}", game.id);
spawn_local(async move {
show(&title, &body, &tag, &url).await;
});
}
/// Build the notification options shared by both delivery paths.
fn options(body: &str, tag: Option<&str>, url: &str) -> web_sys::NotificationOptions {
let options = web_sys::NotificationOptions::new();
options.set_body(body);
options.set_icon("/assets/icons/icon-192.png");
if let Some(tag) = tag {
// renotify needs a tag; the tag replaces the previous move of the
// same game instead of stacking one notification per play.
options.set_tag(tag);
options.set_renotify(true);
}
let data = js_sys::Object::new();
let _ = js_sys::Reflect::set(
&data,
&wasm_bindgen::JsValue::from_str("url"),
&wasm_bindgen::JsValue::from_str(url),
);
options.set_data(&data);
options
}
async fn show(title: &str, body: &str, tag: &str, url: &str) {
// Prefer the service worker registration: it is the only path that
// works for installed PWAs on Android. `getRegistration` resolves
// promptly (to `undefined` when nothing is registered), unlike
// `ready`, which never settles without a worker.
if let Some(window) = web_sys::window() {
let container = window.navigator().service_worker();
if let Ok(value) = JsFuture::from(container.get_registration()).await {
if let Some(registration) = value.dyn_ref::<web_sys::ServiceWorkerRegistration>() {
if let Ok(promise) =
registration.show_notification_with_options(title, &options(body, Some(tag), url))
{
if JsFuture::from(promise).await.is_ok() {
return;
}
}
}
}
}
let _ = web_sys::Notification::new_with_options(title, &options(body, None, url));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::GameView;
use serde_json::json;
fn scopone_view(last_move: serde_json::Value, my_turn: bool) -> GameView {
serde_json::from_value(json!({
"id": "g1",
"game_type": "scopone_scientifico",
"phase": "playing",
"hand_number": 2,
"turn": 2,
"your_turn": my_turn,
"players": [
{"sub": "alice", "name": "Alice", "seat": 0},
{"sub": "bob", "name": "Bob", "seat": 1, "hand": ["01D", "02C"]},
{"sub": "carol", "name": "Carol", "seat": 2},
{"sub": "dave", "name": "Dave", "seat": 3},
],
"last_move": last_move,
}))
.unwrap()
}
fn briscola_view(trick: Vec<serde_json::Value>, last_trick: serde_json::Value) -> GameView {
serde_json::from_value(json!({
"id": "g2",
"game_type": "briscola",
"phase": "playing",
"hand_number": 1,
"turn": 1,
"your_turn": false,
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "hand": ["01D"]},
{"sub": "bob", "name": "Bob", "seat": 1},
],
"trick": trick,
"last_trick": last_trick,
}))
.unwrap()
}
fn play(seat: usize, card: &str) -> LastPlay {
LastPlay { seat, card: card.to_string() }
}
#[test]
fn last_play_reads_last_move() {
let g = scopone_view(
json!({"seat": 3, "name": "Dave", "card": "07D", "captured": []}),
false,
);
assert_eq!(Some(play(3, "07D")), last_play(&g));
}
#[test]
fn last_play_reads_the_trick_in_progress() {
let g = briscola_view(
vec![json!({"seat": 0, "card": "04C"}), json!({"seat": 1, "card": "02D"})],
serde_json::Value::Null,
);
assert_eq!(Some(play(1, "02D")), last_play(&g));
}
#[test]
fn last_play_reads_the_completed_trick() {
// The trick-completing play empties `trick`; the newest card is
// the last one of `last_trick.cards`.
let g = briscola_view(
vec![],
json!({"winner": 1, "cards": [
{"seat": 0, "card": "04C"},
{"seat": 1, "card": "02D"},
]}),
);
assert_eq!(Some(play(1, "02D")), last_play(&g));
}
#[test]
fn last_play_is_none_before_any_card() {
let g = briscola_view(vec![], serde_json::Value::Null);
assert_eq!(None, last_play(&g));
}
#[test]
fn opponent_play_skips_the_first_snapshot() {
let g = scopone_view(
json!({"seat": 3, "name": "Dave", "card": "07D"}),
false,
);
assert_eq!(None, opponent_play(false, None, &g));
}
#[test]
fn opponent_play_skips_the_viewer_seat() {
// Bob (seat 1) carries the hand: his own play is not announced.
let g = scopone_view(
json!({"seat": 1, "name": "Bob", "card": "07D"}),
false,
);
assert_eq!(None, opponent_play(true, Some(&play(2, "03B")), &g));
}
#[test]
fn opponent_play_reports_a_new_move_and_not_a_repeat() {
let g = scopone_view(
json!({"seat": 3, "name": "Dave", "card": "07D"}),
false,
);
let prev = play(2, "03B");
assert_eq!(Some(play(3, "07D")), opponent_play(true, Some(&prev), &g));
assert_eq!(None, opponent_play(true, Some(&play(3, "07D")), &g));
}
#[test]
fn opponent_play_stays_quiet_for_spectators() {
let mut g = scopone_view(
json!({"seat": 3, "name": "Dave", "card": "07D"}),
false,
);
g.spectator = true;
for player in &mut g.players {
player.hand = None;
}
assert_eq!(None, opponent_play(true, Some(&play(2, "03B")), &g));
}
#[test]
fn opponent_play_reports_after_a_hand_boundary() {
// The new hand cleared `last_move`; the first play is still news.
let g = scopone_view(
json!({"seat": 0, "name": "Alice", "card": "01D"}),
false,
);
assert_eq!(Some(play(0, "01D")), opponent_play(true, None, &g));
}
#[test]
fn baseline_resets_per_connection() {
let baseline = Baseline::new();
let g = scopone_view(
json!({"seat": 3, "name": "Dave", "card": "07D"}),
false,
);
// First snapshot of the connection: baseline only.
assert_eq!(None, baseline.observe(&g));
// A new play by another seat is reported.
let next = scopone_view(
json!({"seat": 0, "name": "Alice", "card": "06C"}),
false,
);
assert_eq!(Some(play(0, "06C")), baseline.observe(&next));
// A reconnect resyncs to the same snapshot: no notification.
baseline.reset();
assert_eq!(None, baseline.observe(&next));
}
#[test]
fn message_names_the_player_and_card() {
let g = scopone_view(
json!({"seat": 3, "name": "Dave", "card": "07D", "scopa": true}),
true,
);
let (title, body) = message(&g, &play(3, "07D"));
assert_eq!("Dave played 7 di denari", title);
assert_eq!("Hand 2 · Your turn · Scopa!", body);
}
#[test]
fn message_names_the_next_player_and_the_deal() {
let mut g = briscola_view(vec![json!({"seat": 1, "card": "02D"})], serde_json::Value::Null);
g.turn = 0;
let (title, body) = message(&g, &play(1, "02D"));
assert_eq!("Bob played 2 di denari", title);
assert_eq!("Deal 1 · Alice to play", body);
}
}
+4
View File
@@ -7,6 +7,7 @@ use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::components::notify::{notify_toggle, NotifyCtx};
use crate::model::{
hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores,
};
@@ -121,6 +122,7 @@ pub fn briscola_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
now: Signal<f64>,
notify: NotifyCtx,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
@@ -236,6 +238,7 @@ pub fn briscola_table(
let spectator_badge = game.spectator.then(|| view! {
span(class="spectator-badge") { "Spectating" }
});
let bell = (!game.spectator).then(|| notify_toggle(notify));
let watch_link = watch_link(&game.id);
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
let suit = suit_name(&game.briscola_suit);
@@ -257,6 +260,7 @@ pub fn briscola_table(
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
(bell)
}
div(class=grid_cls) {
(top)
+4
View File
@@ -12,6 +12,7 @@ use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::components::notify::{notify_toggle, NotifyCtx};
use crate::model::{
card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView,
};
@@ -255,6 +256,7 @@ pub fn chiamata_table(
on_bid_points: impl Fn(i32) + Copy + 'static,
on_pass: impl Fn() + Copy + 'static,
now: Signal<f64>,
notify: NotifyCtx,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
@@ -423,6 +425,7 @@ pub fn chiamata_table(
let spectator_badge = game.spectator.then(|| view! {
span(class="spectator-badge") { "Spectating" }
});
let bell = (!game.spectator).then(|| notify_toggle(notify));
let watch_link = watch_link(&game.id);
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
let suit = suit_name(&game.briscola_suit);
@@ -446,6 +449,7 @@ pub fn chiamata_table(
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
(bell)
}
div(class="table-grid cinque") {
(top_left)
+80 -17
View File
@@ -9,14 +9,17 @@ use crate::api;
use crate::components::briscola::{deal_rows, deal_summary_modal};
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::components::notify::{notify_toggle, NotifyCtx};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
PlayerView, Scores, ServerMessage,
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, is_scopa, GameView,
MoveView, PlayerView, Scores, ServerMessage,
};
use crate::notify::{self, Permission};
use crate::pages::briscola::briscola_table;
use crate::pages::chiamata::{call_picker, chiamata_table};
use crate::pages::scopa::scopa_table;
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -65,7 +68,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
}
/// The last-move announcement strip.
fn move_banner(mv: MoveView) -> View {
pub(crate) fn move_banner(mv: MoveView) -> View {
let name = mv.name.clone();
let action = if mv.captured.is_empty() {
"played"
@@ -95,7 +98,7 @@ fn move_banner(mv: MoveView) -> View {
}
/// Signals shared by the websocket connection and its reconnect attempts.
#[derive(Clone, Copy)]
#[derive(Clone)]
struct ConnCtx {
socket: Signal<Option<GameSocket>>,
game: Signal<Option<GameView>>,
@@ -110,6 +113,12 @@ struct ConnCtx {
attempts: Signal<u32>,
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
selected: Signal<Option<String>>,
/// Baseline for the move notifications; reset on every (re)connect.
baseline: notify::Baseline,
/// Whether the player opted into move notifications.
notify_enabled: Signal<bool>,
/// The browser's notification permission, as last read.
notify_permission: Signal<Permission>,
}
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
@@ -124,7 +133,11 @@ fn backoff_ms(attempt: u32) -> u32 {
/// The server pushes a full state snapshot on connect, so a reconnect is
/// also a resync: no client-side state merging is needed.
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
// A fresh connection resyncs from scratch: the first snapshot it
// delivers is the notification baseline, not a play to announce.
ctx.baseline.reset();
let on_message = {
let ctx = ctx.clone();
let alive = alive.clone();
move |msg: ServerMessage| {
if !alive.get() {
@@ -133,6 +146,19 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
}
match msg {
ServerMessage::State { game: g } => {
// Observe every snapshot, even with the preference
// off: enabling it mid-game must not replay old moves.
let play = ctx.baseline.observe(&g);
if let Some(play) = play {
// Move notifications are for when the player
// cannot watch the table themselves.
if ctx.notify_enabled.get()
&& ctx.notify_permission.get() == Permission::Granted
&& notify::page_in_background()
{
notify::notify_move(&g, &play);
}
}
ctx.capture_choice.set(None);
ctx.selected.set(None);
// A received state proves the (re)connection works.
@@ -150,6 +176,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
};
let on_close = {
let id = id.clone();
let ctx = ctx.clone();
let alive = alive.clone();
move |_code: Option<u16>| {
if !alive.get() {
@@ -165,6 +192,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
// transient. Only on the first failure: later attempts
// are governed by the retry backoff.
let id = id.clone();
let ctx = ctx.clone();
let alive = alive.clone();
spawn_local(async move {
match api::probe_game(&id).await {
@@ -177,7 +205,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
}
});
} else {
schedule_retry(id.clone(), ctx, alive.clone());
schedule_retry(id.clone(), ctx.clone(), alive.clone());
}
}
};
@@ -280,6 +308,17 @@ pub fn GamePage(id: String) -> View {
}
});
// Move notifications: opt-in and persisted per device. The signals
// are page-scoped (not per table render) so a permission prompt that
// resolves after the table rebuilt still lands somewhere safe.
let notify_enabled = create_signal(notify::enabled());
let notify_permission = create_signal(notify::permission());
let notify_ctx = NotifyCtx {
enabled: notify_enabled,
permission: notify_permission,
alive: alive.clone(),
};
let id = Rc::new(id);
let ctx = ConnCtx {
socket,
@@ -292,13 +331,16 @@ pub fn GamePage(id: String) -> View {
attempts,
capture_choice,
selected,
baseline: notify::Baseline::new(),
notify_enabled,
notify_permission,
};
start_connect(id.clone(), ctx, alive.clone());
start_connect(id.clone(), ctx.clone(), alive.clone());
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
ctx.attempts.set(0);
ctx.gave_up.set(false);
ctx.closed.set(false);
start_connect(id.clone(), ctx, alive.clone());
start_connect(id.clone(), ctx.clone(), alive.clone());
});
// Clicking a card in the player's own hand.
@@ -422,16 +464,28 @@ pub fn GamePage(id: String) -> View {
// Briscola chiamata has its own five-seat table with the
// calling auction and the secret-partner reveal.
Some(g) if is_chiamata(&g.game_type) => {
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
chiamata_table(
g,
on_hand_card,
on_bid_rank,
on_bid_points,
on_pass,
now,
notify_ctx.clone(),
)
}
// Briscola has its own table view for both variants.
Some(g) if is_briscola(&g.game_type) => {
briscola_table(g, on_hand_card, now)
briscola_table(g, on_hand_card, now, notify_ctx.clone())
}
// Scopa has its own table view for both variants.
Some(g) if is_scopa(&g.game_type) => {
scopa_table(g, on_hand_card, now, notify_ctx.clone())
}
// The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
table_view(g, on_hand_card, selected, now)
table_view(g, on_hand_card, selected, now, notify_ctx.clone())
}
Some(g) => unsupported_game_view(g),
})
@@ -450,7 +504,11 @@ pub fn GamePage(id: String) -> View {
_ => view! {},
})
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
Some(g)
if g.phase == "hand_end"
&& (g.game_type == "scopone_scientifico"
|| is_scopa(&g.game_type)) =>
{
hand_summary_modal(g, socket, now)
}
Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => {
@@ -597,6 +655,7 @@ fn table_view(
on_hand_card: impl Fn(String) + Copy + 'static,
selected: Signal<Option<String>>,
now: Signal<f64>,
notify: NotifyCtx,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
@@ -704,6 +763,7 @@ fn table_view(
let spectator_badge = game.spectator.then(|| view! {
span(class="spectator-badge") { "Spectating" }
});
let bell = (!game.spectator).then(|| notify_toggle(notify));
let watch_link = watch_link(&game.id);
view! {
@@ -720,6 +780,7 @@ fn table_view(
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
(bell)
}
div(class="table-grid") {
(top)
@@ -779,8 +840,9 @@ fn capture_picker(
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
let scopa = game.as_ref().is_some_and(|g| is_scopa(&g.game_type));
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa {
return view! {};
}
}
@@ -793,7 +855,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
None => view! {},
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
// Two-player briscola has no teams: name the winner instead.
// Games without partnerships (two-player briscola and scopa)
// name the winner instead.
// Briscola chiamata names the caller's side (or the defenders).
let winner_label = match game.as_ref() {
Some(g) if is_individual_game(&g.game_type) => {
@@ -805,8 +868,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
// Spectators have no match history to look at.
let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's (scopone) or deal's (briscola,
// briscola chiamata) scoring before the result.
// Explain the final hand's (scopone, scopa) or deal's
// (briscola, briscola chiamata) scoring before the result.
let final_summary: View = if briscola || chiamata {
match game.as_ref() {
Some(g) => match g.last_deal.clone() {
@@ -816,8 +879,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
None => view! {},
}
} else {
match game.as_ref().and_then(|g| g.last_hand.clone()) {
Some(hand) => summary_rows(hand),
match game.as_ref().and_then(|g| g.last_hand.clone().map(|h| (g.clone(), h))) {
Some((g, hand)) => summary_rows(&g, hand),
None => view! {},
}
};
+231 -62
View File
@@ -1,14 +1,102 @@
//! Per-game leaderboard page.
//!
//! Elo ratings are stored per game type, so the board is scoped to the
//! game chosen in the dropdown: Elo, wins, matches and points all come
//! from that game's matches only.
//! Elo ratings and the leaderboard aggregates live in one
//! `player_rating` row per player and game, so the board is
//! page-number paginated: the first load lands on the page holding the
//! signed-in player's own ranking (the server resolves it with
//! `focus=me`) and a footer pager changes pages.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::components::toast::toast;
use crate::model::{fallback_game_types, fmt_points, LeaderboardPage};
use crate::model::{
fallback_game_types, fmt_points, LeaderboardPage, LEADERBOARD_PAGE_SIZE,
};
/// Page numbers to show in the pager: the first and last page, the
/// current page and its neighbours, with `None` marking an ellipsis.
fn page_window(page: u32, pages: u32) -> Vec<Option<u32>> {
if pages <= 1 {
return Vec::new();
}
let mut window = vec![Some(1)];
let start = page.saturating_sub(2).max(2);
let end = (page + 2).min(pages.saturating_sub(1));
if start > 2 {
window.push(None);
}
for number in start..=end {
window.push(Some(number));
}
if end + 1 < pages {
window.push(None);
}
window.push(Some(pages));
window
}
/// The footer pager: first/previous, page numbers, next/last. Rendered
/// only when the board has more than one page.
fn pager(
page: &LeaderboardPage,
busy: bool,
load: impl Fn(Option<u32>, bool) + Copy + 'static,
) -> View {
let current = page.page;
let pages = page.pages;
if pages <= 1 {
return view! {};
}
let button = move |label: &'static str, target: u32, enabled: bool| {
view! {
li(class=if enabled { "page-item" } else { "page-item disabled" }) {
button(
class="page-link",
disabled=!enabled || busy,
on:click=move |_| if enabled && !busy {
load(Some(target), false);
},
) { (label) }
}
}
};
let items = page_window(current, pages)
.into_iter()
.map(|entry| match entry {
None => view! {
li(class="page-item disabled") {
span(class="page-link") { "…" }
}
},
Some(number) => {
let active = number == current;
view! {
li(class=if active { "page-item active" } else { "page-item" }) {
button(
class="page-link",
disabled=active || busy,
on:click=move |_| if !active && !busy {
load(Some(number), false);
},
) { (number) }
}
}
}
})
.collect::<Vec<_>>();
view! {
nav(aria-label="Leaderboard pages") {
ul(class="pagination justify-content-center mt-4") {
(button("«", 1, current > 1))
(button("‹", current.saturating_sub(1).max(1), current > 1))
(items)
(button("›", (current + 1).min(pages), current < pages))
(button("»", pages, current < pages))
}
}
}
}
#[component]
pub fn LeaderboardPage() -> View {
@@ -16,26 +104,33 @@ pub fn LeaderboardPage() -> View {
let error = create_signal(Option::<String>::None);
let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string());
// A leaderboard request is in flight; the pager is disabled meanwhile.
let loading = create_signal(false);
// Reload whenever the selection changes; a response for a game the
// user has already switched away from is dropped.
create_effect(move || {
let load = move |target: Option<u32>, focus_me: bool| {
let game_type = selected_game.get_clone();
page.set(None);
loading.set(true);
spawn_local(async move {
match api::leaderboard(&game_type).await {
Ok(p) => {
if selected_game.get_clone() == game_type {
page.set(Some(p));
}
}
Err(e) => {
if selected_game.get_clone() == game_type {
error.set(Some(e));
}
}
let response = api::leaderboard(&game_type, target, focus_me).await;
// A response for a game the user has already switched away
// from is dropped (and must not clear the new game's spinner).
if selected_game.get_clone() != game_type {
return;
}
loading.set(false);
match response {
Ok(p) => page.set(Some(p)),
Err(e) => error.set(Some(e)),
}
});
};
// Reload whenever the selection changes; the first load focuses the
// viewer's own page so they see their name right away.
create_effect(move || {
let _game = selected_game.get_clone();
page.set(None);
load(None, true);
});
spawn_local(async move {
@@ -80,53 +175,86 @@ pub fn LeaderboardPage() -> View {
}
}
(toast(error))
(move || match page.get_clone() {
None => view! {
div(class="d-flex justify-content-center p-5") {
div(class="spinner-border text-warning", role="status") {
span(class="visually-hidden") { "Loading…" }
}
}
},
Some(p) if p.results.is_empty() => view! {
p(class="text-secondary text-center p-5") {
"No ratings yet — finish a match to appear here."
}
},
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
view! {
tr {
td { (i + 1) }
td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
}
(move || {
let busy = loading.get();
match page.get_clone() {
None => view! {
div(class="d-flex justify-content-center p-5") {
div(class="spinner-border text-warning", role="status") {
span(class="visually-hidden") { "Loading…" }
}
})
.collect::<Vec<_>>();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
},
Some(p) if p.results.is_empty() => view! {
p(class="text-secondary text-center p-5") {
"No ratings yet — finish a match to appear here."
}
},
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
let rank = (p.page.saturating_sub(1))
* LEADERBOARD_PAGE_SIZE
+ i as u32
+ 1;
let is_me = p.your_rank == Some(rank);
let cls = if is_me {
"table-active fw-semibold"
} else {
""
};
view! {
tr(class=cls) {
td { (rank) }
td {
(e.display_name.clone())
(is_me.then(|| view! {
span(
class="badge text-bg-warning ms-2"
) {
"you"
}
}))
}
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
}
}
tbody { (rows) }
})
.collect::<Vec<_>>();
let pager_view = pager(&p, busy, load);
let rated = if p.total == 1 {
"1 player rated".to_string()
} else {
format!("{} players rated", p.total)
};
view! {
p(class="text-secondary small mb-2") { (rated) }
div(class="table-responsive") {
table(
class="table table-striped table-hover \
align-middle"
) {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
}
tbody { (rows) }
}
}
(pager_view)
}
}
}
@@ -135,3 +263,44 @@ pub fn LeaderboardPage() -> View {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_window_small_boards() {
// Boards with a single page (or none) have no pager at all.
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 0));
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 1));
assert_eq!(vec![Some(1), Some(2)], page_window(1, 2));
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(1, 3));
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(3, 3));
}
#[test]
fn page_window_ellipsizes_long_boards() {
assert_eq!(
vec![Some(1), Some(2), Some(3), None, Some(10)],
page_window(1, 10)
);
assert_eq!(
vec![
Some(1),
None,
Some(3),
Some(4),
Some(5),
Some(6),
Some(7),
None,
Some(10),
],
page_window(5, 10)
);
assert_eq!(
vec![Some(1), None, Some(8), Some(9), Some(10)],
page_window(10, 10)
);
}
}
+24 -16
View File
@@ -12,6 +12,7 @@ use sycamore::prelude::*;
use sycamore_router::navigate;
use crate::api;
use crate::components::avatar::avatar;
use crate::components::card::card_back;
use crate::components::login::login_card;
use crate::components::toast::toast;
@@ -483,23 +484,30 @@ pub fn LobbyPage() -> View {
Some(Some(_me)) => {
view! {
(crate::components::nav::app_nav("/", move || {
let name = user
.get_clone()
.flatten()
.map(|u| u.name)
let me = user.get_clone().flatten();
let name = me
.as_ref()
.map(|u| u.name.clone())
.unwrap_or_default();
let avatar_view = me
.as_ref()
.map(|u| avatar(&u.sub, u.picture.as_deref()))
.unwrap_or_else(|| view! {});
view! {
span(class="navbar-text me-3") {
"Signed in as " strong { (name) }
(move || ratings
.get_clone()
.into_iter()
.find(|r| r.game_type == selected_game.get_clone())
.map(|r| view! {
span(class="badge text-bg-warning ms-2") {
"Elo " (r.rating)
}
}))
span(class="navbar-text me-3 d-flex align-items-center gap-2") {
(avatar_view)
span() {
"Signed in as " strong { (name) }
(move || ratings
.get_clone()
.into_iter()
.find(|r| r.game_type == selected_game.get_clone())
.map(|r| view! {
span(class="badge text-bg-warning ms-2") {
"Elo " (r.rating)
}
}))
}
}
a(
class="btn btn-sm btn-outline-light",
@@ -514,7 +522,7 @@ pub fn LobbyPage() -> View {
div(class="lobby-hero text-center") {
h1(class="display-5 fw-bold") { "tavolo" }
p(class="lead text-secondary mb-0") {
"Play scopone scientifico and briscola with friends."
"Play scopone, scopa and briscola with friends."
}
}
// Ongoing games: a "Continue" link back into a
+1
View File
@@ -5,3 +5,4 @@ pub mod history;
pub mod leaderboard;
pub mod lobby;
pub mod login;
pub mod scopa;
+241
View File
@@ -0,0 +1,241 @@
//! Scopa table view: classic two-player and four-player partnership.
//!
//! The two variants share this view: four seats render in the usual cross
//! (partners opposite), two seats stack opponent over player. The center
//! holds the cards on the table, the face-down deck and the last-move
//! announcement.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::components::notify::{notify_toggle, NotifyCtx};
use crate::model::{
hand_sort_key, is_individual_game, GameView, PlayerView, Scores,
};
use crate::pages::game::{move_banner, watch_link};
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
/// 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) = player_for_seat(&game, seat) 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_badge = (!is_individual_game(&game.game_type))
.then(|| player.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
let captured = player.captured_count;
let scope = player.scope;
let backs = (0..player.cards_left)
.map(|_| card_back("mini"))
.collect::<Vec<_>>();
view! {
div(class=cls) {
div(class="seat-name") {
(name)
(team_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(captured) " captured · " (scope) " scope"
}
}
}
}
/// The face-down deck with the cards left in the current hand.
fn deck_view(game: &GameView) -> View {
let backs = (0..game.deck_count.min(3))
.map(|_| card_back("stock-card"))
.collect::<Vec<_>>();
let label = format!("{} in deck", game.deck_count);
view! {
div(class="stock") {
div(class="stock-pile") { (backs) }
span(class="stock-count") { (label) }
}
}
}
/// The main scopa table.
pub fn scopa_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
now: Signal<f64>,
notify: NotifyCtx,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat)
.unwrap_or(0);
let player_count = if game.player_count > 0 {
game.player_count
} else {
game.players.len().max(1)
};
let my_turn = game.your_turn == Some(true);
let turn_note = if game.phase == "finished" {
"Match finished".to_string()
} else if game.phase == "hand_end" {
"Hand 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 countdown = game.turn_deadline.as_ref().map(|deadline| {
// A dynamic closure so only the ticking number re-renders.
let deadline_ms = js_sys::Date::parse(deadline);
view! {
span(class="turn-timer") {
"Auto-play in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let target = game.target_score;
let hand_number = game.hand_number;
// Two-player scopa has no partnerships: show the players' names.
let score_line = if is_individual_game(&game.game_type) {
format!(
"{} {} — {} {} (target {})",
game.player_name_for_team("A"),
scores.a,
scores.b,
game.player_name_for_team("B"),
target
)
} else {
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
};
let (grid_cls, top, left, right) = if player_count == 2 {
(
"table-grid duo",
seat_panel(game.clone(), (viewer_seat + 1) % 2, "top"),
view! {},
view! {},
)
} else {
(
"table-grid",
seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"),
seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"),
seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"),
)
};
let viewer = player_for_seat(&game, viewer_seat);
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
hand.sort_by_key(|c| hand_sort_key(c));
let hand_cards = hand
.into_iter()
.map(|code| {
let card_view = card_img(code.clone(), "hand-card");
view! {
button(class="hand-slot", on:click=move |_| {
if my_turn {
on_hand_card(code.clone());
}
}) { (card_view) }
}
})
.collect::<Vec<_>>();
let hint = my_turn.then(|| view! {
p(class="text-secondary small text-center") {
"Click a card to play it. If it can capture in several ways you "
"will be asked to choose."
}
});
// Spectators have no hand: render the pivot seat as a regular panel
// so its captured count and scope stay visible where the hand would be.
let bottom = if game.spectator {
seat_panel(game.clone(), viewer_seat, "bottom")
} else {
view! {
div(class="seat-bottom") {
div(class="seat-stats own-stats") {
(my_captured) " captured · " (my_scope) " scope"
}
div(class="hand") { (hand_cards) }
(hint)
}
}
};
let spectator_badge = game.spectator.then(|| view! {
span(class="spectator-badge") { "Spectating" }
});
let bell = (!game.spectator).then(|| notify_toggle(notify));
let rule_badge = game.asso_pigliatutto.then(|| view! {
span(class="pigliatutto-badge") { "Asso pigliatutto" }
});
let watch_link = watch_link(&game.id);
// Precomputed so the final view's closures only capture owned views.
let banner = game.last_move.clone().map(move_banner);
let table_cards = game
.table
.iter()
.map(|c| card_img(c.clone(), "table-card"))
.collect::<Vec<_>>();
let empty_table = game.table.is_empty().then(|| view! {
p(class="table-empty") { "Empty table" }
});
let deck = deck_view(&game);
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Hand " (hand_number) }
(rule_badge)
(spectator_badge)
span(class="hud-scores") { (score_line) }
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
(bell)
}
div(class=grid_cls) {
(top)
(left)
div(class="center") {
(banner)
div(class="table-cards") {
(table_cards)
(empty_table)
}
(deck)
}
(right)
(bottom)
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Service worker registration.
//!
//! Registering `/sw.js` is what makes the app installable as a PWA; the
//! worker itself is caching-free (see `web/sw.js`). Registration is
//! best-effort: browsers without service worker support, and insecure
//! origins other than localhost, simply skip it.
use wasm_bindgen_futures::{spawn_local, JsFuture};
/// Register the service worker, if the browser supports it.
pub fn register() {
let Some(window) = web_sys::window() else {
return;
};
let container = window.navigator().service_worker();
spawn_local(async move {
// Rejections (e.g. insecure origin) are ignored: the app works
// without a service worker, it just is not installable.
let _ = JsFuture::from(container.register("/sw.js")).await;
});
}
+9
View File
@@ -33,6 +33,15 @@ body {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
display: block;
flex: 0 0 auto;
}
/* ---------- game table ---------- */
.hud {
+73
View File
@@ -0,0 +1,73 @@
// Tavolo service worker: installability and notification clicks, no
// offline caching.
//
// The app is a thin client for a live game server: state arrives over
// /api and /ws and is never safe to replay from a cache, and a new build
// changes the hashed asset names, so stale cached files would break the
// app until the cache expired. The worker therefore only performs the
// passthrough fetch below — enough for the browser to treat the app as
// installable — and leaves every request's caching to the network stack.
// It also handles clicks on the move notifications raised by the game
// page (see src/notify.rs).
self.addEventListener("install", () => {
// A new worker takes over on the next reload instead of waiting for
// every tab of the old version to close.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
// Clicking a move notification focuses the game: an existing window on
// the game is focused as is, any other window of the app is navigated
// there, and when the app is closed a new window is opened.
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
(async () => {
const target = new URL(url, self.location.origin);
const windows = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
const onGame = windows.find((client) => {
const current = new URL(client.url);
return current.origin === target.origin && current.pathname === target.pathname;
});
if (onGame) {
return onGame.focus();
}
const anyWindow = windows.find(
(client) => new URL(client.url).origin === target.origin,
);
if (anyWindow) {
await anyWindow.focus();
try {
return await anyWindow.navigate(url);
} catch {
return anyWindow;
}
}
return self.clients.openWindow(url);
})(),
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Let the browser handle other origins and the live endpoints directly
// (respondWith would only add a hop; /auth navigations must be allowed
// to follow their redirects untouched).
if (
url.origin !== self.location.origin ||
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/auth/") ||
url.pathname.startsWith("/ws")
) {
return;
}
event.respondWith(fetch(event.request));
});