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
woggioni 9eea293471 Give the page background a casino felt texture
CI / Build and push docker image (push) Successful in 59s
Replace the flat radial green with a layered body background: a soft
overhead light and an edge vignette over a fine grain tiled across the
green gradient.

The grain is a tiny seamless SVG tile (feTurbulence fractal noise mapped
to semi-transparent black speckles, stitchTiles so it repeats without
seams) committed under web/assets, so it rides the existing asset
pipeline and works offline; the palette and panel/card colors are
unchanged.
2026-10-09 09:27:05 +08:00
woggioni 4ba5ae1434 Give the match history table fixed columns
CI / Build and push docker image (push) Successful in 1m6s
Rows rendered only their own match's team labels while the header was
the union across all loaded matches. Scopone and briscola have two team
labels and briscola chiamata has three, so a mixed history produced a
five-column header above rows with two or three team cells - every cell
after them (score, winner, you, elo) landed under the wrong header, and
the columns changed with the set of game types loaded.

Replace the variable team columns with a single "Teams" column: the
table is now Finished | Game | Teams | Score | Winner | You | Elo for
every game. The cell lists each team or role on its own line, ordered
Chiamante/Socio/Difensori for chiamata and alphabetically (Team A,
Team B, ...) otherwise, with team-less players last; names read
"Bob, Carol & Dave".
2026-10-09 09:18:46 +08:00
woggioni 0f9fb9b6bb Make an absent chiamata bidder pass instead of bidding randomly
CI / Build and push docker image (push) Successful in 1m37s
The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
2026-10-09 06:51:43 +08:00
woggioni 322ca27bf4 Stop the chiamata call picker from clipping on short windows
The call picker stacked the four 120px cards vertically, making the
dialog about 780px tall, and the overlay centered its content with no
scroll fallback. On typical laptop window heights that pushed the title
and options off-screen (the heading sat at y=-110 on a 560px viewport),
leaving what looked like a free-floating selection with no explanation
and no way to scroll.

Make dialogs scroll instead of clipping: the overlay scrolls and the
panel centers with `margin: auto`, which keeps every modal (the capture
picker and the scoring summaries too) centered when it fits and
reachable when it does not. Lay the call options out in a compact grid
(four across, 2x2 on phones) with 84px cards, name the won rank in the
title ("You won with the Re - name the card") and label each option
"Partner" or "Plays alone" so the choice reads at a glance.
2026-10-09 06:51:39 +08:00
woggioni 0674e06565 Redirect to login on 401 and return afterwards
The SPA now sends the user to a new /login page whenever an API call
answers 401, remembering the page they were on. The OIDC callback always
lands back on the configured post-login redirect, so the saved page is
restored client-side once a session exists.

- auth: save/restore the return path in sessionStorage; defer the
  navigation so the failing request can finish first
- api: centralize 401 handling (me() still treats 401 as logged out)
- login page + shared login card, extracted from the lobby
- guard async signal writes so an in-flight request cannot touch a page
  unmounted by the redirect
2026-10-09 05:53:21 +08:00
woggioni 353f012906 Add briscola chiamata
CI / Build and push docker image (push) Successful in 5m35s
Five-player briscola with a descending calling auction and a secret
partner. Starting with the player after the dealer, each player bids a
lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the
standing bid is the two the auction continues on points (62, 64, ...).
The last player standing must bid, and the winner names any card of the
won rank: its holder is the secret partner, or the caller plays alone
when it is one of their own cards, and its suit becomes the trump. The
calling side must reach 61 (or the raised target); a 60-60 deal with the
plain target is a patta and is replayed. A match is a single deal, so
the caller and partner (or the lone caller) against the other players
map onto the platform's two-team result, Elo and match history.

The rules live in tavolo.briscola.chiamata with the
BriscolaChiamataEngine adapter registered alongside the other briscola
variants; the partner's role and the side totals stay hidden until the
called card is played. The web client gets a five-seat table, the
auction panel with legal bids and point raises, the call picker, the
reveal bands and a chiamata-aware final summary.
2026-10-08 21:50:56 +08:00
woggioni 6427afd12b Reset the game options form when the game changes
The creation options were rendered as a keyed list, so a field shared by
two games (target_score) kept the previous game's widget when the game
changed: after switching scopone -> briscola the input still showed 11
with scopone's min/max instead of the briscola default 61 (1/100 instead
of 61/120), even though the submitted payload was already correct.

Recreate the option inputs on every game switch instead, so each one is
initialized from the new game's defaults and schema constraints.
2026-10-06 18:08:58 +08:00
90 changed files with 10778 additions and 591 deletions
+67 -19
View File
@@ -1,22 +1,27 @@
# tavolo
A platform for multiplayer card games. Two games are included:
A platform for multiplayer card games. Four games are included:
**scopone scientifico** — the four-player, fixed-partnership Italian card
game — and **briscola**, in its classic two-player and four-player
partnership (*briscola a quattro*) variants:
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
@@ -39,13 +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.
**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
@@ -53,16 +61,53 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (choosing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
Briscola chiamata arms the same countdown for its auction turns and for
the auction winner's called-card choice: an absent bidder passes, the
last player standing with no bid is made to bid the ace, and a timed-out
call names a random suit of the won rank.
## Watching
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
spectators see the table, the score and every player's captured cards
(and scope for scopone), but never a hand and never the join code. The
(and scope for scopone), but never a hand and never the join code. In
briscola chiamata they also see no role labels and no side totals until
the called card is played, so the secret partner stays secret. The
"Copy watch link" button in the waiting room and in the game HUD copies
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/`):
@@ -71,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
```
@@ -100,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).
+131 -29
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,10 +16,15 @@ 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`) and
four-player partnership (`briscola_a_quattro`) variants: pure rules
plus the platform adapter.
Italian trick-taking game, in its classic two-player (`briscola`),
four-player partnership (`briscola_a_quattro`) and five-player
called-partner (`briscola_chiamata`) variants: pure rules plus the
platform adapter.
- **`tavolo-app`** (`packages/tavolo-app/`) — the
composition root wiring the platform to the games, with the
environment-driven settings and the SPA shell.
@@ -55,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
@@ -235,14 +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).
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
@@ -256,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 \
@@ -275,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}`. 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
@@ -309,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"]}
@@ -341,6 +355,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
every player has acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
For briscola chiamata the game actions are the calling auction plus play:
```json
{"action": "bid", "rank": 3}
{"action": "bid", "points": 64}
{"action": "pass"}
{"action": "call", "card": "03D"}
{"action": "play", "card": "04C"}
```
- `bid` with a `rank` calls a lower rank than the standing bid
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
target once the standing bid is the two.
- `pass` leaves the auction; the last player standing must bid.
- `call` names any card of the won rank; naming one of your own cards
means playing alone.
- `play` follows briscola: any card in hand is legal.
After every accepted move the new state is broadcast to every connected
client, spectators included.
@@ -353,18 +385,21 @@ is required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected.
match keeps progressing even with every player disconnected. Briscola
chiamata uses the same timeout for its auction turns (an absent bidder
passes; the last player standing with no bid is made to bid the ace) and
for the called-card choice (a random suit of the won rank).
### Hand-end summary
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
@@ -389,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
@@ -416,21 +484,48 @@ player must dismiss. A play attempted in this phase is rejected with an
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
opposite).
### Briscola chiamata
- 40-card Italian deck, eight cards each for five players: the whole deck
is dealt, so there is no stock and no face-up card.
- The calling auction starts with the player after the dealer: each
player either passes (leaving the auction) or bids a lower rank than
the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the
standing bid is the two, the auction continues on points (62, 64, …).
The last player standing must bid, so every deal gets a caller.
- The winner names any card of the won rank. If the winner holds it, the
caller plays alone against the other four; otherwise its holder is the
secret partner and the two form the calling side. The called card's
suit is the trump; the partner's identity stays hidden until the
called card is played.
- Play and trick resolution follow briscola; eight tricks exhaust every
hand. The calling side must reach the declared target (61 unless
raised); with the plain target a 60–60 deal is a *patta* and is
replayed with the next dealer. A match is a single deal, so the caller
and partner (or the lone caller) against the other players map onto
the platform's two-team result, Elo and history.
- Auction turns, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
player standing with no bid is made to bid the ace, an absent winner's
call names a random suit of the won rank, and plays are auto-played.
## Development
```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
```
@@ -469,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)
@@ -489,12 +584,19 @@ 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
├── engine.py # pure briscola rules (2- and 4-player)
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
├── errors.py # briscola-specific rule violations
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
packages/tavolo-app/ # deployable app: composition root (own pyproject)
src/tavolo/
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
+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",
+29 -1
View File
@@ -39,11 +39,17 @@ from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.openapi import OpenAPIMixin
from kaya.session import SessionMixin
from redis.asyncio import Redis
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.briscola import (
BriscolaChiamataEngine,
BriscolaEngine,
BriscolaQuattroEngine,
)
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
@@ -132,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,
@@ -144,6 +162,12 @@ registry.register(
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaChiamataEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
log.debug(
"game timeouts: hand_ack=%ds turn=%ds",
@@ -179,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
@@ -195,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
+59 -17
View File
@@ -2,10 +2,12 @@
Briscola — the Italian trick-taking game — as a
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
Two variants share one rules core:
Three variants share one rules core:
- `briscola` — the classic two-player game;
- `briscola_a_quattro` — four-player fixed-partnership briscola.
- `briscola_a_quattro` — four-player fixed-partnership briscola;
- `briscola_chiamata` — five-player briscola with a calling auction and a
secret partner.
## Contents
@@ -13,27 +15,42 @@ Two variants share one rules core:
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, deals, trick resolution
(trump first, then the led suit; strength ace > three > king > horse >
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
jack 2), patta re-deals and the target-score match end, deal-end
acknowledgements and auto-play. Deterministic and I/O-free apart from
logging, so the whole rule set is unit-testable.
- `engine.py` — the pure rules engine for two- and four-player briscola:
deck, deals, trick resolution (trump first, then the led suit; strength
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
(trick winner first, the face-up briscola last), card points (ace 11,
three 10, king 4, horse 3, jack 2), patta re-deals and the
target-score match end, deal-end acknowledgements and auto-play.
Deterministic and I/O-free apart from logging, so the whole rule set is
unit-testable.
- `chiamata.py` — the pure rules engine for five-player briscola
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
round-trip), the 8-card deal with no stock, the descending calling
auction with point raises, the winner's call (including calling one's
own card, which means playing alone), the called-card reveal, single-
deal scoring with the 60–60 patta and the auto-actions for timeouts.
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
(`GameEngine`): the platform-facing adapters. They validate the
`target_score` creation option (61–120, default 61) and extract the
`MatchResult` (teams, winner, per-player scores and the match summary
persisted as the match's JSON `result`).
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
The first two validate the `target_score` creation option (61–120,
default 61) and extract the `MatchResult` (teams, winner, per-player
scores and the match summary persisted as the match's JSON `result`).
Briscola chiamata takes no creation options.
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
application composition root. Briscola chiamata uses the turn timeout
for its auction turns, the called-card choice and the plays: an absent
bidder passes, the last player standing with no bid is made to bid the
ace, and an absent winner's call names a random suit of the won rank. It
never enters the between-deals acknowledgement phase (a single deal ends
the match), so the hand-ack timeout is accepted but unused.
## Rules
### Briscola (two-player and four-player)
- Three cards per player; the next card is turned face up: its suit is
the trump (briscola) for the deal and it is the last card drawn.
- The winner of a trick is the highest trump, or the highest card of the
@@ -47,6 +64,31 @@ application composition root.
- Between deals of an unfinished match every player acknowledges the
summary (or the hand-end timeout deals on), exactly like scopone.
### Briscola chiamata (five-player)
- The whole 40-card deck is dealt out, eight cards each: there is no
stock and no face-up card.
- Starting with the player after the dealer, each player either passes
(leaving the auction for good) or bids a **lower** rank than the
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
the first bidder may bid any rank. Once the standing bid is the two,
the auction continues on points instead (62, 64, …): the calling side
then has to reach the raised target. The last player standing must
bid, so a deal always gets a caller.
- The auction winner names any card of the winning rank. If the winner
holds it, they **play alone** against the other four; otherwise its
holder is the **secret partner** and the two form the calling side.
The called card's suit is the trump for the deal. The partner's
identity is hidden until the called card is played.
- Play follows briscola: the player after the dealer leads, the highest
trump (or highest led-suit card) takes the trick and leads the next
one; eight tricks exhaust every hand.
- The calling side (caller plus partner, or the lone caller) must reach
the declared target — 61 unless the auction raised it. With the plain
target a 60–60 deal is a patta and is replayed with the next dealer.
A match is a single deal, so the two sides map onto the platform's
two-team result (Elo rating, match history).
## Development (from `server/`)
```sh
@@ -54,5 +96,5 @@ application composition root.
.venv/bin/python -m mypy -p tavolo.briscola
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
`test_engine.py`/`test_plugin.py` cover the two classic variants;
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
@@ -1,20 +1,31 @@
"""Briscola: tavolo's trick-taking game implementation.
Two variants share the same rules core and platform adapter:
Three variants share the same rules core and platform adapter:
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
briscola (``briscola``);
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
fixed-partnership briscola a quattro (``briscola_a_quattro``).
fixed-partnership briscola a quattro (``briscola_a_quattro``);
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
briscola chiamata (``briscola_chiamata``).
The pure rules (:mod:`tavolo.briscola.engine`,
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
:mod:`tavolo.briscola.plugin` adapts them to the platform's
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
adapts them to the platform's
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them.
"""
from __future__ import annotations
from .plugin import BriscolaEngine, BriscolaQuattroEngine
from .plugin import (
BriscolaChiamataEngine,
BriscolaEngine,
BriscolaQuattroEngine,
)
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
__all__ = [
"BriscolaChiamataEngine",
"BriscolaEngine",
"BriscolaQuattroEngine",
]
@@ -0,0 +1,953 @@
"""Pure rules engine for briscola chiamata.
Briscola chiamata is the five-player variant of briscola. The 40-card
deck is dealt out entirely (8 cards each, no stock): there is no face-up
briscola card. Instead the players auction off the right to call a
partner:
* starting with the player after the dealer, each player either passes
(leaving the auction for good) or bids a rank lower than the standing
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
bidder may bid any rank. Once the standing bid is the 2, further bids
raise the points the calling side must reach instead (62, 64, ...).
* the last remaining bidder wins and names the exact card of the winning
rank (any suit). If the winner holds the named card they play alone
against the other four; otherwise its holder is the **secret partner**
and the two form the calling side. The called card's suit is the trump
(*briscola*) for the whole deal.
* play and scoring follow briscola. The calling side must reach the
declared target (61 unless raised); a 60-60 deal with the plain target
is a **patta** and is replayed.
A match is a single deal (patta replays aside), so the two sides map
onto the platform's two-team result (Elo rating, match history). The
partner's identity stays hidden until the called card is played, at
which point the roles are revealed.
Like :mod:`tavolo.briscola.engine` this module is deterministic and
I/O-free apart from logging: it mutates (or reads)
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
:class:`~tavolo.platform.errors.GameError` subclasses on rule
violations. The platform-facing adapter is
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from logging import getLogger
from typing import Any, Dict, List, Optional, Tuple
from tavolo.platform.errors import (
AlreadyJoined,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .engine import TRICK_STRENGTH, card_points, shuffled_deck
from .errors import CardNotInHand
from .state import (
DEFAULT_TURN_TIMEOUT_SECONDS,
PHASE_FINISHED,
PHASE_LOBBY,
PHASE_PLAYING,
SUITS,
Card,
Trick,
TrickCard,
parse_card,
)
log = getLogger(__name__)
# Five seats, the whole 40-card deck dealt out.
PLAYER_COUNT = 5
HAND_SIZE = 8
PHASE_AUCTION = "auction"
PHASE_CALLING = "calling"
# The two sides of the match: the caller (plus the partner, when there is
# one) against the other players.
SIDE_CALLER = 0
SIDE_DEFENDERS = 1
SIDE_NAMES = ("Chiamante", "Difensori")
# Role labels carried by the personalized view once they are public.
ROLE_CALLER = "Chiamante"
ROLE_PARTNER = "Socio"
ROLE_DEFENDER = "Difensori"
# Points the calling side must reach: 61 unless the auction raised it.
DEFAULT_TARGET = 61
MIN_TARGET = 61
MAX_TARGET = 120
# Rank ladder of the auction, strongest first: ace, three, king, horse,
# jack, then the plain ranks downwards.
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
_rng = random.SystemRandom()
# -- state -----------------------------------------------------------------
@dataclass
class ChiamataPlayer:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
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],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "ChiamataPlayer":
return ChiamataPlayer(
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", [])],
)
@dataclass
class Bid:
"""One auction bid: a rank, or (at the 2) a raised points target."""
seat: int
rank: int
points: int
def to_json(self) -> Dict[str, Any]:
return {"seat": self.seat, "rank": self.rank, "points": self.points}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Bid":
return Bid(
seat=int(data["seat"]),
rank=int(data["rank"]),
points=int(data["points"]),
)
@dataclass
class Auction:
"""The state of the calling auction."""
# Seat to act; always one of ``active``.
turn: int = 0
active: List[int] = field(default_factory=list)
passed: List[int] = field(default_factory=list)
current: Optional[Bid] = None
history: List[Bid] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"turn": self.turn,
"active": list(self.active),
"passed": list(self.passed),
"current": self.current.to_json() if self.current else None,
"history": [b.to_json() for b in self.history],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Auction":
return Auction(
turn=int(data.get("turn", 0)),
active=[int(s) for s in data.get("active", [])],
passed=[int(s) for s in data.get("passed", [])],
current=(
Bid.from_json(data["current"]) if data.get("current") else None
),
history=[Bid.from_json(b) for b in data.get("history", [])],
)
@dataclass
class ChiamataState:
phase: str = PHASE_LOBBY
players: List[ChiamataPlayer] = field(default_factory=list)
dealer: int = 0
# 1-based deal counter; grows with patta replays.
hand_number: int = 1
# 0 = the calling side (caller plus partner, when there is one),
# 1 = the defenders. Only set once the match is finished.
winner: Optional[int] = None
scores: List[int] = field(default_factory=lambda: [0, 0])
# The calling auction of the current deal.
auction: Optional[Auction] = None
# Seat that won the auction (public from then on).
caller: Optional[int] = None
# The card named by the caller; its suit is the trump.
called_card: Optional[Card] = None
briscola_suit: str = ""
# Points target from the auction (61 unless raised).
declared_target: int = DEFAULT_TARGET
# True when the caller named a card of their own hand: no partner.
solo: bool = False
# True once the called card has been played; roles become public.
called_played: bool = False
# The partner's seat, recorded when the called card is played; stays
# ``None`` for a solo call.
partner_seat: Optional[int] = None
# Cards of the trick in progress, in play order.
trick: List[TrickCard] = field(default_factory=list)
trick_leader: int = 0
last_trick: Optional[Trick] = None
# Seat currently to act (auction bidder, caller, or player on turn).
turn: int = 0
# Per-deal audit trail, patta entries included.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# While phase == "auction"/"playing": when the server acts for the
# player on turn.
turn_deadline: Optional[str] = None
auction_deadline: Optional[str] = None
# While phase == "calling": when the server names a card for the
# auction winner.
call_deadline: Optional[str] = None
# Seconds a player has to act before the server acts for them.
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"dealer": self.dealer,
"hand_number": self.hand_number,
"winner": self.winner,
"scores": list(self.scores),
"auction": self.auction.to_json() if self.auction else None,
"caller": self.caller,
"called_card": (
self.called_card.to_json() if self.called_card else None
),
"briscola_suit": self.briscola_suit,
"declared_target": self.declared_target,
"solo": self.solo,
"called_played": self.called_played,
"partner_seat": self.partner_seat,
"trick": [t.to_json() for t in self.trick],
"trick_leader": self.trick_leader,
"last_trick": self.last_trick.to_json() if self.last_trick else None,
"turn": self.turn,
"hand_scores": list(self.hand_scores),
"turn_deadline": self.turn_deadline,
"auction_deadline": self.auction_deadline,
"call_deadline": self.call_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "ChiamataState":
return ChiamataState(
phase=str(data.get("phase", PHASE_LOBBY)),
players=[
ChiamataPlayer.from_json(p) for p in data.get("players", [])
],
dealer=int(data.get("dealer", 0)),
hand_number=int(data.get("hand_number", 1)),
winner=data.get("winner"),
scores=[int(x) for x in data.get("scores", [0, 0])],
auction=(
Auction.from_json(data["auction"]) if data.get("auction") else None
),
caller=data.get("caller"),
called_card=(
Card.from_json(data["called_card"])
if data.get("called_card")
else None
),
briscola_suit=str(data.get("briscola_suit", "")),
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
solo=bool(data.get("solo", False)),
called_played=bool(data.get("called_played", False)),
partner_seat=data.get("partner_seat"),
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
trick_leader=int(data.get("trick_leader", 0)),
last_trick=(
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
),
turn=int(data.get("turn", 0)),
hand_scores=list(data.get("hand_scores", [])),
turn_deadline=data.get("turn_deadline"),
auction_deadline=data.get("auction_deadline"),
call_deadline=data.get("call_deadline"),
turn_timeout=int(
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
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
# -- game setup --------------------------------------------------------------
def create_game(
creator_sub: str,
creator_name: str,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> ChiamataState:
"""Create a lobby game with the creator seated first."""
return ChiamataState(
phase=PHASE_LOBBY,
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
turn_timeout=turn_timeout,
)
def join_game(state: ChiamataState, 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) >= PLAYER_COUNT:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
if len(state.players) == PLAYER_COUNT:
start_game(state)
def start_game(state: ChiamataState) -> None:
"""Deal the first hand and open the auction."""
if len(state.players) != PLAYER_COUNT:
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
_deal(state)
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def _set_deadline(seconds: int) -> str:
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
return deadline.isoformat()
def _set_auction_deadline(state: ChiamataState) -> None:
state.auction_deadline = _set_deadline(state.turn_timeout)
def _set_call_deadline(state: ChiamataState) -> None:
state.call_deadline = _set_deadline(state.turn_timeout)
def _set_turn_deadline(state: ChiamataState) -> None:
state.turn_deadline = _set_deadline(state.turn_timeout)
def _deal(state: ChiamataState) -> None:
"""Deal 8 cards each and open the auction."""
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
state.trick = []
state.last_trick = None
state.turn_deadline = None
state.call_deadline = None
state.called_card = None
state.briscola_suit = ""
state.declared_target = DEFAULT_TARGET
state.solo = False
state.called_played = False
state.partner_seat = None
state.caller = None
state.winner = None
state.scores = [0, 0]
# The whole deck is dealt, one card at a time, starting with the
# player after the dealer.
for _ in range(HAND_SIZE):
for offset in range(PLAYER_COUNT):
_player_at(
state, (state.dealer + 1 + offset) % PLAYER_COUNT
).hand.append(deck.pop())
first = (state.dealer + 1) % PLAYER_COUNT
state.turn = first
state.trick_leader = first
state.auction = Auction(
turn=first,
active=[player.seat for player in state.players],
)
state.phase = PHASE_AUCTION
_set_auction_deadline(state)
log.debug(
"deal %d dealt (dealer seat %d), auction opens with seat %d",
state.hand_number,
state.dealer,
first,
)
# -- auction -----------------------------------------------------------------
def _require_auction(
state: ChiamataState, sub: str
) -> Tuple[Auction, ChiamataPlayer]:
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase != PHASE_AUCTION:
raise GameNotStarted("the auction is not open")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
auction = state.auction
if auction is None: # pragma: no cover - auction always exists here
raise GameNotStarted("the auction is not open")
if auction.turn != player.seat:
raise NotYourTurn("it is not your turn to bid")
return auction, player
def legal_bid_ranks(state: ChiamataState) -> List[int]:
"""The ranks the player on turn may bid, empty at the point stage."""
auction = state.auction
if state.phase != PHASE_AUCTION or auction is None:
return []
current = auction.current
if current is None:
return list(BID_LADDER)
if current.rank == 2:
return []
index = _BID_INDEX[current.rank]
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
def legal_bid_points(state: ChiamataState) -> Optional[int]:
"""The lowest legal points raise, ``None`` when there is none.
Points may only be raised once the standing bid is the 2; any value
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
"""
auction = state.auction
if state.phase != PHASE_AUCTION or auction is None:
return None
current = auction.current
if current is None or current.rank != 2:
return None
if current.points >= MAX_TARGET:
return None
return current.points + 2
def _finish_auction(state: ChiamataState) -> None:
"""The last active bidder wins and must name the called card."""
auction = state.auction
if auction is None: # pragma: no cover - guarded by callers
raise GameNotStarted("the auction is not open")
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
raise IllegalMove("the auction cannot be concluded yet")
winner = auction.active[0]
state.caller = winner
state.declared_target = auction.current.points
state.turn = winner
state.phase = PHASE_CALLING
state.auction_deadline = None
_set_call_deadline(state)
log.debug(
"deal %d: seat %d wins the auction (rank %d, target %d)",
state.hand_number,
winner,
auction.current.rank,
auction.current.points,
)
def _advance_auction(state: ChiamataState) -> None:
auction = state.auction
if auction is None: # pragma: no cover - guarded by callers
return
for offset in range(1, PLAYER_COUNT + 1):
seat = (auction.turn + offset) % PLAYER_COUNT
if seat in auction.active:
auction.turn = seat
break
state.turn = auction.turn
_set_auction_deadline(state)
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
auction = state.auction
if auction is None: # pragma: no cover - guarded by callers
raise GameNotStarted("the auction is not open")
auction.current = bid
auction.history.append(bid)
log.debug(
"deal %d: seat %d bids rank %d at %d",
state.hand_number,
bid.seat,
bid.rank,
bid.points,
)
if len(auction.active) == 1:
_finish_auction(state)
return
_advance_auction(state)
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
"""Bid ``rank``; it must be weaker than the standing bid."""
auction, player = _require_auction(state, sub)
if rank not in _BID_INDEX:
raise IllegalMove(f"invalid rank: {rank!r}")
current = auction.current
if current is not None:
if current.rank == 2:
raise IllegalMove("the auction is at the 2: raise the points or pass")
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
raise IllegalMove("a bid must call a lower rank than the standing bid")
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
"""Raise the points target; only legal once the standing bid is the 2."""
auction, player = _require_auction(state, sub)
current = auction.current
if current is None or current.rank != 2:
raise IllegalMove("points can only be raised after a bid of the 2")
if points <= current.points:
raise IllegalMove(
f"the points must be higher than the standing bid ({current.points})"
)
if points > MAX_TARGET:
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
def pass_auction(state: ChiamataState, sub: str) -> None:
"""Leave the auction for good."""
auction, player = _require_auction(state, sub)
if player.seat not in auction.active: # pragma: no cover - turn implies active
raise IllegalMove("you already passed")
if len(auction.active) == 1 and auction.current is None:
# Everyone else passed before any bid: the last player must bid.
raise IllegalMove("you must bid: everyone else has passed")
auction.active.remove(player.seat)
auction.passed.append(player.seat)
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
if len(auction.active) == 1 and auction.current is not None:
_finish_auction(state)
return
_advance_auction(state)
# -- calling -------------------------------------------------------------------
def call(state: ChiamataState, sub: str, card_code: str) -> None:
"""The auction winner names the called card (any suit of the bid rank).
If the caller holds the named card they play alone; the card's suit
becomes the trump for the deal.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase != PHASE_CALLING:
raise GameNotStarted("no call is waiting")
caller = None if state.caller is None else _player_at(state, state.caller)
player = state.player_for(sub)
if player is None or caller is None or player.seat != caller.seat:
raise NotYourTurn("only the auction winner calls the card")
auction = state.auction
if auction is None or auction.current is None: # pragma: no cover
raise GameNotStarted("no call is waiting")
card = parse_card(card_code)
if card.rank != auction.current.rank:
raise IllegalMove(
"the called card must match the rank won in the auction"
)
state.called_card = card
state.briscola_suit = card.suit
state.solo = card in caller.hand
state.phase = PHASE_PLAYING
# The player after the dealer opens the play, whichever seat won the
# auction.
first = (state.dealer + 1) % PLAYER_COUNT
state.turn = first
state.trick_leader = first
state.call_deadline = None
_set_turn_deadline(state)
log.debug(
"deal %d: seat %d calls %s%s",
state.hand_number,
caller.seat,
card.code,
" (playing alone)" if state.solo else "",
)
# -- play ----------------------------------------------------------------------
def play(state: ChiamataState, sub: str, card_code: str) -> None:
"""Apply one move by the player identified by ``sub``.
Any card in hand is a legal play; only turn order and phase are
validated. 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_AUCTION:
raise IllegalMove("the auction is still open")
if state.phase == PHASE_CALLING:
raise IllegalMove("the called card has not been named yet")
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")
player.hand.remove(card)
if state.called_card is not None and card == state.called_card:
state.called_played = True
state.partner_seat = (
None if player.seat == state.caller else player.seat
)
log.info(
"deal %d: the called card %s was played by seat %d (%s)",
state.hand_number,
card.code,
player.seat,
"the caller plays alone" if state.partner_seat is None else "partner revealed",
)
state.trick.append(TrickCard(seat=player.seat, card=card.code))
log.debug(
"deal %d: seat %d played %s (%d/%d in the trick)",
state.hand_number,
player.seat,
card.code,
len(state.trick),
PLAYER_COUNT,
)
if len(state.trick) == PLAYER_COUNT:
_resolve_trick(state)
else:
state.turn = (state.turn + 1) % PLAYER_COUNT
_set_turn_deadline(state)
def _resolve_trick(state: ChiamataState) -> None:
"""Score the completed trick, draw no cards (deck already exhausted)."""
winner = _trick_winner(state)
taken = [parse_card(t.card) for t in state.trick]
_player_at(state, winner).captured.extend(taken)
state.last_trick = Trick(winner=winner, cards=list(state.trick))
state.trick = []
state.trick_leader = winner
state.turn = winner
log.debug(
"deal %d: seat %d takes the trick with %s",
state.hand_number,
winner,
[t.card for t in state.last_trick.cards],
)
if all(not player.hand for player in state.players):
_end_deal(state)
return
_set_turn_deadline(state)
def _trick_winner(state: ChiamataState) -> int:
"""The seat winning the current (complete) trick."""
played = [(t.seat, parse_card(t.card)) for t in state.trick]
leader_suit = played[0][1].suit
trumps = [
(seat, card)
for seat, card in played
if card.suit == state.briscola_suit
]
pool = trumps or [
(seat, card) for seat, card in played if card.suit == leader_suit
]
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
# -- scoring -------------------------------------------------------------------
def side_points(state: ChiamataState) -> Tuple[int, int]:
"""The points captured by the calling side and the defenders.
The partner's pile only joins the caller's once the partner is known
(the called card has been played); until then this is only used to
score a finished deal, where the reveal has always happened.
"""
if state.caller is None:
return (0, 0)
caller_seats = {state.caller}
if state.partner_seat is not None:
caller_seats.add(state.partner_seat)
caller_points = 0
defender_points = 0
for player in state.players:
points = sum(card_points(card) for card in player.captured)
if player.seat in caller_seats:
caller_points += points
else:
defender_points += points
return (caller_points, defender_points)
def _end_deal(state: ChiamataState) -> None:
"""Score the deal: finish the match, or replay after a patta.
A 60-60 deal with the plain 61 target is a patta: its points do not
count, the dealer rotates and the deal is replayed (a new auction).
Otherwise the calling side wins by reaching the declared target.
"""
state.turn_deadline = None
caller_points, defender_points = side_points(state)
patta = (
state.declared_target == DEFAULT_TARGET
and caller_points == 60
and defender_points == 60
)
caller_name = (
_player_at(state, state.caller).name if state.caller is not None else None
)
caller_seats = {state.caller}
if state.partner_seat is not None:
caller_seats.add(state.partner_seat)
caller_cards = sum(
len(player.captured)
for player in state.players
if player.seat in caller_seats
)
entry: Dict[str, Any] = {
"deal": state.hand_number,
"patta": patta,
"points": {"A": caller_points, "B": defender_points},
"cards": {"A": caller_cards, "B": 40 - caller_cards},
"declared_target": state.declared_target,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"caller": caller_name,
"solo": state.solo,
}
state.hand_scores.append(entry)
log.debug(
"deal %d scored caller %d - defenders %d%s (target %d)",
state.hand_number,
caller_points,
defender_points,
" patta" if patta else "",
state.declared_target,
)
if patta:
state.hand_number += 1
state.dealer = (state.dealer + 1) % PLAYER_COUNT
_deal(state)
return
state.scores = [caller_points, defender_points]
state.winner = (
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
)
state.phase = PHASE_FINISHED
log.info(
"match ended, %s win %d-%d",
SIDE_NAMES[state.winner],
caller_points,
defender_points,
)
# -- server-side auto actions ---------------------------------------------------
def auto_bid(state: ChiamataState) -> None:
"""Act for the bidder on turn: always pass, unless forced to bid.
An absent player never outbids anyone: they leave the auction. Only
the last player standing before any bid cannot pass — they are made
to bid the ace, whose suit :func:`auto_call` picks at random if the
call itself times out too.
"""
if state.phase != PHASE_AUCTION:
raise GameNotStarted("the auction is not open")
auction = state.auction
if auction is None: # pragma: no cover - guarded by phase
raise GameNotStarted("the auction is not open")
player = _player_at(state, auction.turn)
if len(auction.active) == 1 and auction.current is None:
bid_rank(state, player.sub, 1)
return
pass_auction(state, player.sub)
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
"""Name a random suit of the auction rank for the winner."""
if state.phase != PHASE_CALLING:
raise GameNotStarted("no call is waiting")
auction = state.auction
if auction is None or auction.current is None: # pragma: no cover
raise GameNotStarted("no call is waiting")
caller = _player_at(state, state.caller) if state.caller is not None else None
if caller is None: # pragma: no cover - guarded by phase
raise GameNotStarted("no call is waiting")
chooser = rng or _rng
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
call(state, caller.sub, card.code)
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
"""Play a random card for the player currently on turn."""
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)
play(state, player.sub, card.code)
# -- views ---------------------------------------------------------------------
def _roles_public(state: ChiamataState) -> bool:
return state.called_played
def state_for_player(
state: ChiamataState, sub: Optional[str]
) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands and secret roles.
Hands are reduced to a count, except for the requesting player's own
hand. The partner's role only appears once the called card has been
played; before that only the caller is labeled. The calling side's
running total is withheld until then too: it would let onlookers
deduce the partner's identity from who captured the points.
"""
viewer = state.player_for(sub)
revealed = _roles_public(state)
players: List[Dict[str, object]] = []
for player in state.players:
role = ""
if state.caller is not None and player.seat == state.caller:
role = ROLE_CALLER
if revealed:
if state.partner_seat is not None and player.seat == state.partner_seat:
role = ROLE_PARTNER
elif player.seat != state.caller:
role = ROLE_DEFENDER
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": role,
"cards_left": len(player.hand),
"captured_count": len(player.captured),
"points": sum(card_points(card) for card in player.captured),
}
if viewer is not None and viewer.seat == player.seat:
view["hand"] = [c.code for c in player.hand]
players.append(view)
auction = state.auction
auction_view: Optional[Dict[str, object]] = None
if auction is not None:
auction_view = {
"turn": auction.turn,
"active": list(auction.active),
"passed": list(auction.passed),
"current": auction.current.to_json() if auction.current else None,
"history": [b.to_json() for b in auction.history],
}
payload: Dict[str, object] = {
"phase": state.phase,
"player_count": PLAYER_COUNT,
"dealer": state.dealer,
"hand_number": state.hand_number,
"turn": state.turn,
"auction": auction_view,
"caller": state.caller,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"briscola_suit": state.briscola_suit,
"declared_target": state.declared_target,
"called_played": state.called_played,
"solo": state.solo if revealed else False,
"partner": (
state.partner_seat if revealed and state.partner_seat is not None else None
),
"players": players,
"trick": [t.to_json() for t in state.trick],
"trick_leader": state.trick_leader,
"last_trick": state.last_trick.to_json() if state.last_trick else None,
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
"auction_deadline": state.auction_deadline,
"call_deadline": state.call_deadline,
"turn_deadline": state.turn_deadline,
"winner": (
None if state.winner is None else SIDE_NAMES[state.winner]
),
}
if revealed and state.caller is not None:
caller_points, defender_points = side_points(state)
payload["scores"] = {"A": caller_points, "B": defender_points}
if viewer is not None:
if state.phase == PHASE_AUCTION and auction is not None:
if auction.turn == viewer.seat:
payload["your_turn"] = True
payload["legal_bids"] = {
"ranks": legal_bid_ranks(state),
"points_min": legal_bid_points(state),
"points_max": MAX_TARGET,
}
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
payload["your_turn"] = True
current = auction.current if auction is not None else None
if current is not None:
payload["callable_cards"] = [
Card(rank=current.rank, suit=suit).code for suit in SUITS
]
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
payload["your_turn"] = True
return payload
@@ -1,9 +1,11 @@
"""The platform-facing adapter for briscola.
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
rules core (:mod:`tavolo.briscola.engine`): classic two-player
:class:`BriscolaEngine` and four-player fixed-partnership
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
:class:`BriscolaEngine`, four-player fixed-partnership
:class:`BriscolaQuattroEngine` and five-player
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
join, websocket actions, deadlines) into rules-engine calls and back,
keeping all briscola knowledge inside this package. Nothing outside
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from . import chiamata, engine
from .chiamata import (
PHASE_AUCTION,
PHASE_CALLING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
SIDE_NAMES,
ChiamataPlayer,
ChiamataState,
)
from .engine import card_points
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
@@ -277,3 +290,299 @@ class BriscolaQuattroEngine(_BriscolaBase):
min_players = 4
max_players = 4
player_count = 4
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
for player in state.players:
if player.seat == seat:
return player
raise GameError(f"no player in seat {seat}")
class BriscolaChiamataEngine(GameEngine):
"""Five-player briscola chiamata as a platform game engine.
One deal per match: the five players auction the call, the winner
names a card (playing alone when it is in their own hand) and the
calling side must reach the declared target (61 unless the auction
raised it). A 60-60 deal with the plain target is a patta and is
replayed. This keeps the match result a clean two-sided affair —
caller and secret partner against the other three, or the lone
caller against the other four — which the platform can rate,
persist and display like any other game.
"""
id = "briscola_chiamata"
name = "Briscola chiamata"
description = (
"Five-player briscola with a descending auction: the winner calls "
"a card, whose holder becomes the secret partner (or, when the "
"caller holds it, the caller plays alone) and whose suit becomes "
"the trump. The calling side must reach 61 points; one deal per "
"match, replayed after a patta."
)
min_players = 5
max_players = 5
options_schema: Mapping[str, Any] = {}
def __init__(
self,
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
# A chiamata match is a single deal, so there is no between-deals
# acknowledgement phase to time out; the parameter is accepted
# for symmetry with the other briscola engines and wired by the
# application root.
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
creator = session.players[0]
session.state = chiamata.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
turn_timeout=self._turn_timeout,
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
chiamata.join_game(session.state, user_sub, display_name)
session.players.append(
Seat(user_sub=user_sub, display_name=display_name)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: ChiamataState = session.state
return {
"phase": state.phase,
"declared_target": state.declared_target,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: ChiamataState = session.state
if action == "bid":
rank = payload.get("rank")
points = payload.get("points")
if rank is not None:
if isinstance(rank, bool) or not isinstance(rank, int):
raise IllegalMove("'rank' must be an integer")
chiamata.bid_rank(state, user_sub, rank)
elif points is not None:
if isinstance(points, bool) or not isinstance(points, int):
raise IllegalMove("'points' must be an integer")
chiamata.bid_points(state, user_sub, points)
else:
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
elif action == "pass":
chiamata.pass_auction(state, user_sub)
elif action == "call":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
chiamata.call(state, user_sub, card)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "play":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
chiamata.play(state, user_sub, card)
except ValueError:
raise IllegalMove("invalid card code")
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return chiamata.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: ChiamataState = session.state
caller = (
_chiamata_player(state, state.caller) if state.caller is not None else None
)
partner = (
_chiamata_player(state, state.partner_seat)
if state.partner_seat is not None
else None
)
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": (
None if state.winner is None else SIDE_NAMES[state.winner]
),
"declared_target": state.declared_target,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"caller": None if caller is None else caller.name,
"partner": None if partner is None else partner.name,
"solo": state.solo,
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, ChiamataState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return ChiamataState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: ChiamataState = session.state
if state.phase == PHASE_AUCTION and state.auction_deadline:
due_ms = _deadline_ms(state.auction_deadline)
if due_ms is None:
return None
return Deadline(
kind="auction",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_CALLING and state.call_deadline:
due_ms = _deadline_ms(state.call_deadline)
if due_ms is None:
return None
return Deadline(
kind="call",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"call:{state.hand_number}:{due_ms}",
)
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}",
)
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 bid, call or play landed in time):
# nothing to do.
raise GameError("stale deadline")
state: ChiamataState = session.state
if kind == "auction":
seat = state.turn
auction = state.auction
passed_before = len(auction.passed) if auction is not None else 0
chiamata.auto_bid(state)
action = (
"auto-passed"
if auction is not None and len(auction.passed) > passed_before
else "forced to bid the ace"
)
log.info(
"seat %s %s (auction timeout, deal %d)",
seat,
action,
state.hand_number,
)
elif kind == "call":
caller = state.caller
chiamata.auto_call(state)
log.info(
"auto-called for seat %s (call timeout, deal %d)",
caller,
state.hand_number,
)
elif kind == "turn":
seat = state.turn
chiamata.auto_play(state)
log.info(
"auto-played for seat %s (turn timeout, deal %d)",
seat,
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: ChiamataState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
if state.caller is None: # pragma: no cover - a winner implies a caller
raise GameError("no result: the caller is unknown")
caller = state.caller
partner = state.partner_seat
caller_seats = [caller] + ([partner] if partner is not None else [])
teams = [
[_chiamata_player(state, seat).sub for seat in caller_seats],
[
player.sub
for player in state.players
if player.seat not in caller_seats
],
]
caller_points, defender_points = chiamata.side_points(state)
side_scores = [caller_points, defender_points]
players = []
for player in state.players:
if player.seat == caller:
role = ROLE_CALLER
side = 0
elif partner is not None and player.seat == partner:
role = ROLE_PARTNER
side = 0
else:
role = ROLE_DEFENDER
side = 1
players.append(
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=side == state.winner,
team=role,
score=float(side_scores[side]),
details={
"points": sum(card_points(card) for card in player.captured),
"captured_cards": len(player.captured),
},
)
)
return MatchResult(
teams=teams,
winner_team=state.winner,
players=players,
summary={
"team_a_score": caller_points,
"team_b_score": defender_points,
"winner_team": SIDE_NAMES[state.winner],
"declared_target": state.declared_target,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"caller": _chiamata_player(state, caller).name,
"partner": (
_chiamata_player(state, partner).name
if partner is not None
else None
),
"solo": state.solo,
"deals_played": state.hand_number,
"deal_scores": state.hand_scores,
},
)
@@ -0,0 +1,667 @@
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import chiamata as rules
from tavolo.briscola.chiamata import (
BID_LADDER,
Bid,
ChiamataPlayer,
ChiamataState,
PHASE_AUCTION,
PHASE_CALLING,
PHASE_FINISHED,
PHASE_PLAYING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
SIDE_CALLER,
SIDE_DEFENDERS,
)
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import Card
def card(code: str) -> Card:
return Card.parse(code)
def started_state() -> ChiamataState:
"""A five-player game whose auction has just opened (dealer seat 0)."""
state = rules.create_game("p0", "p0")
for i in range(1, 5):
rules.join_game(state, f"p{i}", f"p{i}")
return state
def bid(state: ChiamataState, seat: int, rank: int) -> None:
rules.bid_rank(state, f"p{seat}", rank)
def make_state(
hands,
*,
briscola_suit: str = "S",
called_card: str | None = None,
caller: int | None = None,
solo: bool = False,
called_played: bool = False,
partner_seat: int | None = None,
captured=None,
turn: int = 0,
dealer: int = 0,
declared_target: int = rules.DEFAULT_TARGET,
phase: str = PHASE_PLAYING,
hand_number: int = 1,
) -> ChiamataState:
"""Build a controlled playing state directly (bypassing the auction)."""
state = ChiamataState(
phase=phase,
dealer=dealer,
hand_number=hand_number,
declared_target=declared_target,
briscola_suit=briscola_suit,
called_card=card(called_card) if called_card else None,
caller=caller,
solo=solo,
called_played=called_played,
partner_seat=partner_seat,
turn=turn,
)
for seat, hand in enumerate(hands):
state.players.append(
ChiamataPlayer(
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]
return state
def play_all(state: ChiamataState) -> None:
"""Play the first card of each hand in turn order until the trick resolves."""
for _ in range(rules.PLAYER_COUNT):
player = next(p for p in state.players if p.seat == state.turn)
rules.play(state, player.sub, player.hand[0].code)
class SetupTest(unittest.TestCase):
def test_five_player_deal(self) -> None:
state = started_state()
self.assertEqual(PHASE_AUCTION, state.phase)
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
for player in state.players:
self.assertEqual(rules.HAND_SIZE, len(player.hand))
# The whole deck is dealt: no stock, no face-up card.
dealt = [c.code for p in state.players for c in p.hand]
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
# The auction opens with the player after the dealer.
assert state.auction is not None
self.assertEqual(1, state.auction.turn)
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
self.assertIsNone(state.auction.current)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
state = rules.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
rules.join_game(state, "p0", "p0")
for i in range(1, 5):
rules.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_AUCTION, state.phase)
with self.assertRaises(GameNotStarted):
rules.join_game(state, "p5", "p5")
lobby = rules.create_game("p0", "p0")
for i in range(1, 5):
rules.join_game(lobby, f"p{i}", f"p{i}")
# Joining a sixth player is impossible once the fifth started the
# match; exercise LobbyFull on an artificially unfilled start.
unfilled = rules.create_game("p0", "p0")
unfilled.phase = rules.PHASE_LOBBY
for i in range(1, 5):
unfilled.players.append(
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
)
with self.assertRaises(LobbyFull):
rules.join_game(unfilled, "p5", "p5")
class AuctionTest(unittest.TestCase):
def test_first_bid_accepts_any_rank(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 5)
assert state.auction is not None
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
def test_bids_must_descend(self) -> None:
state = started_state()
bid(state, 1, 1) # ace
with self.assertRaises(IllegalMove):
bid(state, 2, 1) # same rank
bid(state, 2, 3) # three, weaker than the ace
bid(state, 3, 10) # king, weaker than the three
with self.assertRaises(IllegalMove):
bid(state, 4, 3) # three is stronger than the king
bid(state, 4, 9) # horse
def test_bid_ladder_ordering(self) -> None:
# A bid must be weaker than the standing bid: descending strength
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
state = started_state()
bid(state, 1, 10) # king
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
rules.bid_rank(state, f"p{opponent}", rank)
# 3 and A are stronger than 6: rejected.
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p1", 3)
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p1", 1)
rules.bid_rank(state, "p1", 5)
def test_not_your_turn(self) -> None:
state = started_state()
with self.assertRaises(NotYourTurn):
bid(state, 2, 1)
def test_pass_eliminates_and_last_standing_bids(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
assert state.auction is not None
# Only seat 0 remains and nobody has bid: it must bid.
self.assertEqual([0], state.auction.active)
with self.assertRaises(IllegalMove):
rules.pass_auction(state, "p0")
rules.bid_rank(state, "p0", 2)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(0, state.caller)
self.assertEqual(61, state.declared_target)
def test_auction_ends_when_one_bidder_remains(self) -> None:
state = started_state()
bid(state, 1, 1) # ace
rules.pass_auction(state, "p2")
bid(state, 3, 10) # king
rules.pass_auction(state, "p4")
rules.pass_auction(state, "p0")
rules.pass_auction(state, "p1")
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(3, state.caller)
self.assertEqual(61, state.declared_target)
def test_point_raises_only_after_the_two(self) -> None:
state = started_state()
bid(state, 1, 1)
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p2", 62)
bid(state, 2, 2)
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p3", 2) # no ranks below the 2
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p3", 61) # not higher
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p3", 121) # above the cap
rules.bid_points(state, "p3", 63)
assert state.auction is not None
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
state = started_state()
bid(state, 1, 10) # king
view = rules.state_for_player(state, "p2")
self.assertTrue(view["your_turn"])
legal = view["legal_bids"]
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
self.assertIsNone(legal["points_min"])
# A player who is not on turn gets no legal bids.
other = rules.state_for_player(state, "p3")
self.assertNotIn("legal_bids", other)
self.assertNotIn("your_turn", other)
def test_legal_points_exposed_at_the_two(self) -> None:
state = started_state()
bid(state, 1, 2)
view = rules.state_for_player(state, "p2")
self.assertEqual([], view["legal_bids"]["ranks"])
self.assertEqual(63, view["legal_bids"]["points_min"])
self.assertEqual(120, view["legal_bids"]["points_max"])
def test_auto_bid_always_passes_when_legal(self) -> None:
state = started_state()
assert state.auction is not None
rules.auto_bid(state)
# Seat 1 passes: nobody has bid and the turn moves on.
self.assertEqual([1], state.auction.passed)
self.assertIsNone(state.auction.current)
self.assertEqual(2, state.auction.turn)
# An absent bidder still passes when there is a standing bid.
rules.bid_rank(state, "p2", 10)
rules.auto_bid(state)
self.assertEqual([1, 3], state.auction.passed)
assert state.auction.current is not None
self.assertEqual(10, state.auction.current.rank)
def test_auto_bid_passes_at_the_point_stage(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 2)
rules.bid_points(state, "p2", 62)
rules.auto_bid(state) # seat 3
assert state.auction is not None
self.assertIn(3, state.auction.passed)
assert state.auction.current is not None
self.assertEqual(62, state.auction.current.points)
def test_auto_bid_forced_to_call_the_ace(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
# Seat 0 is the last one standing and nobody has bid yet: the
# forced auto-bid calls the ace.
rules.auto_bid(state)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(0, state.caller)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(1, state.auction.current.rank)
self.assertEqual(61, state.declared_target)
class CallTest(unittest.TestCase):
def _called_state(self, rank: int = 1) -> ChiamataState:
state = started_state()
bid(state, 1, rank)
for seat in (2, 3, 4, 0):
rules.pass_auction(state, f"p{seat}")
return state
def test_call_must_match_the_winning_rank(self) -> None:
state = self._called_state(1)
with self.assertRaises(IllegalMove):
rules.call(state, "p1", "03D")
# Any suit of the winning rank is fine.
rules.call(state, "p1", "01D")
self.assertEqual("D", state.briscola_suit)
self.assertEqual(PHASE_PLAYING, state.phase)
def test_only_the_winner_calls(self) -> None:
state = self._called_state()
with self.assertRaises(NotYourTurn):
rules.call(state, "p2", "01D")
def test_call_in_hand_means_playing_alone(self) -> None:
state = self._called_state(1)
# Seat 1 calls a card it holds: solo.
state.players[1].hand[0] = card("01S")
rules.call(state, "p1", "01S")
self.assertTrue(state.solo)
self.assertIsNone(state.partner_seat)
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
state = self._called_state(1)
rules.call(state, "p1", "01D")
self.assertEqual(1, state.turn)
self.assertNotEqual(state.caller, None)
def test_auto_call_names_the_bid_rank(self) -> None:
state = self._called_state(10)
rules.auto_call(state, random.Random(3))
assert state.called_card is not None
self.assertEqual(10, state.called_card.rank)
self.assertEqual(PHASE_PLAYING, state.phase)
def test_calling_phase_view(self) -> None:
state = self._called_state(1)
view = rules.state_for_player(state, "p1")
self.assertEqual(PHASE_CALLING, view["phase"])
self.assertTrue(view["your_turn"])
self.assertEqual(
["01D", "01C", "01S", "01B"], view["callable_cards"]
)
other = rules.state_for_player(state, "p2")
self.assertNotIn("callable_cards", other)
class PlayTest(unittest.TestCase):
def test_five_player_trick_resolution(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
called_card="01D",
called_played=True,
partner_seat=4,
turn=0,
)
play_all(state)
assert state.last_trick is not None
self.assertEqual(3, state.last_trick.winner)
def test_trump_wins(self) -> None:
state = make_state(
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
briscola_suit="C",
caller=0,
called_card="01D",
called_played=True,
partner_seat=4,
turn=0,
)
play_all(state)
assert state.last_trick is not None
self.assertEqual(3, state.last_trick.winner)
def test_called_card_reveals_the_partner(self) -> None:
state = make_state(
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="D",
caller=0,
called_card="01S",
turn=1,
)
self.assertFalse(state.called_played)
view = rules.state_for_player(state, "p2")
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertNotIn("scores", view)
rules.play(state, "p1", "01D")
# The called card has not been played yet.
self.assertFalse(state.called_played)
# Drive the trick until the called card appears: give it to seat 3.
state.players[3].hand = [card("01S")]
state.turn = 3
rules.play(state, "p3", "01S")
self.assertTrue(state.called_played)
self.assertEqual(3, state.partner_seat)
view = rules.state_for_player(state, "p2")
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertEqual(ROLE_PARTNER, roles[3])
self.assertEqual(ROLE_DEFENDER, roles[1])
self.assertIn("scores", view)
def test_called_card_played_by_the_caller_is_solo(self) -> None:
state = make_state(
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
briscola_suit="S",
caller=0,
called_card="01S",
solo=True,
turn=0,
)
rules.play(state, "p0", "01S")
self.assertTrue(state.called_played)
self.assertIsNone(state.partner_seat)
view = rules.state_for_player(state, "p4")
self.assertTrue(view["solo"])
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertEqual(ROLE_DEFENDER, roles[1])
def test_play_validations(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
turn=0,
)
with self.assertRaises(NotYourTurn):
rules.play(state, "p1", "05D")
with self.assertRaises(CardNotInHand):
rules.play(state, "p0", "01D")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
rules.play(state, "p0", "02D")
def test_play_rejected_before_the_call(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
caller=0,
turn=0,
phase=PHASE_CALLING,
)
with self.assertRaises(IllegalMove):
rules.play(state, "p0", "02D")
def test_auto_play_advances(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
called_card="09B",
turn=0,
)
rules.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.trick))
self.assertEqual(1, len(state.players[0].hand))
class ScoringTest(unittest.TestCase):
def _piles(self):
# 60 points in denari + coppe, 60 in spade + bastoni.
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
]
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
]
return a_cards, b_cards
def test_side_points_split_caller_side_and_defenders(self) -> None:
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=3,
called_played=True,
captured=[
["01D"], # caller, side A: 11
["03C", "01S"], # defender: 21
["10D"], # defender: 4
["01C"], # partner, side A: 11
["03S"], # defender: 10
],
)
caller_points, defender_points = rules.side_points(state)
self.assertEqual(22, caller_points)
self.assertEqual(35, defender_points)
def test_solo_side_points(self) -> None:
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
solo=True,
called_played=True,
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
)
caller_points, defender_points = rules.side_points(state)
self.assertEqual(11, caller_points)
self.assertEqual(35, defender_points)
def test_calling_side_wins_at_the_target(self) -> None:
a_cards, b_cards = self._piles()
b_rest = [c for c in b_cards if c != "01S"]
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards, ["01S"], [], b_rest, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_CALLER, state.winner)
self.assertEqual([71, 49], state.scores)
def test_defenders_win_below_the_declared_target(self) -> None:
a_cards, b_cards = self._piles()
# Calling side only 30 points of the 61 needed.
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards[:5], [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_DEFENDERS, state.winner)
self.assertEqual([30, 60], state.scores)
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
a_cards, b_cards = self._piles()
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards, [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_AUCTION, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual(1, state.dealer)
assert state.auction is not None
self.assertEqual(2, state.auction.turn)
self.assertIsNone(state.caller)
self.assertIsNone(state.called_card)
# The patta entry is kept in the audit trail.
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
def test_raised_target_disables_patta(self) -> None:
a_cards, b_cards = self._piles()
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
declared_target=62,
captured=[a_cards, [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_DEFENDERS, state.winner)
def test_full_deal_scores_120(self) -> None:
state = started_state()
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 5000:
if state.phase == PHASE_AUCTION:
rules.auto_bid(state)
elif state.phase == PHASE_CALLING:
rules.auto_call(state, rng)
else:
rules.auto_play(state, rng)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(120, sum(state.scores))
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
captured = sum(len(p.captured) for p in state.players)
self.assertEqual(40, captured)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=0,
called_card="01D",
called_played=False,
turn=0,
)
view = rules.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(["02D", "03C"], players[0]["hand"])
self.assertNotIn("hand", players[1])
self.assertEqual(1, players[1]["cards_left"])
self.assertEqual("01D", view["called_card"])
self.assertEqual(5, view["player_count"])
def test_spectator_sees_public_information_only(self) -> None:
state = make_state(
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=2,
called_card="01D",
called_played=False,
)
view = rules.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("scores", view)
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[2])
self.assertEqual("", roles[0])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=0,
captured=[["01C", "03B"], ["10D"], [], [], []],
)
view = rules.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(21, players[0]["points"])
self.assertEqual(4, players[1]["points"])
self.assertEqual(2, players[0]["captured_count"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = started_state()
bid(state, 1, 10)
bid(state, 2, 7)
restored = ChiamataState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.turn, restored.turn)
assert restored.auction is not None and state.auction is not None
self.assertEqual(state.auction.active, restored.auction.active)
self.assertEqual(state.auction.passed, restored.auction.passed)
self.assertEqual(
state.auction.history, restored.auction.history
)
self.assertEqual(
[[c.code for c in p.hand] for p in state.players],
[[c.code for c in p.hand] for p in restored.players],
)
def test_legacy_state_defaults(self) -> None:
state = started_state()
data = state.to_json()
del data["auction"]
del data["called_card"]
del data["declared_target"]
restored = ChiamataState.from_json(data)
self.assertIsNone(restored.auction)
self.assertIsNone(restored.called_card)
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
self.assertFalse(restored.solo)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,421 @@
"""Adapter tests: the platform contract over the briscola chiamata rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.briscola import BriscolaChiamataEngine
from tavolo.briscola import chiamata as rules
from tavolo.briscola.chiamata import (
PHASE_AUCTION,
PHASE_CALLING,
PHASE_FINISHED,
PHASE_LOBBY,
PHASE_PLAYING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
ChiamataState,
)
from tavolo.briscola.state import Card
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameNotStarted,
IllegalMove,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="c1",
game_type=engine.id,
join_code="CODE01",
creator_sub="p0",
players=[Seat(user_sub="p0", display_name="P0")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for i in range(1, engine.max_players):
engine.join(session, f"p{i}", f"P{i}")
return session
def _player_on_turn(session: GameSession):
state = session.state
return next(p for p in state.players if p.seat == state.turn)
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
moves = 0
while not engine.is_finished(session) and moves < limit:
state = session.state
if state.phase == PHASE_AUCTION:
player = _player_on_turn(session)
try:
engine.handle_action(session, player.sub, "pass", {})
except IllegalMove:
# The last player standing must bid.
engine.handle_action(session, player.sub, "bid", {"rank": 2})
elif state.phase == PHASE_CALLING:
assert state.auction is not None and state.auction.current is not None
caller = next(
p for p in state.players if p.seat == state.caller
)
card = Card(rank=state.auction.current.rank, suit="D")
engine.handle_action(session, caller.sub, "call", {"card": card.code})
else:
player = _player_on_turn(session)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
assert engine.is_finished(session), "match did not finish"
class MetadataTest(unittest.TestCase):
def test_metadata(self) -> None:
engine = BriscolaChiamataEngine()
self.assertEqual("briscola_chiamata", engine.id)
self.assertEqual("Briscola chiamata", engine.name)
self.assertTrue(engine.description)
self.assertEqual(5, engine.min_players)
self.assertEqual(5, engine.max_players)
self.assertEqual({}, engine.options_schema)
class CreateJoinTest(unittest.TestCase):
def test_create_seats_creator_without_a_team(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
self.assertIsNone(session.players[0].team)
self.assertIsInstance(session.state, ChiamataState)
self.assertEqual(PHASE_LOBBY, session.state.phase)
self.assertTrue(engine.in_lobby(session))
def test_join_starts_when_full(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
for i in range(1, 4):
engine.join(session, f"p{i}", f"P{i}")
self.assertTrue(engine.in_lobby(session))
engine.join(session, "p4", "P4")
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_AUCTION, session.state.phase)
self.assertEqual(5, len(session.players))
for seat in session.players:
self.assertIsNone(seat.team)
def test_join_errors(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "p0", "P0")
for i in range(1, 5):
engine.join(session, f"p{i}", f"P{i}")
with self.assertRaises(GameNotStarted):
engine.join(session, "p5", "P5")
def test_timeouts_come_from_the_engine(self) -> None:
engine = BriscolaChiamataEngine(
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "dance", {})
def test_bid_payload_validation(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"rank": True})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"points": False})
# points cannot be raised before a bid of the 2
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"points": 64})
def test_auction_flow_through_actions(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 1})
engine.handle_action(session, "p2", "pass", {})
engine.handle_action(session, "p3", "bid", {"rank": 2})
engine.handle_action(session, "p4", "bid", {"points": 64})
engine.handle_action(session, "p0", "pass", {})
engine.handle_action(session, "p1", "pass", {})
engine.handle_action(session, "p3", "pass", {})
state = session.state
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(4, state.caller)
self.assertEqual(64, state.declared_target)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(2, state.auction.current.rank)
engine.handle_action(session, "p4", "call", {"card": "02S"})
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual("S", state.briscola_suit)
# Play the first card of the player on turn.
player = _player_on_turn(session)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
self.assertEqual(1, len(state.trick))
def test_call_payload_validation(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 2})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "call", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "call", {"card": "nope"})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "call", {"card": "01D"})
def test_finished_match_rejects_actions(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
_drive_to_finish(engine, session)
with self.assertRaises(GameError):
engine.handle_action(session, session.players[0].user_sub, "play", {})
class ViewTest(unittest.TestCase):
def test_view_hides_other_hands_and_roles(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
view = engine.view_for(session, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
self.assertNotIn("your_turn", view)
self.assertNotIn("scores", view)
self.assertEqual(5, view["player_count"])
# p1 opens the auction.
auction = view["auction"]
self.assertEqual(1, auction["turn"])
self.assertIsNone(auction["current"])
def test_view_marks_the_acting_player(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
view = engine.view_for(session, "p1")
self.assertTrue(view["your_turn"])
self.assertIn("legal_bids", view)
# The caller is labeled as soon as the auction is won.
engine.handle_action(session, "p1", "bid", {"rank": 1})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
view = engine.view_for(session, "p0")
self.assertIn("callable_cards", engine.view_for(session, "p1"))
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[1])
def test_spectator_view(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
def test_lobby_view(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(61, lobby["declared_target"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 10})
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ChiamataState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
assert restored.auction is not None and session.state.auction is not None
self.assertEqual(session.state.auction.history, restored.auction.history)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = BriscolaChiamataEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_auction_deadline_and_revalidation(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("auction", deadline.kind)
with self.assertRaises(GameError):
engine.fire_deadline(session, "auction", "auction:1:1:0")
engine.fire_deadline(session, deadline.kind, deadline.token)
# The auto-bid moved the auction on: a fresh deadline exists.
state = session.state
self.assertIn(
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
)
self.assertIsNotNone(engine.next_deadline(session))
def test_stale_auction_deadline_after_a_bid(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
engine.handle_action(session, "p1", "bid", {"rank": 1})
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_call_deadline_auto_calls(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 2})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("call", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
state = session.state
self.assertEqual(PHASE_PLAYING, state.phase)
assert state.called_card is not None
self.assertEqual(2, state.called_card.rank)
def test_turn_deadline_auto_plays(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 2})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
engine.handle_action(session, "p1", "call", {"card": "02D"})
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
state = session.state
before = state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(1, len(state.trick))
self.assertNotEqual(before, state.turn)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
_drive_to_finish(engine, session)
state = session.state
result = engine.result(session)
sizes = sorted(len(team) for team in result.teams)
self.assertIn(sizes, ([2, 3], [1, 4]))
self.assertEqual(5, len(result.players))
self.assertIn(result.winner_team, (0, 1))
for player in result.players:
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
self.assertEqual(
player.won,
(player.team in (ROLE_CALLER, ROLE_PARTNER))
== (result.winner_team == 0),
)
self.assertIn("points", player.details)
summary = result.summary
for key in (
"team_a_score",
"team_b_score",
"winner_team",
"declared_target",
"called_card",
"caller",
"solo",
"deals_played",
"deal_scores",
):
self.assertIn(key, summary)
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
self.assertEqual(state.hand_number, summary["deals_played"])
def test_result_requires_finished_match(self) -> None:
engine = BriscolaChiamataEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_solo_result_teams(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
state = session.state
# Force a finished solo match: seat 0 called its own card.
state.caller = 0
state.solo = True
state.partner_seat = None
state.called_played = True
state.called_card = Card.parse("01D")
# Caller side captures 71 (60 + 11), defenders 49.
state.players[0].captured = [
Card.parse(code)
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
+ ["01S"]
]
state.players[1].captured = [
Card.parse(code)
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
]
state.scores = [71, 49]
state.winner = 0
state.phase = PHASE_FINISHED
result = engine.result(session)
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
# The lone caller won: exactly one winner.
self.assertEqual(1, sum(1 for p in result.players if p.won))
caller = next(p for p in result.players if p.team == ROLE_CALLER)
self.assertTrue(caller.won)
self.assertEqual(71.0, caller.score)
def test_game_over_view(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
state = session.state
state.phase = PHASE_FINISHED
state.winner = 1
state.scores = [55, 65]
state.caller = 2
state.solo = False
state.partner_seat = 4
state.called_card = Card.parse("03C")
over = engine.game_over_view(session, "p0")
self.assertEqual({"A": 55, "B": 65}, over["scores"])
self.assertEqual("Difensori", over["winner"])
self.assertEqual("03C", over["called_card"])
if __name__ == "__main__":
unittest.main()
+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:
+390
View File
@@ -0,0 +1,390 @@
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
The per-package suite covers the rules and the engine contract; these
tests wire the game through the full platform stack (REST, WebSocket,
deadline scheduler).
"""
from __future__ import annotations
import asyncio
import unittest
from datetime import datetime, timedelta, timezone
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.briscola.chiamata import (
PHASE_AUCTION,
PHASE_CALLING,
PHASE_PLAYING,
Auction,
Bid,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
async def _create_and_join(client: AsyncClient) -> dict:
"""Create a chiamata game and seat all five players; return the view."""
with oidc_user(PLAYERS[0]):
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
assert created.status_code == 201, created.text
code = created.json()["join_code"]
body = created.json()
for player in PLAYERS[1:]:
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
assert joined.status_code == 200, joined.text
body = joined.json()
return body
class ChiamataRoutesTest(unittest.TestCase):
@async_test
async def test_create_and_seat_five_players(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(PLAYERS[0]):
created = await client.post(
"/api/games", json={"game_type": "briscola_chiamata"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("briscola_chiamata", body["game_type"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(4, body["seats_open"])
self.assertIsNone(body["players"][0]["team"])
code = body["join_code"]
for player in PLAYERS[1:4]:
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
self.assertEqual("lobby", joined.json()["phase"])
self.assertEqual(
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
)
with oidc_user(PLAYERS[4]):
started = await client.post("/api/games/join", json={"code": code})
state = started.json()
self.assertEqual(PHASE_AUCTION, state["phase"])
self.assertEqual(0, state["seats_open"])
self.assertEqual(5, len(state["players"]))
for seat in state["players"]:
# Roles are hidden until the called card is played.
self.assertEqual("", seat["team"])
self.assertEqual(8, seat["cards_left"])
# The auction opens with the player after the dealer (seat 0).
self.assertEqual(1, state["auction"]["turn"])
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
self.assertIsNone(state["auction"]["current"])
self.assertIsNotNone(state["auction_deadline"])
# The last joiner's personalized view carries their own hand.
self.assertIn("hand", state["players"][4])
self.assertEqual(8, len(state["players"][4]["hand"]))
self.assertNotIn("hand", state["players"][0])
# There is no stock in this variant.
self.assertNotIn("stock_count", state)
# A non-player watches without hands or the join code.
with oidc_user("mallory"):
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 seat in spectator["players"]:
self.assertNotIn("hand", seat)
@async_test
async def test_preview_lists_chiamata(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": "briscola_chiamata"}
)
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("briscola_chiamata", body["game_type"])
self.assertEqual("Briscola chiamata", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(4, body["seats_open"])
class ChiamataWebSocketTest(unittest.TestCase):
@async_test
async def test_auction_call_and_play_over_the_socket(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await _create_and_join(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"), make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
self.assertTrue(first["game"].get("your_turn"))
self.assertIn("legal_bids", first["game"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
# Bob (seat 1) opens the bidding with the two.
await bob_ws.send_json({"action": "bid", "rank": 2})
update = await bob_ws.receive_json()
self.assertEqual("state", update["type"])
current = update["game"]["auction"]["current"]
self.assertEqual(
{"seat": 1, "rank": 2, "points": 61}, current
)
self.assertEqual(2, update["game"]["auction"]["turn"])
# Alice receives the broadcast of Bob's bid.
broadcast = await alice_ws.receive_json()
self.assertEqual("state", broadcast["type"])
# Alice (seat 0) is not on turn: her bid is an error.
await alice_ws.send_json({"action": "bid", "rank": 3})
error = await alice_ws.receive_json()
self.assertEqual("error", error["type"])
# The remaining players pass; Bob wins the auction and is
# asked to name the card.
engine = platform.registry.require("briscola_chiamata")
session = await game_store.load(game_id)
assert session is not None
for sub in ("carol", "dave", "erin", "alice"):
engine.handle_action(session, sub, "pass", {})
await game_store.save(session)
self.assertEqual(PHASE_CALLING, session.state.phase)
self.assertEqual(1, session.state.caller)
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 ws:
first = await ws.receive_json()
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
self.assertTrue(first["game"].get("your_turn"))
self.assertEqual(
["02D", "02C", "02S", "02B"],
first["game"]["callable_cards"],
)
await ws.send_json(
{"action": "call", "card": "02D"}
)
update = await ws.receive_json()
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
self.assertEqual("D", update["game"]["briscola_suit"])
self.assertEqual("02D", update["game"]["called_card"])
self.assertEqual(1, update["game"]["turn"])
# Bob is first to play (player after the dealer).
bob = next(
p for p in update["game"]["players"] if p["seat"] == 1
)
card = bob["hand"][0]
await ws.send_json({"action": "play", "card": card})
update = await ws.receive_json()
self.assertEqual(
[{"seat": 1, "card": card}], update["game"]["trick"]
)
@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 _create_and_join(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 seat in first["game"]["players"]:
self.assertNotIn("hand", seat)
await ws.send_json({"action": "bid", "rank": 1})
error = await ws.receive_json()
self.assertEqual("forbidden", error["code"])
class ChiamataTimeoutTest(unittest.TestCase):
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
from tavolo.briscola import chiamata as rules
session = GameSession(
id=game_id,
game_type="briscola_chiamata",
join_code=join_code,
creator_sub=PLAYERS[0],
players=[
Seat(user_sub=name, display_name=name.capitalize())
for name in PLAYERS
],
)
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
for name in PLAYERS[1:]:
rules.join_game(session.state, name, name.capitalize())
state_mutator(session.state)
await game_store.save(session)
return session.id
@async_test
async def test_auction_timeout_auto_passes(self) -> None:
game_id = await self._seed(
"chiamata-auction-timeout-1", "CH0001", lambda state: None
)
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 ws:
first = await ws.receive_json()
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
self.assertIsNotNone(first["game"]["auction_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 1 in update["game"]["auction"]["passed"]
):
break
self.assertIsNotNone(update)
assert update is not None
# The absent seat 1 passed: nobody bid and the turn
# moved to seat 2.
auction = update["game"]["auction"]
self.assertIn(1, auction["passed"])
self.assertIsNone(auction["current"])
self.assertEqual(2, auction["turn"])
@async_test
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
def seed(state) -> None:
state.phase = PHASE_AUCTION
state.auction = Auction(
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
)
state.turn = 0
state.auction_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
state.call_deadline = None
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
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 ws:
first = await ws.receive_json()
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
# The auction times out: seat 0 is forced to bid the
# ace; its call then times out too and a random ace
# suit is named.
played = None
for _ in range(20):
try:
played = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
played.get("type") == "state"
and played["game"]["phase"] == PHASE_PLAYING
):
break
self.assertIsNotNone(played)
assert played is not None
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
self.assertEqual(0, played["game"]["caller"])
card = played["game"]["called_card"]
self.assertIsNotNone(card)
self.assertTrue(card.startswith("01"))
@async_test
async def test_call_and_turn_timeouts(self) -> None:
def seed(state) -> None:
state.phase = PHASE_CALLING
state.auction = Auction(
turn=1,
active=[1],
passed=[0, 2, 3, 4],
current=Bid(seat=1, rank=2, points=61),
)
state.caller = 1
state.turn = 1
state.auction_deadline = None
state.call_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
game_id = await self._seed(
"chiamata-call-timeout-1", "CH0002", seed
)
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 ws:
first = await ws.receive_json()
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
called = None
for _ in range(20):
try:
called = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
called.get("type") == "state"
and called["game"]["phase"] == PHASE_PLAYING
):
break
self.assertIsNotNone(called)
assert called is not None
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
card = called["game"]["called_card"]
self.assertIsNotNone(card)
self.assertTrue(card.startswith("02"))
played = None
for _ in range(20):
try:
played = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
played.get("type") == "state"
and len(played["game"]["trick"]) == 1
):
break
self.assertIsNotNone(played)
assert played is not None
# Seat 1 was on turn and was auto-played for.
self.assertEqual(1, played["game"]["trick"][0]["seat"])
if __name__ == "__main__":
unittest.main()
+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))
+49 -3
View File
@@ -300,7 +300,14 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(
["scopone_scientifico", "briscola", "briscola_a_quattro"],
[
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
],
[g["id"] for g in results],
)
scopone = results[0]
@@ -309,22 +316,61 @@ 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[5]
self.assertEqual("Briscola chiamata", chiamata["name"])
self.assertTrue(chiamata["description"])
self.assertEqual(5, chiamata["min_players"])
self.assertEqual(5, chiamata["max_players"])
self.assertEqual({}, chiamata["options_schema"])
@async_test
async def test_create_defaults_game_type(self) -> None:
+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()
+240 -2
View File
@@ -12,10 +12,14 @@ import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app, platform, tortoise_mixin
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
from tavolo.briscola.state import Card
from tavolo.platform import GameSession, Seat
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
@@ -129,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:
@@ -222,5 +240,225 @@ class BriscolaStatsTest(unittest.TestCase):
self.assertEqual(2, len(results[0]["players"]))
async def _finished_chiamata_session() -> GameSession:
engine = platform.registry.require("briscola_chiamata")
session = GameSession(
id="stats-chiamata-1",
game_type=engine.id,
join_code="BC0001",
creator_sub="alice",
players=[
Seat(user_sub=name, display_name=name.capitalize())
for name in ("alice", "bob", "carol", "dave", "erin")
],
)
engine.create(session, {})
for name in ("bob", "carol", "dave", "erin"):
engine.join(session, name, name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == PHASE_AUCTION:
player = next(p for p in state.players if p.seat == state.turn)
try:
engine.handle_action(session, player.sub, "pass", {})
except IllegalMove:
engine.handle_action(session, player.sub, "bid", {"rank": 2})
elif state.phase == PHASE_CALLING:
assert state.auction is not None and state.auction.current is not None
caller = next(p for p in state.players if p.seat == state.caller)
card = Card(rank=state.auction.current.rank, suit="D")
engine.handle_action(session, caller.sub, "call", {"card": card.code})
else:
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
assert engine.is_finished(session)
return session
class ChiamataStatsTest(unittest.TestCase):
@async_test
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("briscola_chiamata")
session = await _finished_chiamata_session()
with ctx:
await save_match_result(session, engine)
self.assertEqual(1, await Match.all().count())
self.assertEqual(5, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual("briscola_chiamata", match.game_type)
summary = match.result
for key in (
"team_a_score",
"team_b_score",
"winner_team",
"declared_target",
"called_card",
"caller",
"solo",
"deals_played",
"deal_scores",
):
self.assertIn(key, summary)
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
players = await MatchPlayer.all()
roles = {p.team for p in players}
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
# Roles match the sides: exactly one caller, the rest defenders
# plus at most one partner.
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
winner_team = summary["winner_team"]
for row in players:
on_caller_side = row.team in ("Chiamante", "Socio")
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
self.assertEqual(5, len(ratings))
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},
)
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"] }
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"
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
speckles, tiled as a CSS background over the green table gradient.
stitchTiles + a filter region equal to the tile keep the seams
invisible. -->
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
</filter>
<rect width="240" height="240" filter="url(#felt)"/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

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"
}
]
}
+49 -31
View File
@@ -1,13 +1,32 @@
//! REST client for the tavolo backend. Same-origin requests carry the
//! session cookie automatically.
use crate::auth;
use crate::model::*;
use gloo_net::http::Request;
use gloo_net::http::{Request, Response};
fn server_error(status: u16) -> String {
format!("server returned {status}")
}
/// Map a failed response to an error. A `401` additionally starts the
/// login flow: the session is gone (or the caller was never logged in),
/// and the user is returned to this page after logging in.
fn check(resp: &Response) -> Result<(), String> {
if resp.status() == 401 {
auth::redirect_to_login();
return Err("login required".to_string());
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
Ok(())
}
/// Fetch the current user; `None` when unauthenticated (401).
///
/// Deliberately bypasses [`check`]: an unauthenticated answer is how the
/// app renders its logged-out state, so it must not trigger the login
/// redirect.
pub async fn me() -> Result<Option<User>, String> {
let resp = Request::get("/api/me")
.send()
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.providers)
}
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 401 {
auth::redirect_to_login();
return Err("login required".to_string());
}
if !resp.ok() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let message = body
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
if resp.status() == 404 {
return Ok(None);
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map(Some).map_err(|e| e.to_string())
}
@@ -135,9 +150,7 @@ pub async fn my_games() -> Result<Vec<MyGame>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
@@ -148,9 +161,7 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
None => "/api/me/matches?limit=10".to_string(),
};
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -160,21 +171,28 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
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())?;
if !resp.ok() {
return Err(server_error(resp.status()));
/// 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())
}
+74
View File
@@ -0,0 +1,74 @@
//! Client-side login redirect handling.
//!
//! The OIDC callback always returns the browser to the configured
//! post-login redirect (usually `/`), and the backend's login flow has no
//! per-request `next` support. So when the API answers `401`, the page the
//! user is on is saved in `sessionStorage` and restored once a session
//! exists again.
use sycamore_router::navigate_replace;
/// `sessionStorage` key holding the page to return to after login.
const RETURN_TO_KEY: &str = "tavolo.login.return_to";
fn session_storage() -> Option<web_sys::Storage> {
web_sys::window()?.session_storage().ok().flatten()
}
/// The current page (path plus query), the URL to come back to after
/// login.
pub fn current_path() -> String {
let Some(location) = web_sys::window().map(|w| w.location()) else {
return String::new();
};
format!(
"{}{}",
location.pathname().unwrap_or_default(),
location.search().unwrap_or_default()
)
}
/// Whether a path is a page worth returning to: not the login flow
/// itself, and not an absolute/protocol-relative URL.
fn is_restorable(path: &str) -> bool {
path.starts_with('/')
&& !path.starts_with("//")
&& !path.starts_with("/auth/")
&& !path.starts_with("/api/")
&& path != "/login"
}
/// Handle an expired session: remember the current page and go to the
/// login page.
///
/// The navigation is deferred to a timer so the caller can finish handling
/// the failed response (e.g. set an error) before its page is unmounted.
pub fn redirect_to_login() {
let path = current_path();
if is_restorable(&path) {
if let Some(storage) = session_storage() {
let _ = storage.set_item(RETURN_TO_KEY, &path);
}
}
gloo_timers::callback::Timeout::new(0, move || {
// Several in-flight requests may fail at once; only the first
// takes us to the login page.
if current_path() != "/login" {
navigate_replace("/login");
}
})
.forget();
}
/// The page saved by [`redirect_to_login`], if any.
pub fn saved_return_to() -> Option<String> {
let storage = session_storage()?;
let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?;
is_restorable(&path).then_some(path)
}
/// Drop the saved page, e.g. after it has been restored.
pub fn clear_return_to() {
if let Some(storage) = session_storage() {
let _ = storage.remove_item(RETURN_TO_KEY);
}
}
+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")
);
}
}
+35 -9
View File
@@ -7,14 +7,16 @@
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{is_individual_game, DealSummary, GameView};
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
use crate::ws::GameSocket;
/// Names of the two sides: the players' names for games without
/// partnerships (classic two-player briscola), "Team A"/"Team B"
/// otherwise.
/// Names of the two sides: the caller and partner for briscola chiamata,
/// the players' names for games without partnerships (classic two-player
/// briscola), "Team A"/"Team B" otherwise.
fn side_names(game: &GameView) -> (String, String) {
if is_individual_game(&game.game_type) {
if is_chiamata(&game.game_type) {
game.chiamata_sides()
} else if is_individual_game(&game.game_type) {
(
game.player_name_for_team("A"),
game.player_name_for_team("B"),
@@ -66,6 +68,25 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
"60–60: patta — the deal is replayed and its points do not count."
}
});
let called_row = if is_chiamata(&game.game_type) {
game.called_card.clone().map(|code| {
let label = card_label(&code);
let text = if game.solo {
format!("{name_a} called {label} in hand and plays alone.")
} else {
format!("{label}: its holder is the secret partner; its suit is trump.")
};
summary_row(
card_img(code, "score-mini"),
"Chiamata",
text,
"score-row chiamata-row",
label,
)
})
} else {
None
};
let points_row = summary_row(
card_img("01D".to_string(), "score-mini"),
"Deal points",
@@ -84,6 +105,7 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
});
view! {
div(class="score-rows") {
(called_row)
(points_row)
(patta_note)
(cards)
@@ -94,9 +116,13 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
/// Running totals with progress toward the target, including the points
/// gained in the deal just played (zero for a patta).
fn totals(game: &GameView, summary: &DealSummary) -> View {
// Briscola chiamata plays a single deal: there is no running total.
let Some(running) = summary.totals else {
return view! {};
};
let target = game.target_score.max(1);
let pct_a = (100 * summary.totals.a / target).min(100);
let pct_b = (100 * summary.totals.b / target).min(100);
let pct_a = (100 * running.a / target).min(100);
let pct_b = (100 * running.b / target).min(100);
let (gained_a, gained_b) = if summary.patta {
(0, 0)
} else {
@@ -111,7 +137,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(summary.totals.a) " / " (target)
(running.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
@@ -121,7 +147,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(summary.totals.b) " / " (target)
(running.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Login card, shared by the lobby's logged-out state and the `/login`
//! page.
use sycamore::prelude::*;
use crate::model::AuthProvider;
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Card offering one login button per configured provider.
///
/// `providers` is filled by the caller from `GET /api/auth/providers`;
/// while it is empty (or the fetch failed) the card falls back to the
/// generic `/auth/login` route, which redirects to the first configured
/// provider.
pub fn login_card(providers: Signal<Vec<AuthProvider>>) -> View {
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the generic
// route redirects to the first configured
// provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
}
}
+3
View File
@@ -1,6 +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);
+36 -1
View File
@@ -1,22 +1,29 @@
//! Application entry point.
mod api;
mod auth;
mod components;
mod model;
mod notify;
mod pages;
mod pwa;
mod ws;
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
use sycamore_router::{navigate, HistoryIntegration, Route, Router};
use wasm_bindgen_futures::spawn_local;
use pages::game::GamePage;
use pages::history::HistoryPage;
use pages::leaderboard::LeaderboardPage;
use pages::lobby::LobbyPage;
use pages::login::LoginPage;
#[derive(Route, Clone)]
enum AppRoutes {
#[to("/")]
Lobby,
#[to("/login")]
Login,
#[to("/game/<id>")]
Game { id: String },
#[to("/history")]
@@ -27,6 +34,31 @@ enum AppRoutes {
NotFound,
}
/// After the OIDC callback lands the SPA back on the configured
/// post-login redirect, forward the user to the page they were on before
/// being sent to the login page (see [`auth`]) — but only once a session
/// exists, so a cancelled login never bounces into a stale page.
fn restore_page_after_login() {
let Some(path) = auth::saved_return_to() else {
return;
};
spawn_local(async move {
match api::me().await {
Ok(Some(_)) => {
auth::clear_return_to();
if auth::current_path() != path {
navigate(&path);
}
}
// Not logged in: keep the target for the next successful
// login (a reload of the login page must not lose it).
Ok(None) => {}
// The probe failed (offline?): keep the target, too.
Err(_) => {}
}
});
}
fn main() {
console_error_panic_hook::set_once();
sycamore::render(|| {
@@ -38,6 +70,7 @@ fn main() {
div(class="app") {
(match route.get_clone() {
AppRoutes::Lobby => view! { LobbyPage() },
AppRoutes::Login => view! { LoginPage() },
AppRoutes::Game { id } => view! { GamePage(id=id) },
AppRoutes::History => view! { HistoryPage() },
AppRoutes::Leaderboard => view! { LeaderboardPage() },
@@ -60,4 +93,6 @@ fn main() {
)
}
});
pwa::register();
restore_page_after_login();
}
+491 -13
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)]
@@ -19,7 +21,10 @@ pub struct PlayerView {
pub sub: String,
pub name: String,
pub seat: usize,
pub team: String,
/// Game-defined role/team label; empty before briscola chiamata
/// reveals the roles.
#[serde(default)]
pub team: Option<String>,
#[serde(default)]
pub cards_left: usize,
#[serde(default)]
@@ -84,9 +89,21 @@ pub struct DealSummary {
#[serde(default)]
pub patta: bool,
pub points: Scores,
pub totals: Scores,
/// Running match totals; absent for briscola chiamata, whose match is
/// a single deal.
#[serde(default)]
pub totals: Option<Scores>,
#[serde(default)]
pub cards: Option<TeamCounts>,
/// Briscola chiamata: the points target of the played deal.
#[serde(default)]
pub declared_target: Option<i32>,
/// Briscola chiamata: the card named by the caller.
#[serde(default)]
pub called_card: Option<String>,
/// Briscola chiamata: the caller played without a partner.
#[serde(default)]
pub solo: bool,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -178,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)]
@@ -227,6 +247,83 @@ pub struct GameView {
/// Briscola: scoring breakdown of the most recent deal.
#[serde(default)]
pub last_deal: Option<DealSummary>,
/// Briscola chiamata: the calling auction of the current deal.
#[serde(default)]
pub auction: Option<AuctionView>,
/// Briscola chiamata: seat that won the auction.
#[serde(default)]
pub caller: Option<usize>,
/// Briscola chiamata: the named card; its suit is the trump.
#[serde(default)]
pub called_card: Option<String>,
/// Briscola chiamata: points the calling side must reach.
#[serde(default)]
pub declared_target: i32,
/// Briscola chiamata: the called card has been played; roles are
/// public from then on.
#[serde(default)]
pub called_played: bool,
/// Briscola chiamata: the partner's seat, once revealed.
#[serde(default)]
pub partner: Option<usize>,
/// Briscola chiamata: the caller named one of their own cards.
#[serde(default)]
pub solo: bool,
/// Briscola chiamata: legal bids for the player on turn.
#[serde(default)]
pub legal_bids: Option<LegalBids>,
/// Briscola chiamata: the cards the auction winner may name.
#[serde(default)]
pub callable_cards: Option<Vec<String>>,
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
#[serde(default)]
pub auction_deadline: Option<String>,
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
#[serde(default)]
pub call_deadline: Option<String>,
}
/// One bid of the briscola chiamata auction.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AuctionBid {
pub seat: usize,
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
pub rank: i32,
/// Points target the bid commits its side to.
#[serde(default)]
pub points: i32,
}
/// The briscola chiamata calling auction.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AuctionView {
/// Seat to act.
pub turn: usize,
#[serde(default)]
pub active: Vec<usize>,
#[serde(default)]
pub passed: Vec<usize>,
/// The standing bid, if any.
#[serde(default)]
pub current: Option<AuctionBid>,
#[serde(default)]
pub history: Vec<AuctionBid>,
}
/// The bids available to the player on turn.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LegalBids {
/// Ranks that may be bid, strongest first (empty at the point stage).
#[serde(default)]
pub ranks: Vec<i32>,
/// Lowest legal points raise, when the standing bid is the two.
#[serde(default)]
pub points_min: Option<i32>,
#[serde(default)]
pub points_max: Option<i32>,
}
impl GameView {
@@ -235,10 +332,38 @@ impl GameView {
pub fn player_name_for_team(&self, team: &str) -> String {
self.players
.iter()
.find(|p| p.team == team)
.find(|p| p.team.as_deref() == Some(team))
.map(|p| p.name.clone())
.unwrap_or_else(|| team.to_string())
}
/// Display name of the player in `seat`.
pub fn player_name_for_seat(&self, seat: usize) -> String {
self.players
.iter()
.find(|p| p.seat == seat)
.map(|p| p.name.clone())
.unwrap_or_else(|| format!("Seat {seat}"))
}
/// Side labels for briscola chiamata: the caller (with the partner's
/// name once revealed, or "(solo)") against the defenders.
pub fn chiamata_sides(&self) -> (String, String) {
let side_a = match self.caller {
Some(caller) => {
let caller_name = self.player_name_for_seat(caller);
match self.partner {
Some(partner) => {
format!("{caller_name} & {}", self.player_name_for_seat(partner))
}
None if self.called_played => format!("{caller_name} (solo)"),
None => caller_name,
}
}
None => "Chiamante".to_string(),
};
(side_a, "Difensori".to_string())
}
}
#[derive(Debug, Deserialize)]
@@ -343,6 +468,10 @@ fn default_elo() -> i32 {
1500
}
fn default_page() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardEntry {
@@ -389,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).
@@ -433,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(),
@@ -449,6 +614,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola_chiamata".to_string(),
name: "Briscola chiamata".to_string(),
description: String::new(),
min_players: 5,
max_players: 5,
options_schema: serde_json::Value::Null,
},
]
}
@@ -735,11 +908,41 @@ 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 {
game_type == "briscola_chiamata"
}
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
/// "Tre", `10` -> "Re", and so on.
pub fn rank_name(rank: i32) -> String {
match rank {
1 => "Asso".to_string(),
2 => "Due".to_string(),
3 => "Tre".to_string(),
4 => "Quattro".to_string(),
5 => "Cinque".to_string(),
6 => "Sei".to_string(),
7 => "Sette".to_string(),
8 => "Fante".to_string(),
9 => "Cavallo".to_string(),
10 => "Re".to_string(),
other => other.to_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").
@@ -936,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",
@@ -948,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},
@@ -969,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);
@@ -1003,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"));
}
@@ -1033,7 +1261,7 @@ mod tests {
assert_eq!(16, g.target_score);
assert!(!g.napola);
assert!(g.asso_pigliatutto);
assert_eq!("A", g.players[0].team);
assert_eq!(Some("A".to_string()), g.players[0].team);
}
#[test]
@@ -1245,7 +1473,10 @@ mod tests {
fn is_briscola_matches_both_variants() {
assert!(is_briscola("briscola"));
assert!(is_briscola("briscola_a_quattro"));
assert!(!is_briscola("briscola_chiamata"));
assert!(!is_briscola("scopone_scientifico"));
assert!(is_chiamata("briscola_chiamata"));
assert!(!is_chiamata("briscola"));
assert_eq!("denari", suit_name("D"));
assert_eq!("coppe", suit_name("C"));
assert_eq!("spade", suit_name("S"));
@@ -1254,9 +1485,29 @@ mod tests {
}
#[test]
fn is_individual_game_only_matches_two_player_briscola() {
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));
assert_eq!("Due", rank_name(2));
assert_eq!("Tre", rank_name(3));
assert_eq!("Fante", rank_name(8));
assert_eq!("Cavallo", rank_name(9));
assert_eq!("Re", rank_name(10));
}
#[test]
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"));
}
@@ -1318,11 +1569,19 @@ mod tests {
let types = fallback_game_types();
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
assert_eq!(
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
vec![
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
],
ids
);
// The lobby's first game (the creation default) must exist.
assert_eq!("scopone_scientifico", types[0].id);
assert_eq!(5, types[5].max_players);
}
#[test]
@@ -1400,7 +1659,7 @@ mod tests {
assert_eq!(1, deal.deal);
assert!(!deal.patta);
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
let cards = deal.cards.unwrap();
assert_eq!(19, cards.a);
assert_eq!(21, cards.b);
@@ -1420,4 +1679,223 @@ mod tests {
assert_eq!(0, d.deal);
assert!(d.cards.is_none());
}
#[test]
fn chiamata_deal_summary_has_no_totals() {
let d: DealSummary = serde_json::from_value(json!({
"deal": 2,
"patta": true,
"points": {"A": 60, "B": 60},
"declared_target": 61,
"called_card": "01D",
"caller": "Alice",
"solo": false,
}))
.unwrap();
assert!(d.patta);
assert_eq!(2, d.deal);
assert!(d.totals.is_none());
assert!(d.cards.is_none());
assert_eq!(Some(61), d.declared_target);
assert_eq!(Some("01D".to_string()), d.called_card);
assert!(!d.solo);
}
#[test]
fn game_view_parses_chiamata_auction() {
let g: GameView = serde_json::from_value(json!({
"id": "c1",
"join_code": "ABC123",
"game_type": "briscola_chiamata",
"phase": "auction",
"player_count": 5,
"dealer": 0,
"hand_number": 1,
"turn": 1,
"declared_target": 61,
"auction": {
"turn": 2,
"active": [0, 1, 2, 3, 4],
"passed": [],
"current": {"seat": 1, "rank": 10, "points": 61},
"history": [{"seat": 1, "rank": 10, "points": 61}],
},
"your_turn": true,
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
"cards_left": 8, "captured_count": 0, "points": 0},
],
"auction_deadline": "2026-01-01T00:00:30+00:00",
"call_deadline": null,
"turn_deadline": null,
}))
.unwrap();
assert!(is_chiamata(&g.game_type));
assert!(!is_briscola(&g.game_type));
let auction = g.auction.unwrap();
assert_eq!(2, auction.turn);
assert_eq!(5, auction.active.len());
let current = auction.current.unwrap();
assert_eq!(1, current.seat);
assert_eq!(10, current.rank);
assert_eq!(61, current.points);
assert_eq!(1, auction.history.len());
let bids = g.legal_bids.unwrap();
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
assert!(bids.points_min.is_none());
assert_eq!(Some(120), bids.points_max);
assert_eq!(Some("".to_string()), g.players[0].team);
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
}
#[test]
fn game_view_parses_chiamata_playing_and_reveal() {
let g: GameView = serde_json::from_value(json!({
"id": "c2",
"game_type": "briscola_chiamata",
"phase": "playing",
"player_count": 5,
"dealer": 0,
"hand_number": 1,
"turn": 3,
"caller": 1,
"called_card": "03C",
"briscola_suit": "C",
"declared_target": 64,
"called_played": true,
"partner": 3,
"solo": false,
"scores": {"A": 45, "B": 30},
"players": [
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
],
}))
.unwrap();
assert_eq!(Some(1), g.caller);
assert_eq!(Some("03C".to_string()), g.called_card);
assert_eq!("C", g.briscola_suit);
assert_eq!(64, g.declared_target);
assert!(g.called_played);
assert_eq!(Some(3), g.partner);
assert!(!g.solo);
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
}
#[test]
fn chiamata_sides_label_the_solo_caller() {
let g: GameView = serde_json::from_value(json!({
"id": "c3",
"game_type": "briscola_chiamata",
"phase": "finished",
"scores": {"A": 70, "B": 50},
"winner": "Chiamante",
"caller": 2,
"called_played": true,
"solo": true,
"players": [
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
],
}))
.unwrap();
let (a, b) = g.chiamata_sides();
assert_eq!("Carol (solo)", a);
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);
}
}
+9 -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,
};
@@ -27,10 +28,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" }
);
let name = player.name.clone();
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
let team = player.team.clone();
view! { span(class="team-badge") { "Team " (team) } }
});
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 points = player.points;
let captured = player.captured_count;
let backs = (0..player.cards_left)
@@ -120,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.
@@ -235,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);
@@ -256,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)
+533
View File
@@ -0,0 +1,533 @@
//! Briscola chiamata table: five seats, the calling auction and the
//! secret-partner reveal.
//!
//! The auction starts with the player after the dealer: each player bids
//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 >
//! 4 > 2) or passes, and once the standing bid is the two the bidding
//! continues on points. The last bidder names a card of the won rank:
//! its holder is the secret partner, or the caller plays alone when it
//! is one of their own cards, and its suit is the trump. The partner's
//! identity is only revealed when the called card is played.
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,
};
use crate::pages::game::watch_link;
use crate::ws::GameSocket;
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
fn player_name(game: &GameView, seat: usize) -> String {
player_for_seat(game, seat)
.map(|p| p.name)
.unwrap_or_else(|| format!("Seat {seat}"))
}
/// Human label of one auction bid.
fn bid_label(bid: &AuctionBid) -> String {
if bid.rank == 2 {
format!("2 at {} pts", bid.points)
} else {
rank_name(bid.rank)
}
}
/// Panel for one player seat (name, revealed role, hidden cards, points).
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();
// Role labels only appear once the server makes them public.
let role_badge = player
.team
.clone()
.filter(|role| !role.is_empty())
.map(|role| view! { span(class="team-badge role-badge") { (role) } });
let points = player.points;
let captured = player.captured_count;
let backs = (0..player.cards_left)
.map(|_| card_back("mini"))
.collect::<Vec<_>>();
view! {
div(class=cls) {
div(class="seat-name") {
(name)
(role_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(points) " points · " (captured) " cards"
}
}
}
}
/// The cards of the trick in progress, in play order, with player names.
fn trick_view(game: &GameView) -> View {
if game.trick.is_empty() {
return view! { p(class="table-empty") { "Empty table" } };
}
let cards = game
.trick
.iter()
.map(|tc| {
let name = player_name(game, tc.seat);
let card = card_img(tc.card.clone(), "table-card");
view! {
div(class="trick-slot") {
span(class="trick-name") { (name) }
(card)
}
}
})
.collect::<Vec<_>>();
view! { (cards) }
}
/// Announcement strip for the most recently completed trick.
fn last_trick_banner(game: &GameView) -> View {
let Some(trick) = game.last_trick.clone() else {
return view! {};
};
let name = player_name(game, trick.winner);
let cards = trick
.cards
.iter()
.map(|tc| card_img(tc.card.clone(), "move-card"))
.collect::<Vec<_>>();
view! {
div(class="move-banner") {
strong { (name) }
span { " takes the trick " }
(cards)
}
}
}
/// "Patta — the deal is replayed" after a 60-60 deal.
fn patta_banner(game: &GameView) -> View {
let patta = game
.last_deal
.as_ref()
.is_some_and(|deal| deal.patta);
if !patta || game.phase != "auction" {
return view! {};
}
view! {
div(class="move-banner patta-banner") {
strong { "Patta!" }
span { " 60–60 — the deal is replayed." }
}
}
}
/// Announcement of the partner (or the solo caller) once the called card
/// has hit the table.
fn reveal_banner(game: &GameView) -> View {
if !game.called_played || game.phase == "auction" || game.phase == "calling" {
return view! {};
}
let caller = game
.caller
.map(|seat| player_name(game, seat))
.unwrap_or_default();
let text = if game.solo {
format!("{caller} is playing alone")
} else {
let partner = game
.partner
.map(|seat| player_name(game, seat))
.unwrap_or_default();
format!("{partner} is the partner of {caller}")
};
view! {
div(class="move-banner reveal-banner") {
span(class="reveal-text") { (text) }
}
}
}
/// The calling auction panel: standing bid, trail and, on turn, the bids.
fn auction_panel(
game: &GameView,
on_bid_rank: impl Fn(i32) + Copy + 'static,
on_bid_points: impl Fn(i32) + Copy + 'static,
on_pass: impl Fn() + Copy + 'static,
) -> View {
let Some(auction) = game.auction.clone() else {
return view! {};
};
let standing = match &auction.current {
Some(bid) => format!(
"Standing bid: {} — {}",
player_name(game, bid.seat),
bid_label(bid)
),
None => "No bids yet".to_string(),
};
let waiting_for = player_name(game, auction.turn);
let history = auction
.history
.iter()
.map(|bid| {
let text = format!("{}: {}", player_name(game, bid.seat), bid_label(bid));
view! { span(class="auction-chip") { (text) } }
})
.collect::<Vec<_>>();
let actions = if game.your_turn == Some(true) {
let legal = game.legal_bids.clone();
let (rank_buttons, point_buttons) = match legal {
Some(bids) => {
let ranks = bids
.ranks
.iter()
.map(|rank| {
let rank = *rank;
let name = rank_name(rank);
view! {
button(
class="bid-button",
on:click=move |_| on_bid_rank(rank),
) { (name) }
}
})
.collect::<Vec<_>>();
let points = match (bids.points_min, bids.points_max) {
(Some(min), Some(max)) => (0..=2)
.map(|step| min + 2 * step)
.filter(|points| *points <= max)
.map(|points| {
view! {
button(
class="bid-button points",
on:click=move |_| on_bid_points(points),
) { (points) " pts" }
}
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
(ranks, points)
}
None => (Vec::new(), Vec::new()),
};
view! {
div(class="bid-options") {
(rank_buttons)
(point_buttons)
button(class="pass-button", on:click=move |_| on_pass()) { "Pass" }
}
}
} else {
view! {
p(class="text-secondary small mb-0") {
"Waiting for " (waiting_for) " to bid…"
}
}
};
view! {
div(class="auction-panel") {
div(class="auction-standing") { (standing) }
div(class="auction-history") { (history) }
(actions)
}
}
}
/// The main briscola chiamata table.
pub fn chiamata_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
on_bid_rank: impl Fn(i32) + Copy + 'static,
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.
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat)
.unwrap_or(0);
let my_turn = game.your_turn == Some(true);
let can_play = game.phase == "playing" && my_turn;
let turn_note = if game.phase == "finished" {
"Match finished".to_string()
} else if game.phase == "auction" {
if my_turn {
"Your bid".to_string()
} else {
let name = game
.auction
.as_ref()
.map(|a| player_name(&game, a.turn))
.unwrap_or_default();
format!("{name} to bid")
}
} else if game.phase == "calling" {
if my_turn {
"Name the called card".to_string()
} else {
let name = game
.caller
.map(|seat| player_name(&game, seat))
.unwrap_or_default();
format!("{name} is calling")
}
} else if my_turn {
"Your turn".to_string()
} else {
format!("{}'s turn", player_name(&game, game.turn))
};
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
let deadline = if game.phase == "auction" {
game.auction_deadline.clone()
} else if game.phase == "calling" {
game.call_deadline.clone()
} else {
game.turn_deadline.clone()
};
let countdown = deadline.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 in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let score_line = match game.scores {
Some(scores) => {
let (side_a, side_b) = game.chiamata_sides();
format!(
"{side_a} {} — {} {side_b} (target {})",
scores.a, scores.b, game.declared_target
)
}
// Before the called card is played the side totals would leak the
// partner's identity; only the target is shown.
None => format!("Target {}", game.declared_target),
};
// Four opponents around the table: next player on the left, then
// clockwise up to the player before us on the right.
let left = seat_panel(game.clone(), (viewer_seat + 1) % 5, "left");
let top_left = seat_panel(game.clone(), (viewer_seat + 2) % 5, "tl");
let top_right = seat_panel(game.clone(), (viewer_seat + 3) % 5, "tr");
let right = seat_panel(game.clone(), (viewer_seat + 4) % 5, "right");
let patta = patta_banner(&game);
let reveal = reveal_banner(&game);
let center_content = match game.phase.as_str() {
"auction" => auction_panel(&game, on_bid_rank, on_bid_points, on_pass),
"calling" => {
let caller = game
.caller
.map(|seat| player_name(&game, seat))
.unwrap_or_default();
view! {
div(class="auction-panel") {
div(class="auction-standing") { (caller) " won the auction" }
p(class="text-secondary small mb-0") {
"Choosing the called card…"
}
}
}
}
_ => {
let banner = last_trick_banner(&game);
let trick = trick_view(&game);
view! {
(banner)
div(class="table-cards") { (trick) }
}
}
};
let viewer = player_for_seat(&game, viewer_seat);
let my_points = viewer.as_ref().map(|p| p.points).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");
let click_code = code.clone();
view! {
button(class="hand-slot", on:click=move |_| {
if can_play {
on_hand_card(click_code.clone());
}
}) { (card_view) }
}
})
.collect::<Vec<_>>();
let hint = if can_play {
Some(view! {
p(class="text-secondary small text-center") {
"Click a card to play it."
}
})
} else if game.phase == "auction" {
Some(view! {
p(class="text-secondary small text-center") {
"Bid or pass, then play the cards."
}
})
} else if game.phase == "calling" && my_turn {
Some(view! {
p(class="text-secondary small text-center") {
"Name a card: its holder will be your secret partner."
}
})
} else {
None
};
// Spectators have no hand: render the pivot seat as a regular panel so
// its points 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_points) " points" }
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 watch_link = watch_link(&game.id);
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
let suit = suit_name(&game.briscola_suit);
view! { span(class="briscola-badge") { "Briscola: " (suit) } }
});
let called_badge = game.called_card.clone().map(|code| {
let label = card_label(&code);
view! { span(class="called-badge") { "Chiamata: " (label) } }
});
let deal = game.hand_number;
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Deal " (deal) }
(trump_badge)
(called_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="table-grid cinque") {
(top_left)
(top_right)
(left)
div(class="center") {
(patta)
(reveal)
(center_content)
}
(right)
(bottom)
}
}
}
}
/// Overlay for the auction winner: name the called card.
///
/// The winning bid fixes the rank, so the choice is which of the four
/// suits to call: its holder becomes the secret partner, unless the
/// caller holds it too — then that option means playing alone.
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
return view! {};
};
let rank = cards
.first()
.and_then(|code| code.get(..2))
.and_then(|rank| rank.parse::<i32>().ok())
.map(rank_name)
.unwrap_or_default();
let title = if rank.is_empty() {
"Name the called card".to_string()
} else {
format!("You won with the {rank} — name the card")
};
let own_hand = game
.players
.iter()
.find(|p| p.hand.is_some())
.and_then(|p| p.hand.clone())
.unwrap_or_default();
let options = cards
.into_iter()
.map(|code| {
let held = own_hand.contains(&code);
let label = card_label(&code);
let img = card_img(code.clone(), "call-card");
let note = if held {
view! { span(class="call-note solo") { "Plays alone" } }
} else {
view! { span(class="call-note partner") { "Partner" } }
};
let click_code = code.clone();
view! {
button(class="call-option", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.call_card(&click_code);
}
}) {
(img)
span(class="call-label") { (label) }
(note)
}
}
})
.collect::<Vec<_>>();
view! {
div(class="overlay") {
div(class="picker call-picker") {
h3 { (title) }
p(class="text-secondary small") {
"Its holder becomes your secret partner; name one of your "
"own cards to play alone."
}
div(class="call-options") { (options) }
}
}
}
}
+132 -22
View File
@@ -9,13 +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_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>>) {
@@ -43,7 +47,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" }
);
let name = player.name.clone();
let team = player.team.clone();
let team = player.team.clone().unwrap_or_default();
let captured = player.captured_count;
let scope = player.scope;
let backs = (0..player.cards_left)
@@ -64,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"
@@ -94,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>>,
@@ -109,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.
@@ -123,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() {
@@ -132,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.
@@ -149,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() {
@@ -164,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 {
@@ -176,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());
}
}
};
@@ -279,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,
@@ -291,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.
@@ -324,6 +367,32 @@ pub fn GamePage(id: String) -> View {
}
};
// Briscola chiamata: auction and call actions.
let on_bid_rank = move |rank: i32| {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.bid_rank(rank);
}
};
let on_bid_points = move |points: i32| {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.bid_points(points);
}
};
let on_pass = move || {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.pass();
}
};
let reconnect_banner = reconnect.clone();
view! {
div(class="game-page") {
@@ -392,22 +461,54 @@ pub fn GamePage(id: String) -> View {
}
}
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// 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,
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),
})
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
// Briscola chiamata: the auction winner names the called card.
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
Some(g)
if is_chiamata(&g.game_type)
&& g.phase == "calling"
&& g.your_turn == Some(true) =>
{
call_picker(g, socket)
}
_ => view! {},
})
(move || match game.get_clone() {
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) => {
@@ -440,10 +541,11 @@ fn LobbyView(game: GameView) -> View {
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team_badge = show_teams.then(|| {
let team = p.team.clone();
view! { span(class="badge text-bg-info") { "Team " (team) } }
});
let team_badge = show_teams
.then(|| p.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
view! {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
@@ -553,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.
@@ -660,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! {
@@ -676,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)
@@ -734,8 +839,10 @@ fn capture_picker(
/// End-of-match overlay: the final hand/deal breakdown and the winner.
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 {
if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa {
return view! {};
}
}
@@ -748,19 +855,22 @@ 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) => {
g.player_name_for_team(&winner)
}
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
_ => format!("Team {winner}"),
};
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)
// scoring before the result.
let final_summary: View = if briscola {
// 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() {
Some(deal) => deal_rows(g, &deal),
@@ -769,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! {},
}
};
+177 -49
View File
@@ -12,34 +12,58 @@ use crate::api;
use crate::components::toast::toast;
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
/// Distinct team labels in seat order; players without a team render in
/// a shared "Players" column.
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
let mut teams = BTreeSet::new();
/// The match's teams, each as a `(label, names)` pair, in a readable
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
/// then other team labels alphabetically (A, B, ...), with team-less
/// players last.
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
for p in &m.players {
teams.insert(p.team.clone());
labels.insert(p.team.clone());
}
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
// Seated teams first (in first-seat order), then the team-less column.
ordered.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
ordered.sort_by_key(team_sort_key);
ordered
.into_iter()
.map(|label| (team_header(&label), team_names(m, &label)))
.collect()
}
/// Sort key for team labels: chiamata roles in play order, other labels
/// alphabetically, team-less players last.
fn team_sort_key(label: &Option<String>) -> (u8, String) {
match label.as_deref() {
Some("Chiamante") => (0, String::new()),
Some("Socio") => (1, String::new()),
Some("Difensori") => (2, String::new()),
Some(other) => (0, other.to_string()),
None => (9, String::new()),
}
}
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
m.players
let names = m
.players
.iter()
.filter(|p| &p.team == team)
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ")
.collect::<Vec<_>>();
match names.len() {
0 => String::new(),
1 => names[0].clone(),
_ => {
let (last, rest) = names.split_last().expect("non-empty");
format!("{} & {}", rest.join(", "), last)
}
}
}
fn team_header(team: &Option<String>) -> String {
match team {
Some(t) => format!("Team {t}"),
// Single-letter labels are the A/B team names; longer labels
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
Some(t) if t.len() <= 2 => format!("Team {t}"),
Some(t) => t.clone(),
None => "Players".to_string(),
}
}
@@ -55,7 +79,11 @@ fn score_text(m: &MatchSummary) -> String {
/// "Team A" from `winner_team`, a plain `winner`, or "—".
fn winner_text(m: &MatchSummary) -> String {
if let Some(team) = m.result_str("winner_team") {
return format!("Team {team}");
return if team.len() <= 2 {
format!("Team {team}")
} else {
team
};
}
m.result_str("winner").unwrap_or_else(|| "—".to_string())
}
@@ -98,18 +126,29 @@ pub fn HistoryPage() -> View {
spawn_local(async move {
match api::my_matches(next.as_deref()).await {
Ok(p) => {
// The page can unmount (e.g. the login redirect) while
// the request is in flight.
if !page.is_alive() {
return;
}
cursor.set(p.next_cursor.clone());
rows.update(|acc| acc.extend(p.results.iter().cloned()));
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
Err(e) => {
if error.is_alive() {
error.set(Some(e));
}
}
}
});
};
spawn_local(async move {
if let Ok(types) = api::game_types().await {
game_types.set(types);
if game_types.is_alive() {
game_types.set(types);
}
}
});
@@ -117,11 +156,18 @@ pub fn HistoryPage() -> View {
spawn_local(async move {
match api::my_matches(None).await {
Ok(p) => {
if !page.is_alive() {
return;
}
cursor.set(p.next_cursor.clone());
rows.set(p.results.clone());
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
Err(e) => {
if error.is_alive() {
error.set(Some(e));
}
}
}
});
@@ -150,10 +196,18 @@ pub fn HistoryPage() -> View {
.map(|m| {
let finished = local_finished(&m.finished_at);
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
let teams: Vec<View> = team_groups(&m)
.into_iter()
.map(|(label, names)| {
view! {
div(class="match-team") {
span(class="text-secondary") {
(label) ": "
}
(names)
}
}
})
.collect();
let score = score_text(&m);
let winner = winner_text(&m);
@@ -173,37 +227,18 @@ pub fn HistoryPage() -> View {
} else {
"text-secondary"
};
let mut row_cells = vec![
let row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
view! { td { (teams) } },
view! { td { (score) } },
view! { td { (winner) } },
view! { td(class=cls) { (outcome) } },
view! { td(class=cls) { (elo_delta) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
@@ -211,7 +246,7 @@ pub fn HistoryPage() -> View {
tr {
th { "Finished" }
th { "Game" }
(header_cells)
th { "Teams" }
th { "Score" }
th { "Winner" }
th { "You" }
@@ -236,3 +271,96 @@ pub fn HistoryPage() -> View {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn match_summary(players: serde_json::Value) -> MatchSummary {
serde_json::from_value(json!({
"id": "m1",
"started_at": "2026-01-01T00:00:00+00:00",
"finished_at": "2026-01-01T01:00:00+00:00",
"players": players,
}))
.unwrap()
}
#[test]
fn team_groups_order_chiamata_roles() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "Difensori", "won": false},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "Chiamante", "won": true},
{"user_sub": "c", "display_name": "Carol", "seat": 2,
"team": "Difensori", "won": false},
{"user_sub": "d", "display_name": "Dave", "seat": 3,
"team": "Socio", "won": true},
{"user_sub": "e", "display_name": "Erin", "seat": 4,
"team": "Difensori", "won": false},
]));
assert_eq!(
vec![
("Chiamante".to_string(), "Bob".to_string()),
("Socio".to_string(), "Dave".to_string()),
(
"Difensori".to_string(),
"Alice, Carol & Erin".to_string()
),
],
team_groups(&m)
);
}
#[test]
fn team_groups_keep_a_and_b_in_order() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "A", "won": true},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "B", "won": false},
]));
assert_eq!(
vec![
("Team A".to_string(), "Alice".to_string()),
("Team B".to_string(), "Bob".to_string()),
],
team_groups(&m)
);
}
#[test]
fn team_groups_put_teamless_players_last() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": null, "won": false},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "A", "won": true},
]));
assert_eq!(
vec![
("Team A".to_string(), "Bob".to_string()),
("Players".to_string(), "Alice".to_string()),
],
team_groups(&m)
);
}
#[test]
fn team_names_join_three_names_readably() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "A", "won": true},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "A", "won": true},
{"user_sub": "c", "display_name": "Carol", "seat": 2,
"team": "A", "won": true},
]));
assert_eq!(
"Alice, Bob & Carol",
team_names(&m, &Some("A".to_string()))
);
}
}
+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)
);
}
}
+64 -85
View File
@@ -12,22 +12,15 @@ 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;
use crate::model::{
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
MyGame, OptionField, OptionKind, PlayerRating, User,
};
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
@@ -386,18 +379,30 @@ pub fn LobbyPage() -> View {
if me.is_some() {
spawn_local(async move {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
if ratings.is_alive() {
ratings.set(p.results);
}
}
if let Ok(games) = api::my_games().await {
my_games.set(games);
if my_games.is_alive() {
my_games.set(games);
}
}
});
}
user.set(Some(me));
// The page may have been unmounted (e.g. by the login
// redirect triggered while this request was in flight).
if user.is_alive() {
user.set(Some(me));
}
}
Err(e) => {
error.set(Some(e));
user.set(Some(None));
if error.is_alive() {
error.set(Some(e));
}
if user.is_alive() {
user.set(Some(None));
}
}
}
});
@@ -405,6 +410,9 @@ pub fn LobbyPage() -> View {
spawn_local(async move {
match api::game_types().await {
Ok(types) if !types.is_empty() => {
if !game_types.is_alive() {
return;
}
selected_game.set(types[0].id.clone());
game_types.set(types);
}
@@ -414,7 +422,9 @@ pub fn LobbyPage() -> View {
spawn_local(async move {
if let Ok(list) = api::auth_providers().await {
auth_providers.set(list);
if auth_providers.is_alive() {
auth_providers.set(list);
}
}
});
@@ -470,73 +480,34 @@ pub fn LobbyPage() -> View {
}
}
},
Some(None) => view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = auth_providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the
// generic route redirects to the first
// configured provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
},
Some(None) => view! { (login_card(auth_providers)) },
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",
@@ -551,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
@@ -671,13 +642,21 @@ pub fn LobbyPage() -> View {
div(class="mb-3") { (players_note) }
}
}))
Keyed(
list=fields,
view=move |f| view! {
OptionInput(field=f.clone(), options=options)
},
key=|f| f.key.clone(),
)
// Recreate the option inputs on every
// game switch: keyed rendering kept
// the previous game's field (value,
// min/max) for shared keys such as
// target_score.
(move || {
let _game = selected_game.get_clone();
fields
.get_clone()
.into_iter()
.map(|f| view! {
OptionInput(field=f, options=options)
})
.collect::<Vec<_>>()
})
button(
class="btn btn-warning btn-lg w-100 mt-auto",
on:click=on_create,
+41
View File
@@ -0,0 +1,41 @@
//! Login page, shown after the API answered `401` (see [`crate::auth`]):
//! the session expired, or the visitor tried to open a page that needs an
//! account.
//!
//! The backend's OIDC callback lands the SPA back on the configured
//! post-login redirect; [`crate::auth`] then returns the user to the page
//! they were on before this one.
use sycamore::prelude::*;
use sycamore_router::navigate_replace;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::auth;
use crate::components::login::login_card;
use crate::model::AuthProvider;
#[component]
pub fn LoginPage() -> View {
let providers = create_signal(Vec::<AuthProvider>::new());
spawn_local(async move {
// Already signed in: no business here — return to where the user
// was headed, or the lobby.
if matches!(api::me().await, Ok(Some(_))) {
let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string());
auth::clear_return_to();
navigate_replace(&target);
return;
}
if let Ok(list) = api::auth_providers().await {
// The page can unmount while the fetch is in flight.
if providers.is_alive() {
providers.set(list);
}
}
});
view! {
(login_card(providers))
}
}
+3
View File
@@ -1,5 +1,8 @@
pub mod briscola;
pub mod chiamata;
pub mod game;
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;
});
}
+20
View File
@@ -45,6 +45,26 @@ impl GameSocket {
self.send_json(serde_json::json!({ "action": "ack" }));
}
/// Briscola chiamata: bid a rank in the calling auction.
pub fn bid_rank(&self, rank: i32) {
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
}
/// Briscola chiamata: raise the points target (standing bid is the 2).
pub fn bid_points(&self, points: i32) {
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
}
/// Briscola chiamata: leave the auction.
pub fn pass(&self) {
self.send_json(serde_json::json!({ "action": "pass" }));
}
/// Briscola chiamata: name the called card.
pub fn call_card(&self, card: &str) {
self.send_json(serde_json::json!({ "action": "call", "card": card }));
}
fn send_json(&self, value: serde_json::Value) {
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
}
+224 -3
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 {
@@ -292,8 +301,12 @@ body {
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
/* Tall dialogs (the chiamata call picker, the scoring summaries) can
* exceed short windows: allow scrolling instead of clipping them. */
overflow-y: auto;
padding: 1rem;
z-index: 20;
}
@@ -302,8 +315,11 @@ body {
border-radius: 14px;
padding: 1.5rem 2rem;
text-align: center;
min-width: min(320px, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem);
/* Centers the dialog when it fits and keeps it reachable (top
* aligned, scrollable via the overlay) when it does not. */
margin: auto;
min-width: min(320px, calc(100vw - 3rem));
max-width: calc(100vw - 3rem);
}
.capture-options {
@@ -667,3 +683,208 @@ body {
gap: 0.4rem;
}
}
/* ---------- briscola chiamata ---------- */
/* Five seats: two across the top and one on each side of the viewer. */
.table-grid.cinque {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
grid-template-areas:
"tl . tr"
"left center right"
"bottom bottom bottom";
}
.seat-tl {
grid-area: tl;
}
.seat-tr {
grid-area: tr;
}
.called-badge {
background: var(--panel-light);
color: var(--accent);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--accent);
}
.role-badge {
background: var(--accent);
color: var(--felt-dark);
}
.reveal-banner {
background: rgba(232, 197, 71, 0.15);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.35rem 0.75rem;
}
.patta-banner {
background: rgba(217, 83, 79, 0.25);
}
/* The calling auction: standing bid, trail and the bid buttons. */
.auction-panel {
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
max-width: 32rem;
}
.auction-standing {
font-weight: 600;
text-align: center;
}
.auction-history {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
justify-content: center;
}
.auction-chip {
background: var(--panel-light);
color: var(--muted);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
}
.bid-options {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
}
.bid-button {
background: var(--panel-light);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.bid-button:hover {
background: var(--accent);
color: var(--felt-dark);
}
.bid-button.points {
border-color: var(--team-a);
}
.pass-button {
background: transparent;
color: var(--muted);
border: 1px solid var(--muted);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.pass-button:hover {
color: var(--text);
border-color: var(--text);
}
/* The auction winner's call picker: the four cards of the won rank,
* laid out in a compact grid so the dialog stays short. */
.call-picker {
max-width: min(34rem, calc(100vw - 3rem));
}
.call-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin: 1rem 0 0;
}
@media (min-width: 576px) {
.call-options {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.call-option {
background: var(--panel-light);
border: 1px solid #4c6a54;
border-radius: 10px;
padding: 0.4rem 0.3rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.call-option:hover {
border-color: var(--accent);
}
/* Smaller than a hand card: the picker must fit short laptop windows. */
.playing-card.call-card {
height: 84px;
margin: 0 auto;
}
.call-label {
display: block;
font-size: 0.8rem;
color: var(--text);
margin-top: 0.15rem;
}
.call-note {
display: block;
font-size: 0.7rem;
}
.call-note.partner {
color: var(--team-a);
}
.call-note.solo {
color: var(--accent);
}
.chiamata-row .score-title {
color: var(--accent);
}
@media (max-width: 576px) {
.table-grid.cinque {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"tl tr"
"left right"
"center center"
"bottom bottom";
}
.auction-panel {
padding: 0.5rem;
max-width: 100%;
}
}
/* ---------- match history ---------- */
/* One line per team inside the "Teams" cell of the history table. */
.match-team {
font-size: 0.85rem;
line-height: 1.4;
}
+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));
});
+21 -5
View File
@@ -40,11 +40,27 @@
body {
min-height: 100vh;
background-color: var(--felt-dark);
background-image: radial-gradient(
ellipse at 50% -10%,
var(--felt) 0%,
var(--felt-dark) 65%
);
/* Casino-table felt: a fine grain texture tiled over the green
* gradient, lit from above and darkened towards the edges. The first
* layer is the topmost one. */
background-image:
radial-gradient(
ellipse at 50% 45%,
transparent 55%,
rgba(0, 0, 0, 0.38) 100%
),
radial-gradient(
ellipse at 50% -10%,
rgba(255, 255, 255, 0.07),
transparent 60%
),
url("/assets/felt.svg"),
radial-gradient(
ellipse at 50% -10%,
var(--felt) 0%,
var(--felt-dark) 65%
);
background-repeat: no-repeat, no-repeat, repeat, no-repeat;
background-attachment: fixed;
color: var(--text);
}