Compare commits

..
23 Commits
Author SHA1 Message Date
woggioni 2d5c64218f Migrate the backend from pip to uv
CI / Build and push docker image (push) Successful in 2m42s
Replace requirements.txt with a uv workspace and lockfile, install the five tavolo packages from packages/* in development and non-editable in the image, and build the Docker image with uv. Move dev tools to a PEP 735 dependency group and refresh the locked dependency versions.
2026-10-20 07:25:25 +08:00
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
woggioni d2dae476f0 Prefer preferred_username for the displayed player name
CI / Build and push docker image (push) Successful in 1m8s
The free-form "name" claim can change and two accounts can share it;
preferred_username is the account's stable handle. display_name() now
falls back preferred_username -> name -> email -> sub, with tests
pinning the claim priority.
2026-10-06 17:53:27 +08:00
woggioni 18877b8109 Offer a Continue link for ongoing games in the lobby
A player who accidentally closes the game page can now get back to it
from the lobby.

Backend:
- GameStore.list_ongoing() returns live unfinished sessions (newest
  first), skipping expired keys and finished matches;
- GET /api/me/games lists the games the caller is seated in, with the
  game name/description, players, open seats and your seat (no join
  code);
- store, platform route and app integration tests.

Frontend:
- MyGame/MyGamesPage model plus api::my_games();
- the lobby shows a "Your games" card with phase, seats, players and a
  Continue button linking to /game/<id>; hidden when empty or logged
  out.
2026-10-06 17:41:44 +08:00
woggioni b821274f0b Use player names in two-player briscola
Classic briscola has no partnerships: its "teams" are just the two
players, but the UI labelled them Team A/Team B.

- add is_individual_game() and GameView::player_name_for_team() to the
  model;
- the briscola table HUD reads "Alice 0 — 0 Bob (target 61)" and the
  seat panels drop the Team badge for two-player games;
- the deal summary and game-over screen name the sides and the winner;
- the waiting room and join preview hide the Team badge.

briscola a quattro and scopone keep their team labels, and the match
history table is unchanged.
2026-10-06 17:37:55 +08:00
woggioni 6222e38da3 Make the game table usable on phones
CI / Build and push docker image (push) Successful in 1m3s
The game page was wider than the viewport on mobile (528px at 390px).

- HUD: wrap its items instead of one non-wrapping row, which clipped the
  left items and pushed the page wider than the screen.
- Table grid: use minmax(0, ...) tracks and allow the seat panels and
  centre to shrink; wrap the seat name and move banner; under 576px trim
  paddings/gaps, shrink the cards and let the hidden-hand backs wrap.
- Deal summary and pickers: cap their width at the viewport so the
  420px/320px minimums no longer overflow narrow screens.
- Stock pile: the card backs overlapped so much they collapsed into a
  single card; reduce the negative margin so the stack reads as a stack.
2026-10-06 16:56:01 +08:00
woggioni 97d4b3f217 Scope the leaderboard to a selectable game
Elo ratings are stored per game type, so the leaderboard now always
requests /api/leaderboard?game_type=<id> and shows only that game's
players, Elo, wins, matches and points instead of mixing all games'
aggregates with the default game's rating.

- api::leaderboard takes the game type;
- a "Game" dropdown next to the heading lists every registered game and
  reloads the board on change (stale in-flight responses are dropped);
- move fallback_game_types() from the lobby to model.rs so both the
  lobby and leaderboard selectors work when /api/game-types fails.
2026-10-06 16:55:51 +08:00
woggioni 7bb6b74fdc Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s
New tavolo-briscola distribution implementing briscola on the platform:

- pure rules (state.py, engine.py): 3-card deals, a face-up trump that
  is the last card drawn, trump-first trick resolution (A > 3 > K > H >
  J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a
  configurable target score (default 61), deal-end acknowledgements and
  turn auto-play;
- BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine
  (briscola_a_quattro, 4 players in fixed partnerships) adapters;
- registered in tavolo-app, packaged in the Docker image, documented.

Platform fixes surfaced by a two-player lobby:

- the websocket state and snapshot payloads now carry seats_open (they
  were only in the REST lobby payload), so lobbies with player counts
  other than four render correctly; seats_open is clamped at zero;
- the web client logs unparseable server messages instead of silently
  dropping them.

Web client: briscola table for both variants (stock, face-up trump,
trick, last-trick banner), deal summary modal and game-over overlay.

Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
2026-10-06 14:08:25 +08:00
woggioni b5219062f2 Let anyone with the game URL watch as a read-only spectator
CI / Build and push docker image (push) Successful in 3m0s
Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.

Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
  spectators; only unknown games are refused. The state payload carries
  spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
  (player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
  and are documented to hide privileged information for non-seated
  viewers.

Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
  captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
  the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
  the game-over overlay hides "My matches".

Tests and docs updated.
2026-10-06 12:51:53 +08:00
203 changed files with 28664 additions and 17237 deletions
+6 -6
View File
@@ -7,13 +7,13 @@
.env.example
**/.venv/
**/__pycache__/
*.pyc
*.pyo
*.egg-info/
.mypy_cache/
.pytest_cache/
**/*.pyc
**/*.pyo
**/*.egg-info/
**/.mypy_cache/
**/.pytest_cache/
web/target/
web/dist/
server/target/
server/tests/
docker-compose.yml
opencode.json
+88 -28
View File
@@ -1,20 +1,27 @@
# tavolo
A platform for multiplayer card games. The first game is **scopone
scientifico** — the four-player, fixed-partnership Italian card game:
A platform for multiplayer card games. Four games are included:
**scopone scientifico** — the four-player, fixed-partnership Italian card
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: Rust + [Axum](https://github.com/tokio-rs/axum),
OIDC login, live games in Redis, match statistics in Postgres. A Cargo
workspace of three crates: the game-independent `tavolo-platform`, the
scopone game `tavolo-scopone`, and the `tavolo-app` composition root
(the `tavolo` binary: `serve`, `migrate`, `backfill-elo`).
See [server/README.md](server/README.md).
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres.
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
@@ -37,36 +44,87 @@ 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**: every player sees how each category was won
(carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled)
with 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
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 (randomizing among the legal captures when the
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. 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/`):
Backend (from `server/`), using [uv](https://docs.astral.sh/uv/):
```sh
cd server
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # serves on :8080 (Postgres required)
cargo run -p tavolo-app -- migrate # apply the embedded sqlx migrations
uv sync --package tavolo-app --extra otel
uv run python -m unittest discover -s tests -t .
uv run python -m unittest discover -s packages/tavolo-platform/tests
uv run python -m unittest discover -s packages/tavolo-scopone/tests
uv run python -m unittest discover -s packages/tavolo-scopa/tests
uv run python -m unittest discover -s packages/tavolo-briscola/tests
```
The Redis- and Postgres-backed tests run when `TEST_REDIS_URL` /
`TEST_DATABASE_URL` point at throwaway services (e.g. from
`docker compose up postgres redis` plus the port mappings) and are skipped
otherwise.
The `otel` extra is optional; without it the telemetry integration tests
skip. Development tools (`mypy`, `httpx-ws`) are installed by default from
the workspace's `dev` dependency group.
Frontend (from `web/`), with the backend running on :8080:
@@ -86,8 +144,10 @@ OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
uv run granian --host 127.0.0.1 --port 8080 tavolo.app:app
```
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.
+11 -12
View File
@@ -13,10 +13,9 @@
#
# Reachability notes:
# - The OIDC issuer URL must resolve from the browser AND from the pods
# (the server discovers the provider lazily on the first login, so
# startup succeeds even when the issuer is unreachable, but logins then
# fail). If the provider is internal-only, you need split DNS or a public
# issuer URL.
# (kaya does discovery lazily on the first login, so startup succeeds
# even when the issuer is unreachable, but logins then fail). If the
# provider is internal-only, you need split DNS or a public issuer URL.
# - The Postgres NetworkPolicy (if any) must allow ingress from the
# tavolo namespace.
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
@@ -62,8 +61,7 @@ data:
GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_TIMEOUT_SECONDS: "30"
# CORS (Starlette-compatible middleware). Disabled unless
# CORS_ALLOW_ORIGINS or
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
# served from the same origin. See server/.env.example for details.
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
@@ -73,9 +71,9 @@ data:
# CORS_ALLOW_CREDENTIALS: "false"
# CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600"
# OpenTelemetry (compiled into the image): traces (HTTP/WebSocket),
# process + tavolo metrics and logs via OTLP/HTTP, disabled unless
# OTEL_ENABLED is set.
# OpenTelemetry (kaya-otel + contrib instrumentations): traces
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
# disabled unless OTEL_ENABLED is set. The image bundles the otel extra.
# OTEL_ENABLED: "true"
# OTEL_SERVICE_NAME: "tavolo"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
@@ -202,7 +200,7 @@ spec:
---
# The app (backend + compiled SPA + WebSocket endpoint, one image).
# The "migrate" initContainer runs `tavolo migrate` before the app starts,
# The "migrate" initContainer runs `aerich upgrade` before the app starts,
# so this single file can be applied safely in any order. Migrations are
# idempotent, but if you ever scale above 1 replica, move this to a Job to
# avoid concurrent upgrades.
@@ -235,8 +233,9 @@ spec:
initContainers:
- name: migrate
image: gitea.woggioni.net/woggioni/tavolo:latest
# sqlx migrations are embedded in the binary.
command: ["tavolo", "migrate"]
# aerich reads [tool.aerich] from pyproject.toml in /app.
command: ["aerich", "upgrade"]
workingDir: /app
envFrom:
# Migrations need the non-secret DATABASE_* parts too.
- configMapRef:
+11 -7
View File
@@ -61,14 +61,16 @@ services:
timeout: 3s
retries: 10
# One-shot: applies pending sqlx migrations before the app starts (the
# migrations are embedded in the image; build cache makes the second build
# of the same Dockerfile instant).
# One-shot: applies pending aerich migrations before the app starts
# (build cache makes the second build of the same Dockerfile instant).
db-migrate:
build:
context: .
dockerfile: server/Dockerfile
command: ["tavolo", "migrate"]
# aerich reads [tool.aerich] from /app/pyproject.toml (default config
# file) and finds migrations in ./migrations relative to working_dir.
working_dir: /app
command: ["aerich", "upgrade"]
environment:
DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
@@ -122,9 +124,11 @@ services:
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
# OpenTelemetry (compiled into the image): traces (HTTP/WebSocket),
# metrics (process + tavolo gauges) and logs via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set.
# OpenTelemetry (kaya-otel + contrib instrumentations): traces
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
# disabled unless OTEL_ENABLED is set.
# The image bundles the otel extra (see packages/tavolo-app/pyproject.toml
# and server/uv.lock).
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+10 -10
View File
@@ -9,8 +9,8 @@ DATABASE_NAME=tavolo
DATABASE_USER=tavolo
DATABASE_PASSWORD=password
DATABASE_OPTIONS=
# Full-DSN override: when set, the parts above are ignored (managed-DB
# URLs). The Rust server supports Postgres only.
# Full-DSN override: when set, the parts above are ignored. Used by the
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
@@ -52,10 +52,9 @@ HAND_ACK_TIMEOUT_SECONDS=30
# for them (covers disconnects and idle players).
TURN_TIMEOUT_SECONDS=30
# Accepted for compatibility with the Python server but ignored: the Rust
# server configures logging through RUST_LOG (tracing env filter syntax).
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset logs DEBUG to the console.
#LOGGING_CONFIG=/path/to/logging.yaml
#RUST_LOG=tavolo=info,sqlx=warn,redis=warn
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
@@ -79,11 +78,12 @@ TURN_TIMEOUT_SECONDS=30
# Seconds browsers may cache the preflight response (default 600):
#CORS_MAX_AGE=600
# OpenTelemetry (compiled in by default). Off unless OTEL_ENABLED is
# truthy; exports traces (HTTP/WebSocket) and process metrics, plus custom
# tavolo.active_users / tavolo.ongoing_games gauges and logs over OTLP/HTTP.
# The endpoint defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT
# (localhost:4318) when unset.
# OpenTelemetry (via kaya-otel + the contrib instrumentation packages).
# Off unless OTEL_ENABLED is truthy; exports traces (HTTP/WebSocket,
# Postgres via asyncpg, Redis), metrics and logs over OTLP/HTTP (requires
# the tavolo-app "otel" extra, bundled in the Docker image). The endpoint
# defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT (localhost:4318)
# when unset.
#OTEL_ENABLED=true
#OTEL_SERVICE_NAME=tavolo
#OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
+1
View File
@@ -0,0 +1 @@
3.14
-3534
View File
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
[workspace]
resolver = "2"
members = [
"crates/tavolo-platform",
"crates/tavolo-scopone",
"crates/tavolo-app",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://gitea.woggioni.net/woggioni/tavolo"
description = "Multiplayer card-game platform backend"
[workspace.dependencies]
async-trait = "0.1"
axum = { version = "0.8", features = ["ws", "macros"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
futures-util = "0.3"
hex = "0.4"
jsonwebtoken = "9"
percent-encoding = "2"
rand = "0.8"
redis = { version = "0.32", features = ["tokio-comp", "connection-manager", "script"] }
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid", "json", "migrate", "macros"] }
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[profile.release]
lto = "thin"
codegen-units = 1
+83 -47
View File
@@ -1,28 +1,24 @@
# syntax=docker/dockerfile:1
# Multi-stage build for the tavolo stack (Rust/Axum backend +
# Sycamore/WASM frontend). The Docker build context is the REPOSITORY ROOT
# (see docker-compose.yml) so this single image assembles both parts:
# Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM
# frontend). The Docker build context is the REPOSITORY ROOT (see
# docker-compose.yml) so this single image assembles both parts:
#
# web-builder rust + trunk -> compiles web/ into web/dist
# rust-builder rust -> compiles server/ into the tavolo binary
# runtime debian slim -> the binary + the compiled frontend
# web-builder rust + trunk -> compiles web/ into web/dist
# builder alpine python -> python venv with the backend + deps
# runtime alpine python + the venv + the compiled frontend
#
# The backend embeds its sqlx migrations, serves /static and /assets itself
# and hosts the SPA shell, so the runtime image needs no Python, Granian or
# migration files.
#
# Build and run it with (from the repository root):
# docker compose up --build
# apk and uv both use BuildKit cache mounts (type=cache): the package
# caches persist in the builder's cache across builds instead of being
# re-downloaded, and never land in the image layers.
# --- Web builder -------------------------------------------------------------
FROM rust:1-slim-bookworm AS web-builder
FROM rust:1-slim AS web-builder
ARG TRUNK_VERSION=0.21.14
# The build environment blocks plain HTTP: force the apt mirrors to HTTPS.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
{ sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list 2>/dev/null || true; } \
&& { sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; } \
sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
@@ -34,62 +30,102 @@ 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.
COPY web/vendor ./vendor
COPY web/src ./src
# --public-url makes trunk emit asset URLs under /static, the prefix the
# server mounts in the runtime image. Dev builds (trunk serve) keep the
# default "/" public URL.
# --public-url makes trunk emit asset URLs under /static, the prefix Granian
# serves in the runtime image (see GRANIAN_STATIC_PATH_* below). Dev builds
# (trunk serve) keep the default "/" public URL.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/web/target \
trunk build --release --public-url /static/
# --- Rust builder ------------------------------------------------------------
FROM rust:1-slim-bookworm AS rust-builder
# --- Python builder ----------------------------------------------------------
FROM alpine:3.24 AS builder
# The official uv image ships a statically linked musl binary, so it works
# on Alpine. Pin the version so image builds do not change implicitly.
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 build-base python3-dev
# Use Alpine's Python instead of downloading a managed interpreter, install
# the venv at the same path the runtime stage uses, and copy rather than
# hardlink from the cache mount (cache and target are different filesystems).
ENV UV_PYTHON_DOWNLOADS=0 \
UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_LINK_MODE=copy
WORKDIR /build
COPY server/Cargo.toml server/Cargo.lock ./
COPY server/crates ./crates
# uv needs the workspace root plus the member pyprojects to know which
# package to sync. Copying only the manifests lets the third-party
# dependency layer stay cached while application sources change.
# server/pyproject.toml also carries [tool.aerich], read by the db-migrate
# compose service with working_dir=/app, next to ./migrations.
COPY server/uv.lock server/pyproject.toml ./
COPY server/packages/tavolo-platform/pyproject.toml packages/tavolo-platform/
COPY server/packages/tavolo-scopone/pyproject.toml packages/tavolo-scopone/
COPY server/packages/tavolo-scopa/pyproject.toml packages/tavolo-scopa/
COPY server/packages/tavolo-briscola/pyproject.toml packages/tavolo-briscola/
COPY server/packages/tavolo-app/pyproject.toml packages/tavolo-app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-workspace --package tavolo-app --extra otel --no-dev
# The target directory lives in a cache mount (so repeated builds reuse it)
# and the binary is copied out of the mount into the image layer.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release --locked \
&& cp target/release/tavolo /tavolo
# The five sibling distributions are workspace members (PEP 420 namespace
# packages sharing the `tavolo` namespace). --no-editable installs them as
# wheels, so the runtime image does not need their source tree.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --package tavolo-app --extra otel --no-dev
# --- Runtime -----------------------------------------------------------------
FROM debian:bookworm-slim
# aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/
# The slim base ships no CA store; seed it from the builder so the HTTPS apt
# mirrors (and later, outbound TLS) can be verified.
COPY --from=web-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# --- Runtime ---------------------------------------------------------------
FROM alpine:3.24
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
{ sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list 2>/dev/null || true; } \
&& { sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; } \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& addgroup --system app && adduser --system --ingroup app app
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 ca-certificates tzdata \
&& addgroup -S app && adduser -S -G app app
COPY --from=rust-builder /tavolo /usr/local/bin/tavolo
# The compiled single-page application: hashed js/wasm/css under /static and
# the card images under /assets (mounted by the server from STATIC_DIR).
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /build/migrations /app/migrations
# aerich reads [tool.aerich] from pyproject.toml (its default config file);
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
COPY --from=builder /build/pyproject.toml /app/pyproject.toml
# The compiled single-page application. Granian serves the assets directly
# in Rust — hashed js/wasm/css under /static/* and the card images under
# /assets/* (see the GRANIAN_STATIC_PATH_* env vars below; click splits
# multi-value env vars on whitespace for routes and ':' for paths). The
# Python app only serves the SPA shell (index.html) at / and for
# client-side routes (STATIC_DIR).
COPY --from=web-builder /web/dist /app/web/dist
ENV STATIC_DIR=/app/web/dist
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi \
GRANIAN_STATIC_PATH_ROUTE="/static /assets" \
GRANIAN_STATIC_PATH_MOUNT="/app/web/dist:/app/web/dist/assets" \
GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \
STATIC_DIR=/app/web/dist
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
CMD ["tavolo", "serve"]
CMD ["granian", "tavolo.app:app"]
+409 -221
View File
@@ -1,25 +1,38 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, written in Rust with
[Axum](https://github.com/tokio-rs/axum). It is a Cargo workspace of three
crates mirroring the original Python layout:
The backend for a multiplayer card-game platform, built on the
[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-scopa/README.md`](packages/tavolo-scopa/README.md)
and
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
- **`crates/tavolo-platform/`** — everything game-independent: the lobby API,
the live-game store, the WebSocket endpoint, the deadline scheduler, match
persistence and the Elo leaderboards, plus the `GameEngine` contract games
implement.
- **`crates/tavolo-scopone/`** — scopone scientifico, the four-player,
fixed-partnership variant of the classic Italian card game: pure rules plus
the platform adapter.
- **`crates/tavolo-app/`** — the composition root: environment-driven
settings, OIDC login, sessions, CORS, OpenTelemetry, SPA hosting and the
`tavolo` binary (`serve` / `migrate` / `backfill-elo`).
- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
game-independent: the lobby API, the live-game store, the WebSocket
endpoint, the deadline scheduler, match persistence and the Elo
leaderboards, plus the `GameEngine` contract games implement.
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
scientifico, the four-player, fixed-partnership variant of the classic
Italian card game: pure rules plus the platform adapter.
- **`tavolo-scopa`** (`packages/tavolo-scopa/`) — scopa, the classic
Italian fishing game, in its two-player (`scopa`) and four-player
fixed-partnership (`scopa_a_quattro`) variants, with the optional asso
pigliatutto rule (scopa d'assi): pure rules plus the platform adapters.
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
Italian trick-taking game, in its classic two-player (`briscola`),
four-player partnership (`briscola_a_quattro`) and five-player
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.
Players authenticate with the configured **OIDC** provider. Live game state
is kept in **Redis** (with real-time play over WebSocket), and completed
matches — every participant and the game-specific result — are persisted to
**Postgres** for history and leaderboard statistics. The HTTP/WebSocket
contract is byte-compatible with the SPA in `../web/`.
Players authenticate with the configured **OIDC** provider. Live game
state is kept in **Redis** (with real-time play over WebSocket), and
completed matches — every participant and the game-specific result — are
persisted to **Postgres** for history and leaderboard statistics.
## Quick start (Docker Compose)
@@ -30,10 +43,10 @@ docker compose up --build
The stack starts Postgres, Redis, a mock OIDC provider
(navikt/mock-oauth2-server) with four ready-made players
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service
(`tavolo migrate`) and the app itself on `http://127.0.0.1:8080`. The same
container also serves the Sycamore/WASM frontend (built from `../web/` by the
Docker image), so the UI is available at that address.
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service,
and the app itself on `http://127.0.0.1:8080`. The same container also
serves the Sycamore/WASM frontend (built from `../web/` by the Docker
image), so the UI is available at that address.
Because the browser and the app both talk to the OIDC issuer at
`http://mockoauth:8180/tavolo`, add a host entry once:
@@ -48,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
@@ -56,14 +71,14 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver (Postgres only) |
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432) |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
| `DATABASE_NAME` | `tavolo` | Postgres database name |
| `DATABASE_USER` | `tavolo` | Postgres user |
| `DATABASE_PASSWORD` | `password` | Postgres password |
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (managed-DB URLs) |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
@@ -73,11 +88,10 @@ All configuration comes from environment variables (see `.env.example`):
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements (wired into the game engines) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them (wired into the game engines) |
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
| `LOGGING_CONFIG` | unset | Accepted for compatibility; **ignored** (the Rust server logs via `RUST_LOG`) |
| `RUST_LOG` | `debug` (app), INFO+ deps | tracing-subscriber env filter, e.g. `tavolo=info,sqlx=warn` |
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
@@ -85,7 +99,7 @@ All configuration comes from environment variables (see `.env.example`):
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (compiled in by default) |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `uv sync --package tavolo-app --extra otel`) |
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
@@ -93,7 +107,6 @@ All configuration comes from environment variables (see `.env.example`):
| `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports |
| `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
| `STATIC_DIR` | `web/dist` | Directory holding the compiled frontend (the Docker image sets `/app/web/dist`) |
¹ When no `OIDC_<n>_*` variable is set at all, a single provider named
`default` pointing at the dev mock IdP is used. Indices must be contiguous
@@ -101,8 +114,9 @@ from 0 (`OIDC_0_*`, `OIDC_1_*`, ...).
### Multiple OIDC providers
Providers are configured in indexed blocks; each becomes its own login route
`/auth/<provider>/login` with its own callback URL and session namespace:
Providers are configured in indexed blocks; each becomes its own login
route `/auth/<provider>/login` with its own callback URL and session
namespace:
```sh
OIDC_0_PROVIDER=google
@@ -120,46 +134,83 @@ OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback
The login page reads `GET /api/auth/providers` and shows one button per
provider. `GET /auth/login` redirects to the first provider (useful for
bookmarks and scripts), and `GET /auth/logout` signs out whichever provider
the session is logged in with. Sessions hold a single identity: logging in
with another provider replaces the previous one.
bookmarks and scripts), and `GET /auth/logout` signs out whichever
provider the session is logged in with.
Sessions hold a single identity: logging in with another provider
replaces the previous one. Each provider's `sub` is a separate account,
so match history and Elo ratings are per provider identity (someone using
two providers has two accounts). Migrating from the pre-0.0.6 unindexed
variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add
`OIDC_0_PROVIDER`, and register the new callback path
(`/auth/<provider>/callback`) at the provider. Sessions survive only when
the provider is named `default`; otherwise users log in once more.
## Logging
The Rust server logs through `tracing`, configured with `RUST_LOG`
(`tracing-subscriber` env-filter syntax). The default filter is `debug` for
the application with noisy dependencies capped (`sqlx`, `redis`, `hyper`,
`reqwest` at WARN). `LOGGING_CONFIG` (a Python `logging.config.dictConfig`
YAML path) is accepted so existing deployments do not break, but its value is
ignored with a warning.
The application logs through the Python stdlib `logging` module, one
`getLogger(__name__)` per module: lifecycle and business events at INFO
(game created/joined, websocket connections, match results, auto-plays),
per-move and store detail at DEBUG.
Lifecycle and business events are logged at INFO (game created/joined,
websocket connections, match results, auto-plays); per-move and store detail
at DEBUG.
By default everything at DEBUG level goes to the console. Set
`LOGGING_CONFIG` to the path of a YAML file to take over the
configuration; the file follows the
[`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)
schema. Keep `disable_existing_loggers: false` — Granian configures its own
loggers before importing the app, and disabling them would silence the
server. Example for quieter production logs (WARNING for third parties,
INFO for the application):
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
```
## OpenTelemetry
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP:
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP (kaya-otel plus
`tavolo.telemetry`):
- **Traces**: one span per HTTP request and one per WebSocket connection,
tagged with the request's session and user: `session.id` is a truncated
SHA-256 of the session id (the cookie is a credential, so it is never
exported verbatim) and `user.id` is the OIDC `sub`, present once logged in.
Database-client auto-instrumentation is not included.
- **Metrics**: the `http.server.request.duration` histogram, plus
- **Traces**: HTTP server spans, one span per WebSocket connection, plus
database spans for the Postgres (asyncpg, through Tortoise) and Redis
clients. Redis command values are redacted by the instrumentation
(`GET ? ?`) and asyncpg spans carry the parameterized SQL only — no
parameter values. Each HTTP/WebSocket span is tagged with the request's
session and user, so all traces of a session can be found together:
`session.id` is a truncated SHA-256 of the session id (the cookie is a
credential, so it is never exported verbatim) and `user.id` is the OIDC
`sub`, present once logged in.
- **Metrics**: the HTTP server metrics from kaya-otel, plus
`tavolo.active_users` (sessions in the session store),
`tavolo.ongoing_games` (live matches not finished yet), and process metrics:
`process.cpu.time`, `process.cpu.utilization`, `process.memory.usage`,
`process.memory.virtual`. The Python build's CPython gc metrics have no
Rust equivalent.
- **Logs**: tracing events forwarded to OTLP (OpenTelemetry's own records are
filtered out to avoid export loops).
`tavolo.ongoing_games` (live matches not finished yet, maintained by the
game store), and process/CPython metrics from
`opentelemetry-instrumentation-system-metrics`: `process.cpu.time`,
`process.cpu.utilization`, `process.memory.usage`,
`process.memory.virtual`, `cpython.gc.collections`,
`cpython.gc.collected_objects` and `cpython.gc.uncollectable_objects`.
- **Logs**: forwarded from the root logger at the configured console level
(OpenTelemetry's own records are filtered out to avoid export loops).
The session/game gauges are polled every
`OTEL_METRICS_POLL_INTERVAL_SECONDS` (Redis-backed stores report
cluster-wide counts, in-memory stores the process's own). With multiple
replicas each process reports the same global counts: aggregate with `max`,
not `sum`.
cluster-wide counts, in-memory stores the process's own). Note that with
multiple workers or replicas each process reports the same global counts:
aggregate with `max`, not `sum`.
## Data model
@@ -167,102 +218,112 @@ not `sum`.
- `tavolo:game:<uuid>` — the whole session as JSON: the platform-owned
envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the
game-specific `state` blob, which only the registered engine interprets.
Sliding TTL (`GAME_TTL_SECONDS`). The layout is identical to the original
Python implementation, so in-flight games survive the backend swap.
game-specific `state` blob, which only the registered engine
interprets. Sliding TTL (`GAME_TTL_SECONDS`).
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
signals; every open WebSocket reloads the session and pushes the
personalized view to its player.
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
timeouts (scopone's turn auto-plays and hand-end auto-continues). Every
worker runs a consumer that fires due entries under the per-game lock by
calling the engine's `fire_deadline`, so timeouts do not depend on any
player being connected and survive the death of any worker (delivery is
at-least-once; engines revalidate the entry's token against the live state
before firing).
timeouts (e.g. the games' turn auto-plays and hand-end auto-continues).
Every worker runs a consumer that fires due entries under the per-game
lock by calling the engine's `fire_deadline`, so timeouts do not depend
on any player being connected and survive the death of any worker
(delivery is at-least-once; engines revalidate the entry's token
against the live state before firing).
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
deadlines; it wakes every worker's consumer immediately, while the
heartbeat poll remains as a backstop for lost signals.
- `tavolo:games:ongoing` — sorted set of running games for the
`tavolo.ongoing_games` gauge.
Sessions live under `tavolo:session:<id>` as JSON (the Python server used
pickled values under `kaya:session:`; existing sessions are invalidated once
on cutover, users log in again).
### Postgres (statistics, via Tortoise ORM + aerich migrations)
### Postgres (statistics, via sqlx migrations)
- `match` — one row per finished match: the game played (`game_type`,
indexed so statistics can be 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).
- `match` — one row per finished match: the game played (`game_type`, one
of the ids from `GET /api/game-types`, indexed so statistics can be
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 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.
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 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 stays
in Redis until its TTL expires so clients can still fetch the final board.
Postgres (once, guarded by a flag on the session); the finished session
stays in Redis until its TTL expires so clients can still fetch the final
board.
### Elo ratings
Players carry a chess-style Elo rating per game type (`tavolo.platform.elo`):
everyone starts at 1500, a team's rating is the 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, with Python-compatible
banker's rounding. Ratings update in the same transaction as the match
result. To recompute every rating from the recorded match history:
Players carry a chess-style Elo rating per game type
(`tavolo.platform.elo`): everyone starts at 1500, a team's rating is the
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 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
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
cargo run -p tavolo-app -- backfill-elo
# or, in the container image:
docker compose run --rm tavolo tavolo backfill-elo
uv run python -m tavolo.platform.backfill_elo \
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
```
## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
`/api/game-types` and `/api/leaderboard` require authentication.
`/api/game-types`, `/api/leaderboard` and the game snapshot
(`GET /api/games/{id}`) require authentication.
| 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}}`. 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}` | Personalized snapshot (only your own hand is visible) |
| `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
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; the upgrade is refused before the handshake completes
with an HTTP status mirroring `GET /api/games/{id}`: `401` unauthenticated,
`404` unknown game, `403` not seated. (Browsers report a refused handshake as
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
who knows the game id is accepted: seated players get their personalized
view, everyone else — anonymous users and authenticated non-players — is
a read-only **spectator**. Only an unknown game id is refused before the
handshake completes, with `404`. (Browsers report a refused handshake as
an abnormal close — there is no close code to read.)
Server → client messages are JSON objects with a `type`:
- `{"type": "state", "game": {...}}` — personalized game view. Your own hand
is included; other players expose only their card counts. `your_turn: true`
is present when it is your move.
- `{"type": "state", "game": {...}}` — personalized game view. Your own
hand is included; other players expose only their card counts.
Spectators get no hand at all, `spectator: true` marks the view and
`join_code` is omitted for them. `your_turn: true` is present when it
is your move. `seats_open` reports the free chairs (0 once the match
started).
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages are JSON objects with an `action`. The 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. For scopone scientifico:
Client → server messages are JSON objects with an `action`. The
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 and scopa (both variants):
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -276,35 +337,74 @@ payload. For scopone scientifico:
settebello).
- `capture` lists the table cards to take. When a capture is legal it is
mandatory to provide one; when no capture exists it must be omitted.
- `ack` acknowledges the hand-end scoring summary (see below). The next hand
is dealt once all four players have acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
- `ack` acknowledges the hand-end scoring summary (see below). The next
hand is dealt once all four players have acknowledged, or automatically
after `HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
For briscola (`briscola`, `briscola_a_quattro`) the game actions are
`play` (with `card` only — every card in hand is legal) and `ack`:
```json
{"action": "play", "card": "04C"}
{"action": "ack"}
```
- `card` uses the same `RRSUIT` convention.
- `ack` acknowledges the deal-end summary; the next deal is dealt once
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.
### Turn timeout
The state carries a `turn_deadline` while a hand is being played. If the
player on turn does not move before it, the server plays a random legal card
for them (picking one of the legal captures at random when a capture is
required), so a disconnected or idle player cannot stall the match. The
player on turn does not move before it, the server plays a random legal
card for them (picking one of the legal captures at random when a capture
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, not from timers tied to client connections, so the match keeps
progressing even with every player disconnected.
queue (see above), not from timers tied to client connections, so the
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
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), 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.
`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 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
### Scopone scientifico
- 40-card Italian deck, ten cards per player, empty table at hand start.
- A card captures a **single card of equal rank** (mandatory when present)
or a **combination of table cards whose ranks sum to its own**.
@@ -324,110 +424,198 @@ attempted in this phase is rejected with an `illegal_move` error.
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
- 40-card Italian deck, three cards per player. The card turned up after
the deal fixes the trump (*briscola*) suit for the whole deal and is the
last card drawn from the stock.
- Trick play: the highest trump wins; when no trump was played, the
highest card of the led suit wins. The winner takes the trick and leads
the next one.
- After each trick every player draws back up to three cards, starting
with the trick winner; the face-up briscola card goes to whoever's turn
to draw it is.
- Card points: ace 11, three 10, king 4, horse 3, jack 2 — 120 points per
deal. Trick strength is unrelated to points: ace > three > king > horse
> jack > 7 > 6 > 5 > 4 > 2.
- Match points accumulate across deals; a 60–60 deal is a *patta*: its
points are discarded and the deal is replayed. The match ends when a
team reaches the target score (default 61, configurable per game via
the `target_score` creation option) with a clear lead; a tie at or
above the target is broken by another deal.
- `briscola` is the classic two-player game; `briscola_a_quattro` seats
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
Backend (from `server/`):
The backend is a [uv](https://docs.astral.sh/uv/) workspace: `pyproject.toml`
declares the five packages under `packages/` as members, and `uv.lock` pins
their full dependency closure. The checked-in `.python-version` selects
Python 3.14, matching the Docker image. Install uv, then from `server/`:
```sh
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # = tavolo serve (needs the env below)
cargo run -p tavolo-app -- migrate
uv sync --package tavolo-app --extra otel
# app integration suite:
uv run python -m unittest discover -s tests -t .
# per-package suites (each package is self-contained):
uv run python -m unittest discover -s packages/tavolo-platform/tests
uv run python -m unittest discover -s packages/tavolo-scopone/tests
uv run python -m unittest discover -s packages/tavolo-scopa/tests
uv run python -m unittest discover -s packages/tavolo-briscola/tests
# type checks (see packages/tavolo-app/README.md for the app modules):
uv run mypy -p tavolo.platform
uv run mypy -p tavolo.scopone
uv run mypy -p tavolo.scopa
uv run mypy -p tavolo.briscola
```
For a local run without Docker:
`uv sync` installs the workspace's `dev` dependency group (`mypy`,
`httpx-ws`) automatically; pass `--no-dev` for a runtime-only environment.
The `otel` extra is optional, but without it the telemetry integration tests
skip. The lockfile is committed: use `uv lock --upgrade` (or
`uv lock --upgrade-package <name>`) to update dependencies deliberately.
```sh
DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo \
REDIS_URL=redis://localhost:6379/0 \
cargo run -p tavolo-app
```
Tests run fully in-process: in-memory stores, a `DummyEngine` toy game for
the platform suite, a mock OIDC provider for the login flow, and ephemeral
TCP servers for WebSocket coverage. The Redis- and Postgres-backed tests are
gated on `TEST_REDIS_URL` / `TEST_DATABASE_URL` and skipped when unset.
Frontend (from `web/`), with the backend running on :8080:
```sh
cd web
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
```
For the login redirect to land back on the dev server, run the backend with:
```sh
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
re-downloads them if needed.
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
suite additionally runs against a `DummyEngine` toy game, so the
game-independent machinery is covered without importing scopone.
### Migrations
The Postgres schema is owned by embedded sqlx migrations in
`crates/tavolo-app/migrations/`, applied by `tavolo migrate` (the
`db-migrate` compose service and the Kubernetes init container). The
migration is idempotent (`CREATE TABLE IF NOT EXISTS`) and matches the
original aerich-created schema, so it can be applied to an existing database
without touching data; the `aerich` bookkeeping table is left alone. To
change the schema, add a new numbered `.sql` file next to the baseline and
run `cargo test -p tavolo-app` (the migration is embedded at compile time).
The Postgres schema is owned by aerich migrations in `migrations/`. The
`db-migrate` compose service runs `aerich upgrade` before the app starts.
To add a migration after changing
`packages/tavolo-platform/src/tavolo/platform/models.py`:
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich upgrade
```
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing
baseline.)
## Layout
```
crates/tavolo-platform/ # game-independent platform (own crate + tests)
src/
├── engine.rs # the platform<->game contract
├── registry.rs # game id -> engine lookup
├── errors.rs # GameError + platform Error
├── store.rs # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.rs # connection-independent timeout scheduler
├── session.rs # HTTP sessions (in-memory / Redis) + middleware
├── oidc.rs # OIDC discovery, PKCE, JWKS validation, logout
├── auth.rs # session -> user helpers
├── stats.rs # match persistence + history/leaderboard queries + Elo
├── models.rs # sqlx row models
├── elo.rs # chess-style Elo math (1500 start, K=32)
├── pagination.rs # keyset (cursor) pagination
├── http.rs # JSON request/response helpers
├── platform.rs # the collaborators shared by all endpoints
├── routes/ # health / me / auth / games / stats / openapi
└── ws.rs # WebSocket live-play endpoint (generic envelope)
crates/tavolo-scopone/ # scopone scientifico game (own crate + tests)
src/{state,engine,plugin,errors}.rs
crates/tavolo-app/ # deployable app: composition root (own crate)
src/{main,app,config,cors,logging,static_files,telemetry}.rs
migrations/ # embedded sqlx migrations
dev/mockoauth/ # dev OIDC provider image (compose)
Dockerfile # web-builder + cargo + slim runtime
.env.example
packages/tavolo-platform/ # game-independent platform (own pyproject + tests)
src/tavolo/platform/
├── __init__.py # public surface (GameEngine, GameSession, Platform, ...)
├── engine.py # the platform<->game contract
├── registry.py # game id -> engine lookup
├── mixin.py # Platform + PlatformMixin (mounts everything on a kaya app)
├── errors.py # shared GameError hierarchy
├── 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 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)
├── auth.py # auth helpers (HTTP + WebSocket)
├── http.py # JSON request/response helpers
├── pagination.py # keyset (cursor) pagination
├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
└── routes/
├── health.py # GET /api/health
├── me.py # GET /api/me
├── games.py # lobby: game-types / create / join / snapshot
└── stats.py # match history + leaderboard + Elo ratings
packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
src/tavolo/scopone/
├── state.py # ScoponeState / PlayerState / Card, JSON (de)serialization
├── 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 + BriscolaChiamataEngine
packages/tavolo-app/ # deployable app: composition root (own pyproject)
src/tavolo/
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
├── config.py # env -> frozen Settings
├── telemetry.py # OTLP metric/log providers, app gauges, count poller
├── aerich_config.py # aerich CLI configuration
├── logging_config.py # logging setup
└── static.py # SPA shell hosting
pyproject.toml # uv workspace root + [tool.aerich] / [tool.mypy]
uv.lock # locked dependency set for the whole workspace
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + games wired together)
```
## Differences from the original Python server
The Python implementation that lived in this directory was replaced by the
Rust one; it remains available in git history. Behavioral compatibility is
intentional and tested (same JSON payloads, status codes, error messages,
Redis layout and Postgres schema), with these documented differences:
| Area | Behavior |
|---|---|
| Sessions | Stored as JSON under `tavolo:session:` (Python: pickle under `kaya:session:`). Existing sessions are invalidated once on cutover; users log in again. |
| Logging | `LOGGING_CONFIG` is accepted but ignored; use `RUST_LOG`. |
| Database | Postgres only (`DATABASE_URL=sqlite://...` is rejected). |
| OpenTelemetry | Rust process metrics replace the CPython gc metrics; no Postgres/Redis client auto-instrumentation. |
| Deadline members | Compact JSON instead of Python's spaced `json.dumps`; stale duplicates are harmless (engines revalidate tokens). |
| CORS | Hand-written middleware replicating kaya-cors / Starlette `CORSMiddleware` semantics, including `400 Disallowed CORS ...` preflight rejections. |
| CLI | `tavolo migrate` / `tavolo backfill-elo` replace `aerich upgrade` / `python -m tavolo.platform.backfill_elo`. |
+17
View File
@@ -0,0 +1,17 @@
"""Shared test fixtures.
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
so they need no running Postgres, and with ``REDIS_URL`` unset so sessions
and live games use their in-memory stores. The environment is set before
:mod:`tavolo.app` is imported by the test modules.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_0_PROVIDER", "default")
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
os.environ.pop("REDIS_URL", None)
-63
View File
@@ -1,63 +0,0 @@
[package]
name = "tavolo-app"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "The deployable tavolo application: composition root and HTTP/WebSocket server"
[[bin]]
name = "tavolo"
path = "src/main.rs"
[dependencies]
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
clap.workspace = true
futures-util.workspace = true
hex.workspace = true
jsonwebtoken.workspace = true
opentelemetry = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs"] }
opentelemetry-appender-tracing = { version = "0.33", optional = true, default-features = false }
opentelemetry-otlp = { version = "0.33", optional = true, default-features = false, features = ["http-proto", "reqwest-client", "trace", "metrics", "logs"] }
opentelemetry_sdk = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs", "rt-tokio"] }
percent-encoding.workspace = true
rand.workspace = true
redis.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
sysinfo = { version = "0.39", optional = true }
tavolo-platform = { path = "../tavolo-platform" }
tavolo-scopone = { path = "../tavolo-scopone" }
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-opentelemetry = { version = "0.34", optional = true, default-features = false }
tracing-subscriber.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
axum = { workspace = true, features = ["ws", "macros"] }
futures-util.workspace = true
http-body-util = "0.1"
tokio-tungstenite = "0.24"
tower = { workspace = true, features = ["util"] }
[features]
default = ["otel"]
otel = [
"dep:opentelemetry",
"dep:opentelemetry_sdk",
"dep:opentelemetry-otlp",
"dep:opentelemetry-appender-tracing",
"dep:tracing-opentelemetry",
"dep:sysinfo",
]
@@ -1,41 +0,0 @@
-- Final tavolo schema, expressed idempotently so the migration can be applied
-- to a database created by the Python aerich migrations as well as to a fresh
-- one. The `aerich` bookkeeping table (when present) is left untouched.
CREATE TABLE IF NOT EXISTS "match" (
"id" UUID NOT NULL PRIMARY KEY,
"game_type" VARCHAR(32) NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"finished_at" TIMESTAMPTZ NOT NULL,
"result" JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS "idx_match_game_type" ON "match" ("game_type");
CREATE TABLE IF NOT EXISTS "match_player" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(200) NOT NULL,
"seat" SMALLINT NOT NULL,
"team" VARCHAR(32),
"won" BOOL NOT NULL,
"match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE,
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
"elo_delta" SMALLINT,
"details" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "uid_match_player_match_user" UNIQUE ("match_id", "user_sub")
);
CREATE INDEX IF NOT EXISTS "idx_match_player_user_sub" ON "match_player" ("user_sub");
CREATE TABLE IF NOT EXISTS "player_rating" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"game_type" VARCHAR(32) NOT NULL,
"rating" INT NOT NULL,
"matches_played" INT NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "uid_player_rating_user_game" UNIQUE ("user_sub", "game_type")
);
CREATE INDEX IF NOT EXISTS "idx_player_rating_game_rating" ON "player_rating" ("game_type", "rating");
-191
View File
@@ -1,191 +0,0 @@
//! Application composition root: registry, stores, platform, router.
//!
//! Mirrors `tavolo.app` from the Python implementation:
//!
//! * the [`ScoponeEngine`] is registered with the configured timeouts;
//! * sessions and live games use Redis when `REDIS_URL` is set, in-memory
//! stores otherwise;
//! * finished matches are persisted to Postgres through
//! [`PostgresStatsRepo`];
//! * the platform routes are merged with the SPA shell hosting and wrapped in
//! the session middleware and the optional CORS layer.
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::oidc::OidcProviders;
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::routes;
use tavolo_platform::session::{
session_middleware, InMemorySessionStore, RedisSessionStore, SharedSessionStore,
};
use tavolo_platform::stats::{InMemoryStatsRepo, PostgresStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, RedisGameStore, SharedGameStore};
use tavolo_scopone::ScoponeEngine;
use tower_http::services::ServeDir;
use crate::config::Settings;
use crate::cors::CorsConfig;
use crate::static_files::{mount_dirs, SpaFallback};
pub struct App {
pub router: Router,
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
}
/// Build the game registry (the first registered game is the default).
pub fn build_registry(settings: &Settings) -> Result<Arc<GameRegistry>, String> {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(ScoponeEngine::new(
settings.turn_timeout_seconds,
settings.hand_ack_timeout_seconds,
)))
.map_err(|err| err.to_string())?;
Ok(Arc::new(registry))
}
/// Build the session and live-game stores from `REDIS_URL`.
pub async fn build_stores(
settings: &Settings,
registry: Arc<GameRegistry>,
) -> Result<(SharedSessionStore, SharedGameStore), String> {
if let Some(url) = &settings.redis_url {
let session_store: SharedSessionStore = Arc::new(
RedisSessionStore::new(url)
.await
.map_err(|err| format!("could not connect to Redis: {err}"))?,
);
let game_store: SharedGameStore = Arc::new(
RedisGameStore::new(url, registry, settings.game_ttl_seconds)
.await
.map_err(|err| format!("could not connect to Redis: {err}"))?,
);
Ok((session_store, game_store))
} else {
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry));
Ok((session_store, game_store))
}
}
/// Build the [`Platform`] bundle and start the deadline scheduler.
pub fn build_platform(
settings: &Settings,
registry: Arc<GameRegistry>,
game_store: SharedGameStore,
stats: SharedStatsRepo,
) -> Result<SharedPlatform, String> {
let oidc = Arc::new(
OidcProviders::new(settings.oidc_providers.clone()).map_err(|err| err.to_string())?,
);
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats.clone(),
settings.deadline_heartbeat_ms,
1000,
);
scheduler.start();
Ok(Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats,
}))
}
/// Build the axum router: platform routes, static mounts, SPA fallback,
/// session middleware and optional CORS.
pub fn build_router(
platform: SharedPlatform,
session_store: SharedSessionStore,
settings: &Settings,
) -> Router {
let (root, assets) = mount_dirs(&settings.static_dir);
let provider_names: Vec<String> = settings
.oidc_providers
.iter()
.map(|provider| provider.provider.clone())
.collect();
let mut router = Router::new()
.merge(routes::router(&provider_names).with_state(platform))
.nest_service("/static", ServeDir::new(root))
.nest_service("/assets", ServeDir::new(assets))
.fallback_service(SpaFallback::new(&settings.static_dir));
// Added before the session layer, so request spans see the loaded
// session while the session middleware stays the outer layer.
let excluded = Arc::new(settings.otel_excluded_paths.clone());
router = router.layer(axum::middleware::from_fn(move |request, next| {
let excluded = Arc::clone(&excluded);
crate::telemetry::trace_middleware(excluded, request, next)
}));
router = router.layer(axum::middleware::from_fn_with_state(
session_store,
session_middleware,
));
if let Some(config) = CorsConfig::new(
settings.cors_allow_origins.as_deref().unwrap_or(&[]),
settings.cors_allow_methods.as_deref(),
settings.cors_allow_headers.as_deref(),
settings.cors_allow_credentials,
settings.cors_allow_origin_regex.as_deref(),
settings.cors_expose_headers.as_deref().unwrap_or(&[]),
settings.cors_max_age,
) {
router = router.layer(axum::middleware::from_fn_with_state(
Arc::new(config),
crate::cors::cors_middleware,
));
}
router
}
/// Build everything in-memory: useful for tests and ephemeral dev without
/// Postgres or Redis.
pub fn in_memory_platform(
settings: &Settings,
) -> Result<(SharedPlatform, SharedSessionStore), String> {
let registry = build_registry(settings)?;
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let stats: SharedStatsRepo = Arc::new(InMemoryStatsRepo::new());
let platform = build_platform(settings, registry, game_store, stats)?;
Ok((platform, session_store))
}
/// Connect to Postgres.
pub async fn connect_database(settings: &Settings) -> Result<sqlx::PgPool, String> {
if settings.database_url.starts_with("sqlite") {
return Err(
"the Rust server supports Postgres only; sqlite DATABASE_URLs are not supported"
.to_string(),
);
}
sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(10))
.connect(&settings.database_url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))
}
/// Build the production application (requires Postgres; Redis optional).
pub async fn build(settings: &Settings) -> Result<App, String> {
let registry = build_registry(settings)?;
let (session_store, game_store) = build_stores(settings, registry.clone()).await?;
let pool = connect_database(settings).await?;
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
let platform = build_platform(settings, registry, game_store, stats)?;
let router = build_router(platform.clone(), session_store.clone(), settings);
Ok(App {
router,
platform,
session_store,
})
}
-308
View File
@@ -1,308 +0,0 @@
//! Environment-driven configuration for the tavolo application.
//!
//! Mirrors `tavolo.config` from the Python implementation: read environment
//! variables into a plain struct, with the same defaults and validation.
use std::collections::BTreeSet;
use regex::Regex;
use tavolo_platform::oidc::OidcProviderConfig;
const OIDC_VARIABLES: [&str; 7] = [
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
];
/// The pre-0.0.6 unindexed names, detected only to point the migration out.
const LEGACY_OIDC_VARIABLES: [&str; 6] = [
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo";
const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo";
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn env_or(name: &str, default: &str) -> String {
env(name).unwrap_or_else(|| default.to_string())
}
fn env_list(name: &str) -> Option<Vec<String>> {
let value = std::env::var(name).ok()?;
if value.trim().is_empty() {
return None;
}
Some(
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect(),
)
}
fn env_bool(name: &str, default: bool) -> bool {
match std::env::var(name) {
Ok(value) if !value.is_empty() => {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
_ => default,
}
}
fn parse_oidc_providers() -> Result<Vec<OidcProviderConfig>, String> {
let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES
.iter()
.copied()
.filter(|name| env(name).is_some())
.collect();
if !legacy.is_empty() {
let renamed: Vec<String> = legacy
.iter()
.map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..]))
.collect();
return Err(format!(
"the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)",
renamed.join(", ")
));
}
let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex");
let mut indices: BTreeSet<usize> = BTreeSet::new();
for (name, _) in std::env::vars() {
let Some(captures) = pattern.captures(&name) else {
continue;
};
let variable = captures.get(2).expect("group 2").as_str();
if !OIDC_VARIABLES.contains(&variable) {
return Err(format!(
"unknown OIDC environment variable {name:?}; expected one of {}",
OIDC_VARIABLES
.iter()
.map(|variable| format!("OIDC_<n>_{variable}"))
.collect::<Vec<_>>()
.join(", ")
));
}
indices.insert(
captures
.get(1)
.expect("group 1")
.as_str()
.parse()
.map_err(|_| format!("invalid OIDC provider index in {name:?}"))?,
);
}
if indices.is_empty() {
return Ok(vec![OidcProviderConfig {
provider: "default".into(),
issuer: DEFAULT_OIDC_ISSUER.into(),
client_id: DEFAULT_OIDC_CLIENT_ID.into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}]);
}
let max = *indices.iter().next_back().expect("non-empty");
let missing: Vec<usize> = (0..=max).filter(|index| !indices.contains(index)).collect();
if !missing.is_empty() {
return Err(format!(
"OIDC provider indices must be contiguous from 0; missing {}",
missing
.iter()
.map(|index| format!("OIDC_{index}_*"))
.collect::<Vec<_>>()
.join(", ")
));
}
let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for index in indices {
let prefix = format!("OIDC_{index}_");
let required = |variable: &str| -> Result<String, String> {
env(&format!("{prefix}{variable}"))
.ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}"))
};
let provider = required("PROVIDER")?;
if !provider_name.is_match(&provider) {
return Err(format!(
"{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})"
));
}
if !seen.insert(provider.clone()) {
return Err(format!("duplicate OIDC provider name {provider:?}"));
}
providers.push(OidcProviderConfig {
issuer: required("ISSUER")?,
client_id: required("CLIENT_ID")?,
client_secret: env(&format!("{prefix}CLIENT_SECRET")),
redirect_uri: env_or(
&format!("{prefix}REDIRECT_URI"),
&format!("http://localhost:8080/auth/{provider}/callback"),
),
post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"),
post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"),
provider,
});
}
Ok(providers)
}
/// Percent-encode a DSN component like Python's `quote(value, safe="")`,
/// which leaves the RFC 3986 unreserved characters alone.
fn quote(value: &str) -> String {
const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'_')
.remove(b'-')
.remove(b'.')
.remove(b'~');
percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string()
}
fn database_url_from_parts(
engine: &str,
user: &str,
password: Option<&str>,
host: &str,
port: &str,
name: &str,
options: &str,
) -> String {
let mut netloc = quote(user);
if let Some(password) = password {
if !password.is_empty() {
netloc.push(':');
netloc.push_str(&quote(password));
}
}
netloc.push('@');
netloc.push_str(host);
if !port.is_empty() {
netloc.push(':');
netloc.push_str(port);
}
let mut url = format!("{engine}://{netloc}/{name}");
let options = options.trim_start_matches('?');
if !options.is_empty() {
url.push('?');
url.push_str(options);
}
url
}
#[derive(Debug, Clone)]
pub struct Settings {
pub database_url: String,
pub oidc_providers: Vec<OidcProviderConfig>,
pub app_host: String,
pub app_port: u16,
pub redis_url: Option<String>,
pub game_ttl_seconds: i64,
pub static_dir: String,
pub hand_ack_timeout_seconds: i64,
pub turn_timeout_seconds: i64,
pub deadline_heartbeat_ms: u64,
/// Accepted for compatibility; Rust logging is configured via RUST_LOG.
pub logging_config: Option<String>,
pub cors_allow_origins: Option<Vec<String>>,
pub cors_allow_origin_regex: Option<String>,
pub cors_allow_methods: Option<Vec<String>>,
pub cors_allow_headers: Option<Vec<String>>,
pub cors_allow_credentials: bool,
pub cors_expose_headers: Option<Vec<String>>,
pub cors_max_age: i64,
pub otel_enabled: bool,
pub otel_service_name: String,
pub otel_exporter_endpoint: Option<String>,
pub otel_exporter_headers: Option<Vec<String>>,
pub otel_excluded_paths: Vec<String>,
pub otel_metric_export_interval_millis: u64,
pub otel_metrics_poll_interval_seconds: u64,
}
impl Settings {
pub fn from_env() -> Result<Self, String> {
let database_url = env("DATABASE_URL").unwrap_or_else(|| {
database_url_from_parts(
&env_or("DATABASE_ENGINE", "postgres"),
&env_or("DATABASE_USER", "tavolo"),
Some(&env_or("DATABASE_PASSWORD", "password")),
&env_or("DATABASE_HOST", "localhost"),
&std::env::var("DATABASE_PORT").unwrap_or_default(),
&env_or("DATABASE_NAME", "tavolo"),
&std::env::var("DATABASE_OPTIONS").unwrap_or_default(),
)
});
let app_port = env_or("APP_PORT", "8080")
.parse::<u16>()
.map_err(|_| "APP_PORT must be a valid port number".to_string())?;
let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS");
Ok(Self {
database_url,
oidc_providers: parse_oidc_providers()?,
app_host: env_or("APP_HOST", "0.0.0.0"),
app_port,
redis_url: env("REDIS_URL"),
game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400")
.parse()
.map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?,
static_dir: env_or("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?,
turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?,
deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000")
.parse()
.map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?,
logging_config: env("LOGGING_CONFIG"),
cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"),
cors_allow_methods: env_list("CORS_ALLOW_METHODS"),
cors_allow_headers: env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false),
cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"),
cors_max_age: env_or("CORS_MAX_AGE", "600")
.parse()
.map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?,
otel_enabled: env_bool("OTEL_ENABLED", false),
otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_headers: otel_headers,
otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS")
.unwrap_or_else(|| vec!["/api/health".to_string()]),
otel_metric_export_interval_millis: env_or(
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"60000",
)
.parse()
.map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?,
otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
.parse()
.map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?,
})
}
}
-354
View File
@@ -1,354 +0,0 @@
//! CORS middleware replicating kaya-cors' `CorsMixin` (modeled after
//! Starlette's `CORSMiddleware`).
//!
//! * requests without an `Origin` header pass through untouched;
//! * preflight requests (`OPTIONS` carrying `Origin` and
//! `Access-Control-Request-Method`) are answered directly: `200 OK` when
//! the origin, method and headers are allowed, `400` with a
//! `Disallowed CORS ...` body otherwise;
//! * simple cross-origin requests get `Access-Control-Allow-Origin` (and the
//! configured credentials/expose headers) merged into the response without
//! overwriting headers the handler already set.
use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use regex::Regex;
const ALL_METHODS: [&str; 8] = [
"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY",
];
const SAFELISTED_HEADERS: [&str; 4] = [
"Accept",
"Accept-Language",
"Content-Language",
"Content-Type",
];
pub struct CorsConfig {
allow_all_origins: bool,
allow_credentials: bool,
allow_origins: Vec<String>,
allow_origin_regex: Option<Regex>,
allow_methods: Vec<String>,
allow_all_headers: bool,
/// Lowercased set checked against requested headers.
allow_headers_lower: HashSet<String>,
preflight_explicit_allow_origin: bool,
simple_headers: Vec<(HeaderName, HeaderValue)>,
preflight_headers: Vec<(HeaderName, HeaderValue)>,
}
impl CorsConfig {
pub fn new(
allow_origins: &[String],
allow_methods: Option<&[String]>,
allow_headers: Option<&[String]>,
allow_credentials: bool,
allow_origin_regex: Option<&str>,
expose_headers: &[String],
max_age: i64,
) -> Option<Self> {
if allow_origins.is_empty() && allow_origin_regex.is_none() {
return None;
}
let allow_all_origins = allow_origins.iter().any(|origin| origin == "*");
let allow_all_headers = allow_headers
.map(|headers| headers.iter().any(|header| header == "*"))
.unwrap_or(false);
let methods: Vec<String> = match allow_methods {
Some(methods) if methods.iter().any(|method| method == "*") => ALL_METHODS
.iter()
.map(|method| method.to_string())
.collect(),
Some(methods) => methods.to_vec(),
None => vec!["GET".to_string()],
};
let configured = allow_headers.unwrap_or_default();
let mut sorted_allow_headers: Vec<String> = SAFELISTED_HEADERS
.iter()
.map(|header| header.to_string())
.chain(configured.iter().cloned())
.collect::<HashSet<_>>()
.into_iter()
.collect();
sorted_allow_headers.sort();
let allow_headers_lower: HashSet<String> = sorted_allow_headers
.iter()
.map(|header| header.to_lowercase())
.collect();
let preflight_explicit_allow_origin = !allow_all_origins || allow_credentials;
let simple_headers =
build_simple_headers(allow_all_origins, allow_credentials, expose_headers);
let preflight_headers = build_preflight_headers(
&methods,
&sorted_allow_headers,
allow_all_headers,
allow_credentials,
max_age,
preflight_explicit_allow_origin,
);
Some(Self {
allow_all_origins,
allow_credentials,
allow_origins: allow_origins.to_vec(),
allow_origin_regex: allow_origin_regex.and_then(|pattern| Regex::new(pattern).ok()),
allow_methods: methods,
allow_all_headers,
allow_headers_lower,
preflight_explicit_allow_origin,
simple_headers,
preflight_headers,
})
}
fn is_allowed_origin(&self, origin: &str) -> bool {
if self.allow_all_origins {
return true;
}
if let Some(regex) = &self.allow_origin_regex {
if regex.is_match(origin) {
return true;
}
}
self.allow_origins.iter().any(|allowed| allowed == origin)
}
fn simple_response_headers(&self, origin: &str) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = self.simple_headers.clone();
if self.allow_all_origins && self.allow_credentials {
// Credentials require the specific origin instead of '*'.
upsert(
&mut headers,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
);
upsert(
&mut headers,
header::VARY,
HeaderValue::from_static("Origin"),
);
} else if !self.allow_all_origins && self.is_allowed_origin(origin) {
// Specific origins are mirrored back in the response.
upsert(
&mut headers,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
);
upsert(
&mut headers,
header::VARY,
HeaderValue::from_static("Origin"),
);
}
headers
}
fn preflight_response(&self, request_headers: &HeaderMap, origin: &str) -> Response {
let mut headers = self.preflight_headers.clone();
let mut failures: Vec<&'static str> = Vec::new();
if self.is_allowed_origin(origin) {
if self.preflight_explicit_allow_origin {
if let Ok(value) = HeaderValue::from_str(origin) {
headers.push((header::ACCESS_CONTROL_ALLOW_ORIGIN, value));
}
}
} else {
failures.push("origin");
}
let requested_method = request_headers
.get("access-control-request-method")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if !self
.allow_methods
.iter()
.any(|method| method == requested_method)
{
failures.push("method");
}
let requested_headers = request_headers
.get("access-control-request-headers")
.and_then(|value| value.to_str().ok());
if self.allow_all_headers && requested_headers.is_some() {
if let Ok(value) = HeaderValue::from_str(requested_headers.unwrap_or_default()) {
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
}
} else if let Some(requested_headers) = requested_headers {
for header in requested_headers.split(',') {
if !self
.allow_headers_lower
.contains(header.trim().to_lowercase().as_str())
{
failures.push("headers");
break;
}
}
}
let (status, body) = if failures.is_empty() {
(StatusCode::OK, "OK".to_string())
} else {
(
StatusCode::BAD_REQUEST,
format!("Disallowed CORS {}", failures.join(", ")),
)
};
let mut response = Response::new(axum::body::Body::from(body));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
for (name, value) in headers {
if !response.headers().contains_key(&name) {
response.headers_mut().insert(name, value);
}
}
response
}
}
fn upsert(headers: &mut Vec<(HeaderName, HeaderValue)>, name: HeaderName, value: HeaderValue) {
if let Some(existing) = headers.iter_mut().find(|(existing, _)| *existing == name) {
existing.1 = value;
} else {
headers.push((name, value));
}
}
fn build_simple_headers(
allow_all_origins: bool,
allow_credentials: bool,
expose_headers: &[String],
) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = Vec::new();
if allow_all_origins {
headers.push((
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
));
}
if allow_credentials {
headers.push((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
));
}
if !expose_headers.is_empty() {
if let Ok(value) = HeaderValue::from_str(&expose_headers.join(", ")) {
headers.push((header::ACCESS_CONTROL_EXPOSE_HEADERS, value));
}
}
headers
}
fn build_preflight_headers(
methods: &[String],
sorted_allow_headers: &[String],
allow_all_headers: bool,
allow_credentials: bool,
max_age: i64,
explicit_allow_origin: bool,
) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = Vec::new();
if explicit_allow_origin {
headers.push((header::VARY, HeaderValue::from_static("Origin")));
} else {
headers.push((
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
));
}
if let Ok(value) = HeaderValue::from_str(&methods.join(", ")) {
headers.push((header::ACCESS_CONTROL_ALLOW_METHODS, value));
}
if let Ok(value) = HeaderValue::from_str(&max_age.to_string()) {
headers.push((header::ACCESS_CONTROL_MAX_AGE, value));
}
if !sorted_allow_headers.is_empty() && !allow_all_headers {
if let Ok(value) = HeaderValue::from_str(&sorted_allow_headers.join(", ")) {
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
}
}
if allow_credentials {
headers.push((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
));
}
headers
}
/// Merge CORS headers into a response: headers already set by the handler are
/// never overwritten; a `Vary` value is appended to an existing header when
/// not already present.
fn merge_headers(response: &mut Response, headers: Vec<(HeaderName, HeaderValue)>) {
for (name, value) in headers {
if name == header::VARY {
let existing = response
.headers()
.get(header::VARY)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let present = existing
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case("origin"));
if present {
continue;
}
if existing.is_empty() {
response.headers_mut().insert(header::VARY, value);
} else {
if let Ok(combined) = HeaderValue::from_str(&format!(
"{existing}, {}",
value.to_str().unwrap_or("Origin")
)) {
response.headers_mut().insert(header::VARY, combined);
}
}
continue;
}
if !response.headers().contains_key(&name) {
response.headers_mut().insert(name, value);
}
}
}
pub async fn cors_middleware(
State(config): State<Arc<CorsConfig>>,
request: Request,
next: Next,
) -> Response {
let origin = request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let Some(origin) = origin else {
return next.run(request).await;
};
if request.method() == Method::OPTIONS
&& request
.headers()
.contains_key("access-control-request-method")
{
return config.preflight_response(request.headers(), &origin);
}
let headers = config.simple_response_headers(&origin);
let mut response = next.run(request).await;
merge_headers(&mut response, headers);
response
}
-6
View File
@@ -1,6 +0,0 @@
pub mod app;
pub mod config;
pub mod cors;
pub mod logging;
pub mod static_files;
pub mod telemetry;
-58
View File
@@ -1,58 +0,0 @@
//! Logging setup for the tavolo application.
//!
//! The Python server configured logging through `LOGGING_CONFIG` (a
//! `logging.config.dictConfig` YAML file). The Rust server deliberately does
//! not emulate that schema: logging is configured through `RUST_LOG`
//! (tracing-subscriber env filters). `LOGGING_CONFIG` is still accepted so
//! existing deployments do not break, but its value is ignored with a
//! warning.
//!
//! When telemetry is enabled the same subscriber also carries the
//! OpenTelemetry trace layer and the OTLP log bridge.
use crate::config::Settings;
use crate::telemetry::Telemetry;
/// Default filter mirrors the Python default (DEBUG for the application) while
/// keeping noisy dependencies at INFO+.
const DEFAULT_FILTER: &str =
"debug,sqlx=warn,redis=warn,hyper=warn,reqwest=warn,tower_http=info,tungstenite=warn";
pub fn configure_logging(settings: &Settings, telemetry: Option<&Telemetry>) {
use tracing_subscriber::layer::{Layer, SubscriberExt};
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if settings.logging_config.is_some() {
eprintln!(
"LOGGING_CONFIG is set but the Rust server configures logging via RUST_LOG; \
the YAML file is ignored"
);
}
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER));
let subscriber = tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_target(true));
#[cfg(feature = "otel")]
if let Some(telemetry) = telemetry {
use tracing_subscriber::filter::FilterFn;
let telemetry_layer = tracing_opentelemetry::layer().with_tracer(telemetry.tracer());
// Drop OpenTelemetry's own records to avoid export loops.
let log_bridge = opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
telemetry.logger_provider(),
)
.with_filter(FilterFn::new(|metadata| {
!metadata.target().starts_with("opentelemetry")
}));
let subscriber = subscriber.with(telemetry_layer).with(log_bridge);
let _ = subscriber.try_init();
return;
}
#[cfg(not(feature = "otel"))]
let _ = telemetry;
let _ = subscriber.try_init();
}
-160
View File
@@ -1,160 +0,0 @@
//! The `tavolo` binary: `serve` (default), `migrate` and `backfill-elo`.
//!
//! `migrate` replaces the Python `aerich upgrade` migration service: it
//! applies the embedded sqlx migrations (idempotent against databases already
//! at the aerich schema head). `backfill-elo` replaces
//! `python -m tavolo.platform.backfill_elo`.
use std::net::SocketAddr;
use clap::{Parser, Subcommand};
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_app::logging::configure_logging;
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
#[derive(Parser)]
#[command(
name = "tavolo",
version,
about = "Multiplayer card-game platform backend"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Serve the HTTP + WebSocket application (default).
Serve,
/// Apply the database migrations and exit.
Migrate,
/// Recompute every Elo rating from the recorded match history.
BackfillElo {
/// Database URL; defaults to the configured DATABASE_* parts.
#[arg(long, env = "DATABASE_URL")]
database_url: Option<String>,
},
}
fn main() {
let cli = Cli::parse();
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = match cli.command.unwrap_or(Command::Serve) {
Command::Serve => runtime.block_on(serve()),
Command::Migrate => runtime.block_on(migrate()),
Command::BackfillElo { database_url } => {
runtime.block_on(backfill_elo(database_url.as_deref()))
}
};
if let Err(message) = result {
eprintln!("error: {message}");
std::process::exit(1);
}
}
async fn serve() -> Result<(), String> {
use std::sync::Arc;
use tavolo_platform::stats::{PostgresStatsRepo, SharedStatsRepo};
let settings = Settings::from_env()?;
let registry = app::build_registry(&settings)?;
let (session_store, game_store) = app::build_stores(&settings, registry.clone()).await?;
let telemetry =
tavolo_app::telemetry::init(&settings, session_store.clone(), game_store.clone())?;
configure_logging(&settings, telemetry.as_ref());
tracing::info!(
"registered games: {}",
registry
.all()
.iter()
.map(|engine| engine.id())
.collect::<Vec<_>>()
.join(", ")
);
if settings.redis_url.is_some() {
tracing::info!(
"using Redis stores (sessions + live games, game TTL {}s)",
settings.game_ttl_seconds
);
} else {
tracing::info!("REDIS_URL unset: using in-memory stores (sessions + live games)");
}
if telemetry.is_some() {
tracing::info!(
"OpenTelemetry enabled: service={} endpoint={} (traces, metrics, logs)",
settings.otel_service_name,
settings
.otel_exporter_endpoint
.as_deref()
.unwrap_or("(OTLP default)"),
);
}
let pool = app::connect_database(&settings).await?;
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
let platform = app::build_platform(&settings, registry, game_store, stats)?;
let router = app::build_router(platform, session_store, &settings);
let address: SocketAddr = format!("{}:{}", settings.app_host, settings.app_port)
.parse()
.map_err(|_| {
format!(
"invalid bind address {}:{}",
settings.app_host, settings.app_port
)
})?;
let listener = tokio::net::TcpListener::bind(address)
.await
.map_err(|err| format!("could not bind {address}: {err}"))?;
tracing::info!("listening on http://{address}");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.map_err(|err| format!("server error: {err}"))
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
}
fn database_url(override_url: Option<&str>) -> Result<String, String> {
match override_url {
Some(url) => Ok(url.to_string()),
None => Ok(Settings::from_env()?.database_url),
}
}
async fn migrate() -> Result<(), String> {
let settings = Settings::from_env()?;
configure_logging(&settings, None);
let url = database_url(None)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|err| format!("migration failed: {err}"))?;
println!("migrations applied");
Ok(())
}
async fn backfill_elo(override_url: Option<&str>) -> Result<(), String> {
let url = database_url(override_url)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))?;
let repo = PostgresStatsRepo::new(pool);
let replayed = repo
.backfill_elo()
.await
.map_err(|err| format!("backfill failed: {err}"))?;
println!("Recomputed ratings from {replayed} matches.");
Ok(())
}
@@ -1,74 +0,0 @@
//! SPA shell hosting and static asset mounts.
//!
//! Static assets (wasm, js, css, card images) are served by
//! [`tower_http::services::ServeDir`] under `/static` (mapped to the frontend
//! build root) and `/assets` (mapped to `<root>/assets`), mirroring the
//! Granian static mounts of the Python deployment. Every path no API or auth
//! route claimed falls back to `index.html`, so client-side routes
//! (`/game/<id>`) work on direct loads and refreshes.
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::extract::Request;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use tower::Service;
/// Fallback service serving the SPA shell, or 404 when the frontend build is
/// missing.
#[derive(Clone)]
pub struct SpaFallback {
index: Arc<PathBuf>,
}
impl SpaFallback {
pub fn new(static_dir: &str) -> Self {
Self {
index: Arc::new(Path::new(static_dir).join("index.html")),
}
}
}
impl Service<Request> for SpaFallback {
type Response = Response;
type Error = Infallible;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: Request) -> Self::Future {
let index = Arc::clone(&self.index);
Box::pin(async move {
match tokio::fs::read(index.as_ref()).await {
Ok(body) => Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response()),
Err(_) => Ok(StatusCode::NOT_FOUND.into_response()),
}
})
}
}
/// The `/static` and `/assets` mount directories for a build root.
pub fn mount_dirs(static_dir: &str) -> (PathBuf, PathBuf) {
let root = PathBuf::from(static_dir);
let assets = root.join("assets");
(root, assets)
}
/// Build a 404 response body-less response (kept for symmetry with the SPA
/// fallback).
pub fn not_found() -> Response {
Body::empty().into_response()
}
-389
View File
@@ -1,389 +0,0 @@
//! OpenTelemetry wiring: OTLP traces, metrics and logs.
//!
//! Mirrors `tavolo.telemetry` from the Python implementation within what the
//! Rust ecosystem offers:
//!
//! * **traces** — HTTP request spans (tagged with a hashed `session.id` and
//! the OIDC `user.id`) and one span per WebSocket connection, exported over
//! OTLP/HTTP;
//! * **metrics** — `tavolo.active_users` (sessions in the session store),
//! `tavolo.ongoing_games` (live matches not finished yet) and Rust process
//! metrics (`process.cpu.time`, `process.cpu.utilization`,
//! `process.memory.usage`, `process.memory.virtual`) in place of the
//! CPython-specific gauges;
//! * **logs** — tracing events forwarded to OTLP (OpenTelemetry's own records
//! are filtered out upstream).
//!
//! Database client instrumentation (asyncpg/redis spans in Python) has no
//! direct Rust equivalent and is omitted.
use std::sync::Arc;
use crate::config::Settings;
use tavolo_platform::session::SharedSessionStore;
use tavolo_platform::store::SharedGameStore;
#[cfg(feature = "otel")]
mod inner {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use opentelemetry_otlp::{
LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig,
};
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use super::{Settings, SharedGameStore, SharedSessionStore};
pub(super) static HTTP_DURATION: OnceLock<opentelemetry::metrics::Histogram<f64>> =
OnceLock::new();
#[derive(Clone, Copy, Default)]
pub(super) struct ProcessSnapshot {
pub cpu_seconds: f64,
pub cpu_utilization: f64,
pub memory_usage: u64,
pub memory_virtual: u64,
}
pub(super) struct OtelInner {
pub tracer_provider: SdkTracerProvider,
pub meter_provider: SdkMeterProvider,
pub logger_provider: SdkLoggerProvider,
pub poll_task: tokio::task::JoinHandle<()>,
}
impl Drop for OtelInner {
fn drop(&mut self) {
self.poll_task.abort();
let _ = self.logger_provider.shutdown();
let _ = self.meter_provider.shutdown();
let _ = self.tracer_provider.shutdown();
}
}
fn signal_endpoint(base: Option<&str>, signal: &str) -> Option<String> {
base.map(|base| format!("{base}/{signal}"))
}
fn parse_headers(raw: Option<&[String]>) -> HashMap<String, String> {
raw.unwrap_or(&[])
.iter()
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (key.to_string(), value.trim().to_string()))
.collect()
}
fn refresh_process(snapshot: &Mutex<ProcessSnapshot>, pid: sysinfo::Pid) {
use sysinfo::ProcessesToUpdate;
let mut system = sysinfo::System::new();
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
if let Some(process) = system.process(pid) {
let mut snapshot = snapshot.lock().expect("process snapshot");
snapshot.cpu_seconds = process.accumulated_cpu_time() as f64 / 1000.0;
snapshot.cpu_utilization = process.cpu_usage() as f64;
snapshot.memory_usage = process.memory();
snapshot.memory_virtual = process.virtual_memory();
}
}
pub(super) fn init(
settings: &Settings,
session_store: SharedSessionStore,
game_store: SharedGameStore,
) -> Result<Option<super::Telemetry>, String> {
let endpoint = settings.otel_exporter_endpoint.clone();
let headers = parse_headers(settings.otel_exporter_headers.as_deref());
let resource = Resource::builder()
.with_service_name(settings.otel_service_name.clone())
.build();
let span_exporter = SpanExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/traces").unwrap_or_default())
.with_headers(headers.clone())
.build()
.map_err(|err| format!("could not build the OTLP span exporter: {err}"))?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(span_exporter)
.with_resource(resource.clone())
.build();
let metric_exporter = MetricExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/metrics").unwrap_or_default())
.with_headers(headers.clone())
.build()
.map_err(|err| format!("could not build the OTLP metric exporter: {err}"))?;
let reader = PeriodicReader::builder(metric_exporter)
.with_interval(Duration::from_millis(
settings.otel_metric_export_interval_millis.max(1),
))
.build();
let meter_provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource.clone())
.build();
let log_exporter = LogExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/logs").unwrap_or_default())
.with_headers(headers)
.build()
.map_err(|err| format!("could not build the OTLP log exporter: {err}"))?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
opentelemetry::global::set_meter_provider(meter_provider.clone());
let meter = opentelemetry::metrics::MeterProvider::meter(&meter_provider, "tavolo");
let active_users = Arc::new(AtomicI64::new(0));
let ongoing_games = Arc::new(AtomicI64::new(0));
let process = Arc::new(Mutex::new(ProcessSnapshot::default()));
let pid = sysinfo::get_current_pid()
.map_err(|err| format!("could not determine the current process id: {err}"))?;
refresh_process(&process, pid);
let _ = meter
.u64_observable_gauge("tavolo.active_users")
.with_description("Sessions currently held in the session store.")
.with_unit("{user}")
.with_callback({
let active_users = active_users.clone();
move |observer| {
observer.observe(active_users.load(Ordering::Relaxed).max(0) as u64, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("tavolo.ongoing_games")
.with_description("Live games that have not finished yet.")
.with_unit("{game}")
.with_callback({
let ongoing_games = ongoing_games.clone();
move |observer| {
observer.observe(ongoing_games.load(Ordering::Relaxed).max(0) as u64, &[])
}
})
.build();
let _ = meter
.f64_observable_gauge("process.cpu.utilization")
.with_description("Process CPU utilization since the last poll, in percent.")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.cpu_utilization / 100.0, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("process.memory.usage")
.with_description("Process resident memory, in bytes.")
.with_unit("By")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.memory_usage, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("process.memory.virtual")
.with_description("Process virtual memory, in bytes.")
.with_unit("By")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.memory_virtual, &[])
}
})
.build();
let _ = meter
.f64_observable_counter("process.cpu.time")
.with_description("Process CPU time, in seconds.")
.with_unit("s")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.cpu_seconds, &[]);
}
})
.build();
let _ = HTTP_DURATION.set(
meter
.f64_histogram("http.server.request.duration")
.with_description("HTTP server request duration.")
.with_unit("s")
.build(),
);
let poll_interval = Duration::from_secs(settings.otel_metrics_poll_interval_seconds.max(1));
let poll_task = tokio::spawn({
let session_store = session_store.clone();
let game_store = game_store.clone();
let active_users = active_users.clone();
let ongoing_games = ongoing_games.clone();
let process = process.clone();
async move {
loop {
match session_store.count().await {
Ok(count) => active_users.store(count as i64, Ordering::Relaxed),
Err(err) => tracing::warn!("active_users refresh failed: {err}"),
}
match game_store.count_ongoing().await {
Ok(count) => ongoing_games.store(count as i64, Ordering::Relaxed),
Err(err) => tracing::warn!("ongoing_games refresh failed: {err}"),
}
let process = process.clone();
let _ =
tokio::task::spawn_blocking(move || refresh_process(&process, pid)).await;
tokio::time::sleep(poll_interval).await;
}
}
});
Ok(Some(super::Telemetry {
inner: OtelInner {
tracer_provider,
meter_provider,
logger_provider,
poll_task,
},
}))
}
}
/// The live telemetry pipeline; dropping it shuts the providers down.
pub struct Telemetry {
#[cfg(feature = "otel")]
inner: inner::OtelInner,
}
#[cfg(feature = "otel")]
impl Telemetry {
pub fn tracer(&self) -> opentelemetry_sdk::trace::SdkTracer {
use opentelemetry::trace::TracerProvider as _;
self.inner.tracer_provider.tracer("tavolo")
}
pub fn logger_provider(&self) -> &opentelemetry_sdk::logs::SdkLoggerProvider {
&self.inner.logger_provider
}
}
/// Initialize the telemetry pipeline when `OTEL_ENABLED` is set.
pub fn init(
settings: &Settings,
session_store: SharedSessionStore,
game_store: SharedGameStore,
) -> Result<Option<Telemetry>, String> {
if !settings.otel_enabled {
return Ok(None);
}
#[cfg(feature = "otel")]
{
inner::init(settings, session_store, game_store)
}
#[cfg(not(feature = "otel"))]
{
let _ = (session_store, game_store);
Err("OTEL_ENABLED is set but the binary was built without the 'otel' feature".to_string())
}
}
/// Tag a span with the hashed session id and the authenticated user.
pub fn session_attributes(
session: &tavolo_platform::session::SessionData,
) -> (Option<String>, Option<String>) {
let session_id = session.id().map(hash_session_id);
let user = tavolo_platform::auth::session_user(session).map(|user| user.sub);
(session_id, user)
}
/// Truncated SHA-256 of the session id (the cookie is a credential, so it is
/// never exported verbatim).
pub fn hash_session_id(session_id: &str) -> String {
tavolo_platform::auth::session_tag(session_id)
}
/// An HTTP span per request, tagged with session/user; excludes the paths in
/// `OTEL_EXCLUDED_PATHS`. Also records the HTTP server duration histogram.
pub async fn trace_middleware(
excluded: Arc<Vec<String>>,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use tracing::Instrument;
let path = request.uri().path().to_string();
if excluded.contains(&path) {
return next.run(request).await;
}
let method = request.method().clone();
let span = tracing::info_span!(
"http.server.request",
"http.request.method" = %method,
"url.path" = %path,
"http.response.status_code" = tracing::field::Empty,
"session.id" = tracing::field::Empty,
"user.id" = tracing::field::Empty,
);
let session = request
.extensions()
.get::<tavolo_platform::session::SessionHandle>()
.cloned();
async move {
let started = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status().as_u16();
tracing::Span::current().record("http.response.status_code", status);
// Recorded after the handler ran so logins/mutations are visible;
// the session middleware mints the cookie (and its id on first use)
// after this layer returns.
if let Some(session) = session {
let guard = session.lock().await;
let (session_id, user) = session_attributes(&guard);
if let Some(session_id) = session_id {
tracing::Span::current().record("session.id", session_id);
}
if let Some(user) = user {
tracing::Span::current().record("user.id", user);
}
}
record_http_duration(started.elapsed(), &method, status);
response
}
.instrument(span)
.await
}
fn record_http_duration(duration: std::time::Duration, method: &axum::http::Method, status: u16) {
#[cfg(feature = "otel")]
{
if let Some(histogram) = inner::HTTP_DURATION.get() {
histogram.record(
duration.as_secs_f64(),
&[
opentelemetry::KeyValue::new("http.request.method", method.to_string()),
opentelemetry::KeyValue::new("http.response.status_code", status as i64),
],
);
}
}
#[cfg(not(feature = "otel"))]
{
let _ = (duration, method, status);
}
}
-319
View File
@@ -1,319 +0,0 @@
//! End-to-end OIDC login flow against a mock provider.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::Utc;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u
l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5
8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH
EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV
q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U
Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm
aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9
W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc
x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx
XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq
XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5
y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI
8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd
2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH
0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq
z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl
MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0
iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6
c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs
4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP
sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h
NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2
rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP
WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh
nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr
BMZbIcEMr49N2ns4yHzJ3w==
-----END PRIVATE KEY-----
"#;
const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#;
#[derive(Clone, Default)]
struct IdpState {
issuer: String,
nonce: Arc<Mutex<String>>,
}
struct Idp {
state: IdpState,
addr: SocketAddr,
}
async fn start_idp() -> Idp {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind idp");
let addr = listener.local_addr().expect("idp addr");
let base = format!("http://{addr}");
let state = IdpState {
issuer: base.clone(),
..Default::default()
};
let router = Router::new()
.route(
"/.well-known/openid-configuration",
get({
let base = base.clone();
move || async move {
Json(json!({
"issuer": base,
"authorization_endpoint": format!("{base}/authorize"),
"token_endpoint": format!("{base}/token"),
"userinfo_endpoint": format!("{base}/userinfo"),
"jwks_uri": format!("{base}/jwks"),
}))
}
}),
)
.route(
"/authorize",
get(
|State(state): State<IdpState>,
Query(params): Query<std::collections::HashMap<String, String>>| async move {
if let Some(nonce) = params.get("nonce") {
*state.nonce.lock().await = nonce.clone();
}
let state_param = params.get("state").cloned().unwrap_or_default();
let redirect_uri = params
.get("redirect_uri")
.cloned()
.unwrap_or_else(|| "/".into());
let location = format!("{redirect_uri}?code=test-code&state={state_param}");
(
axum::http::StatusCode::FOUND,
[(axum::http::header::LOCATION, location)],
)
},
),
)
.route(
"/token",
post(|State(state): State<IdpState>| async move {
let nonce = state.nonce.lock().await.clone();
let now = Utc::now().timestamp();
let claims = json!({
"iss": state.issuer,
"aud": "tavolo",
"sub": "alice",
"exp": now + 600,
"iat": now,
"nonce": nonce,
});
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
&claims,
&jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(),
)
.unwrap();
Json(json!({
"access_token": "access-token",
"token_type": "Bearer",
"id_token": token,
}))
}),
)
.route(
"/userinfo",
get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }),
)
.route(
"/jwks",
get(|| async { Json(json!({"keys": [serde_json::from_str::<Value>(JWK).unwrap()]})) }),
)
.with_state(state.clone());
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Idp { state, addr }
}
fn test_settings(issuer: &str, redirect_uri: &str) -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: issuer.into(),
client_id: "tavolo".into(),
client_secret: Some("dev-secret".into()),
redirect_uri: redirect_uri.into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: "/nonexistent-web-dist".into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: 30,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
fn cookie_header(response: &axum::response::Response) -> String {
let value = response
.headers()
.get(axum::http::header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.expect("cookie header");
value.split(';').next().expect("cookie pair").to_string()
}
#[tokio::test]
async fn oidc_login_callback_and_logout_flow() {
// The mock IdP must know its own issuer before we can start it, so bind
// first and rebuild the settings from the recorded address.
let idp = start_idp().await;
let issuer = format!("http://{}", idp.addr);
let callback = "http://localhost:8080/auth/default/callback".to_string();
let settings = test_settings(&issuer, &callback);
let (platform, session_store) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, session_store.clone(), &settings);
// 1. Start the login: a 302 to the provider with state/nonce/PKCE and a
// fresh session cookie.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/login")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
let location = response.headers()["location"].to_str().unwrap().to_string();
let login_cookie = cookie_header(&response);
let url = url::Url::parse(&location).expect("authorization url");
let params: std::collections::HashMap<String, String> = url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
assert_eq!(params["client_id"], "tavolo");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["scope"], "openid email profile");
assert_eq!(
params["redirect_uri"],
"http://localhost:8080/auth/default/callback"
);
let state = params["state"].clone();
// Simulate the browser visiting the provider: the mock signs the nonce
// the client generated.
*idp.state.nonce.lock().await = params["nonce"].clone();
// 2. The provider redirects back with the code and state.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri(format!(
"/auth/default/callback?code=test-code&state={state}"
))
.header("cookie", &login_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
assert_eq!(response.headers()["location"], "/");
let session_cookie = cookie_header(&response);
assert_ne!(
session_cookie, login_cookie,
"session id must rotate on login"
);
// A wrong state is rejected without touching the provider.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/callback?code=test-code&state=wrong")
.header("cookie", &login_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 400);
// 3. The session is authenticated and /api/me reflects the userinfo.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/me")
.header("cookie", &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let me: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(me, json!({"sub": "alice", "name": "Alice"}));
// 4. Logout clears the session and sends an expired cookie.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/logout")
.header("cookie", &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
let clear_cookie = response
.headers()
.get(axum::http::header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(
clear_cookie.contains("Max-Age=0"),
"cookie not cleared: {clear_cookie}"
);
}
@@ -1,380 +0,0 @@
//! Configuration parsing and CORS behavior of the app composition root.
use std::sync::Mutex;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const ENV_VARS: &[&str] = &[
"DATABASE_URL",
"DATABASE_ENGINE",
"DATABASE_HOST",
"DATABASE_PORT",
"DATABASE_NAME",
"DATABASE_USER",
"DATABASE_PASSWORD",
"DATABASE_OPTIONS",
"APP_HOST",
"APP_PORT",
"REDIS_URL",
"GAME_TTL_SECONDS",
"STATIC_DIR",
"HAND_ACK_TIMEOUT_SECONDS",
"TURN_TIMEOUT_SECONDS",
"DEADLINE_HEARTBEAT_MS",
"LOGGING_CONFIG",
"CORS_ALLOW_ORIGINS",
"CORS_ALLOW_ORIGIN_REGEX",
"CORS_ALLOW_METHODS",
"CORS_ALLOW_HEADERS",
"CORS_ALLOW_CREDENTIALS",
"CORS_EXPOSE_HEADERS",
"CORS_MAX_AGE",
"OTEL_ENABLED",
"OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_EXCLUDED_PATHS",
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"OTEL_METRICS_POLL_INTERVAL_SECONDS",
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
fn clear_oidc_env() {
for (name, _) in std::env::vars() {
if name.starts_with("OIDC_") {
std::env::remove_var(&name);
}
}
for name in ENV_VARS {
std::env::remove_var(name);
}
}
fn with_env<T>(vars: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().expect("env lock");
clear_oidc_env();
for (name, value) in vars {
std::env::set_var(name, value);
}
let result = f();
clear_oidc_env();
result
}
#[test]
fn defaults_match_the_python_settings() {
let settings = with_env(&[], Settings::from_env).expect("settings");
assert_eq!(settings.app_host, "0.0.0.0");
assert_eq!(settings.app_port, 8080);
assert_eq!(settings.game_ttl_seconds, 86400);
assert_eq!(settings.static_dir, "web/dist");
assert_eq!(settings.oidc_providers.len(), 1);
let provider = &settings.oidc_providers[0];
assert_eq!(provider.provider, "default");
assert_eq!(provider.issuer, "http://localhost:8180/tavolo");
assert_eq!(provider.client_id, "tavolo");
assert_eq!(
provider.redirect_uri,
"http://localhost:8080/auth/default/callback"
);
assert_eq!(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo"
);
assert!(!settings.otel_enabled);
assert_eq!(settings.otel_excluded_paths, vec!["/api/health"]);
}
#[test]
fn indexed_oidc_providers_are_parsed_in_order() {
let settings = with_env(
&[
("OIDC_0_PROVIDER", "google"),
("OIDC_0_ISSUER", "https://accounts.google.com"),
("OIDC_0_CLIENT_ID", "gid"),
("OIDC_0_CLIENT_SECRET", "gsecret"),
("OIDC_1_PROVIDER", "github"),
("OIDC_1_ISSUER", "https://github.com/login/oauth"),
("OIDC_1_CLIENT_ID", "ghid"),
],
Settings::from_env,
)
.expect("settings");
assert_eq!(settings.oidc_providers.len(), 2);
assert_eq!(settings.oidc_providers[0].provider, "google");
assert_eq!(
settings.oidc_providers[0].redirect_uri,
"http://localhost:8080/auth/google/callback"
);
assert_eq!(
settings.oidc_providers[0].client_secret.as_deref(),
Some("gsecret")
);
assert_eq!(settings.oidc_providers[1].provider, "github");
assert!(settings.oidc_providers[1].client_secret.is_none());
}
#[test]
fn invalid_oidc_configurations_are_rejected() {
let error = with_env(
&[
("OIDC_0_PROVIDER", "a"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
("OIDC_2_PROVIDER", "b"),
("OIDC_2_ISSUER", "https://y"),
("OIDC_2_CLIENT_ID", "y"),
],
Settings::from_env,
)
.expect_err("missing index must fail");
assert!(error.contains("contiguous"), "{error}");
let error = with_env(&[("OIDC_ISSUER", "https://legacy")], Settings::from_env)
.expect_err("legacy variables must fail");
assert!(error.contains("indexed per provider"), "{error}");
let error = with_env(
&[
("OIDC_0_PROVIDER", "bad name!"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
],
Settings::from_env,
)
.expect_err("invalid provider names must fail");
assert!(error.contains("letters, digits"), "{error}");
let error = with_env(
&[
("OIDC_0_PROVIDER", "a"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
("OIDC_0_WAT", "?"),
],
Settings::from_env,
)
.expect_err("unknown variables must fail");
assert!(
error.contains("unknown OIDC environment variable"),
"{error}"
);
}
#[test]
fn database_dsn_quotes_credentials() {
let settings = with_env(
&[
("DATABASE_USER", "ta:v@lo"),
("DATABASE_PASSWORD", "p@ss word"),
("DATABASE_HOST", "db.example"),
("DATABASE_PORT", "5433"),
("DATABASE_NAME", "tavolo"),
("DATABASE_OPTIONS", "ssl=require"),
],
Settings::from_env,
)
.expect("settings");
assert_eq!(
settings.database_url,
"postgres://ta%3Av%40lo:p%40ss%20word@db.example:5433/tavolo?ssl=require"
);
}
#[tokio::test]
async fn cors_is_disabled_without_configuration() {
let settings = base_settings();
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn cors_allows_configured_origins_and_regexes() {
let mut settings = base_settings();
settings.cors_allow_origins = Some(vec!["https://cards.example".into()]);
settings.cors_allow_origin_regex = Some(r"https://preview-[a-z0-9-]+\.example\.com".into());
settings.cors_allow_methods = Some(vec!["GET".into(), "POST".into()]);
settings.cors_max_age = 3600;
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
// Allowed origin: echoed. Disallowed origin: no header, request still runs.
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://cards.example"
);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://preview-abc.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://preview-abc.example.com"
);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://mallory.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
// Preflight.
let response = router
.clone()
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/health")
.header("origin", "https://cards.example")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let methods = response.headers()["access-control-allow-methods"]
.to_str()
.unwrap()
.to_string();
assert!(methods.contains("POST"), "{methods}");
assert_eq!(response.headers()["access-control-max-age"], "3600");
let response = router
.clone()
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/health")
.header("origin", "https://mallory.example")
.header("access-control-request-method", "GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn cors_credentials_echo_the_origin_for_wildcards() {
let mut settings = base_settings();
settings.cors_allow_origins = Some(vec!["*".into()]);
settings.cors_allow_credentials = true;
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://cards.example"
);
assert_eq!(
response.headers()["access-control-allow-credentials"],
"true"
);
}
fn base_settings() -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: "http://localhost:8180/tavolo".into(),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: "/nonexistent-web-dist".into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: 30,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
@@ -1,320 +0,0 @@
//! End-to-end scopone flow: lobby, four players, live play over the
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
use std::net::SocketAddr;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
use tavolo_platform::session::SharedSessionStore;
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
fn settings(static_dir: &str, turn_timeout: i64) -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: "http://localhost:8180/tavolo".into(),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: static_dir.into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: turn_timeout,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
"oidc:default:user".into(),
json!({"sub": sub, "preferred_username": name}),
);
store.save(&id, &data, Some(3600)).await.expect("session");
format!("session_id={id}")
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
}
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("cookie", cookie)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap()
}
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).unwrap()
}
async fn start_server(router: axum::Router) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = format!("ws://{addr}/ws/games/{game_id}")
.into_client_request()
.expect("request");
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
);
request
}
async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket.next().await.expect("message").expect("ok");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json")
}
other => panic!("expected text, got {other:?}"),
}
}
#[tokio::test]
async fn scopone_lobby_play_and_turn_timeout() {
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
let test_settings = settings(temp.to_str().unwrap(), 1);
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
let router = app::build_router(platform, sessions.clone(), &test_settings);
// Create the game as alice and join with the other three players.
let alice = login(&sessions, "alice", "Alice").await;
let created = router
.clone()
.oneshot(post(
"/api/games",
&alice,
&json!({"options": {"target_score": 11}}),
))
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let lobby = body_json(created).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
assert_eq!(lobby["seats_open"], 3);
assert_eq!(lobby["players"][0]["team"], "A");
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
let cookie = login(&sessions, player, &player.to_uppercase()).await;
let response = router
.clone()
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "player {player}");
let body = body_json(response).await;
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
if index == 3 {
assert_eq!(body["phase"], "playing");
assert_eq!(body["hand_number"], 1);
assert_eq!(body["dealer"], 0);
assert_eq!(body["turn"], 1);
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
}
}
// Bob is on turn (seat 1): his hand is visible to him and hidden from
// alice's personalized view.
let bob = login(&sessions, "bob", "Bob").await;
let bob_view = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
.await
.unwrap(),
)
.await;
assert_eq!(bob_view["turn"], 1);
assert_eq!(bob_view["your_turn"], true);
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
assert!(bob_view["players"][0].get("hand").is_none());
assert!(bob_view["legal_moves"].is_object());
let alice_view = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
.await
.unwrap(),
)
.await;
assert_eq!(
alice_view["players"][0]["hand"].as_array().unwrap().len(),
10
);
assert!(alice_view["players"][1].get("hand").is_none());
let addr = start_server(router.clone()).await;
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
.await
.expect("bob connects");
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
// The table starts empty and the turn belongs to bob (seat 1).
assert_eq!(state["game"]["turn"], 1);
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
// Bob is on turn on the websocket view.
assert_eq!(state["game"]["your_turn"], true);
let hand = state["game"]["players"][1]["hand"]
.as_array()
.unwrap()
.clone();
let card = hand[0].as_str().unwrap().to_string();
let legal = state["game"]["legal_moves"]
.as_object()
.and_then(|moves| moves.get(&card))
.cloned();
let mut message = json!({"action": "play", "card": card});
if let Some(legal) = legal {
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
message["capture"] = option.clone();
}
}
ws.send(tokio_tungstenite::tungstenite::Message::Text(
message.to_string(),
))
.await
.expect("send play");
let update = read_json(&mut ws).await;
assert_eq!(update["type"], "state");
assert_eq!(update["game"]["turn"], 2);
assert_eq!(update["game"]["last_move"]["seat"], 1);
assert_eq!(update["game"]["last_move"]["card"], card);
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
let after = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
.await
.unwrap(),
)
.await;
assert_eq!(after["turn"], 3);
assert_eq!(
after["players"][2]["cards_left"].as_u64().unwrap(),
before - 1
);
assert_eq!(after["last_move"]["seat"], 2);
}
#[tokio::test]
async fn spa_shell_and_static_mounts() {
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
let test_settings = settings(temp.to_str().unwrap(), 30);
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
let router = app::build_router(platform, sessions, &test_settings);
let response = router.clone().oneshot(get("/", None)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
let response = router
.clone()
.oneshot(get("/game/abc-123", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
let response = router
.clone()
.oneshot(get("/static/index.html", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(get("/assets/card.png", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], b"png-bytes");
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
// an unknown OIDC provider path (no route registered) does too.
let response = router
.clone()
.oneshot(get("/api/does-not-exist", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(get("/auth/nope/login", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
std::fs::remove_dir_all(temp).ok();
}
-36
View File
@@ -1,36 +0,0 @@
[package]
name = "tavolo-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Game-independent half of the tavolo platform: lobby, live play, history, ratings"
[dependencies]
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
rand.workspace = true
redis.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
axum = { workspace = true }
futures-util = { workspace = true }
http-body-util = "0.1"
tokio = { workspace = true }
tokio-tungstenite = "0.24"
tower = { workspace = true }
-79
View File
@@ -1,79 +0,0 @@
//! Authentication helpers on top of the session store.
//!
//! The platform has no application roles: every authenticated user may create
//! and join games. Authorization beyond login is game membership, checked
//! against the seats of the live session.
//!
//! kaya-oidc namespaces its session state per provider as
//! `oidc:{provider}:user`, so the lookup scans for any provider's user.
use serde_json::{Map, Value};
use crate::session::{SessionData, SessionHandle};
const OIDC_PREFIX: &str = "oidc:";
const USER_SUFFIX: &str = ":user";
/// Read-only view of the OIDC userinfo / ID token claims.
#[derive(Debug, Clone)]
pub struct User {
pub sub: String,
pub claims: Map<String, Value>,
}
/// Return the authenticated user stored in a session, if any.
pub fn session_user(session: &SessionData) -> Option<User> {
for key in session.keys() {
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
if let Some(Value::Object(claims)) = session.get(key) {
let sub = claims.get("sub").and_then(Value::as_str)?.to_string();
return Some(User {
sub,
claims: claims.clone(),
});
}
}
}
None
}
/// Return the authenticated user of a request, if any.
pub async fn current_user(session: &SessionHandle) -> Option<User> {
let session = session.lock().await;
session_user(&session)
}
/// Best-effort human-readable name for a user.
pub fn display_name(user: &User) -> String {
for key in ["name", "preferred_username", "email"] {
if let Some(Value::String(value)) = user.claims.get(key) {
if !value.is_empty() {
return value.clone();
}
}
}
user.sub.clone()
}
/// Truncated SHA-256 of a session id; the cookie is a credential, so it is
/// never exported verbatim (used for span attributes).
pub fn session_tag(session_id: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(session_id.as_bytes());
let hex = format!("{digest:x}");
hex[..16].to_string()
}
/// The names of every provider with a user in this session.
pub fn logged_in_providers(session: &SessionData) -> Vec<String> {
let mut providers = Vec::new();
for key in session.keys() {
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
let provider = &key[OIDC_PREFIX.len()..key.len() - USER_SUFFIX.len()];
if !provider.is_empty() {
providers.push(provider.to_string());
}
}
}
providers
}
@@ -1,64 +0,0 @@
//! ISO-8601 datetime helpers matching Python's `datetime.isoformat()` /
//! `datetime.fromisoformat()` behavior for UTC timestamps.
use chrono::{DateTime, NaiveDateTime, Utc};
/// Format a UTC timestamp the way Python's `datetime.isoformat()` does for an
/// aware UTC datetime: microseconds are included only when non-zero, and the
/// UTC offset is rendered as `+00:00` (never `Z`).
pub fn to_iso(dt: DateTime<Utc>) -> String {
if dt.timestamp_subsec_micros() == 0 {
dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string()
} else {
dt.format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
}
}
/// Parse an ISO-8601 timestamp, tolerating both `Z` and explicit offsets.
///
/// Timestamps without an offset are interpreted as UTC (Python would treat
/// them as local time, but every timestamp this platform produces carries an
/// offset).
pub fn from_iso(value: &str) -> Option<DateTime<Utc>> {
if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
return Some(parsed.with_timezone(&Utc));
}
if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
return Some(DateTime::from_naive_utc_and_offset(naive, Utc));
}
None
}
/// Parse a JSON value carrying an ISO timestamp.
pub fn from_json(value: &serde_json::Value) -> Option<DateTime<Utc>> {
value.as_str().and_then(from_iso)
}
/// Serde `serialize_with` helper emitting Python-compatible ISO-8601.
pub fn serialize_iso<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&to_iso(*dt))
}
/// The current UTC instant, with microsecond precision.
pub fn now() -> DateTime<Utc> {
Utc::now()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn round_trips_with_and_without_microseconds() {
let base = Utc.with_ymd_and_hms(2026, 9, 19, 6, 59, 17).unwrap();
assert_eq!(to_iso(base), "2026-09-19T06:59:17+00:00");
let dt = base + chrono::Duration::microseconds(123456);
assert_eq!(to_iso(dt), "2026-09-19T06:59:17.123456+00:00");
assert_eq!(from_iso("2026-09-19T06:59:17+00:00"), Some(base));
assert_eq!(from_iso("2026-09-19T06:59:17.123456Z"), Some(dt));
}
}
@@ -1,250 +0,0 @@
//! Deadline-driven timeouts, independent of player connections.
//!
//! In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
//! auto-continue) are driven by absolute deadlines, never by which players
//! (or whether any players) are connected. The platform owns the scheduling
//! machinery; the meaning of each deadline belongs to the game engine:
//!
//! * after every mutation the engine's
//! [`GameEngine::next_deadline`](crate::engine::GameEngine::next_deadline)
//! computes the deadline the new state implies, and the scheduler enqueues
//! it in the store's shared deadline queue (a Redis sorted set in
//! production); enqueueing is idempotent;
//! * enqueueing a deadline announces it on a shared pub/sub channel so every
//! worker's consumer wakes immediately; the consumer also wakes on a slow
//! heartbeat, the backstop recovering lost notifications. Due entries are
//! handed to the engine's
//! [`GameEngine::fire_deadline`](crate::engine::GameEngine::fire_deadline)
//! under the per-game lock; the engine revalidates the token against the
//! live state, so duplicate or stale deliveries are harmless.
//!
//! Delivery is at-least-once: an entry is removed from the queue only after
//! it has been processed. Entries whose game has expired are dropped the
//! first time they fire.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tokio::sync::Notify;
use crate::engine::GameSession;
use crate::errors::{Error, Result};
use crate::registry::GameRegistry;
use crate::stats::SharedStatsRepo;
use crate::store::SharedGameStore;
pub struct DeadlineScheduler {
store: SharedGameStore,
registry: Arc<GameRegistry>,
stats: SharedStatsRepo,
heartbeat: Duration,
retry_delay: Duration,
wake: Notify,
started: AtomicBool,
}
impl DeadlineScheduler {
pub fn new(
store: SharedGameStore,
registry: Arc<GameRegistry>,
stats: SharedStatsRepo,
heartbeat_ms: u64,
retry_ms: u64,
) -> Arc<Self> {
Arc::new(Self {
store,
registry,
stats,
heartbeat: Duration::from_millis(heartbeat_ms),
retry_delay: Duration::from_millis(retry_ms),
wake: Notify::new(),
started: AtomicBool::new(false),
})
}
/// Start the consumer and listener tasks (idempotent).
pub fn start(self: &Arc<Self>) {
if self.started.swap(true, Ordering::SeqCst) {
return;
}
tokio::spawn(run_consumer(Arc::clone(self)));
tokio::spawn(run_listener(Arc::clone(self)));
tracing::debug!("deadline consumer started");
}
/// Enqueue the deadline the current state implies, if any.
///
/// Called after every mutation that can set a deadline and as a backstop
/// when a client connects. Enqueueing is idempotent: an identical entry
/// is already queued with the same due time, so re-adding changes
/// nothing.
pub async fn sync_deadline(self: &Arc<Self>, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?.clone();
let Some(deadline) = engine.next_deadline(session)? else {
return Ok(());
};
self.start();
let entry = json!({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
});
let member = serde_json::to_string(&entry)?;
let due_at = deadline.due_at.timestamp() as f64
+ deadline.due_at.timestamp_subsec_micros() as f64 / 1e6;
self.store.add_deadline(&member, due_at).await?;
if let Err(err) = self.store.notify_deadline().await {
tracing::warn!(
"deadline enqueue notification failed; relying on the poll backstop: {err}"
);
}
self.wake.notify_waiters();
Ok(())
}
/// Persist a successful mutation, notify subscribers and enqueue the next
/// deadline. Callers must hold the per-game lock. Handles the terminal
/// transition: the match result is written to Postgres once (guarded by
/// `stats_saved`).
pub async fn finalize_mutation(self: &Arc<Self>, session: &mut GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?.clone();
if engine.is_finished(session) {
if session.finished_at.is_none() {
session.finished_at = Some(crate::datetime::now());
}
if !session.stats_saved {
self.stats.save_match(session, engine.as_ref()).await?;
session.stats_saved = true;
}
tracing::info!("game {} ({}) finished", session.id, session.game_type);
}
self.store.save(session).await?;
self.store.publish(&session.id).await?;
self.sync_deadline(session).await?;
Ok(())
}
/// Fire a single due deadline entry.
///
/// The engine revalidates the entry against the live state under the
/// per-game lock; stale or foreign entries are discarded without effect.
/// The entry is removed from the queue once handled; if handling fails
/// unexpectedly (e.g. the lock cannot be acquired), the entry is left in
/// the queue so another consumer retries it.
pub async fn process_due(self: &Arc<Self>, member: &str) -> Result<()> {
let Ok(entry) = serde_json::from_str::<serde_json::Value>(member) else {
tracing::warn!("deadline consumer: dropping malformed entry {member:?}");
self.store.remove_deadline(member).await?;
return Ok(());
};
let game_id = entry.get("game_id").and_then(serde_json::Value::as_str);
let kind = entry.get("kind").and_then(serde_json::Value::as_str);
let token = entry
.get("token")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let (Some(game_id), Some(kind)) = (game_id, kind) else {
self.store.remove_deadline(member).await?;
return Ok(());
};
let lock = self.store.acquire_lock(game_id).await?;
let outcome = async {
let Some(mut session) = self.store.load(game_id).await? else {
return Ok(());
};
let engine = self.registry.require(&session.game_type)?.clone();
match engine.fire_deadline(&mut session, kind, token) {
Ok(()) => self.finalize_mutation(&mut session).await,
Err(err) if err.as_game().is_some() => {
// Stale or inapplicable entry: nothing to do.
Ok(())
}
Err(err) => Err(err),
}
}
.await;
lock.release().await?;
outcome?;
self.store.remove_deadline(member).await?;
Ok(())
}
}
async fn run_consumer(scheduler: Arc<DeadlineScheduler>) {
loop {
// Clear before polling so an enqueue racing the poll re-wakes us.
let mut delay = scheduler.heartbeat;
let mut failed = false;
match scheduler.store.due_deadlines(now_secs(), 32).await {
Ok(members) => {
for member in members {
match scheduler.process_due(&member).await {
Ok(()) => {}
Err(err) => {
tracing::error!(
"deadline consumer: failed to process {member:?}: {err}"
);
failed = true;
}
}
}
}
Err(err) => {
tracing::error!("deadline consumer: poll failed; retrying: {err}");
failed = true;
}
}
match scheduler.store.next_deadline().await {
Ok(Some(next_due)) => {
let until = (next_due - now_secs()).max(0.0);
delay = delay.min(Duration::from_secs_f64(until));
}
Ok(None) => {}
Err(err) => {
tracing::error!("deadline consumer: next_deadline failed: {err}");
failed = true;
}
}
if failed {
// A failed entry is still due: back off briefly instead of
// polling it again immediately.
delay = scheduler.retry_delay;
}
tokio::select! {
_ = scheduler.wake.notified() => {}
_ = tokio::time::sleep(delay) => {}
}
}
}
async fn run_listener(scheduler: Arc<DeadlineScheduler>) {
loop {
match scheduler.store.subscribe_deadlines().await {
Ok(mut events) => {
use futures_util::StreamExt;
while let Some(()) = events.next().await {
scheduler.wake.notify_waiters();
}
}
Err(err) => {
tracing::error!("deadline listener failed; reconnecting: {err}");
}
}
tokio::time::sleep(scheduler.retry_delay).await;
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
/// Convenience constructor returning an error when the registry is empty.
pub fn scheduler_error() -> Error {
Error::Internal("deadline scheduler misconfigured".into())
}
-65
View File
@@ -1,65 +0,0 @@
//! Chess-style Elo ratings, generalized to two-team matches.
//!
//! Every player starts at [`INITIAL_RATING`]. A team's rating is the mean of
//! its members' current ratings, so the usual chess formula applies unchanged
//! between the two teams:
//!
//! * expected score `E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))`
//! * actual score `S` is 1 for a win and 0 for a loss (matches never draw)
//! * every member of a team gains/loses the same `round(K * (S - E))`
//!
//! Deltas are rounded to integers and ratings are stored as integers, so the
//! system is exactly zero-sum. Rounding uses Python's banker's rounding
//! (`round()` half-to-even), which Rust exposes as `f64::round_ties_even`.
pub const INITIAL_RATING: i64 = 1500;
pub const K_FACTOR: f64 = 32.0;
/// Expected score (0..1) of a side rated `rating` against `opponent_rating`.
pub fn expected_score(rating: f64, opponent_rating: f64) -> f64 {
1.0 / (1.0 + 10.0_f64.powf((opponent_rating - rating) / 400.0))
}
/// A team's rating is the mean of its members' ratings.
pub fn team_rating(ratings: &[f64]) -> Result<f64, &'static str> {
if ratings.is_empty() {
return Err("a team needs at least one rating");
}
Ok(ratings.iter().sum::<f64>() / ratings.len() as f64)
}
/// Rating change applied to each member of team A.
///
/// `winner_team` is 0 when team A won, 1 when team B won. Team B members
/// change by the negation of the returned value (zero-sum).
pub fn match_delta(team_a_ratings: &[f64], team_b_ratings: &[f64], winner_team: usize) -> i64 {
let rating_a = team_rating(team_a_ratings).unwrap_or_default();
let rating_b = team_rating(team_b_ratings).unwrap_or_default();
let expected = expected_score(rating_a, rating_b);
let score = if winner_team == 0 { 1.0 } else { 0.0 };
(K_FACTOR * (score - expected)).round_ties_even() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_ratings_move_16_points() {
assert_eq!(match_delta(&[1500.0], &[1500.0], 0), 16);
assert_eq!(match_delta(&[1500.0], &[1500.0], 1), -16);
}
#[test]
fn team_rating_is_the_mean() {
assert_eq!(team_rating(&[1400.0, 1600.0]).unwrap(), 1500.0);
assert!(team_rating(&[]).is_err());
}
#[test]
fn banker_rounding_matches_python() {
// Python: round(32 * (1 - 0.5)) == 16; ties-to-even for exact halves.
assert_eq!((16.5_f64).round_ties_even(), 16.0);
assert_eq!((17.5_f64).round_ties_even(), 18.0);
}
}
-335
View File
@@ -1,335 +0,0 @@
//! The contract between the platform and a game implementation.
//!
//! The platform owns everything that is game-independent: the lobby, live
//! game storage, websockets, timeouts, match history and leaderboards. A
//! game (e.g. scopone scientifico) implements [`GameEngine`] and plugs into
//! the platform through [`crate::registry::GameRegistry`]; the platform
//! never imports a concrete game module.
//!
//! The central type is [`GameSession`]: the platform-owned envelope carrying
//! the lifecycle metadata (id, join code, seats, timestamps) plus an opaque
//! `state` blob that only the engine interprets. The engine serializes and
//! deserializes that blob so the store stays game-agnostic.
use std::any::Any;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::errors::{GameError, Result};
/// One occupied chair in a game, owned by the platform.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Seat {
pub user_sub: String,
pub display_name: String,
/// Game-defined team label ("A"/"B", ...); `None` for games without
/// fixed teams.
pub team: Option<String>,
}
impl Seat {
pub fn new(user_sub: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: None,
}
}
pub fn to_json(&self) -> Value {
serde_json::json!({
"user_sub": self.user_sub,
"display_name": self.display_name,
"team": self.team,
})
}
pub fn from_json(data: &Value) -> Result<Self> {
Ok(Self {
user_sub: data
.get("user_sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
display_name: data
.get("display_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
team: data.get("team").and_then(Value::as_str).map(str::to_string),
})
}
}
/// The platform-owned envelope of a live game.
///
/// `state` is opaque to the platform: only the engine registered for
/// `game_type` reads or writes it.
pub struct GameSession {
pub id: String,
pub game_type: String,
pub join_code: String,
pub creator_sub: String,
pub players: Vec<Seat>,
pub created_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
/// Set once the finished match has been copied to Postgres.
pub stats_saved: bool,
pub state: Option<Box<dyn Any + Send + Sync>>,
}
impl GameSession {
/// A fresh lobby session created by [`GameEngine::create`].
pub fn new(
id: impl Into<String>,
game_type: impl Into<String>,
join_code: impl Into<String>,
) -> Self {
Self {
id: id.into(),
game_type: game_type.into(),
join_code: join_code.into(),
creator_sub: String::new(),
players: Vec::new(),
created_at: None,
finished_at: None,
stats_saved: false,
state: None,
}
}
/// Whether `sub` occupies a seat in this game.
pub fn seated(&self, sub: &str) -> bool {
self.players.iter().any(|seat| seat.user_sub == sub)
}
/// The seat occupied by `sub`, if any.
pub fn seat_of(&self, sub: &str) -> Option<&Seat> {
self.players.iter().find(|seat| seat.user_sub == sub)
}
/// The seat index occupied by `sub`, if any.
pub fn seat_index_of(&self, sub: &str) -> Option<usize> {
self.players.iter().position(|seat| seat.user_sub == sub)
}
}
/// A timeout the engine wants the platform to fire.
///
/// `kind` is an engine-defined string (e.g. `"turn"`); `token` is an opaque
/// revalidation token: when the deadline fires, the engine must recompute it
/// from the live session and refuse to act (returning an error) if it no
/// longer matches, which makes duplicate or overtaken deliveries harmless.
#[derive(Debug, Clone, PartialEq)]
pub struct Deadline {
pub kind: String,
pub due_at: DateTime<Utc>,
pub token: String,
}
/// The outcome of a finished match for one participant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerResult {
pub user_sub: String,
pub seat: i64,
pub won: bool,
pub team: Option<String>,
/// Points scored, aggregated by the leaderboard.
pub score: f64,
/// Game-specific extras persisted alongside the participation.
pub details: Value,
}
/// The game-independent outcome of a finished match.
///
/// `teams` groups the participants' subs into the sides the Elo rating treats
/// as opponents (exactly two for now); `winner_team` is the index of the
/// winning side. `summary` is persisted verbatim as the match's JSON
/// `result` column.
#[derive(Debug, Clone)]
pub struct MatchResult {
pub teams: Vec<Vec<String>>,
pub winner_team: usize,
pub players: Vec<PlayerResult>,
pub summary: Value,
}
impl MatchResult {
pub fn new(
teams: Vec<Vec<String>>,
winner_team: usize,
players: Vec<PlayerResult>,
summary: Value,
) -> Self {
Self {
teams,
winner_team,
players,
summary,
}
}
}
/// The interface every playable game implements.
///
/// Implementations must be deterministic and I/O-free apart from logging:
/// all persistence, locking and transport concerns belong to the platform.
/// Rule violations are reported by returning [`GameError`] values.
pub trait GameEngine: Send + Sync {
/// Unique id carried by sessions and persisted matches.
fn id(&self) -> &'static str;
/// Human-readable name, for the lobby UI.
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn min_players(&self) -> usize;
fn max_players(&self) -> usize;
/// JSON-schema fragment describing the game-specific creation options
/// accepted by [`GameEngine::create`] (exposed via `GET /api/game-types`).
fn options_schema(&self) -> Value;
/// Initialize `session.state` for a fresh lobby game.
///
/// The platform has already filled the session envelope and seated the
/// creator (`session.players[0]`, without a team label yet). Assign the
/// creator's team here if the game has fixed teams, then validate
/// `options` and initialize `session.state`.
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()>;
/// Seat a player, starting the match when the lobby fills up.
///
/// Must append a [`Seat`] to `session.players` and mirror whatever
/// per-player state the game keeps in `session.state`.
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()>;
/// Apply one player action received over the websocket.
///
/// `payload` is the client message minus its `action` field. Unknown
/// actions and rule violations return [`GameError`].
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()>;
/// The personalized game view for one player.
///
/// Must hide information the player is not entitled to (e.g. other
/// players' hands). The platform merges in the envelope fields
/// (`id`, `join_code`, `game_type`) itself.
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value;
/// Game-specific fields of the lobby payload (options echo, phase).
fn lobby_view(&self, session: &GameSession) -> Value;
/// Whether the game is still waiting for players.
fn in_lobby(&self, session: &GameSession) -> bool;
/// Whether the match is over and a result can be extracted.
fn is_finished(&self, session: &GameSession) -> bool;
/// The outcome of the match. Only called when finished.
fn result(&self, session: &GameSession) -> Result<MatchResult>;
/// Serialize the opaque game state to plain JSON.
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value>;
/// Rebuild the opaque game state from its JSON form.
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>>;
/// The deadline the current state implies, if any.
///
/// Called after every mutation; the platform enqueues the returned
/// deadline (enqueueing is idempotent) and never removes previously
/// enqueued ones — stale entries are discarded at fire time by
/// [`GameEngine::fire_deadline`] revalidation.
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>>;
/// Apply the timeout action for a due deadline.
///
/// Must recompute the current deadline and return a [`GameError`] without
/// acting when `kind`/`token` no longer match the live state.
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()>;
/// Extra fields merged into the `game_over` websocket message.
fn game_over_view(&self, _session: &GameSession, _user_sub: &str) -> Value {
serde_json::json!({})
}
}
/// Build the JSON envelope for a session: platform fields plus the engine's
/// state blob (mirrors `tavolo.platform.store.session_to_json`).
pub fn session_to_json(session: &GameSession, engine: &dyn GameEngine) -> Result<Value> {
let state = match session.state.as_ref() {
Some(state) => engine.state_to_json(state.as_ref())?,
None => Value::Object(Map::new()),
};
Ok(serde_json::json!({
"id": session.id,
"game_type": session.game_type,
"join_code": session.join_code,
"creator_sub": session.creator_sub,
"players": session.players.iter().map(Seat::to_json).collect::<Vec<_>>(),
"created_at": session.created_at.map(crate::datetime::to_iso),
"finished_at": session.finished_at.map(crate::datetime::to_iso),
"stats_saved": session.stats_saved,
"state": state,
}))
}
/// Rebuild a session from its JSON envelope, delegating the state blob to
/// the engine registered for its `game_type` (mirrors
/// `tavolo.platform.store.session_from_json`).
pub fn session_from_json(data: &Value, engine: &dyn GameEngine) -> Result<GameSession> {
let game_type = data
.get("game_type")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("session is missing game_type"))?
.to_string();
if game_type != engine.id() {
return Err(
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into(),
);
}
let players = data
.get("players")
.and_then(Value::as_array)
.map(|list| list.iter().map(Seat::from_json).collect::<Result<Vec<_>>>())
.transpose()?
.unwrap_or_default();
let state = match data.get("state") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(engine.state_from_json(data.get("state").unwrap())?)
}
_ => Some(engine.state_from_json(&Value::Object(Map::new()))?),
};
Ok(GameSession {
id: data
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
game_type,
join_code: data
.get("join_code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
creator_sub: data
.get("creator_sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
players,
created_at: data.get("created_at").and_then(crate::datetime::from_json),
finished_at: data.get("finished_at").and_then(crate::datetime::from_json),
stats_saved: data
.get("stats_saved")
.and_then(Value::as_bool)
.unwrap_or(false),
state,
})
}
-101
View File
@@ -1,101 +0,0 @@
//! Typed errors shared between the platform and game implementations.
//!
//! Game engines return [`GameError`] on rule/validation failures; the
//! platform translates them into 4xx HTTP responses or `error` WebSocket
//! messages, using [`GameError::code`] as the machine-readable error code.
//! Engines themselves stay transport-agnostic.
//!
//! Mirrors `tavolo.platform.errors` from the Python implementation.
use thiserror::Error;
/// Base error for every rule/validation failure a game can raise.
#[derive(Debug, Clone, Error)]
#[error("{message}")]
pub struct GameError {
/// Machine-readable code carried by WebSocket `error` messages.
pub code: &'static str,
/// Human-readable failure reason.
pub message: String,
}
impl GameError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// The requested action violates the rules of the game.
pub fn illegal_move(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A player attempted to act out of turn.
pub fn not_your_turn(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// An action was attempted before the game left the lobby.
pub fn game_not_started(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// An action was attempted after the match ended.
pub fn game_finished(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A game already has its full complement of players.
pub fn lobby_full(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A player tried to join a game they are already seated in.
pub fn already_joined(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// No live game exists for the given id or join code.
pub fn game_not_found(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A request named a `game_type` no registered engine provides.
pub fn unknown_game_type(message: impl Into<String>) -> Self {
Self::new("unknown_game_type", message)
}
}
/// Errors raised by the platform outside game rules (storage, transport,
/// serialization).
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Game(#[from] GameError),
#[error("store error: {0}")]
Store(String),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Redis(#[from] redis::RedisError),
#[error(transparent)]
Sql(#[from] sqlx::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("{0}")]
Internal(String),
}
impl Error {
/// The game error behind this error, when there is one.
pub fn as_game(&self) -> Option<&GameError> {
match self {
Error::Game(err) => Some(err),
_ => None,
}
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
-138
View File
@@ -1,138 +0,0 @@
//! JSON request/response helpers for the HTTP handlers.
//!
//! Mirrors `tavolo.platform.http` from the Python implementation: the error
//! messages produced here are part of the public API contract.
use std::collections::HashMap;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use serde_json::{json, Value};
/// Raised when the request body is not valid JSON or not a JSON object.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{0}")]
pub struct JsonRequestError(pub String);
/// Drain and parse a request body as a JSON object.
pub fn read_json(body: &[u8]) -> Result<Value, JsonRequestError> {
if body.is_empty() {
return Err(JsonRequestError("empty body".into()));
}
let parsed: Value =
serde_json::from_slice(body).map_err(|_| JsonRequestError("invalid JSON".into()))?;
if !parsed.is_object() {
return Err(JsonRequestError("JSON body must be an object".into()));
}
Ok(parsed)
}
/// Like [`read_json`] but treats an empty body as `{}`.
pub fn read_json_optional(body: &[u8]) -> Result<Value, JsonRequestError> {
match read_json(body) {
Ok(value) => Ok(value),
Err(JsonRequestError(message)) if message == "empty body" => Ok(json!({})),
Err(err) => Err(err),
}
}
/// Parse a raw query string into a mapping of param name to all values.
pub fn extract_query_params(query: Option<&str>) -> HashMap<String, Vec<String>> {
let mut params: HashMap<String, Vec<String>> = HashMap::new();
if let Some(query) = query {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params
.entry(key.into_owned())
.or_default()
.push(value.into_owned());
}
}
params
}
/// Send `payload` as a JSON response.
pub fn send_json(status: StatusCode, payload: &Value) -> Response {
let body = serde_json::to_vec(payload).unwrap_or_else(|_| b"null".to_vec());
(status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
}
/// Send a JSON error envelope.
pub fn send_error(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
send_json(status, &json!({ "error": message }))
}
/// Send a plain body with an explicit content type.
pub fn send_text(status: StatusCode, body: impl Into<String>, content_type: &str) -> Response {
(
status,
[(header::CONTENT_TYPE, content_type.to_string())],
body.into(),
)
.into_response()
}
/// Send an empty response with a `Location` header.
pub fn redirect(location: impl Into<String>) -> Response {
(StatusCode::FOUND, [(header::LOCATION, location.into())]).into_response()
}
/// The 401 JSON envelope used by the authentication gate.
pub fn unauthorized() -> Response {
send_json(
StatusCode::UNAUTHORIZED,
&json!({ "error": "unauthenticated" }),
)
}
/// Render a JSON value the way Python's `repr()` would, so error messages
/// like `unknown game_type: 'foo'` match the Python server.
pub fn py_repr(value: &Value) -> String {
match value {
Value::Null => "None".to_string(),
Value::Bool(true) => "True".to_string(),
Value::Bool(false) => "False".to_string(),
Value::Number(number) => {
if let Some(int) = number.as_i64() {
int.to_string()
} else if let Some(float) = number.as_f64() {
if float.fract() == 0.0 && float.is_finite() {
format!("{float:.1}")
} else {
float.to_string()
}
} else {
number.to_string()
}
}
Value::String(text) => py_repr_str(text),
Value::Array(items) => {
let rendered: Vec<String> = items.iter().map(py_repr).collect();
format!("[{}]", rendered.join(", "))
}
Value::Object(map) => {
let rendered: Vec<String> = map
.iter()
.map(|(key, value)| format!("{}: {}", py_repr_str(key), py_repr(value)))
.collect();
format!("{{{}}}", rendered.join(", "))
}
}
}
fn py_repr_str(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('\'');
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('\'');
out
}
-34
View File
@@ -1,34 +0,0 @@
#![allow(clippy::result_large_err)]
//! The game-independent half of tavolo: lobby, live play, history, ratings.
//!
//! This crate hosts every concern that does not depend on the rules of a
//! specific game: the lobby HTTP API, the live-game store, the websocket
//! endpoint, the deadline scheduler, match persistence and the Elo
//! leaderboards. A game plugs in by implementing [`engine::GameEngine`] and
//! registering it in a [`registry::GameRegistry`].
pub mod auth;
pub mod datetime;
pub mod deadlines;
pub mod elo;
pub mod engine;
pub mod errors;
pub mod http;
pub mod models;
pub mod oidc;
pub mod pagination;
pub mod platform;
pub mod registry;
pub mod routes;
pub mod session;
pub mod stats;
pub mod store;
pub mod ws;
pub use engine::{
session_from_json, session_to_json, Deadline, GameEngine, GameSession, MatchResult,
PlayerResult, Seat,
};
pub use errors::{Error, GameError, Result};
pub use platform::{Platform, SharedPlatform};
pub use registry::GameRegistry;
@@ -1,43 +0,0 @@
//! Database row models: match statistics persisted in Postgres.
//!
//! The schema is unchanged from the Python implementation
//! (`server/migrations/`):
//!
//! * [`MatchRow`] — one row per finished match; game-specific data lives in
//! the JSON `result` column produced by the engine.
//! * [`MatchPlayerRow`] — one row per participant.
//! * [`PlayerRatingRow`] — current Elo rating per `(user_sub, game_type)`.
use chrono::{DateTime, Utc};
use serde_json::Value;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MatchRow {
pub id: uuid::Uuid,
pub game_type: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub result: Value,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MatchPlayerRow {
pub id: uuid::Uuid,
pub match_id: uuid::Uuid,
pub user_sub: String,
pub display_name: String,
pub seat: i16,
pub team: Option<String>,
pub won: bool,
pub score: f64,
pub elo_delta: Option<i16>,
pub details: Value,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct PlayerRatingRow {
pub user_sub: String,
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
-551
View File
@@ -1,551 +0,0 @@
//! OpenID Connect login flows (discovery, PKCE, ID-token validation).
//!
//! Mirrors `kaya.oidc`'s `OIDCProviders` / `OIDCMixin`: each configured
//! provider gets `/auth/<provider>/login`, `/auth/<provider>/callback` and
//! `/auth/<provider>/logout` routes, namespaces its session state as
//! `oidc:<provider>:...`, and shares one session store. The configured
//! strategy is single-identity: a successful login evicts every other
//! provider's identity.
use std::collections::HashMap;
use std::sync::Arc;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use regex::Regex;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::errors::{Error, Result};
use crate::session::SessionHandle;
pub const BASE_PATH: &str = "/auth";
const SCOPES: &str = "openid email profile";
const IDENTITY_FIELDS: [&str; 4] = ["user", "id_token", "access_token", "refresh_token"];
/// Configuration for one OpenID Connect provider.
#[derive(Debug, Clone)]
pub struct OidcProviderConfig {
pub provider: String,
pub issuer: String,
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_uri: String,
pub post_login_redirect: String,
pub post_logout_redirect: String,
}
/// A flow error surfaced to the browser as a plain-text 400 (like kaya's
/// callback error responses).
#[derive(Debug, Clone, thiserror::Error)]
pub enum OidcFlowError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Internal(String),
}
impl OidcFlowError {
pub fn status(&self) -> axum::http::StatusCode {
match self {
OidcFlowError::BadRequest(_) => axum::http::StatusCode::BAD_REQUEST,
OidcFlowError::Internal(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
struct OidcClient {
config: OidcProviderConfig,
http: reqwest::Client,
metadata: Mutex<Option<Value>>,
jwks: Mutex<Option<Value>>,
}
impl OidcClient {
fn new(config: OidcProviderConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
metadata: Mutex::new(None),
jwks: Mutex::new(None),
}
}
async fn metadata(&self) -> Result<Value, OidcFlowError> {
let mut cache = self.metadata.lock().await;
if let Some(metadata) = cache.as_ref() {
return Ok(metadata.clone());
}
let url = format!(
"{}/.well-known/openid-configuration",
self.config.issuer.trim_end_matches('/')
);
let response = self
.http
.get(&url)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
let metadata: Value = response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
*cache = Some(metadata.clone());
Ok(metadata)
}
async fn jwks(&self) -> Result<Value, OidcFlowError> {
{
let cache = self.jwks.lock().await;
if let Some(jwks) = cache.as_ref() {
return Ok(jwks.clone());
}
}
let metadata = self.metadata().await?;
let jwks_uri = metadata
.get("jwks_uri")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise a jwks_uri".into())
})?;
let response = self
.http
.get(jwks_uri)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
let jwks: Value = response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
*self.jwks.lock().await = Some(jwks.clone());
Ok(jwks)
}
/// Return (authorization_url, state, nonce, code_verifier).
async fn build_authorization_url(
&self,
) -> Result<(String, String, String, String), OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("authorization_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal(
"OIDC provider does not advertise an authorization_endpoint".into(),
)
})?;
let state = random_token(32);
let nonce = random_token(32);
let code_verifier = random_token(64);
let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("client_id", &self.config.client_id);
serializer.append_pair("response_type", "code");
serializer.append_pair("scope", SCOPES);
serializer.append_pair("redirect_uri", &self.config.redirect_uri);
serializer.append_pair("state", &state);
serializer.append_pair("nonce", &nonce);
serializer.append_pair("code_challenge", &code_challenge);
serializer.append_pair("code_challenge_method", "S256");
let query = serializer.finish();
Ok((format!("{endpoint}?{query}"), state, nonce, code_verifier))
}
async fn fetch_token(
&self,
code: &str,
code_verifier: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("token_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise a token_endpoint".into())
})?;
let form = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", self.config.redirect_uri.as_str()),
("client_id", self.config.client_id.as_str()),
("code_verifier", code_verifier),
];
let mut request = self.http.post(endpoint).form(&form);
if let Some(secret) = &self.config.client_secret {
request = request.basic_auth(&self.config.client_id, Some(secret));
}
let response = request
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("token response is not JSON: {err}")))
}
async fn validate_id_token(
&self,
id_token: &str,
nonce: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let payload = self.decode_id_token(id_token).await?;
if payload.get("nonce").and_then(Value::as_str) != Some(nonce) {
return Err(OidcFlowError::BadRequest(
"Authentication failed: ID token nonce mismatch".into(),
));
}
Ok(payload)
}
async fn decode_id_token(&self, id_token: &str) -> Result<Map<String, Value>, OidcFlowError> {
let header = jsonwebtoken::decode_header(id_token)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let alg_name = format!("{:?}", header.alg);
if alg_name == "none" {
return Err(OidcFlowError::BadRequest(
"Authentication failed: Unsigned ID tokens are not allowed".into(),
));
}
let allowed = [
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512",
"EdDSA",
];
if !allowed.contains(&alg_name.as_str()) {
return Err(OidcFlowError::BadRequest(format!(
"Authentication failed: ID token algorithm {alg_name} is not allowed"
)));
}
let jwks = self.jwks().await?;
let keys = jwks.get("keys").and_then(Value::as_array).ok_or_else(|| {
OidcFlowError::BadRequest("Authentication failed: JWKS contains no keys".into())
})?;
let selected = if let Some(kid) = header.kid.as_deref() {
keys.iter()
.find(|key| key.get("kid").and_then(Value::as_str) == Some(kid))
.ok_or_else(|| {
OidcFlowError::BadRequest(format!(
"Authentication failed: No signing key found for kid {kid}"
))
})?
} else if keys.len() == 1 {
&keys[0]
} else {
return Err(OidcFlowError::BadRequest(
"Authentication failed: ID token has no kid and JWKS contains multiple keys".into(),
));
};
let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(selected.clone())
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let mut validation = jsonwebtoken::Validation::new(header.alg);
validation.set_audience(&[self.config.client_id.as_str()]);
validation.validate_nbf = true;
let data = jsonwebtoken::decode::<Map<String, Value>>(id_token, &key, &validation)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let claims = data.claims;
let metadata = self.metadata().await?;
let expected = metadata
.get("issuer")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise an issuer".into())
})?;
let expected = if expected.contains("{tenantid}") || expected.contains("{tenantId}") {
let tenant = claims.get("tid").and_then(Value::as_str).ok_or_else(|| {
OidcFlowError::BadRequest("Authentication failed: Invalid issuer".into())
})?;
let re = Regex::new(r"(?i)\{tenantid\}").expect("valid tenant regex");
re.replace(expected, tenant).to_string()
} else {
expected.to_string()
};
if claims.get("iss").and_then(Value::as_str) != Some(expected.as_str()) {
return Err(OidcFlowError::BadRequest(
"Authentication failed: Invalid issuer".into(),
));
}
Ok(claims)
}
async fn fetch_userinfo(
&self,
access_token: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("userinfo_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal(
"OIDC provider does not advertise a userinfo_endpoint".into(),
)
})?;
let response = self
.http
.get(endpoint)
.bearer_auth(access_token)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("userinfo response is not JSON: {err}")))
}
async fn build_logout_url(
&self,
id_token: Option<&str>,
) -> Result<Option<String>, OidcFlowError> {
let metadata = self.metadata().await?;
let Some(endpoint) = metadata.get("end_session_endpoint").and_then(Value::as_str) else {
return Ok(None);
};
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair(
"post_logout_redirect_uri",
&self.config.post_logout_redirect,
);
if let Some(id_token) = id_token {
serializer.append_pair("id_token_hint", id_token);
}
Ok(Some(format!("{endpoint}?{}", serializer.finish())))
}
}
pub struct OidcProvider {
config: OidcProviderConfig,
client: OidcClient,
}
impl OidcProvider {
fn key(&self, name: &str) -> String {
format!("oidc:{}:{}", self.config.provider, name)
}
pub fn name(&self) -> &str {
&self.config.provider
}
/// Start the login flow: build the authorization URL and stash state,
/// nonce and PKCE verifier in the session; returns the redirect target.
pub async fn begin_login(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
let (url, state, nonce, code_verifier) = self.client.build_authorization_url().await?;
let mut session = session.lock().await;
session.insert(self.key("state"), json!(state));
session.insert(self.key("nonce"), json!(nonce));
session.insert(self.key("code_verifier"), json!(code_verifier));
Ok(url)
}
/// Handle the provider callback; returns the post-login redirect target.
pub async fn handle_callback(
&self,
session: &SessionHandle,
query: &HashMap<String, Vec<String>>,
) -> Result<String, OidcFlowError> {
let first = |name: &str| query.get(name).and_then(|values| values.first()).cloned();
if let Some(error) = first("error") {
let mut message = format!("OIDC error: {error}");
if let Some(description) = first("error_description") {
message.push_str(&format!(": {description}"));
}
return Err(OidcFlowError::BadRequest(message));
}
let (Some(code), Some(state)) = (first("code"), first("state")) else {
return Err(OidcFlowError::BadRequest(
"Missing code or state".to_string(),
));
};
let (expected_state, nonce, code_verifier) = {
let session = session.lock().await;
(
session
.get(&self.key("state"))
.and_then(Value::as_str)
.map(str::to_string),
session
.get(&self.key("nonce"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
session
.get(&self.key("code_verifier"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
};
if Some(state.as_str()) != expected_state.as_deref() {
return Err(OidcFlowError::BadRequest("Invalid state".to_string()));
}
let token_response = self.client.fetch_token(&code, &code_verifier).await?;
let Some(id_token) = token_response.get("id_token").and_then(Value::as_str) else {
return Err(OidcFlowError::BadRequest(
"No id_token in token response".to_string(),
));
};
let id_token = id_token.to_string();
let claims = self.client.validate_id_token(&id_token, &nonce).await?;
let mut user = claims.clone();
let access_token = token_response
.get("access_token")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(access_token) = access_token.as_deref() {
let userinfo = self.client.fetch_userinfo(access_token).await?;
for (key, value) in userinfo {
user.insert(key, value);
}
}
let mut session = session.lock().await;
session.remove(&self.key("state"));
session.remove(&self.key("code_verifier"));
session.insert(self.key("user"), Value::Object(user));
session.insert(self.key("id_token"), json!(id_token));
if let Some(access_token) = access_token {
session.insert(self.key("access_token"), json!(access_token));
}
if let Some(refresh_token) = token_response.get("refresh_token") {
session.insert(self.key("refresh_token"), refresh_token.clone());
}
self.strip_other_identities(&mut session);
session.regenerate_id();
Ok(self.config.post_login_redirect.clone())
}
/// Drop this provider's session state; returns the post-logout redirect.
pub async fn logout(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
let mut session = session.lock().await;
let id_token = session
.get(&self.key("id_token"))
.and_then(Value::as_str)
.map(str::to_string);
let prefix = format!("oidc:{}:", self.config.provider);
let keys: Vec<String> = session
.keys()
.filter(|key| key.starts_with(&prefix))
.cloned()
.collect();
for key in keys {
session.remove(&key);
}
let any_user = session
.keys()
.any(|key| key.starts_with("oidc:") && key.ends_with(":user"));
if !any_user {
session.invalidate();
}
drop(session);
match self.client.build_logout_url(id_token.as_deref()).await {
Ok(Some(url)) => Ok(url),
_ => Ok(self.config.post_logout_redirect.clone()),
}
}
fn strip_other_identities(&self, session: &mut crate::session::SessionData) {
let own_prefix = format!("oidc:{}:", self.config.provider);
let keys: Vec<String> = session
.keys()
.filter(|key| {
key.starts_with("oidc:")
&& !key.starts_with(&own_prefix)
&& IDENTITY_FIELDS
.iter()
.any(|field| key.ends_with(&format!(":{field}")))
})
.cloned()
.collect();
for key in keys {
session.remove(&key);
}
}
}
/// The set of configured providers, in configuration order.
pub struct OidcProviders {
providers: Vec<Arc<OidcProvider>>,
}
impl OidcProviders {
pub fn new(configs: Vec<OidcProviderConfig>) -> Result<Self> {
let pattern = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid provider regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for config in configs {
if !pattern.is_match(&config.provider) {
return Err(Error::Internal(format!(
"provider must be a non-empty string of letters, digits, underscores or hyphens (got {:?})",
config.provider
)));
}
if !seen.insert(config.provider.clone()) {
return Err(Error::Internal(format!(
"duplicate OIDC provider name {:?}",
config.provider
)));
}
let client = OidcClient::new(config.clone());
providers.push(Arc::new(OidcProvider { config, client }));
}
if providers.is_empty() {
return Err(Error::Internal(
"At least one OIDC provider is required".into(),
));
}
Ok(Self { providers })
}
pub fn base_path(&self) -> &'static str {
BASE_PATH
}
pub fn providers(&self) -> impl Iterator<Item = &Arc<OidcProvider>> {
self.providers.iter()
}
pub fn first(&self) -> &Arc<OidcProvider> {
&self.providers[0]
}
pub fn get(&self, name: &str) -> Option<&Arc<OidcProvider>> {
self.providers
.iter()
.find(|provider| provider.name() == name)
}
pub fn login_url(&self, name: &str) -> String {
format!("{BASE_PATH}/{name}/login")
}
}
@@ -1,122 +0,0 @@
//! Cursor-based pagination for listing endpoints.
//!
//! Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
//! cursor encoding the sort key tuple of the last item on that page. The
//! cursor is a base64url-encoded JSON object mapping the sort-field names to
//! the values of the last item; it is opaque to callers.
use std::collections::HashMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::{Map, Value};
pub const DEFAULT_LIMIT: usize = 20;
pub const MAX_LIMIT: usize = 100;
pub const MIN_LIMIT: usize = 1;
pub const CURSOR_PARAM: &str = "cursor";
pub const LIMIT_PARAM: &str = "limit";
/// Raised when the `cursor`/`limit` query params cannot be decoded; the
/// message is sent verbatim in the 400 response.
#[derive(Debug, Clone, thiserror::Error)]
pub enum CursorDecodeError {
#[error("invalid cursor")]
InvalidCursor,
#[error("invalid limit")]
InvalidLimit,
}
#[derive(Debug, Clone)]
pub struct Cursor {
pub limit: usize,
pub after: Option<Map<String, Value>>,
}
/// Encode sort-key values as an opaque cursor.
pub fn encode_cursor(values: &Map<String, Value>) -> String {
let raw = serde_json::to_vec(values).unwrap_or_default();
URL_SAFE_NO_PAD.encode(raw)
}
/// Decode an opaque cursor; tolerates missing base64 padding.
pub fn decode_cursor(value: Option<&str>) -> Result<Option<Map<String, Value>>, CursorDecodeError> {
let Some(value) = value else {
return Ok(None);
};
if value.is_empty() {
return Ok(None);
}
// Tolerate both padded and unpadded base64url input.
let unpadded = value.trim_end_matches('=');
let raw = URL_SAFE_NO_PAD
.decode(unpadded)
.map_err(|_| CursorDecodeError::InvalidCursor)?;
let parsed: Value =
serde_json::from_slice(&raw).map_err(|_| CursorDecodeError::InvalidCursor)?;
match parsed {
Value::Object(map) => Ok(Some(map)),
_ => Err(CursorDecodeError::InvalidCursor),
}
}
/// Parse the `limit` and `cursor` query parameters.
pub fn parse_cursor_params(
params: &HashMap<String, Vec<String>>,
) -> Result<Cursor, CursorDecodeError> {
let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) {
Some(value) => value
.parse::<i64>()
.map_err(|_| CursorDecodeError::InvalidLimit)?,
None => DEFAULT_LIMIT as i64,
};
let limit = limit.clamp(MIN_LIMIT as i64, MAX_LIMIT as i64) as usize;
let after = decode_cursor(
params
.get(CURSOR_PARAM)
.and_then(|values| values.first())
.map(String::as_str),
)?;
Ok(Cursor { limit, after })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cursor_round_trip() {
let mut values = Map::new();
values.insert("finished_at".into(), json!("2026-09-19T06:59:17+00:00"));
values.insert("id".into(), json!("7c9e6679-7425-40de-944b-e07fc1f90ae7"));
let encoded = encode_cursor(&values);
assert!(!encoded.contains('='));
let decoded = decode_cursor(Some(&encoded)).unwrap().unwrap();
assert_eq!(decoded, values);
// Padded input is tolerated too.
let padded = format!("{encoded}==");
let decoded = decode_cursor(Some(&padded)).unwrap().unwrap();
assert_eq!(decoded, values);
}
#[test]
fn parse_defaults_and_clamps() {
let params = HashMap::new();
let cursor = parse_cursor_params(&params).unwrap();
assert_eq!(cursor.limit, 20);
assert!(cursor.after.is_none());
let mut params = HashMap::new();
params.insert("limit".into(), vec!["1000".into()]);
assert_eq!(parse_cursor_params(&params).unwrap().limit, 100);
let mut params = HashMap::new();
params.insert("limit".into(), vec!["zero".into()]);
assert!(matches!(
parse_cursor_params(&params),
Err(CursorDecodeError::InvalidLimit)
));
}
}
@@ -1,22 +0,0 @@
//! The collaborators every platform endpoint needs, bundled so route and
//! websocket modules never reach for global state (mirrors
//! `tavolo.platform.mixin.Platform`).
use std::sync::Arc;
use crate::deadlines::DeadlineScheduler;
use crate::oidc::OidcProviders;
use crate::registry::GameRegistry;
use crate::stats::SharedStatsRepo;
use crate::store::SharedGameStore;
#[derive(Clone)]
pub struct Platform {
pub registry: Arc<GameRegistry>,
pub game_store: SharedGameStore,
pub scheduler: Arc<DeadlineScheduler>,
pub oidc: Arc<OidcProviders>,
pub stats: SharedStatsRepo,
}
pub type SharedPlatform = Arc<Platform>;
@@ -1,64 +0,0 @@
//! Registry of the games the platform can host.
//!
//! The registry maps a `game_type` id to its [`GameEngine`] implementation
//! and is the single source of truth for which games exist: the lobby lists
//! it, the store uses it to deserialize opaque game state, and the stats
//! endpoints use it to validate `game_type` filters. Games register
//! themselves at composition time; the platform itself ships no game.
use std::collections::HashMap;
use std::sync::Arc;
use crate::engine::GameEngine;
use crate::errors::{GameError, Result};
/// An ordered collection of game engines, keyed by their id.
#[derive(Default)]
pub struct GameRegistry {
engines: Vec<Arc<dyn GameEngine>>,
by_id: HashMap<String, usize>,
}
impl GameRegistry {
pub fn new() -> Self {
Self::default()
}
/// Add `engine`; the first registered becomes the default.
pub fn register(&mut self, engine: Arc<dyn GameEngine>) -> Result<()> {
if self.by_id.contains_key(engine.id()) {
return Err(GameError::illegal_move(format!(
"duplicate game engine id: {:?}",
engine.id()
))
.into());
}
self.by_id
.insert(engine.id().to_string(), self.engines.len());
self.engines.push(engine);
Ok(())
}
/// Return the engine for `game_type`, or `None`.
pub fn get(&self, game_type: &str) -> Option<&Arc<dyn GameEngine>> {
self.by_id.get(game_type).map(|index| &self.engines[*index])
}
/// Return the engine for `game_type` or raise an error.
pub fn require(&self, game_type: &str) -> Result<&Arc<dyn GameEngine>> {
self.get(game_type).ok_or_else(|| {
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into()
})
}
/// The first registered engine, used when no type is requested.
pub fn default_engine(&self) -> Result<&Arc<dyn GameEngine>> {
self.engines
.first()
.ok_or_else(|| crate::errors::Error::Internal("no game engines registered".into()))
}
pub fn all(&self) -> &[Arc<dyn GameEngine>] {
&self.engines
}
}
@@ -1,175 +0,0 @@
//! Authentication routes for the single-page app.
//!
//! Each configured provider gets `/auth/<provider>/login`, `.../callback`
//! and `.../logout` routes (registered per provider, so an unknown provider
//! path falls through to the SPA fallback like in the Python server). This
//! module adds the provider-agnostic pieces:
//!
//! * `GET /api/auth/providers` lists the configured providers with their
//! login URLs, so the SPA can offer a choice without hardcoding names;
//! * `GET /auth/login` redirects to the first configured provider;
//! * `GET /auth/logout` redirects to the provider this session is logged in
//! with (or the SPA root when not logged in).
use axum::extract::{Extension, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::auth::logged_in_providers;
use crate::http::{extract_query_params, redirect, send_json, send_text};
use crate::oidc::OidcFlowError;
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes(providers: &[String]) -> Router<SharedPlatform> {
let mut router = Router::new()
.route("/api/auth/providers", get(providers_handler))
.route("/auth/login", get(generic_login))
.route("/auth/logout", get(generic_logout));
for provider in providers {
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/login"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_login(state, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/callback"),
get(
move |state: State<SharedPlatform>,
query: RawQuery,
session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_callback(state, query, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/logout"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_logout(state, session, name).await }
},
),
);
}
router
}
fn flow_error_response(error: OidcFlowError) -> Response {
match error {
OidcFlowError::BadRequest(message) => send_text(
StatusCode::BAD_REQUEST,
message,
"text/plain; charset=utf-8",
),
OidcFlowError::Internal(message) => send_text(
StatusCode::INTERNAL_SERVER_ERROR,
message,
"text/plain; charset=utf-8",
),
}
}
/// Names and login URLs of the configured providers, in configuration order.
async fn providers_handler(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<serde_json::Value> = platform
.oidc
.providers()
.map(|provider| {
json!({
"name": provider.name(),
"login_url": platform.oidc.login_url(provider.name()),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "providers": results }))
}
/// Redirect to the first configured provider's login.
async fn generic_login(State(platform): State<SharedPlatform>) -> Response {
redirect(platform.oidc.login_url(platform.oidc.first().name()))
}
/// Redirect to the logged-in provider's logout (or the SPA root).
async fn generic_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let logged_in = {
let session = session.lock().await;
logged_in_providers(&session)
};
match logged_in.first() {
Some(provider) if platform.oidc.get(provider).is_some() => {
redirect(format!("/auth/{provider}/logout"))
}
_ => redirect("/"),
}
}
async fn provider_login(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.begin_login(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_callback(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
let params = extract_query_params(query.as_deref());
match provider.handle_callback(&session, &params).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.logout(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
@@ -1,421 +0,0 @@
//! Game lobby endpoints.
//!
//! A game starts as a lobby: the creator is seated first and shares the
//! six-character `join_code`. When the lobby fills up, the engine starts the
//! match. Live play then happens over the `/ws/games/{id}` websocket; these
//! endpoints cover creation, joining and snapshotting state.
//!
//! Everything game-specific — which options a game accepts, when the lobby
//! is full, what the personalized view looks like — is delegated to the
//! engine registered for the session's `game_type`.
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::{get, post};
use axum::Extension;
use axum::Router;
use rand::seq::SliceRandom;
use serde_json::{json, Value};
use crate::auth::{current_user, display_name};
use crate::datetime::now;
use crate::engine::GameSession;
use crate::http::{
extract_query_params, py_repr, read_json, read_json_optional, send_error, send_json,
unauthorized,
};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// Join codes avoid characters that are easy to confuse when read aloud.
const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH: usize = 6;
const MAX_CODE_ATTEMPTS: usize = 20;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/game-types", get(list_game_types))
.route("/api/games", post(create_game))
.route("/api/games/join", post(join_game))
.route("/api/games/preview", get(preview_game))
.route("/api/games/{game_id}", get(get_game))
}
fn random_code() -> String {
let mut rng = rand::thread_rng();
(0..CODE_LENGTH)
.map(|_| *CODE_ALPHABET.choose(&mut rng).expect("non-empty alphabet") as char)
.collect()
}
async fn unique_code(platform: &Platform) -> Result<String, Response> {
for _ in 0..MAX_CODE_ATTEMPTS {
let code = random_code();
match platform.game_store.find_by_code(&code).await {
Ok(None) => return Ok(code),
Ok(Some(_)) => continue,
Err(err) => {
tracing::error!("join code lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a join code",
));
}
}
}
Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a unique join code",
))
}
fn merge(base: &mut Value, extra: Value) {
if let (Value::Object(base), Value::Object(extra)) = (base, extra) {
for (key, value) in extra {
base.insert(key, value);
}
}
}
fn lobby_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let players: Vec<Value> = game
.players
.iter()
.enumerate()
.map(|(index, seat)| {
json!({
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
})
})
.collect();
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
"players": players,
"seats_open": engine.max_players().saturating_sub(game.players.len()),
});
merge(&mut payload, engine.lobby_view(game));
Ok(payload)
}
fn view_payload(platform: &Platform, game: &GameSession, sub: &str) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
merge(&mut payload, engine.view_for(game, sub));
Ok(payload)
}
fn preview_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = lobby_payload(platform, game)?;
merge(
&mut payload,
json!({
"game_name": engine.name(),
"game_description": engine.description(),
}),
);
Ok(payload)
}
/// Every game the platform can host, for the match-creation dropdown.
async fn list_game_types(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<Value> = platform
.registry
.all()
.iter()
.map(|engine| {
json!({
"id": engine.id(),
"name": engine.name(),
"description": engine.description(),
"min_players": engine.min_players(),
"max_players": engine.max_players(),
"options_schema": engine.options_schema(),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
/// Create a lobby game and seat the caller in seat 0.
async fn create_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json_optional(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let engine = match body.get("game_type") {
None | Some(Value::Null) => match platform.registry.default_engine() {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"no game engines registered",
)
}
},
Some(Value::String(name)) => match platform.registry.require(name) {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(name.clone()))
),
)
}
},
Some(other) => {
return send_error(
StatusCode::BAD_REQUEST,
format!("unknown game_type: {}", py_repr(other)),
)
}
};
let options = body.get("options").cloned().unwrap_or_else(|| json!({}));
let Value::Object(options) = options else {
return send_error(StatusCode::BAD_REQUEST, "options must be an object");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let join_code = match unique_code(&platform).await {
Ok(code) => code,
Err(response) => return response,
};
let mut game = GameSession::new(uuid::Uuid::new_v4().to_string(), engine.id(), join_code);
game.creator_sub = user.sub.clone();
game.players = vec![crate::engine::Seat::new(&user.sub, display_name(&user))];
game.created_at = Some(now());
if let Err(err) = engine.create(&mut game, &options) {
match err.as_game() {
Some(game_error) => {
return send_error(StatusCode::BAD_REQUEST, game_error.message.clone())
}
None => {
tracing::error!("game create failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("game save failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
tracing::info!(
"game {} created by {} ({}, options {})",
game.id,
user.sub,
engine.id(),
serde_json::to_string(&options).unwrap_or_default()
);
match lobby_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::CREATED, &payload),
Err(response) => response,
}
}
/// Join with `{"code": "ABC123"}`; the last player triggers the start.
async fn join_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let Some(code) = body
.get("code")
.and_then(Value::as_str)
.filter(|code| !code.is_empty())
else {
return send_error(StatusCode::BAD_REQUEST, "code is required");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let existing = match platform.game_store.find_by_code(code).await {
Ok(Some(existing)) => existing,
Ok(None) => {
tracing::debug!("join rejected for {}: unknown code {code:?}", user.sub);
return send_error(StatusCode::NOT_FOUND, "unknown join code");
}
Err(err) => {
tracing::error!("join lookup failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let game_id = existing.id.clone();
let lock = match platform.game_store.acquire_lock(&game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("join lock failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(&game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("join load failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}) else {
return Err(send_error(StatusCode::NOT_FOUND, "unknown join code"));
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("join engine lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
};
if let Err(err) = engine.join(&mut game, &user.sub, &display_name(&user)) {
match err.as_game() {
Some(game_error) => {
tracing::debug!(
"join rejected for {} in game {game_id}: {}",
user.sub,
game_error.message
);
return Err(send_error(StatusCode::CONFLICT, game_error.message.clone()));
}
None => {
tracing::error!("join failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("join save failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
let _ = platform.game_store.publish(&game.id).await;
if let Err(err) = platform.scheduler.sync_deadline(&game).await {
tracing::error!("join sync_deadline failed: {err}");
}
let seat = game.seat_index_of(&user.sub).unwrap_or(0);
if engine.in_lobby(&game) {
tracing::info!(
"{} joined game {} (seat {}, {}/{} players)",
user.sub,
game.id,
seat,
game.players.len(),
engine.max_players()
);
return lobby_payload(&platform, &game);
}
tracing::info!(
"{} joined game {} (seat {}); match started",
user.sub,
game.id,
seat
);
view_payload(&platform, &game, &user.sub)
}
.await;
let _ = lock.release().await;
match result {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
/// Pre-join summary of the lobby behind a code.
async fn preview_game(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let code = params
.get("code")
.and_then(|values| values.first())
.map(|code| code.trim().to_string())
.unwrap_or_default();
if code.is_empty() {
return send_error(StatusCode::BAD_REQUEST, "code is required");
}
match platform.game_store.find_by_code(&code).await {
Ok(Some(game)) => match preview_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
},
Ok(None) => send_error(StatusCode::NOT_FOUND, "unknown join code"),
Err(err) => {
tracing::error!("preview lookup failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not preview game")
}
}
}
/// Personalized snapshot; only seated players may read a game.
async fn get_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
) -> Response {
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => return send_error(StatusCode::NOT_FOUND, "game not found"),
Err(err) => {
tracing::error!("game load failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load game");
}
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
if !game.seated(&user.sub) {
return send_error(StatusCode::FORBIDDEN, "forbidden");
}
match view_payload(&platform, &game, &user.sub) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
@@ -1,20 +0,0 @@
//! Liveness probe.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::http::send_json;
use crate::platform::SharedPlatform;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/health", get(health))
}
/// Health check: the service is up.
async fn health(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &json!({ "status": "ok" }))
}
@@ -1,32 +0,0 @@
//! Whoami endpoint: lets the single-page app detect the login state.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::json;
use crate::auth::{current_user, display_name};
use crate::http::{send_json, unauthorized};
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/me", get(me))
}
/// Current user; 401 when not logged in.
async fn me(
State(_platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
send_json(
StatusCode::OK,
&json!({ "sub": user.sub, "name": display_name(&user) }),
)
}
@@ -1,33 +0,0 @@
//! The platform's HTTP surface: lobby, stats, auth and health routes.
//!
//! Handlers live in submodules mirroring the Python implementation; this
//! module assembles them into one axum [`Router`].
pub mod auth;
pub mod games;
pub mod health;
pub mod me;
pub mod openapi;
pub mod stats;
use axum::routing::get;
use axum::Router;
use crate::platform::SharedPlatform;
use crate::ws;
/// All platform routes (HTTP + the live-play websocket).
///
/// `providers` lists the configured OIDC provider names; login/callback/
/// logout routes are registered per provider so unknown provider paths fall
/// through to the router's fallback.
pub fn router(providers: &[String]) -> Router<SharedPlatform> {
Router::new()
.merge(health::routes())
.merge(me::routes())
.merge(auth::routes(providers))
.merge(games::routes())
.merge(stats::routes())
.merge(openapi::routes())
.route("/ws/games/{game_id}", get(ws::game_socket))
}
@@ -1,314 +0,0 @@
//! OpenAPI document and Swagger UI, mirroring kaya-openapi's output for the
//! tavolo API.
//!
//! The document is assembled from the route metadata below (kaya generated it
//! from the routing tree on every request). Wildcard SPA routes and the
//! websocket endpoint are skipped, exactly like kaya does.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};
use crate::http::{send_json, send_text};
use crate::platform::SharedPlatform;
pub const TITLE: &str = "tavolo";
pub const VERSION: &str = "0.1.0";
pub const DESCRIPTION: &str = "Multiplayer card-game platform API";
pub const SPEC_PATH: &str = "/api/openapi.json";
pub const DOCS_PATH: &str = "/api/docs";
fn query_parameter(name: &str, description: &str, schema: Value) -> Value {
json!({
"name": name,
"in": "query",
"required": false,
"schema": schema,
"description": description,
})
}
fn game_type_parameter() -> Value {
query_parameter(
"game_type",
"Only count matches of this game (id from GET /api/game-types).",
json!({"type": "string"}),
)
}
fn game_id_parameter() -> Value {
json!({
"name": "game_id",
"in": "path",
"required": true,
"schema": {"type": "string"},
})
}
/// The OpenAPI 3.1 document for the platform API.
pub fn spec() -> Value {
json!({
"openapi": "3.1.0",
"info": {
"title": TITLE,
"version": VERSION,
"description": DESCRIPTION,
},
"paths": {
"/api/health": {
"get": {
"summary": "Health check",
"tags": ["health"],
"responses": {"200": {"description": "The service is up"}},
}
},
"/api/me": {
"get": {
"summary": "Current user",
"description": "Returns the authenticated user's identity from the session; 401 when not logged in.",
"tags": ["auth"],
"responses": {
"200": {"description": "The current user"},
"401": {"description": "Not logged in"},
},
}
},
"/api/auth/providers": {
"get": {
"summary": "Configured login providers",
"description": "Names and login URLs of the configured OIDC providers, in configuration order.",
"tags": ["auth"],
"responses": {"200": {"description": "The configured providers"}},
}
},
"/auth/{provider}/login": {
"get": {
"summary": "Start an OIDC login",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider"}},
}
},
"/auth/{provider}/callback": {
"get": {
"summary": "OIDC login callback",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {
"302": {"description": "Redirect to the post-login target"},
"400": {"description": "Authentication failed"},
},
}
},
"/auth/{provider}/logout": {
"get": {
"summary": "Log out of an OIDC provider",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider or the SPA root"}},
}
},
"/api/game-types": {
"get": {
"summary": "List available games",
"description": "Every game the platform can host, for the match-creation dropdown. ``options_schema`` describes the per-game creation options accepted by POST /api/games.",
"tags": ["games"],
"responses": {"200": {"description": "The available game types"}},
}
},
"/api/games": {
"post": {
"summary": "Create a game",
"description": "Creates a lobby game and seats the caller in seat 0. Share the returned join_code with the other players. ``options`` is an opaque object validated by the chosen game (see GET /api/game-types).",
"tags": ["games"],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"description": "One of the ids from GET /api/game-types; defaults to the platform's first registered game",
},
"options": {
"type": "object",
"description": "Game-specific creation options",
},
},
}
}
}
},
"responses": {
"201": {"description": "The created lobby"},
"400": {"description": "Invalid game_type, options or body"},
"401": {"description": "Authentication required"},
},
}
},
"/api/games/join": {
"post": {
"summary": "Join a game by code",
"description": "Seats the caller in the next free chair. Joining as the last player starts the match.",
"tags": ["games"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
}
},
"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"},
},
}
},
"/api/games/preview": {
"get": {
"summary": "Preview a game by join code",
"description": "Looks up a game by its join code without joining, for the lobby's pre-join summary: game name and description, seated players, open seats and the game-specific options echoed by its engine.",
"tags": ["games"],
"responses": {
"200": {"description": "The lobby preview"},
"400": {"description": "Missing code"},
"401": {"description": "Authentication required"},
"404": {"description": "Unknown join code"},
},
}
},
"/api/games/{game_id}": {
"get": {
"summary": "Get a game snapshot",
"description": "Only seated players may read a game; the view is personalized by the engine (e.g. hidden hands).",
"tags": ["games"],
"parameters": [game_id_parameter()],
"responses": {
"200": {"description": "The personalized game state"},
"401": {"description": "Authentication required"},
"403": {"description": "Not a player in this game"},
"404": {"description": "Game not found"},
},
}
},
"/api/me/matches": {
"get": {
"summary": "List my matches",
"description": "Cursor-paginated history of finished matches the caller played, newest first, with the result.",
"tags": ["stats"],
"parameters": [
query_parameter(
"limit",
"Maximum number of results per page (clamped to [1, 100]).",
json!({"type": "integer", "minimum": 1, "maximum": 100, "default": 20}),
),
query_parameter(
"cursor",
"Opaque pagination cursor from a previous response's next_cursor.",
json!({"type": "string"}),
),
game_type_parameter(),
],
"responses": {
"200": {"description": "A page of matches"},
"400": {"description": "Invalid pagination cursor or game_type"},
"401": {"description": "Authentication required"},
},
}
},
"/api/leaderboard": {
"get": {
"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).",
"tags": ["stats"],
"parameters": [game_type_parameter()],
"responses": {
"200": {"description": "The leaderboard"},
"400": {"description": "Unknown game_type"},
},
}
},
"/api/me/ratings": {
"get": {
"summary": "My Elo ratings",
"description": "The caller's current Elo rating for every game type they have played.",
"tags": ["stats"],
"responses": {
"200": {"description": "The caller's ratings"},
"401": {"description": "Authentication required"},
},
}
}
}
})
}
/// The Swagger UI page (assets loaded from a CDN, like kaya-openapi).
pub fn docs_page() -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{TITLE} - API documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {{
window.ui = SwaggerUIBundle({{
url: '{SPEC_PATH}',
dom_id: '#swagger-ui',
}});
}};
</script>
</body>
</html>
"#
)
}
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route(SPEC_PATH, get(spec_handler))
.route(DOCS_PATH, get(docs_handler))
}
async fn spec_handler(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &spec())
}
async fn docs_handler(State(_platform): State<SharedPlatform>) -> Response {
send_text(StatusCode::OK, docs_page(), "text/html; charset=utf-8")
}
@@ -1,165 +0,0 @@
//! Player statistics endpoints, served from the stats repository.
//!
//! Every finished match is persisted by
//! [`StatsRepo::save_match`](crate::stats::StatsRepo::save_match). These
//! endpoints expose a player's own match history and a global leaderboard
//! aggregated from the same tables. The game-specific outcome of each match
//! is exposed verbatim through the `result` JSON column.
use axum::extract::{RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use crate::auth::current_user;
use crate::http::{extract_query_params, py_repr, send_error, send_json, unauthorized};
use crate::pagination::{parse_cursor_params, CursorDecodeError};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/me/matches", get(my_matches))
.route("/api/me/ratings", get(my_ratings))
.route("/api/leaderboard", get(leaderboard))
}
fn parse_game_type(
platform: &Platform,
params: &std::collections::HashMap<String, Vec<String>>,
) -> Result<Option<String>, Response> {
let Some(game_type) = params.get("game_type").and_then(|values| values.first()) else {
return Ok(None);
};
if platform.registry.get(game_type).is_none() {
return Err(send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(game_type.clone()))
),
));
}
Ok(Some(game_type.clone()))
}
fn cursor_error(error: CursorDecodeError) -> Response {
send_error(StatusCode::BAD_REQUEST, error.to_string())
}
/// Cursor-paginated history of finished matches the caller played.
async fn my_matches(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let cursor = match parse_cursor_params(&params) {
Ok(cursor) => cursor,
Err(err) => return cursor_error(err),
};
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform
.stats
.my_matches(
&user.sub,
game_type.as_deref(),
cursor.limit,
cursor.after.as_ref(),
)
.await
{
Ok((entries, next_cursor)) => {
let results: Vec<Value> = entries
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(
StatusCode::OK,
&json!({ "results": results, "next_cursor": next_cursor }),
)
}
Err(err) => {
tracing::error!("match history query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load matches")
}
}
}
/// Global leaderboard, sorted by Elo (the rating for the requested
/// game_type, or the default game when the filter is absent).
async fn leaderboard(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let rating_game = match &game_type {
Some(game_type) => game_type.clone(),
None => match platform.registry.default_engine() {
Ok(engine) => engine.id().to_string(),
Err(err) => {
tracing::error!("leaderboard default game lookup failed: {err}");
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
);
}
},
};
match platform
.stats
.leaderboard(game_type.as_deref(), &rating_game)
.await
{
Ok(ranking) => {
let results: Vec<Value> = ranking
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("leaderboard query failed: {err}");
send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
)
}
}
}
/// The caller's current Elo rating for every game type they have played.
async fn my_ratings(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform.stats.my_ratings(&user.sub).await {
Ok(rows) => {
let results: Vec<Value> = rows
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("ratings query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load ratings")
}
}
}
@@ -1,531 +0,0 @@
//! Server-side HTTP sessions.
//!
//! Mirrors kaya's `SessionMixin` / `SessionStore`:
//!
//! * [`SessionData`] is a dict-like container with modification tracking
//! (`modified` / `regenerate` / `invalidated` flags) used by the
//! middleware to decide whether to persist, rotate or delete it;
//! * [`SessionStore`] is the pluggable backend: Redis in production,
//! in-memory for tests and ephemeral single-process dev;
//! * [`session_middleware`] loads the session from the `session_id` cookie,
//! exposes it as a [`SessionHandle`] request extension, and persists it
//! (refreshing the cookie) after the handler ran.
//!
//! Session data is serialized as JSON (the Python server used pickle; old
//! sessions are deliberately not readable, users log in again).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use serde_json::{Map, Value};
use tokio::sync::{Mutex, MutexGuard};
use crate::errors::{Error, Result};
/// The session cookie name (kaya's default).
pub const COOKIE_NAME: &str = "session_id";
/// The session idle timeout (kaya's default: 14 days).
pub const MAX_AGE_SECONDS: i64 = 14 * 24 * 60 * 60;
/// Key prefix of the Redis session store. The prefix differs from kaya's
/// (`kaya:session:`) because the payload format changed from pickle to JSON.
pub const REDIS_KEY_PREFIX: &str = "tavolo:session:";
/// Opaque session identifier with 256 bits of entropy, matching Python's
/// `secrets.token_urlsafe(32)` (43 URL-safe base64 characters, no padding).
pub fn new_session_id() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Dict-like session container with modification tracking.
#[derive(Debug, Clone, Default)]
pub struct SessionData {
id: Option<String>,
old_id: Option<String>,
data: Map<String, Value>,
modified: bool,
regenerate: bool,
invalidated: bool,
}
impl SessionData {
pub fn new() -> Self {
Self::default()
}
pub fn with_id(id: impl Into<String>, data: Map<String, Value>) -> Self {
Self {
id: Some(id.into()),
old_id: None,
data,
modified: false,
regenerate: false,
invalidated: false,
}
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn is_regenerate(&self) -> bool {
self.regenerate
}
pub fn is_invalidated(&self) -> bool {
self.invalidated
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
self.data.insert(key.into(), value);
self.modified = true;
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
let removed = self.data.remove(key);
if removed.is_some() {
self.modified = true;
}
removed
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.data.keys()
}
pub fn data(&self) -> &Map<String, Value> {
&self.data
}
pub fn mark_modified(&mut self) {
self.modified = true;
}
/// Mark the session for ID rotation (session fixation defense); the
/// middleware mints a new id and deletes the old store entry.
pub fn regenerate_id(&mut self) {
self.old_id = self.id.take();
self.regenerate = true;
self.modified = true;
}
/// Mark the session for deletion and send an expired cookie.
pub fn invalidate(&mut self) {
self.invalidated = true;
self.modified = true;
self.data.clear();
}
}
/// Shared, mutable session attached to a request.
#[derive(Clone)]
pub struct SessionHandle(pub Arc<Mutex<SessionData>>);
impl SessionHandle {
pub fn new(session: SessionData) -> Self {
Self(Arc::new(Mutex::new(session)))
}
pub async fn lock(&self) -> MutexGuard<'_, SessionData> {
self.0.lock().await
}
}
/// Pluggable backend for session persistence.
#[async_trait]
pub trait SessionStore: Send + Sync {
/// Load an existing session, or `None` if unknown or expired. When
/// `max_age` is given the store slides the server-side expiry.
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>>;
/// Persist the session data under `session_id`.
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()>;
/// Remove the session from the store.
async fn delete(&self, session_id: &str) -> Result<()>;
/// Number of live sessions (for the `tavolo.active_users` gauge).
async fn count(&self) -> Result<usize>;
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
struct MemorySession {
data: Map<String, Value>,
expires: Option<Instant>,
}
/// Process-local session store used by tests and when Redis is not configured.
#[derive(Default)]
pub struct InMemorySessionStore {
sessions: Mutex<HashMap<String, MemorySession>>,
}
impl InMemorySessionStore {
pub fn new() -> Self {
Self::default()
}
fn clock(&self) -> Instant {
Instant::now()
}
}
#[async_trait]
impl SessionStore for InMemorySessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut sessions = self.sessions.lock().await;
let Some(entry) = sessions.get_mut(session_id) else {
return Ok(None);
};
let now = self.clock();
if let Some(expires) = entry.expires {
if now > expires {
sessions.remove(session_id);
return Ok(None);
}
}
if max_age.is_some() {
entry.expires = Some(now + Duration::from_secs(max_age.unwrap_or(0) as u64));
}
Ok(Some(SessionData::with_id(
session_id.to_string(),
entry.data.clone(),
)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.insert(
session_id.to_string(),
MemorySession {
data: data.clone(),
expires: max_age.map(|age| self.clock() + Duration::from_secs(age as u64)),
},
);
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
self.sessions.lock().await.remove(session_id);
Ok(())
}
async fn count(&self) -> Result<usize> {
let sessions = self.sessions.lock().await;
let now = self.clock();
Ok(sessions
.values()
.filter(|entry| entry.expires.is_none_or(|expires| expires > now))
.count())
}
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
/// Redis-backed session store: one JSON value per session under
/// `tavolo:session:<id>`, expiry delegated to the key TTL.
pub struct RedisSessionStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
prefix: String,
}
impl RedisSessionStore {
pub async fn new(url: &str) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
prefix: REDIS_KEY_PREFIX.to_string(),
})
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn client(&self) -> redis::Client {
self.client.clone()
}
fn key(&self, session_id: &str) -> String {
format!("{}{}", self.prefix, session_id)
}
}
#[async_trait]
impl SessionStore for RedisSessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut conn = self.manager.clone();
let key = self.key(session_id);
let payload: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
let Some(payload) = payload else {
return Ok(None);
};
if let Some(max_age) = max_age {
redis::cmd("EXPIRE")
.arg(&key)
.arg(max_age)
.query_async::<i64>(&mut conn)
.await?;
}
let data: Map<String, Value> = serde_json::from_str(&payload)?;
Ok(Some(SessionData::with_id(session_id.to_string(), data)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut conn = self.manager.clone();
let payload = serde_json::to_string(data)?;
let mut cmd = redis::cmd("SET");
cmd.arg(self.key(session_id)).arg(payload);
if let Some(max_age) = max_age {
cmd.arg("EX").arg(max_age);
}
cmd.query_async::<()>(&mut conn).await?;
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("DEL")
.arg(self.key(session_id))
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn count(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut cursor: u64 = 0;
let mut total = 0usize;
loop {
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(format!("{}*", self.prefix))
.arg("COUNT")
.arg(500)
.query_async(&mut conn)
.await?;
total += keys.len();
cursor = next;
if cursor == 0 {
break;
}
}
Ok(total)
}
}
/// Convenience alias used by the application.
pub type SharedSessionStore = Arc<dyn SessionStore>;
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
/// Return the value of `name` from a `Cookie` header, if present.
pub fn parse_cookie_value(header_value: &str, name: &str) -> Option<String> {
for part in header_value.split(';') {
let part = part.trim();
if let Some((key, value)) = part.split_once('=') {
if key.trim() == name {
return Some(value.trim().trim_matches('"').to_string());
}
}
}
None
}
/// Build a `Set-Cookie` value the way Python's `SimpleCookie` renders one,
/// i.e. sorted attributes: `HttpOnly; Max-Age=n; Path=/; SameSite=Lax`.
pub fn format_set_cookie(
name: &str,
value: &str,
path: &str,
max_age: Option<i64>,
httponly: bool,
secure: bool,
samesite: Option<&str>,
) -> String {
let mut out = format!("{name}={value}");
if httponly {
out.push_str("; HttpOnly");
}
if let Some(max_age) = max_age {
out.push_str(&format!("; Max-Age={max_age}"));
}
out.push_str(&format!("; Path={path}"));
if let Some(samesite) = samesite {
out.push_str(&format!("; SameSite={samesite}"));
}
if secure {
out.push_str("; Secure");
}
out
}
fn extract_session_id(headers: &axum::http::HeaderMap) -> Option<String> {
let mut found = None;
for value in headers.get_all(axum::http::header::COOKIE) {
if let Ok(value) = value.to_str() {
if let Some(id) = parse_cookie_value(value, COOKIE_NAME) {
found = Some(id);
break;
}
}
}
found
}
/// Load the session, expose it as a [`SessionHandle`] extension and persist
/// it (refreshing the cookie) after the handler ran.
///
/// Mirrors kaya's `SessionMixin`: an existing session is always re-saved and
/// its cookie re-sent, so the client and server expiry windows slide on
/// every request.
pub async fn session_middleware(
State(store): State<SharedSessionStore>,
mut request: Request,
next: Next,
) -> Response {
// Static assets never reach the application in the Python deployment
// (Granian serves them): skip the session round-trip for them.
let path = request.uri().path();
if path.starts_with("/static") || path.starts_with("/assets") {
return next.run(request).await;
}
let session_id = extract_session_id(request.headers());
let session = match session_id.as_deref() {
Some(id) => match store.load(id, Some(MAX_AGE_SECONDS)).await {
Ok(Some(session)) => session,
Ok(None) => SessionData::new(),
Err(err) => {
tracing::error!("session load failed: {err}");
SessionData::new()
}
},
None => SessionData::new(),
};
let handle = SessionHandle::new(session);
request.extensions_mut().insert(handle.clone());
let mut response = next.run(request).await;
let mut session = handle.lock().await;
let mut cookie: Option<String> = None;
if session.invalidated {
let id = session.old_id.clone().or_else(|| session.id.clone());
if let Some(id) = &id {
if let Err(err) = store.delete(id).await {
tracing::error!("session delete failed: {err}");
}
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
id.as_deref().unwrap_or(""),
"/",
Some(0),
true,
false,
Some("Lax"),
));
} else if session.regenerate {
if let Some(old_id) = session.old_id.clone() {
if let Err(err) = store.delete(&old_id).await {
tracing::error!("session delete failed: {err}");
}
session.old_id = None;
}
if session.id.is_none() {
session.id = Some(new_session_id());
}
session.regenerate = false;
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
} else {
if session.id.is_none() && session.is_modified() {
session.id = Some(new_session_id());
}
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
}
drop(session);
if let Some(cookie) = cookie {
if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
response
}
/// Errors from the session layer surfaced as platform errors.
pub fn session_error(message: impl Into<String>) -> Error {
Error::Internal(message.into())
}
-825
View File
@@ -1,825 +0,0 @@
//! Copy finished match results from the live store into Postgres, plus the
//! read side of the statistics endpoints.
//!
//! 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 [`crate::elo`]).
//!
//! This module is game-independent: everything it persists comes from the
//! engine's [`crate::engine::MatchResult`]. The data access is behind
//! [`StatsRepo`] so tests can run without a database.
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{Map, Value};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use crate::datetime::serialize_iso;
use crate::elo::{match_delta, INITIAL_RATING};
use crate::engine::{GameEngine, GameSession};
use crate::errors::{Error, Result};
use crate::models::{MatchPlayerRow, MatchRow};
#[derive(Debug, Clone, Serialize)]
pub struct MatchPlayerView {
pub user_sub: String,
pub display_name: String,
pub seat: i32,
pub team: Option<String>,
pub won: bool,
pub score: f64,
pub elo_delta: Option<i32>,
pub details: Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct MatchHistoryEntry {
pub id: String,
pub game_type: String,
pub result: Value,
#[serde(serialize_with = "serialize_iso")]
pub started_at: DateTime<Utc>,
#[serde(serialize_with = "serialize_iso")]
pub finished_at: DateTime<Utc>,
pub you_won: bool,
pub your_elo_delta: Option<i32>,
pub players: Vec<MatchPlayerView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RatingEntry {
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct LeaderboardEntry {
pub user_sub: String,
pub display_name: String,
pub matches: u64,
pub wins: u64,
pub points: f64,
pub elo: i32,
}
/// Persistence + queries for finished matches and ratings.
#[async_trait]
pub trait StatsRepo: Send + Sync {
/// Persist a finished session. Callers must check `stats_saved` and
/// `engine.is_finished` first and set `session.stats_saved` after success.
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()>;
/// One page of finished matches the user played, newest first.
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)>;
/// The caller's rating per game type, ordered by game type.
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>>;
/// Global leaderboard; `rating_game` selects the rating table to use when
/// no game type filter is given.
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>>;
/// Recompute every rating from the match history; returns the number of
/// matches replayed.
async fn backfill_elo(&self) -> Result<usize>;
}
pub type SharedStatsRepo = std::sync::Arc<dyn StatsRepo>;
// ---------------------------------------------------------------------------
// Postgres implementation
// ---------------------------------------------------------------------------
pub struct PostgresStatsRepo {
pool: PgPool,
}
impl PostgresStatsRepo {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
}
/// Update the Elo ratings of `teams` for `game_type` inside a transaction.
/// Returns the per-player delta.
pub async fn apply_elo(
tx: &mut Transaction<'_, Postgres>,
game_type: &str,
teams: &[Vec<String>],
winner_team: usize,
) -> Result<HashMap<String, i64>> {
if teams.len() != 2 {
return Err(Error::Internal(
"Elo rating requires exactly two teams".into(),
));
}
let mut ratings: HashMap<String, i32> = HashMap::new();
for team in teams {
for sub in team {
sqlx::query(
r#"INSERT INTO player_rating (id, user_sub, game_type, rating, matches_played, updated_at)
VALUES ($1, $2, $3, $4, 0, now())
ON CONFLICT (user_sub, game_type) DO NOTHING"#,
)
.bind(Uuid::new_v4())
.bind(sub)
.bind(game_type)
.bind(INITIAL_RATING as i32)
.execute(&mut **tx)
.await?;
let rating: (i32,) = sqlx::query_as(
r#"SELECT rating FROM player_rating WHERE user_sub = $1 AND game_type = $2 FOR UPDATE"#,
)
.bind(sub)
.bind(game_type)
.fetch_one(&mut **tx)
.await?;
ratings.insert(sub.clone(), rating.0);
}
}
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
let delta_a = match_delta(&team_a, &team_b, winner_team);
let mut deltas = HashMap::new();
for sub in &teams[0] {
deltas.insert(sub.clone(), delta_a);
}
for sub in &teams[1] {
deltas.insert(sub.clone(), -delta_a);
}
for (sub, delta) in &deltas {
sqlx::query(
r#"UPDATE player_rating
SET rating = rating + $1, matches_played = matches_played + 1, updated_at = now()
WHERE user_sub = $2 AND game_type = $3"#,
)
.bind(*delta as i32)
.bind(sub)
.bind(game_type)
.execute(&mut **tx)
.await?;
}
Ok(deltas)
}
fn row_to_entry(row: MatchRow, viewer: &str, players: &[MatchPlayerRow]) -> MatchHistoryEntry {
let viewer_won = players.iter().any(|p| p.user_sub == viewer && p.won);
let viewer_delta = players
.iter()
.find(|p| p.user_sub == viewer)
.and_then(|p| p.elo_delta)
.map(i32::from);
MatchHistoryEntry {
id: row.id.to_string(),
game_type: row.game_type,
result: row.result,
started_at: row.started_at,
finished_at: row.finished_at,
you_won: viewer_won,
your_elo_delta: viewer_delta,
players: players
.iter()
.map(|p| MatchPlayerView {
user_sub: p.user_sub.clone(),
display_name: p.display_name.clone(),
seat: p.seat as i32,
team: p.team.clone(),
won: p.won,
score: p.score,
elo_delta: p.elo_delta.map(i32::from),
details: p.details.clone(),
})
.collect(),
}
}
#[async_trait]
impl StatsRepo for PostgresStatsRepo {
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
let result = engine.result(session)?;
let started_at = session.created_at.unwrap_or_else(Utc::now);
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
let display_names: HashMap<&str, &str> = session
.players
.iter()
.map(|seat| (seat.user_sub.as_str(), seat.display_name.as_str()))
.collect();
let mut tx = self.pool.begin().await?;
let match_id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO "match" (id, game_type, started_at, finished_at, result)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(match_id)
.bind(&session.game_type)
.bind(started_at)
.bind(finished_at)
.bind(&result.summary)
.execute(&mut *tx)
.await?;
let deltas = apply_elo(
&mut tx,
&session.game_type,
&result.teams,
result.winner_team,
)
.await?;
for player in &result.players {
let display_name = display_names
.get(player.user_sub.as_str())
.copied()
.unwrap_or(player.user_sub.as_str());
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
sqlx::query(
r#"INSERT INTO match_player
(id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#,
)
.bind(Uuid::new_v4())
.bind(match_id)
.bind(&player.user_sub)
.bind(display_name)
.bind(player.seat as i16)
.bind(&player.team)
.bind(player.won)
.bind(player.score)
.bind(delta as i16)
.bind(&player.details)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
tracing::info!(
"match result persisted: game {} ({}), team {} of {} won",
session.id,
session.game_type,
result.winner_team,
result.teams.len()
);
Ok(())
}
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
let cursor_finished = after
.and_then(|map| map.get("finished_at"))
.and_then(crate::datetime::from_json);
let cursor_id = after
.and_then(|map| map.get("id"))
.and_then(Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok());
let rows: Vec<MatchRow> = sqlx::query_as(
r#"SELECT DISTINCT m.id, m.game_type, m.started_at, m.finished_at, m.result
FROM "match" m
JOIN match_player p ON p.match_id = m.id
WHERE p.user_sub = $1
AND ($2::text IS NULL OR m.game_type = $2)
AND ($3::timestamptz IS NULL
OR m.finished_at < $3
OR (m.finished_at = $3 AND m.id > $4))
ORDER BY m.finished_at DESC, m.id ASC
LIMIT $5"#,
)
.bind(user_sub)
.bind(game_type)
.bind(cursor_finished)
.bind(cursor_id)
.bind((limit + 1) as i64)
.fetch_all(&self.pool)
.await?;
let has_more = rows.len() > limit;
let page: Vec<MatchRow> = rows.into_iter().take(limit).collect();
let next_cursor = if has_more {
page.last().map(|row| {
let mut values = Map::new();
values.insert(
"finished_at".into(),
Value::String(crate::datetime::to_iso(row.finished_at)),
);
values.insert("id".into(), Value::String(row.id.to_string()));
crate::pagination::encode_cursor(&values)
})
} else {
None
};
let mut players_by_match: HashMap<Uuid, Vec<MatchPlayerRow>> = HashMap::new();
if !page.is_empty() {
let ids: Vec<Uuid> = page.iter().map(|row| row.id).collect();
let players: Vec<MatchPlayerRow> = sqlx::query_as(
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
FROM match_player WHERE match_id = ANY($1)
ORDER BY match_id, seat"#,
)
.bind(&ids)
.fetch_all(&self.pool)
.await?;
for player in players {
players_by_match
.entry(player.match_id)
.or_default()
.push(player);
}
}
let entries = page
.into_iter()
.map(|row| {
let players = players_by_match.remove(&row.id).unwrap_or_default();
row_to_entry(row, user_sub, &players)
})
.collect();
Ok((entries, next_cursor))
}
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
let rows: Vec<(String, i32, i32)> = sqlx::query_as(
r#"SELECT game_type, rating, matches_played FROM player_rating
WHERE user_sub = $1 ORDER BY game_type"#,
)
.bind(user_sub)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|(game_type, rating, matches_played)| RatingEntry {
game_type,
rating,
matches_played,
})
.collect())
}
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>> {
let rows: Vec<(String, String, bool, f64)> = sqlx::query_as(
r#"SELECT mp.user_sub, mp.display_name, mp.won, mp.score
FROM match_player mp
JOIN "match" m ON m.id = mp.match_id
WHERE ($1::text IS NULL OR m.game_type = $1)"#,
)
.bind(game_type)
.fetch_all(&self.pool)
.await?;
let ratings: Vec<(String, i32)> =
sqlx::query_as(r#"SELECT user_sub, rating FROM player_rating WHERE game_type = $1"#)
.bind(rating_game)
.fetch_all(&self.pool)
.await?;
let ratings: HashMap<String, i32> = ratings.into_iter().collect();
let mut order: Vec<String> = Vec::new();
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
for (user_sub, display_name, won, score) in rows {
let entry = aggregate.entry(user_sub.clone()).or_insert_with(|| {
order.push(user_sub.clone());
LeaderboardEntry {
user_sub: user_sub.clone(),
display_name: display_name.clone(),
matches: 0,
wins: 0,
points: 0.0,
elo: ratings
.get(&user_sub)
.copied()
.unwrap_or(INITIAL_RATING as i32),
}
});
entry.matches += 1;
entry.wins += u64::from(won);
entry.points += score;
// Keep the most recent display name seen.
entry.display_name = display_name;
}
let mut ranking: Vec<LeaderboardEntry> = order
.into_iter()
.filter_map(|sub| aggregate.remove(&sub))
.collect();
ranking.sort_by(|a, b| {
b.elo
.cmp(&a.elo)
.then(b.wins.cmp(&a.wins))
.then(
b.points
.partial_cmp(&a.points)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.matches.cmp(&b.matches))
});
Ok(ranking)
}
async fn backfill_elo(&self) -> Result<usize> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM player_rating")
.execute(&mut *tx)
.await?;
let matches: Vec<MatchRow> = sqlx::query_as(
r#"SELECT id, game_type, started_at, finished_at, result FROM "match"
ORDER BY finished_at, id"#,
)
.fetch_all(&mut *tx)
.await?;
let mut replayed = 0usize;
for row in matches {
let players: Vec<MatchPlayerRow> = sqlx::query_as(
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
FROM match_player WHERE match_id = $1"#,
)
.bind(row.id)
.fetch_all(&mut *tx)
.await?;
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for player in &players {
if let Some(team) = &player.team {
by_team
.entry(team.clone())
.or_default()
.push(player.user_sub.clone());
}
}
let teams: Vec<Vec<String>> = by_team.into_values().collect();
let winner_index = teams
.iter()
.position(|members| {
players
.iter()
.any(|p| p.won && members.contains(&p.user_sub))
})
.unwrap_or(0);
let deltas = apply_elo(&mut tx, &row.game_type, &teams, winner_index).await?;
for player in &players {
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
sqlx::query("UPDATE match_player SET elo_delta = $1 WHERE id = $2")
.bind(delta as i16)
.bind(player.id)
.execute(&mut *tx)
.await?;
}
replayed += 1;
}
tx.commit().await?;
tracing::info!("elo backfill complete: {replayed} matches replayed");
Ok(replayed)
}
}
// ---------------------------------------------------------------------------
// In-memory implementation (tests and ephemeral dev)
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct StoredMatch {
id: Uuid,
game_type: String,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
result: Value,
}
#[derive(Clone)]
struct StoredPlayer {
match_id: Uuid,
user_sub: String,
display_name: String,
seat: i16,
team: Option<String>,
won: bool,
score: f64,
elo_delta: Option<i32>,
details: Value,
}
#[derive(Default)]
struct MemoryStats {
matches: Vec<StoredMatch>,
players: Vec<StoredPlayer>,
ratings: HashMap<(String, String), (i32, i32)>,
}
#[derive(Default)]
pub struct InMemoryStatsRepo {
inner: tokio::sync::Mutex<MemoryStats>,
}
impl InMemoryStatsRepo {
pub fn new() -> Self {
Self::default()
}
}
fn memory_apply_elo(
inner: &mut MemoryStats,
game_type: &str,
teams: &[Vec<String>],
winner_team: usize,
) -> Result<HashMap<String, i64>> {
if teams.len() != 2 {
return Err(Error::Internal(
"Elo rating requires exactly two teams".into(),
));
}
let mut ratings: HashMap<String, i32> = HashMap::new();
for team in teams {
for sub in team {
let entry = inner
.ratings
.entry((sub.clone(), game_type.to_string()))
.or_insert((INITIAL_RATING as i32, 0));
ratings.insert(sub.clone(), entry.0);
}
}
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
let delta_a = match_delta(&team_a, &team_b, winner_team);
let mut deltas = HashMap::new();
for sub in &teams[0] {
deltas.insert(sub.clone(), delta_a);
}
for sub in &teams[1] {
deltas.insert(sub.clone(), -delta_a);
}
for (sub, delta) in &deltas {
let entry = inner
.ratings
.get_mut(&(sub.clone(), game_type.to_string()))
.expect("rating entry exists");
entry.0 += *delta as i32;
entry.1 += 1;
}
Ok(deltas)
}
#[async_trait]
impl StatsRepo for InMemoryStatsRepo {
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
let result = engine.result(session)?;
let started_at = session.created_at.unwrap_or_else(Utc::now);
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
let mut inner = self.inner.lock().await;
let match_id = Uuid::new_v4();
let deltas = memory_apply_elo(
&mut inner,
&session.game_type,
&result.teams,
result.winner_team,
)?;
inner.matches.push(StoredMatch {
id: match_id,
game_type: session.game_type.clone(),
started_at,
finished_at,
result: result.summary.clone(),
});
for player in &result.players {
let display_name = session
.seat_of(&player.user_sub)
.map(|seat| seat.display_name.clone())
.unwrap_or_else(|| player.user_sub.clone());
inner.players.push(StoredPlayer {
match_id,
user_sub: player.user_sub.clone(),
display_name,
seat: player.seat as i16,
team: player.team.clone(),
won: player.won,
score: player.score,
elo_delta: deltas.get(&player.user_sub).map(|d| *d as i32),
details: player.details.clone(),
});
}
Ok(())
}
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
let inner = self.inner.lock().await;
let cursor_finished = after
.and_then(|map| map.get("finished_at"))
.and_then(crate::datetime::from_json);
let cursor_id = after
.and_then(|map| map.get("id"))
.and_then(Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok());
let mut candidate_ids: Vec<Uuid> = inner
.players
.iter()
.filter(|p| p.user_sub == user_sub)
.map(|p| p.match_id)
.collect();
candidate_ids.sort();
candidate_ids.dedup();
let mut matches: Vec<&StoredMatch> = candidate_ids
.iter()
.filter_map(|id| inner.matches.iter().find(|m| m.id == *id))
.filter(|m| game_type.is_none_or(|game_type| m.game_type == game_type))
.filter(|m| match (cursor_finished, cursor_id) {
(Some(finished), Some(id)) => {
m.finished_at < finished || (m.finished_at == finished && m.id > id)
}
_ => true,
})
.collect();
matches.sort_by(|a, b| b.finished_at.cmp(&a.finished_at).then(a.id.cmp(&b.id)));
let has_more = matches.len() > limit;
matches.truncate(limit);
let next_cursor = if has_more {
matches.last().map(|row| {
let mut values = Map::new();
values.insert(
"finished_at".into(),
Value::String(crate::datetime::to_iso(row.finished_at)),
);
values.insert("id".into(), Value::String(row.id.to_string()));
crate::pagination::encode_cursor(&values)
})
} else {
None
};
let entries = matches
.into_iter()
.map(|row| {
let players: Vec<MatchPlayerRow> = inner
.players
.iter()
.filter(|p| p.match_id == row.id)
.map(|p| MatchPlayerRow {
id: Uuid::nil(),
match_id: p.match_id,
user_sub: p.user_sub.clone(),
display_name: p.display_name.clone(),
seat: p.seat,
team: p.team.clone(),
won: p.won,
score: p.score,
elo_delta: p.elo_delta.map(|d| d as i16),
details: p.details.clone(),
})
.collect();
row_to_entry(
MatchRow {
id: row.id,
game_type: row.game_type.clone(),
started_at: row.started_at,
finished_at: row.finished_at,
result: row.result.clone(),
},
user_sub,
&players,
)
})
.collect();
Ok((entries, next_cursor))
}
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
let inner = self.inner.lock().await;
let mut rows: Vec<RatingEntry> = inner
.ratings
.iter()
.filter(|((sub, _), _)| sub == user_sub)
.map(|((_, game_type), (rating, matches_played))| RatingEntry {
game_type: game_type.clone(),
rating: *rating,
matches_played: *matches_played,
})
.collect();
rows.sort_by(|a, b| a.game_type.cmp(&b.game_type));
Ok(rows)
}
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>> {
let inner = self.inner.lock().await;
let ratings: HashMap<&str, i32> = inner
.ratings
.iter()
.filter(|((_, gt), _)| gt == rating_game)
.map(|((sub, _), (rating, _))| (sub.as_str(), *rating))
.collect();
let mut order: Vec<String> = Vec::new();
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
for player in &inner.players {
let in_scope = match game_type {
Some(game_type) => inner
.matches
.iter()
.any(|m| m.id == player.match_id && m.game_type == game_type),
None => true,
};
if !in_scope {
continue;
}
let entry = aggregate.entry(player.user_sub.clone()).or_insert_with(|| {
order.push(player.user_sub.clone());
LeaderboardEntry {
user_sub: player.user_sub.clone(),
display_name: player.display_name.clone(),
matches: 0,
wins: 0,
points: 0.0,
elo: ratings
.get(player.user_sub.as_str())
.copied()
.unwrap_or(INITIAL_RATING as i32),
}
});
entry.matches += 1;
entry.wins += u64::from(player.won);
entry.points += player.score;
entry.display_name = player.display_name.clone();
}
let mut ranking: Vec<LeaderboardEntry> = order
.into_iter()
.filter_map(|sub| aggregate.remove(&sub))
.collect();
ranking.sort_by(|a, b| {
b.elo
.cmp(&a.elo)
.then(b.wins.cmp(&a.wins))
.then(
b.points
.partial_cmp(&a.points)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.matches.cmp(&b.matches))
});
Ok(ranking)
}
async fn backfill_elo(&self) -> Result<usize> {
let mut inner = self.inner.lock().await;
inner.ratings.clear();
let matches = inner.matches.clone();
for row in &matches {
let players: Vec<StoredPlayer> = inner
.players
.iter()
.filter(|p| p.match_id == row.id)
.cloned()
.collect();
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for player in &players {
if let Some(team) = &player.team {
by_team
.entry(team.clone())
.or_default()
.push(player.user_sub.clone());
}
}
let teams: Vec<Vec<String>> = by_team.into_values().collect();
let winner_index = teams
.iter()
.position(|members| {
players
.iter()
.any(|p| p.won && members.contains(&p.user_sub))
})
.unwrap_or(0);
let deltas = memory_apply_elo(&mut inner, &row.game_type, &teams, winner_index)?;
for player in inner.players.iter_mut().filter(|p| p.match_id == row.id) {
player.elo_delta = deltas.get(&player.user_sub).map(|d| *d as i32);
}
}
Ok(matches.len())
}
}
-588
View File
@@ -1,588 +0,0 @@
//! Persistence for live games.
//!
//! Game sessions are small, mutable and short-lived, which makes Redis a
//! natural fit: the whole match is a single JSON value under
//! `tavolo:game:<id>` with a sliding TTL, and a join-code index maps the
//! short code a player shares to that id. Completed matches are copied to
//! Postgres (see [`crate::stats`]); Redis keeps serving the finished session
//! until it expires.
//!
//! Two implementations satisfy [`GameStore`]:
//!
//! * [`RedisGameStore`] — production, used when `REDIS_URL` is set.
//! * [`InMemoryGameStore`] — tests and ephemeral dev, used otherwise.
//!
//! Concurrency is handled with a per-game lock so two simultaneous actions
//! cannot interleave. State changes are broadcast on a per-game pub/sub
//! channel as a simple "something changed" signal; every open websocket
//! reloads the session and renders the personalized view.
//!
//! Timeouts are driven by a shared delayed-deadline queue (see
//! [`crate::deadlines`]).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use futures_util::stream::{BoxStream, StreamExt};
use tokio::sync::{broadcast, mpsc, Mutex, OwnedMutexGuard};
use crate::engine::{session_from_json, session_to_json, GameSession};
use crate::errors::{Error, Result};
use crate::registry::GameRegistry;
pub const GAME_KEY_PREFIX: &str = "tavolo:game:";
pub const CODE_KEY_PREFIX: &str = "tavolo:code:";
/// Channel prefix of the per-game change notifications.
pub const CHANNEL_PREFIX: &str = "tavolo:game:";
pub const DEADLINES_KEY: &str = "tavolo:deadlines";
/// Channel announcing newly enqueued deadline entries, so every worker's
/// consumer can wake immediately instead of polling on its heartbeat.
pub const DEADLINES_CHANNEL: &str = "tavolo:deadlines:events";
/// Sorted set of running games (score = expiry timestamp), maintained by the
/// stores so the telemetry gauge can count ongoing matches without loading
/// every session. Finished games are removed on their final save.
pub const ONGOING_GAMES_KEY: &str = "tavolo:games:ongoing";
const LOCK_TIMEOUT_SECONDS: f64 = 10.0;
const LOCK_BLOCKING_TIMEOUT_SECONDS: f64 = 10.0;
fn channel(game_id: &str) -> String {
format!("{CHANNEL_PREFIX}{game_id}:events")
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
/// A held per-game mutation lock. Released explicitly with
/// [`GameLock::release`] or best-effort on drop.
pub enum GameLock {
Memory(OwnedMutexGuard<()>),
Redis {
manager: redis::aio::ConnectionManager,
key: String,
token: String,
released: bool,
},
}
const UNLOCK_SCRIPT: &str = "if redis.call('get', KEYS[1]) == ARGV[1] \
then return redis.call('del', KEYS[1]) else return 0 end";
impl GameLock {
/// Release the lock. Consumes the guard; plain drop also releases it
/// (best-effort for the Redis variant).
pub async fn release(mut self) -> Result<()> {
if let GameLock::Redis {
manager,
key,
token,
released,
} = &mut self
{
if !*released {
*released = true;
let script = redis::Script::new(UNLOCK_SCRIPT);
let mut conn = manager.clone();
let _: i64 = script
.key(key.clone())
.arg(token.clone())
.invoke_async(&mut conn)
.await?;
}
}
Ok(())
}
}
impl Drop for GameLock {
fn drop(&mut self) {
if let GameLock::Redis {
manager,
key,
token,
released,
} = self
{
if !*released {
*released = true;
let mut manager = manager.clone();
let key = key.clone();
let token = token.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let script = redis::Script::new(UNLOCK_SCRIPT);
let _: std::result::Result<i64, _> =
script.key(key).arg(token).invoke_async(&mut manager).await;
});
}
}
}
}
}
/// Abstract persistence + notification layer for live games.
#[async_trait]
pub trait GameStore: Send + Sync {
/// Return the live session for `game_id` or `None`.
async fn load(&self, game_id: &str) -> Result<Option<GameSession>>;
/// Persist `session`, refreshing its TTL and code index.
async fn save(&self, session: &GameSession) -> Result<()>;
/// Return the live session for a join `code` or `None`.
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>>;
/// Number of live games that have not finished yet.
async fn count_ongoing(&self) -> Result<usize>;
/// Acquire the per-game mutation lock, waiting up to ten seconds.
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock>;
/// Signal that the session of `game_id` changed.
async fn publish(&self, game_id: &str) -> Result<()>;
/// Stream of change signals for one game.
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>>;
/// Enqueue `member` to fire at `due_at` (epoch seconds). Idempotent for
/// identical members: re-adding only updates the due time.
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()>;
/// Return up to `limit` enqueued members due at or before `now`.
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>>;
/// Return the earliest pending due time (epoch seconds), if any.
async fn next_deadline(&self) -> Result<Option<f64>>;
/// Remove `member` from the queue; a no-op when absent.
async fn remove_deadline(&self, member: &str) -> Result<()>;
/// Wake every worker's deadline consumer (best effort).
async fn notify_deadline(&self) -> Result<()>;
/// Stream of deadline-enqueue signals.
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>>;
}
pub type SharedGameStore = Arc<dyn GameStore>;
fn broadcast_stream(rx: broadcast::Receiver<()>) -> BoxStream<'static, ()> {
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(()) => return Some(((), rx)),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}))
}
fn mpsc_stream(rx: mpsc::Receiver<()>) -> BoxStream<'static, ()> {
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|()| ((), rx))
}))
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
pub struct RedisGameStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
registry: Arc<GameRegistry>,
ttl: i64,
}
impl RedisGameStore {
pub async fn new(url: &str, registry: Arc<GameRegistry>, ttl_seconds: i64) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
registry,
ttl: ttl_seconds,
})
}
fn game_key(&self, game_id: &str) -> String {
format!("{GAME_KEY_PREFIX}{game_id}")
}
fn lock_key(&self, game_id: &str) -> String {
format!("{GAME_KEY_PREFIX}{game_id}:lock")
}
fn code_key(&self, code: &str) -> String {
format!("{CODE_KEY_PREFIX}{}", code.to_uppercase())
}
async fn subscribe_to(&self, target: String) -> Result<BoxStream<'static, ()>> {
let mut pubsub = self.client.get_async_pubsub().await?;
pubsub.subscribe(target).await?;
let (tx, rx) = mpsc::channel(32);
let mut messages = pubsub.into_on_message();
tokio::spawn(async move {
while let Some(_message) = messages.next().await {
if tx.send(()).await.is_err() {
break;
}
}
});
Ok(mpsc_stream(rx))
}
}
#[async_trait]
impl GameStore for RedisGameStore {
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
let mut conn = self.manager.clone();
let raw: Option<String> = redis::cmd("GET")
.arg(self.game_key(game_id))
.query_async(&mut conn)
.await?;
let Some(raw) = raw else {
tracing::debug!("redis load {game_id}: miss");
return Ok(None);
};
tracing::debug!("redis load {game_id}: hit");
let data: serde_json::Value = serde_json::from_str(&raw)?;
let game_type = data
.get("game_type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
let engine = self.registry.require(game_type)?;
Ok(Some(session_from_json(&data, engine.as_ref())?))
}
async fn save(&self, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?;
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
let mut conn = self.manager.clone();
let mut pipe = redis::pipe();
pipe.atomic();
pipe.cmd("SET")
.arg(self.game_key(&session.id))
.arg(&payload)
.arg("EX")
.arg(self.ttl);
pipe.cmd("SET")
.arg(self.code_key(&session.join_code))
.arg(&session.id)
.arg("EX")
.arg(self.ttl);
if engine.is_finished(session) {
pipe.cmd("ZREM").arg(ONGOING_GAMES_KEY).arg(&session.id);
} else {
pipe.cmd("ZADD")
.arg(ONGOING_GAMES_KEY)
.arg(now_secs() + self.ttl as f64)
.arg(&session.id);
pipe.cmd("EXPIRE").arg(ONGOING_GAMES_KEY).arg(self.ttl);
}
let _: Vec<redis::Value> = pipe.query_async(&mut conn).await?;
tracing::debug!(
"redis save {} (game {}, ttl {}s)",
session.id,
session.game_type,
self.ttl
);
Ok(())
}
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
let mut conn = self.manager.clone();
let game_id: Option<String> = redis::cmd("GET")
.arg(self.code_key(code))
.query_async(&mut conn)
.await?;
match game_id {
Some(game_id) => self.load(&game_id).await,
None => Ok(None),
}
}
async fn count_ongoing(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut pipe = redis::pipe();
pipe.atomic();
pipe.cmd("ZREMRANGEBYSCORE")
.arg(ONGOING_GAMES_KEY)
.arg("-inf")
.arg(now_secs());
pipe.cmd("ZCARD").arg(ONGOING_GAMES_KEY);
let values: Vec<i64> = pipe.query_async(&mut conn).await?;
Ok(values.last().copied().unwrap_or(0).max(0) as usize)
}
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
let key = self.lock_key(game_id);
let token = uuid::Uuid::new_v4().to_string();
let mut conn = self.manager.clone();
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs_f64(LOCK_BLOCKING_TIMEOUT_SECONDS);
loop {
let acquired: Option<String> = redis::cmd("SET")
.arg(&key)
.arg(&token)
.arg("NX")
.arg("PX")
.arg((LOCK_TIMEOUT_SECONDS * 1000.0) as i64)
.query_async(&mut conn)
.await?;
if acquired.is_some() {
return Ok(GameLock::Redis {
manager: self.manager.clone(),
key,
token,
released: false,
});
}
if std::time::Instant::now() >= deadline {
return Err(Error::Store(format!(
"could not acquire the lock for game {game_id}"
)));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
async fn publish(&self, game_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("PUBLISH")
.arg(channel(game_id))
.arg("update")
.query_async::<i64>(&mut conn)
.await?;
tracing::debug!("redis publish {game_id}");
Ok(())
}
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
self.subscribe_to(channel(game_id)).await
}
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("ZADD")
.arg(DEADLINES_KEY)
.arg(due_at)
.arg(member)
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
let mut conn = self.manager.clone();
let members: Vec<String> = redis::cmd("ZRANGE")
.arg(DEADLINES_KEY)
.arg("-inf")
.arg(now)
.arg("BYSCORE")
.arg("LIMIT")
.arg(0)
.arg(limit)
.query_async(&mut conn)
.await?;
Ok(members)
}
async fn next_deadline(&self) -> Result<Option<f64>> {
let mut conn = self.manager.clone();
let earliest: Vec<(String, f64)> = redis::cmd("ZRANGE")
.arg(DEADLINES_KEY)
.arg(0)
.arg(0)
.arg("WITHSCORES")
.query_async(&mut conn)
.await?;
Ok(earliest.first().map(|(_, score)| *score))
}
async fn remove_deadline(&self, member: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("ZREM")
.arg(DEADLINES_KEY)
.arg(member)
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn notify_deadline(&self) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("PUBLISH")
.arg(DEADLINES_CHANNEL)
.arg("deadline")
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
self.subscribe_to(DEADLINES_CHANNEL.to_string()).await
}
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
/// Process-local store used by tests and when Redis is not configured.
pub struct InMemoryGameStore {
registry: Arc<GameRegistry>,
games: Mutex<HashMap<String, String>>,
codes: Mutex<HashMap<String, String>>,
ongoing: Mutex<HashSet<String>>,
locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
subscribers: Mutex<HashMap<String, broadcast::Sender<()>>>,
deadlines: Mutex<HashMap<String, f64>>,
deadline_subscribers: broadcast::Sender<()>,
}
impl InMemoryGameStore {
pub fn new(registry: Arc<GameRegistry>) -> Self {
let (deadline_subscribers, _) = broadcast::channel(64);
Self {
registry,
games: Mutex::new(HashMap::new()),
codes: Mutex::new(HashMap::new()),
ongoing: Mutex::new(HashSet::new()),
locks: Mutex::new(HashMap::new()),
subscribers: Mutex::new(HashMap::new()),
deadlines: Mutex::new(HashMap::new()),
deadline_subscribers,
}
}
async fn sender_for(&self, game_id: &str) -> broadcast::Sender<()> {
let mut subscribers = self.subscribers.lock().await;
subscribers
.entry(game_id.to_string())
.or_insert_with(|| broadcast::channel(64).0)
.clone()
}
}
#[async_trait]
impl GameStore for InMemoryGameStore {
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
let raw = self.games.lock().await.get(game_id).cloned();
let Some(raw) = raw else {
return Ok(None);
};
let data: serde_json::Value = serde_json::from_str(&raw)?;
let game_type = data
.get("game_type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
let engine = self.registry.require(game_type)?;
Ok(Some(session_from_json(&data, engine.as_ref())?))
}
async fn save(&self, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?;
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
self.games.lock().await.insert(session.id.clone(), payload);
self.codes
.lock()
.await
.insert(session.join_code.to_uppercase(), session.id.clone());
if engine.is_finished(session) {
self.ongoing.lock().await.remove(&session.id);
} else {
self.ongoing.lock().await.insert(session.id.clone());
}
Ok(())
}
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
let game_id = self.codes.lock().await.get(&code.to_uppercase()).cloned();
match game_id {
Some(game_id) => self.load(&game_id).await,
None => Ok(None),
}
}
async fn count_ongoing(&self) -> Result<usize> {
Ok(self.ongoing.lock().await.len())
}
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
let lock = {
let mut locks = self.locks.lock().await;
locks
.entry(game_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
Ok(GameLock::Memory(lock.lock_owned().await))
}
async fn publish(&self, game_id: &str) -> Result<()> {
let sender = self.sender_for(game_id).await;
let _ = sender.send(());
Ok(())
}
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
let sender = self.sender_for(game_id).await;
Ok(broadcast_stream(sender.subscribe()))
}
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
self.deadlines
.lock()
.await
.insert(member.to_string(), due_at);
Ok(())
}
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
let deadlines = self.deadlines.lock().await;
let mut due: Vec<(String, f64)> = deadlines
.iter()
.filter(|(_, due_at)| **due_at <= now)
.map(|(member, due_at)| (member.clone(), *due_at))
.collect();
due.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(due
.into_iter()
.take(limit)
.map(|(member, _)| member)
.collect())
}
async fn next_deadline(&self) -> Result<Option<f64>> {
Ok(self
.deadlines
.lock()
.await
.values()
.copied()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)))
}
async fn remove_deadline(&self, member: &str) -> Result<()> {
self.deadlines.lock().await.remove(member);
Ok(())
}
async fn notify_deadline(&self) -> Result<()> {
let _ = self.deadline_subscribers.send(());
Ok(())
}
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
Ok(broadcast_stream(self.deadline_subscribers.subscribe()))
}
}
-324
View File
@@ -1,324 +0,0 @@
//! WebSocket endpoint for live play.
//!
//! Clients connect to `/ws/games/{game_id}` using their session cookie. Only
//! seated players are accepted; the upgrade is refused before the handshake
//! completes with an HTTP status mirroring `GET /api/games/{id}`: 401
//! unauthenticated, 404 unknown game, 403 not seated.
//!
//! Server -> client messages are JSON objects with a `type`: `state`,
//! `game_over` and `error`. Client -> server messages are JSON objects with
//! an `action`: the platform handles `state`/`sync` itself; every other
//! action is dispatched to the session's game engine with the rest of the
//! message as its payload.
//!
//! Mutations run under the per-game lock; after a successful action the new
//! session is saved and a change signal is published. Every connected
//! websocket is subscribed to that signal and re-renders the state.
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Map, Value};
use tokio::sync::Mutex;
use tracing::Instrument;
use crate::auth::current_user;
use crate::engine::GameSession;
use crate::http::py_repr;
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// The live-play endpoint; rejects non-seated callers before the upgrade.
pub async fn game_socket(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
ws: WebSocketUpgrade,
) -> Response {
let Some(user) = current_user(&session).await else {
tracing::debug!("websocket {game_id} rejected: no authenticated user");
return StatusCode::UNAUTHORIZED.into_response();
};
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => {
tracing::debug!("websocket rejected: unknown game {game_id}");
return StatusCode::NOT_FOUND.into_response();
}
Err(err) => {
tracing::error!("websocket game load failed: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if !game.seated(&user.sub) {
tracing::debug!("websocket {game_id} rejected: {} is not seated", user.sub);
return StatusCode::FORBIDDEN.into_response();
}
let session_tag = {
let guard = session.lock().await;
guard.id().map(crate::auth::session_tag)
};
let sub = user.sub.clone();
let span = tracing::info_span!(
"websocket",
"user.id" = %user.sub,
"session.id" = tracing::field::Empty,
"game.id" = %game_id,
);
if let Some(tag) = session_tag {
span.record("session.id", tag);
}
ws.on_upgrade(move |socket| {
async move {
run_socket(platform, socket, game_id, sub).await;
}
.instrument(span)
})
}
#[derive(Clone)]
struct Sender {
sink: Arc<Mutex<SplitSink<WebSocket, Message>>>,
}
impl Sender {
async fn send(&self, payload: Value) {
let Ok(text) = serde_json::to_string(&payload) else {
return;
};
let mut sink = self.sink.lock().await;
let _ = sink.send(Message::Text(text.into())).await;
}
}
fn error_payload(message: impl Into<String>, code: &str) -> Value {
json!({ "type": "error", "code": code, "message": message.into() })
}
fn state_message(platform: &Platform, game: &GameSession, sub: &str) -> Option<Value> {
let engine = platform.registry.require(&game.game_type).ok()?;
let mut game_view = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
if let (Value::Object(view), Value::Object(base)) = (engine.view_for(game, sub), &mut game_view)
{
for (key, value) in view {
base.insert(key, value);
}
}
Some(json!({ "type": "state", "game": game_view }))
}
async fn run_socket(platform: SharedPlatform, socket: WebSocket, game_id: String, sub: String) {
let (sink, mut stream): (SplitSink<WebSocket, Message>, SplitStream<WebSocket>) =
socket.split();
let sender = Sender {
sink: Arc::new(Mutex::new(sink)),
};
tracing::info!("{sub} connected to game {game_id}");
// Initial snapshot and a deadline backstop (the entry may have been lost
// while the game lived on thanks to its sliding TTL).
match platform.game_store.load(&game_id).await {
Ok(Some(game)) => {
if let Some(message) = state_message(&platform, &game, &sub) {
sender.send(message).await;
}
let _ = platform.scheduler.sync_deadline(&game).await;
}
_ => return,
}
let mut events = match platform.game_store.subscribe(&game_id).await {
Ok(events) => events,
Err(err) => {
tracing::error!("websocket subscribe failed: {err}");
return;
}
};
let forward_sender = sender.clone();
let forward_platform = platform.clone();
let forward_game_id = game_id.clone();
let forward_sub = sub.clone();
let forward = tokio::spawn(async move {
while events.next().await.is_some() {
let Ok(Some(game)) = forward_platform.game_store.load(&forward_game_id).await else {
return;
};
if let Some(message) = state_message(&forward_platform, &game, &forward_sub) {
forward_sender.send(message).await;
}
if let Ok(engine) = forward_platform.registry.require(&game.game_type) {
if engine.is_finished(&game) {
let mut payload = json!({ "type": "game_over" });
if let (Value::Object(extra), Value::Object(base)) =
(engine.game_over_view(&game, &forward_sub), &mut payload)
{
for (key, value) in extra {
base.insert(key, value);
}
}
forward_sender.send(payload).await;
return;
}
}
}
});
while let Some(message) = stream.next().await {
match message {
Ok(Message::Text(text)) => {
handle_message(&platform, &sender, &game_id, &sub, text.as_str()).await;
}
Ok(Message::Binary(_)) => {
sender
.send(error_payload(
"expected a text frame with a JSON object",
"invalid",
))
.await;
}
Ok(Message::Close(_)) => break,
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
Err(err) => {
tracing::debug!("websocket {game_id} receive error: {err}");
break;
}
}
}
forward.abort();
tracing::debug!("{sub} disconnected from game {game_id}");
}
async fn handle_message(
platform: &SharedPlatform,
sender: &Sender,
game_id: &str,
sub: &str,
raw: &str,
) {
let parsed: Value = match serde_json::from_str(raw) {
Ok(parsed) => parsed,
Err(_) => {
tracing::debug!("game {game_id}: malformed message from {sub} (not JSON)");
sender.send(error_payload("invalid JSON", "invalid")).await;
return;
}
};
let Value::Object(data) = parsed else {
tracing::debug!("game {game_id}: malformed message from {sub} (not an object)");
sender
.send(error_payload("message must be a JSON object", "invalid"))
.await;
return;
};
let action = data.get("action");
if let Some(Value::String(action)) = action {
if action == "state" || action == "sync" {
if let Ok(Some(game)) = platform.game_store.load(game_id).await {
if let Some(message) = state_message(platform, &game, sub) {
sender.send(message).await;
}
}
return;
}
}
let Some(Value::String(action)) = action else {
let value = action.cloned().unwrap_or(Value::Null);
sender
.send(error_payload(
format!("unknown action: {}", py_repr(&value)),
"invalid",
))
.await;
return;
};
handle_action(platform, sender, game_id, sub, action, &data).await;
}
async fn handle_action(
platform: &SharedPlatform,
sender: &Sender,
game_id: &str,
sub: &str,
action: &str,
data: &Map<String, Value>,
) {
let lock = match platform.game_store.acquire_lock(game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("game {game_id}: lock failed: {err}");
sender
.send(error_payload("internal error", "internal"))
.await;
return;
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("game {game_id}: load failed: {err}");
return Err(());
}
}) else {
sender
.send(error_payload("game not found", "not_found"))
.await;
return Ok(());
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("game {game_id}: engine lookup failed: {err}");
return Err(());
}
};
let payload: Map<String, Value> = data
.iter()
.filter(|(key, _)| key.as_str() != "action")
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
match engine.handle_action(&mut game, sub, action, &payload) {
Ok(()) => {}
Err(err) => match err.as_game() {
Some(game_error) => {
tracing::debug!(
"game {game_id}: rejected {action:?} by {sub}: {}",
game_error.message
);
sender
.send(error_payload(&game_error.message, game_error.code))
.await;
return Ok(());
}
None => {
tracing::error!("game {game_id}: action failed: {err}");
return Err(());
}
},
}
tracing::debug!("game {game_id}: {sub} performed {action:?}");
if let Err(err) = platform.scheduler.finalize_mutation(&mut game).await {
tracing::error!("game {game_id}: finalize failed: {err}");
}
Ok(())
}
.await;
let _ = lock.release().await;
if result.is_err() {
sender
.send(error_payload("internal error", "internal"))
.await;
}
}
@@ -1,469 +0,0 @@
//! Shared fixtures for the tavolo-platform test suite.
//!
//! `DummyEngine` is a tiny two-player game implementing the platform's
//! `GameEngine` contract, so every platform behaviour (lobby, store,
//! websockets, deadlines, stats) is exercised without importing any real
//! game. Its rules: the first player to reach `target` plays wins.
#![allow(dead_code)]
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::Router;
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{Error, GameError, Result};
use tavolo_platform::oidc::{OidcProviderConfig, OidcProviders};
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::session::{session_middleware, InMemorySessionStore, SharedSessionStore};
use tavolo_platform::stats::{InMemoryStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, SharedGameStore};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DummyState {
pub target: i64,
pub plays: Vec<String>,
pub started: bool,
pub finished: bool,
pub winner: Option<String>,
pub deadline_in_seconds: Option<f64>,
}
pub struct DummyEngine;
fn state_of(session: &GameSession) -> Result<&DummyState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
fn state_of_mut(session: &mut GameSession) -> Result<&mut DummyState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
impl DummyEngine {
fn play(session: &mut GameSession, user_sub: &str) -> Result<()> {
let state = state_of_mut(session)?;
state.plays.push(user_sub.to_string());
if state.plays.len() as i64 >= state.target {
state.finished = true;
state.winner = Some(user_sub.to_string());
}
Ok(())
}
}
impl GameEngine for DummyEngine {
fn id(&self) -> &'static str {
"dummy"
}
fn name(&self) -> &'static str {
"Dummy game"
}
fn description(&self) -> &'static str {
"A two-player toy game for testing the platform."
}
fn min_players(&self) -> usize {
2
}
fn max_players(&self) -> usize {
2
}
fn options_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target": {
"type": "integer",
"minimum": 1,
"default": 3,
"description": "Plays needed to win the match.",
},
"deadline_in_seconds": {
"type": "number",
"description": "Arm a tick deadline this far in the future.",
},
},
})
}
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target = options.get("target").and_then(Value::as_i64).unwrap_or(3);
if target < 1 || options.get("target").is_some_and(|v| v.is_boolean()) {
return Err(GameError::illegal_move("target must be a positive integer").into());
}
let creator = session.players[0].clone();
session.players[0] = Seat {
user_sub: creator.user_sub,
display_name: creator.display_name,
team: Some("A".into()),
};
session.state = Some(Box::new(DummyState {
target,
plays: Vec::new(),
started: false,
finished: false,
winner: None,
deadline_in_seconds: options.get("deadline_in_seconds").and_then(Value::as_f64),
}));
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
if state_of(session)?.started {
return Err(GameError::game_not_started("game has already started").into());
}
if session.seated(user_sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if session.players.len() >= 2 {
return Err(GameError::lobby_full("game is full").into());
}
let team = if session.players.is_empty() { "A" } else { "B" };
session.players.push(Seat {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: Some(team.into()),
});
if session.players.len() == 2 {
state_of_mut(session)?.started = true;
}
Ok(())
}
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
_payload: &Map<String, Value>,
) -> Result<()> {
if !state_of(session)?.started {
return Err(GameError::game_not_started("the game has not started yet").into());
}
if state_of(session)?.finished {
return Err(GameError::game_finished("the match is over").into());
}
if !session.seated(user_sub) {
return Err(GameError::not_your_turn("you are not seated in this game").into());
}
if action != "play" {
return Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&Value::String(action.to_string()))
))
.into());
}
DummyEngine::play(session, user_sub)
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"started": state.started,
"finished": state.finished,
"winner": state.winner,
"plays": state.plays.len(),
"target": state.target,
"viewer_seated": session.seated(user_sub),
})
}
fn lobby_view(&self, session: &GameSession) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"phase": if state.started { "playing" } else { "lobby" },
"target": state.target,
})
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| !state.started)
.unwrap_or(false)
}
fn is_finished(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| state.finished)
.unwrap_or(false)
}
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_of(session)?;
let Some(winner) = state.winner.clone() else {
return Err(GameError::illegal_move("no result: the match is not finished").into());
};
let subs: Vec<String> = session
.players
.iter()
.map(|seat| seat.user_sub.clone())
.collect();
Ok(MatchResult {
teams: vec![vec![subs[0].clone()], vec![subs[1].clone()]],
winner_team: subs.iter().position(|sub| *sub == winner).unwrap_or(0),
players: session
.players
.iter()
.enumerate()
.map(|(index, seat)| PlayerResult {
user_sub: seat.user_sub.clone(),
seat: index as i64,
won: seat.user_sub == winner,
team: seat.team.clone(),
score: state
.plays
.iter()
.filter(|sub| **sub == seat.user_sub)
.count() as f64,
details: json!({
"plays": state.plays.iter().filter(|sub| **sub == seat.user_sub).count(),
}),
})
.collect(),
summary: json!({
"target": state.target,
"plays": state.plays.len(),
"winner": winner,
}),
})
}
fn state_to_json(&self, state: &(dyn std::any::Any + Send + Sync)) -> Result<Value> {
let state = state
.downcast_ref::<DummyState>()
.ok_or_else(|| Error::Internal("dummy state missing".into()))?;
Ok(serde_json::to_value(state)?)
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn std::any::Any + Send + Sync>> {
Ok(Box::new(serde_json::from_value::<DummyState>(
data.clone(),
)?))
}
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_of(session)?;
let Some(seconds) = state.deadline_in_seconds else {
return Ok(None);
};
if !state.started || state.finished {
return Ok(None);
}
let due_at = tavolo_platform::datetime::now()
+ chrono::Duration::milliseconds((seconds * 1000.0) as i64);
Ok(Some(Deadline {
kind: "tick".into(),
due_at,
token: format!("tick:{}", state.plays.len()),
}))
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
match current {
Some(current) if current.kind == kind && current.token == token => {}
_ => return Err(GameError::illegal_move("stale deadline").into()),
}
let user_sub = session.players[0].user_sub.clone();
DummyEngine::play(session, &user_sub)
}
}
fn test_oidc_config(provider: &str) -> OidcProviderConfig {
OidcProviderConfig {
provider: provider.into(),
issuer: format!("http://localhost:8180/{provider}"),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: format!("http://localhost:8080/auth/{provider}/callback"),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}
}
pub struct TestPlatform {
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
pub stats: Arc<InMemoryStatsRepo>,
}
impl TestPlatform {
pub fn router(&self) -> Router {
Router::new()
.merge(
tavolo_platform::routes::router(&["default".to_string()])
.with_state(self.platform.clone()),
)
.layer(axum::middleware::from_fn_with_state(
self.session_store.clone(),
session_middleware,
))
}
}
pub fn make_platform(heartbeat_ms: u64) -> TestPlatform {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(DummyEngine))
.expect("register dummy");
let registry = Arc::new(registry);
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let stats = Arc::new(InMemoryStatsRepo::new());
let stats_repo: SharedStatsRepo = stats.clone();
let oidc =
Arc::new(OidcProviders::new(vec![test_oidc_config("default")]).expect("oidc providers"));
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats_repo.clone(),
heartbeat_ms,
20,
);
scheduler.start();
let platform = Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats: stats_repo,
});
TestPlatform {
platform,
session_store,
stats,
}
}
/// Persist a logged-in session and return the `Cookie` header value for it.
pub async fn login_cookie(
store: &SharedSessionStore,
provider: &str,
sub: &str,
name: &str,
) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
format!("oidc:{provider}:user"),
json!({"sub": sub, "preferred_username": name}),
);
store
.save(&id, &data, Some(3600))
.await
.expect("save session");
format!("session_id={id}")
}
pub async fn call(router: &Router, request: Request<Body>) -> Response<axum::body::Body> {
router.clone().oneshot(request).await.expect("request")
}
pub async fn body_json(response: Response<Body>) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json body")
}
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().method("GET").uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).expect("request")
}
pub fn post_json(uri: &str, cookie: Option<&str>, body: &Value) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder
.body(Body::from(serde_json::to_vec(body).expect("json")))
.expect("request")
}
pub async fn read_text(
mut socket: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> String {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => text.to_string(),
other => panic!("expected text message, got {other:?}"),
}
}
pub async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json message")
}
other => panic!("expected text message, got {other:?}"),
}
}
pub fn ws_request(addr: std::net::SocketAddr, game_id: &str, cookie: Option<&str>) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let url = format!("ws://{addr}/ws/games/{game_id}");
let mut request = url.into_client_request().expect("websocket client request");
if let Some(cookie) = cookie {
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie header"),
);
}
request
}
pub async fn start_server(router: Router) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
pub fn json_status(response: &Response<Body>) -> StatusCode {
response.status()
}
@@ -1,410 +0,0 @@
//! HTTP routes and WebSocket protocol of the platform.
mod helpers;
use futures_util::SinkExt;
use helpers::*;
use serde_json::json;
use tokio_tungstenite::tungstenite::handshake::client::Response as WsResponse;
async fn create_and_start(test: &helpers::TestPlatform) -> (String, String, String, String) {
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 3}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
(game_id, code, alice, bob)
}
#[tokio::test]
async fn game_types_lists_the_registered_engine() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, get("/api/game-types", None)).await;
assert_eq!(response.status(), 200);
let body = body_json(response).await;
assert_eq!(body["results"][0]["id"], "dummy");
assert_eq!(body["results"][0]["min_players"], 2);
assert_eq!(body["results"][0]["options_schema"]["type"], "object");
}
#[tokio::test]
async fn create_requires_authentication() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, post_json("/api/games", None, &json!({}))).await;
assert_eq!(response.status(), 401);
let body = body_json(response).await;
assert_eq!(body["error"], "unauthenticated");
}
#[tokio::test]
async fn create_validates_game_type_and_options() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"game_type": "nope"})),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "unknown game_type: 'nope'");
let response = call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 0}}),
),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "target must be a positive integer");
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"options": 3})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(
body_json(response).await["error"],
"options must be an object"
);
}
#[tokio::test]
async fn join_flow_and_snapshot_authorization() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&alice), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
// Missing / unknown code.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(body_json(response).await["error"], "code is required");
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": "ZZZZZZ"})),
)
.await;
assert_eq!(response.status(), 404);
// Preview before joining.
let preview = body_json(
call(
&router,
get(&format!("/api/games/preview?code={code}"), Some(&bob)),
)
.await,
)
.await;
assert_eq!(preview["game_name"], "Dummy game");
assert_eq!(preview["seats_open"], 1);
assert_eq!(preview["players"][0]["sub"], "alice");
// The creator cannot join their own (still open) lobby twice.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"already joined this game"
);
// Bob joins and starts the match.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
let started = body_json(response).await;
assert_eq!(started["started"], true);
assert_eq!(started["your_turn"], json!(null));
// Bob cannot rejoin a started game; a third player finds the lobby full.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let response = call(
&router,
post_json("/api/games/join", Some(&carol), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
// Snapshots: unknown game 404, non-seated 403, seated 200, anonymous 401.
let response = call(&router, get("/api/games/no-such-id", Some(&alice))).await;
assert_eq!(response.status(), 404);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&carol))).await;
assert_eq!(response.status(), 403);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&alice))).await;
assert_eq!(response.status(), 200);
let snapshot = body_json(response).await;
assert_eq!(snapshot["id"], game_id);
assert_eq!(snapshot["viewer_seated"], true);
let response = call(&router, get(&format!("/api/games/{game_id}"), None)).await;
assert_eq!(response.status(), 401);
// The whoami endpoint reflects the session identity.
let me = body_json(call(&router, get("/api/me", Some(&alice))).await).await;
assert_eq!(me["sub"], "alice");
assert_eq!(me["name"], "Alice");
let response = call(&router, get("/api/me", None)).await;
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn auth_helpers_are_provider_agnostic() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let providers = body_json(call(&router, get("/api/auth/providers", None)).await).await;
assert_eq!(
providers["providers"],
json!([{"name": "default", "login_url": "/auth/default/login"}])
);
let response = call(&router, get("/auth/login", None)).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/login"
);
let response = call(&router, get("/auth/logout", Some(&alice))).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/logout"
);
let response = call(&router, get("/auth/logout", None)).await;
assert_eq!(response.headers()["location"].to_str().unwrap(), "/");
let response = call(&router, get("/api/openapi.json", None)).await;
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn websocket_rejects_unauthorized_unknown_and_unseated() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let addr = start_server(test.router()).await;
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, None))
.await
.expect_err("anonymous upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 401);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, "unknown-game", Some(&alice)))
.await
.expect_err("unknown game upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 404);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&carol)))
.await
.expect_err("non-seated upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 403);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn websocket_broadcasts_moves_and_game_over() {
let test = make_platform(50);
let (game_id, _code, alice, bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut alice_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
let (mut bob_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&bob)))
.await
.expect("bob connects");
let first = read_json(&mut alice_ws).await;
assert_eq!(first["type"], "state");
assert_eq!(first["game"]["viewer_seated"], true);
let first_bob = read_json(&mut bob_ws).await;
assert_eq!(first_bob["type"], "state");
// Alice plays; both connections see the update.
alice_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.expect("send play");
let update = read_json(&mut alice_ws).await;
assert_eq!(update["game"]["plays"], 1);
let update = read_json(&mut bob_ws).await;
assert_eq!(update["game"]["plays"], 1);
// Requesting a snapshot resends the state.
bob_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "state"}).to_string(),
))
.await
.expect("send state");
let snapshot = read_json(&mut bob_ws).await;
assert_eq!(snapshot["type"], "state");
assert_eq!(snapshot["game"]["plays"], 1);
}
#[tokio::test]
async fn websocket_reports_errors_and_malformed_messages() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
"not json".into(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["message"], "invalid JSON");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!([1, 2]).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "message must be a JSON object");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "dance"}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["code"], "illegal_move");
assert_eq!(error["message"], "unknown action: 'dance'");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": 7}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "unknown action: 7");
}
#[tokio::test]
async fn websocket_sends_game_over_once_the_match_finishes() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 1}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.unwrap();
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
assert_eq!(state["game"]["finished"], true);
let game_over = read_json(&mut ws).await;
assert_eq!(game_over["type"], "game_over");
}
@@ -1,160 +0,0 @@
//! Postgres-backed stats tests, run only when `TEST_DATABASE_URL` points at a
//! throwaway Postgres database. The compose stack exposes one on
//! `postgres://tavolo:password@localhost:5432/tavolo`.
mod helpers;
use helpers::DummyEngine;
use serde_json::Map;
use sqlx::PgPool;
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
async fn pool() -> Option<PgPool> {
let url = std::env::var("TEST_DATABASE_URL")
.ok()
.filter(|url| !url.is_empty())?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.expect("connect");
sqlx::raw_sql(include_str!("../../tavolo-app/migrations/0001_init.sql"))
.execute(&pool)
.await
.expect("schema");
// Each test starts from a clean slate; other sessions may hold rows, so
// only this test's users are removed.
sqlx::query("DELETE FROM match_player WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
.execute(&pool)
.await
.expect("clean players");
sqlx::query("DELETE FROM player_rating WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
.execute(&pool)
.await
.expect("clean ratings");
sqlx::query(
"DELETE FROM \"match\" WHERE id NOT IN (SELECT DISTINCT match_id FROM match_player)",
)
.execute(&pool)
.await
.expect("clean matches");
Some(pool)
}
fn finished_game(id: &str, winner_plays: &str) -> GameSession {
let engine = DummyEngine;
let mut game = GameSession::new(id.to_string(), engine.id(), "ABCDEF");
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice"), Seat::new("bob", "Bob")];
game.players[0].team = Some("A".into());
game.players[1].team = Some("B".into());
game.created_at = Some(tavolo_platform::datetime::now() - chrono::Duration::minutes(5));
let mut options = Map::new();
options.insert("target".into(), serde_json::json!(1));
engine
.create(&mut game, &options)
.expect("create dummy state");
{
let state = game
.state
.as_mut()
.and_then(|state| state.downcast_mut::<helpers::DummyState>())
.expect("dummy state");
state.started = true;
}
engine
.handle_action(&mut game, winner_plays, "play", &Map::new())
.expect("winning play");
game.finished_at = Some(tavolo_platform::datetime::now());
game
}
#[tokio::test]
async fn postgres_persists_matches_history_ratings_and_leaderboard() {
let Some(pool) = pool().await else {
eprintln!("TEST_DATABASE_URL unset: skipping");
return;
};
let repo = PostgresStatsRepo::new(pool.clone());
let engine = DummyEngine;
let game = finished_game("00000000-0000-0000-0000-000000000001", "bob");
assert!(engine.is_finished(&game));
repo.save_match(&game, &engine).await.expect("save match");
// History for the winner includes the result and the Elo delta.
let (matches, cursor) = repo
.my_matches("bob", None, 20, None)
.await
.expect("history");
assert_eq!(matches.len(), 1);
assert!(cursor.is_none());
assert_eq!(matches[0].result["winner"], "bob");
assert!(matches[0].you_won);
assert_eq!(matches[0].your_elo_delta, Some(16));
assert_eq!(matches[0].players.len(), 2);
// Ratings: 1500 + 16 for bob, 1500 - 16 for alice.
let ratings = repo.my_ratings("bob").await.expect("ratings");
assert_eq!(ratings.len(), 1);
assert_eq!(ratings[0].rating, 1516);
assert_eq!(ratings[0].matches_played, 1);
let alice_ratings = repo.my_ratings("alice").await.expect("ratings");
assert_eq!(alice_ratings[0].rating, 1484);
// Leaderboard: winner first.
let leaderboard = repo.leaderboard(None, "dummy").await.expect("leaderboard");
let bob = leaderboard
.iter()
.find(|entry| entry.user_sub == "bob")
.expect("bob ranked");
let alice = leaderboard
.iter()
.find(|entry| entry.user_sub == "alice")
.expect("alice ranked");
assert_eq!(bob.elo, 1516);
assert_eq!(bob.wins, 1);
assert_eq!(alice.elo, 1484);
assert_eq!(leaderboard[0].user_sub, "bob");
// Backfill replays the same match and reproduces the deltas.
let replayed = repo.backfill_elo().await.expect("backfill");
assert!(replayed >= 1);
let ratings = repo.my_ratings("bob").await.expect("ratings");
assert_eq!(ratings[0].rating, 1516);
// A second match loses to a fresh page cursor: page size 1 has more.
let game2 = finished_game("00000000-0000-0000-0000-000000000002", "alice");
repo.save_match(&game2, &engine)
.await
.expect("save match 2");
let (page, cursor) = repo.my_matches("alice", None, 1, None).await.expect("page");
assert_eq!(page.len(), 1);
assert!(cursor.is_some(), "expected a next cursor");
let cursor_map = cursor.as_deref().map(parse_cursor);
let (next, _) = repo
.my_matches("alice", None, 1, cursor_map.as_ref())
.await
.expect("next page");
assert_eq!(next.len(), 1);
assert_ne!(page[0].id, next[0].id);
// Game-type filters are applied.
let (filtered, _) = repo
.my_matches("alice", Some("dummy"), 20, None)
.await
.expect("filtered");
assert_eq!(filtered.len(), 2);
let (none, _) = repo
.my_matches("alice", Some("scopone_scientifico"), 20, None)
.await
.expect("filtered");
assert!(none.is_empty());
}
fn parse_cursor(cursor: &str) -> serde_json::Map<String, serde_json::Value> {
tavolo_platform::pagination::decode_cursor(Some(cursor))
.expect("cursor")
.expect("some")
}
@@ -1,138 +0,0 @@
//! Redis-backed store tests, run only when `TEST_REDIS_URL` points at a
//! throwaway Redis instance. The compose stack exposes one on
//! `redis://127.0.0.1:6379/0`.
mod helpers;
use std::sync::Arc;
use helpers::DummyEngine;
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::store::{GameStore, RedisGameStore};
fn redis_url() -> Option<String> {
std::env::var("TEST_REDIS_URL")
.ok()
.filter(|url| !url.is_empty())
}
async fn store(ttl: i64) -> Option<RedisGameStore> {
let url = redis_url()?;
let mut registry = GameRegistry::new();
registry.register(Arc::new(DummyEngine)).expect("register");
Some(
RedisGameStore::new(&url, Arc::new(registry), ttl)
.await
.expect("redis store"),
)
}
async fn create_game(store: &RedisGameStore) -> tavolo_platform::GameSession {
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
let engine = DummyEngine;
let unique = uuid::Uuid::new_v4().to_string();
let mut game = GameSession::new(unique.clone(), engine.id(), unique[..6].to_uppercase());
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice")];
game.created_at = Some(tavolo_platform::datetime::now());
engine
.create(&mut game, &serde_json::Map::new())
.expect("create");
store.save(&game).await.expect("save");
game
}
#[tokio::test]
async fn redis_round_trips_sessions_codes_and_ongoing_counts() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let loaded = store.load(&game.id).await.expect("load").expect("found");
assert_eq!(loaded.join_code, game.join_code);
assert!(loaded.seated("alice"));
let by_code = store
.find_by_code(&game.join_code.to_lowercase())
.await
.expect("find")
.expect("found");
assert_eq!(by_code.id, game.id);
assert!(store.count_ongoing().await.expect("count") >= 1);
// Save with TTL still lists the game; a second save does not duplicate.
store.save(&loaded).await.expect("save again");
assert!(store.count_ongoing().await.expect("count") >= 1);
store
.remove_deadline(&format!("missing-{}", game.id))
.await
.unwrap();
store
.remove_deadline(&serde_json::json!({}).to_string())
.await
.unwrap();
}
#[tokio::test]
async fn redis_locks_are_exclusive_and_released() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let first = store.acquire_lock(&game.id).await.expect("first lock");
let second_store = store_second().await.expect("second store");
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired_clone = acquired.clone();
let game_id = game.id.clone();
let waiter = tokio::spawn(async move {
let _lock = second_store
.acquire_lock(&game_id)
.await
.expect("second lock");
acquired_clone.store(true, std::sync::atomic::Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst));
first.release().await.expect("release");
tokio::time::timeout(std::time::Duration::from_secs(3), waiter)
.await
.expect("waiter acquired")
.expect("waiter task");
assert!(acquired.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn redis_pubsub_signals_and_deadline_queue() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
use futures_util::StreamExt;
let game = create_game(&store).await;
let mut events = store.subscribe(&game.id).await.expect("subscribe");
// The subscription registers asynchronously; a short delay avoids racing
// the PUBLISH with the SUBSCRIBE.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
store.publish(&game.id).await.expect("publish");
let signalled = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await;
assert!(signalled.is_ok(), "no change signal received");
let member = format!("deadline-{}", uuid::Uuid::new_v4());
store.add_deadline(&member, 0.0).await.expect("add");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let due = store
.due_deadlines(tavolo_platform::datetime::now().timestamp() as f64, 10)
.await
.expect("due");
assert!(due.contains(&member), "{due:?}");
assert!(store.next_deadline().await.expect("next").is_some());
store.remove_deadline(&member).await.expect("remove");
}
async fn store_second() -> Option<RedisGameStore> {
store(60).await
}
@@ -1,317 +0,0 @@
//! Store, deadline scheduler and stats behavior of the platform.
mod helpers;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures_util::StreamExt;
use helpers::*;
use serde_json::{json, Map};
use tokio::sync::Notify;
#[tokio::test]
async fn store_round_trips_a_session_and_indexes_join_codes() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let response = call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await;
assert_eq!(response.status(), 201);
let lobby = body_json(response).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
assert_eq!(lobby["seats_open"], 1);
let platform = &test.platform;
let loaded = platform.game_store.load(&game_id).await.unwrap().unwrap();
assert_eq!(loaded.join_code, code);
assert!(loaded.seated("alice"));
assert_eq!(platform.game_store.count_ongoing().await.unwrap(), 1);
// Codes are case-insensitive.
let by_code = platform
.game_store
.find_by_code(&code.to_lowercase())
.await
.unwrap()
.unwrap();
assert_eq!(by_code.id, game_id);
}
#[tokio::test]
async fn store_lock_serializes_mutations() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let store = test.platform.game_store.clone();
let first = store.acquire_lock(&game_id).await.unwrap();
let acquired = Arc::new(AtomicBool::new(false));
let acquired_clone = acquired.clone();
let store_clone = store.clone();
let game_id_clone = game_id.clone();
let waiter = tokio::spawn(async move {
let _lock = store_clone.acquire_lock(&game_id_clone).await.unwrap();
acquired_clone.store(true, Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(!acquired.load(Ordering::SeqCst), "lock was not exclusive");
first.release().await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("waiter acquired the lock")
.expect("waiter task");
assert!(acquired.load(Ordering::SeqCst));
}
#[tokio::test]
async fn store_publish_reaches_subscribers() {
let test = make_platform(50);
let store = test.platform.game_store.clone();
let mut events = store.subscribe("some-game").await.unwrap();
store.publish("some-game").await.unwrap();
let notified = tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await;
assert!(notified.is_ok(), "subscriber was not notified");
}
#[tokio::test]
async fn deadline_fires_an_auto_play() {
let test = make_platform(20);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
// Joining the last player arms the tick deadline.
let response = call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let view = test
.platform
.registry
.require("dummy")
.unwrap()
.view_for(&game, "alice");
assert!(
view["plays"].as_u64().unwrap() >= 1,
"deadline never fired: {view}"
);
}
#[tokio::test]
async fn deadline_finishes_a_match_and_persists_it() {
let test = make_platform(20);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 1, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
assert!(engine.is_finished(&game));
assert!(game.stats_saved, "the deadline finish was not persisted");
assert_eq!(test.platform.game_store.count_ongoing().await.unwrap(), 0);
let matches = test
.platform
.stats
.my_matches("alice", None, 20, None)
.await
.unwrap()
.0;
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].result["target"], 1);
}
#[tokio::test]
async fn finalized_match_updates_history_ratings_and_leaderboard() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 1}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
// Bob plays the winning move directly through the platform.
let mut game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
engine
.handle_action(&mut game, "bob", "play", &Map::new())
.unwrap();
test.platform
.scheduler
.finalize_mutation(&mut game)
.await
.unwrap();
let response = call(&router, get("/api/me/matches", Some(&bob_cookie))).await;
assert_eq!(response.status(), 200);
let history = body_json(response).await;
assert_eq!(history["next_cursor"], json!(null));
let entry = &history["results"][0];
assert_eq!(entry["you_won"], true);
assert_eq!(entry["your_elo_delta"], 16);
assert_eq!(entry["players"].as_array().unwrap().len(), 2);
let ratings = body_json(call(&router, get("/api/me/ratings", Some(&bob_cookie))).await).await;
assert_eq!(ratings["results"][0]["rating"], 1516);
assert_eq!(ratings["results"][0]["matches_played"], 1);
let leaderboard = body_json(call(&router, get("/api/leaderboard", None)).await).await;
let results = leaderboard["results"].as_array().unwrap();
assert_eq!(results[0]["user_sub"], "bob");
assert_eq!(results[0]["wins"], 1);
assert_eq!(results[1]["user_sub"], "alice");
assert_eq!(results[1]["elo"], 1484);
}
#[tokio::test]
async fn stale_deadline_is_discarded_without_effect() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
// Wait for one auto-play, then deliver the same (now stale) entry again.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
let plays = engine.view_for(&game, "alice")["plays"].as_u64().unwrap();
let stale = json!({
"game_id": game_id,
"kind": "tick",
"token": format!("tick:{}", plays.saturating_sub(1)),
})
.to_string();
test.platform.scheduler.process_due(&stale).await.unwrap();
let after = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
assert_eq!(
engine.view_for(&after, "alice")["plays"].as_u64().unwrap(),
plays
);
}
#[tokio::test]
async fn in_memory_notify_wakes_without_deadlines() {
let notify = Arc::new(Notify::new());
let wait = notify.clone();
let task = tokio::spawn(async move {
wait.notified().await;
true
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
notify.notify_waiters();
assert!(
tokio::time::timeout(std::time::Duration::from_secs(1), task)
.await
.unwrap()
.unwrap()
);
}
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "tavolo-scopone"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Scopone scientifico rules engine and platform adapter"
[dependencies]
chrono.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
tavolo-platform = { path = "../tavolo-platform" }
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true }
-791
View File
@@ -1,791 +0,0 @@
//! Pure rules engine for scopone scientifico.
//!
//! Every function here is deterministic and I/O-free (the only side effect is
//! debug logging): it mutates (or reads) [`ScoponeState`] and returns a
//! [`GameError`] on rule violations. This makes the whole rule set
//! unit-testable without a store or transport. The platform-facing adapter is
//! [`crate::plugin::ScoponeEngine`].
//!
//! Rules implemented
//! -----------------
//! * 40-card Italian deck (4 suits x ranks 1-10), ten cards per player, empty
//! table at the start of every hand.
//! * 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.
//! * Emptying the table with a capture is a **scopa** (+1), except on the very
//! last play of a hand.
//! * 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.
//! * Optional `napola` rule (enabled by default): the longest run of
//! consecutive denari starting from the ace scores one point per card when
//! it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
//! capturing the whole denari suit (ace to king) wins the match instantly.
//! * Optional `asso_pigliatutto` rule (disabled by default): 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, 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 team reaches the target score with a clear lead; a
//! tie at or above the target is broken by playing another hand.
use std::collections::HashSet;
use rand::seq::SliceRandom;
use rand::Rng;
use serde_json::{json, Map, Value};
use tavolo_platform::datetime::{now, to_iso};
use tavolo_platform::errors::{GameError, Result};
use crate::errors::card_not_in_hand;
use crate::state::{
parse_card, Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY,
PHASE_PLAYING, SUITS, TEAM_NAMES,
};
/// Number of cards dealt to each player at the start of a hand.
pub const HAND_SIZE: usize = 10;
pub const PLAYERS: usize = 4;
/// Default seconds the hand-end summary waits before dealing anyway. Games
/// carry their own copy in [`ScoponeState::hand_ack_timeout`].
pub const DEFAULT_HAND_ACK_TIMEOUT_SECONDS: i64 = 30;
/// Default seconds a player has to play before the server plays a random
/// legal card for them. Games carry their own copy in
/// [`ScoponeState::turn_timeout`].
pub const DEFAULT_TURN_TIMEOUT_SECONDS: i64 = 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.
fn primiera_value(rank: u32) -> i64 {
match rank {
7 => 21,
6 => 18,
1 => 16,
5 => 15,
4 => 14,
3 => 13,
2 => 12,
8..=10 => 10,
_ => 0,
}
}
/// Return the 40 cards of the Italian deck in canonical order.
pub fn full_deck() -> Vec<Card> {
let mut deck = Vec::with_capacity(40);
for &suit in SUITS.iter() {
for rank in 1..=10u32 {
deck.push(Card { rank, suit });
}
}
deck
}
/// Return a shuffled deck. Pass `rng` for deterministic tests.
pub fn shuffled_deck<R: Rng + ?Sized>(rng: &mut R) -> Vec<Card> {
let mut deck = full_deck();
deck.shuffle(rng);
deck
}
/// Every `size`-element subset of `items`, in the same lexicographic order
/// `itertools.combinations` produces.
fn combinations(items: &[Card], size: usize) -> Vec<Vec<Card>> {
let mut result = Vec::new();
if size == 0 {
return vec![Vec::new()];
}
if size > items.len() {
return result;
}
let mut indices: Vec<usize> = (0..size).collect();
loop {
result.push(indices.iter().map(|&i| items[i].clone()).collect());
// Advance to the next combination; stop once the last one is reached.
let mut i = size;
loop {
if i == 0 {
return result;
}
i -= 1;
if indices[i] != i + items.len() - size {
break;
}
}
indices[i] += 1;
for j in (i + 1)..size {
indices[j] = indices[j - 1] + 1;
}
}
}
/// 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.
pub fn legal_captures(table: &[Card], card: Card, asso_pigliatutto: bool) -> Vec<Vec<Card>> {
let equal: Vec<Card> = table
.iter()
.filter(|c| c.rank == card.rank)
.cloned()
.collect();
if !equal.is_empty() {
return equal.into_iter().map(|c| vec![c]).collect();
}
if asso_pigliatutto && card.rank == 1 {
return vec![table.to_vec()];
}
let candidates: Vec<Card> = table
.iter()
.filter(|c| c.rank <= card.rank)
.cloned()
.collect();
let mut captures: Vec<Vec<Card>> = Vec::new();
// A sum-equal capture needs at least two cards (single non-equal cards
// cannot sum to the played card).
for size in 2..=candidates.len() {
for combo in combinations(&candidates, size) {
if combo.iter().map(|c| c.rank).sum::<u32>() == card.rank {
captures.push(combo);
}
}
}
captures
}
/// Create a lobby game with the creator seated first.
pub fn create_game(
creator_sub: &str,
creator_name: &str,
target_score: i64,
hand_ack_timeout: i64,
turn_timeout: i64,
napola: bool,
asso_pigliatutto: bool,
) -> Result<ScoponeState> {
if !(1..=100).contains(&target_score) {
return Err(GameError::illegal_move("target_score must be between 1 and 100").into());
}
Ok(ScoponeState {
target_score,
napola,
asso_pigliatutto,
phase: PHASE_LOBBY.to_string(),
players: vec![PlayerState {
sub: creator_sub.to_string(),
name: creator_name.to_string(),
seat: 0,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
}],
hand_ack_timeout,
turn_timeout,
..Default::default()
})
}
/// Seat `sub` in the next free chair, starting the match when full.
pub fn join_game<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
name: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_LOBBY {
return Err(GameError::game_not_started("game has already started").into());
}
if state.seated(sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if state.players.len() >= PLAYERS {
return Err(GameError::lobby_full("game is full").into());
}
let seat = state.players.len() as i64;
state.players.push(PlayerState {
sub: sub.to_string(),
name: name.to_string(),
seat,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
});
if state.players.len() == PLAYERS {
start_game(state, rng)?;
}
Ok(())
}
/// Deal the first hand and switch the game to playing.
pub fn start_game<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.players.len() != PLAYERS {
return Err(GameError::game_not_started("need exactly four players to start").into());
}
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Arm the auto-play deadline for whoever is on turn.
fn set_turn_deadline(state: &mut ScoponeState) {
let deadline = now() + chrono::Duration::seconds(state.turn_timeout);
state.turn_deadline = Some(to_iso(deadline));
}
fn deal_hand<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) {
let mut deck = shuffled_deck(rng);
for player in state.players.iter_mut() {
player.hand = Vec::new();
player.captured = Vec::new();
player.scope = 0;
}
state.table = Vec::new();
state.last_taker = None;
// Dealer rotates each hand; the first card is played by the player to the
// dealer's left.
state.turn = (state.dealer + 1) % PLAYERS as i64;
set_turn_deadline(state);
for _offset in 0..HAND_SIZE {
for seat_offset in 0..PLAYERS {
let seat = (state.dealer + 1 + seat_offset as i64) % PLAYERS as i64;
let index = player_index_at(state, seat).expect("seated player");
state.players[index]
.hand
.push(deck.pop().expect("deck has enough cards"));
}
}
}
/// The index in `state.players` of the player in `seat`.
fn player_index_at(state: &ScoponeState, seat: i64) -> Result<usize> {
state
.players
.iter()
.position(|player| player.seat == seat)
.ok_or_else(|| GameError::illegal_move(format!("no player in seat {seat}")).into())
}
/// 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.
fn match_option(options: &[Vec<Card>], requested: &[Card]) -> Option<Vec<Card>> {
let mut wanted: Vec<String> = requested.iter().map(Card::code).collect();
wanted.sort();
for option in options {
let mut have: Vec<String> = option.iter().map(Card::code).collect();
have.sort();
if have == wanted {
return Some(option.clone());
}
}
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 [`legal_captures`]) when one exists and empty otherwise.
/// Returns a [`GameError`] on any violation.
pub fn play(
state: &mut ScoponeState,
sub: &str,
card_code: &str,
capture_codes: Option<&[String]>,
) -> Result<()> {
if state.phase == PHASE_FINISHED {
return Err(GameError::game_finished("the match is over").into());
}
if state.phase == PHASE_HAND_END {
return Err(GameError::illegal_move(
"the hand is over; acknowledge the summary to continue",
)
.into());
}
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = state
.players
.iter()
.position(|player| player.sub == sub)
.filter(|&i| state.players[i].seat == state.turn);
let index = match index {
Some(index) => index,
None => return Err(GameError::not_your_turn("it is not your turn").into()),
};
let card = parse_card(card_code)?;
let hand_position = state.players[index].hand.iter().position(|c| c == &card);
let hand_position = match hand_position {
Some(position) => position,
None => return Err(card_not_in_hand(&card.code()).into()),
};
// Remove the card now so the scopa check below can tell whether this was
// the last play of the hand.
state.players[index].hand.remove(hand_position);
let mut requested: Vec<Card> = Vec::new();
if let Some(codes) = capture_codes {
for code in codes {
requested.push(parse_card(code)?);
}
}
let table_before = state.table.clone();
let options = legal_captures(&table_before, card.clone(), 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.
let pigliatutto =
state.asso_pigliatutto && card.rank == 1 && table_before.iter().all(|c| c.rank != 1);
let mut taken: Vec<Card> = Vec::new();
let mut scopa = false;
if options.is_empty() {
if !requested.is_empty() {
return Err(GameError::illegal_move("no capture is possible with that card").into());
}
state.table.push(card.clone());
} else {
let chosen = match match_option(&options, &requested) {
Some(chosen) => chosen,
None => {
return Err(GameError::illegal_move("the requested capture is not legal").into())
}
};
for captured in &chosen {
if let Some(position) = state.table.iter().position(|c| c == captured) {
state.table.remove(position);
}
state.players[index].captured.push(captured.clone());
}
state.players[index].captured.push(card.clone());
state.last_taker = Some(state.players[index].seat);
// A scopa scores only if cards remain to be played this hand.
let hands_empty = state.players.iter().all(|player| player.hand.is_empty());
if state.table.is_empty() && !hands_empty && !pigliatutto {
state.players[index].scope += 1;
scopa = true;
}
taken = chosen;
}
state.last_move = Some(Move {
seat: state.players[index].seat,
name: state.players[index].name.clone(),
card: card.code(),
captured: taken.iter().map(Card::code).collect(),
scopa,
pigliatutto,
});
if state.players.iter().all(|player| player.hand.is_empty()) {
end_hand(state)?;
} else {
state.turn = (state.turn + 1) % PLAYERS as i64;
set_turn_deadline(state);
}
Ok(())
}
/// 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 [`play`], so the move is fully validated and can
/// end the hand or the match. Pass `rng` for deterministic tests.
pub fn auto_play<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = player_index_at(state, state.turn)?;
if state.players[index].hand.is_empty() {
return Err(GameError::illegal_move("the player on turn has no cards").into());
}
let card = state.players[index]
.hand
.choose(rng)
.cloned()
.expect("hand is not empty");
let options = legal_captures(&state.table, card.clone(), state.asso_pigliatutto);
let capture: Option<Vec<Card>> = if options.is_empty() {
None
} else {
options.choose(rng).cloned()
};
let sub = state.players[index].sub.clone();
let capture_codes: Option<Vec<String>> = capture
.as_ref()
.map(|cards| cards.iter().map(Card::code).collect());
play(state, &sub, &card.code(), capture_codes.as_deref())
}
/// 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
/// [`acknowledge_hand`] once all four players have 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.
fn end_hand(state: &mut ScoponeState) -> Result<()> {
state.turn_deadline = None;
if !state.table.is_empty() {
if let Some(taker) = state.last_taker {
let index = player_index_at(state, taker)?;
let swept: Vec<Card> = std::mem::take(&mut state.table);
state.players[index].captured.extend(swept);
}
}
let (points, mut details) = hand_points(state);
for (score, awarded) in state.scores.iter_mut().zip(points.iter()) {
*score += awarded;
}
details.insert("hand".to_string(), json!(state.hand_number));
details.insert("team_a_points".to_string(), json!(points[0]));
details.insert("team_b_points".to_string(), json!(points[1]));
state.hand_scores.push(Value::Object(details.clone()));
// A full napola (the whole denari suit) wins the match outright,
// regardless of the score.
if let Some(Value::Object(napola)) = details.get("napola") {
for (team, name) in TEAM_NAMES.iter().enumerate() {
if napola.get(*name).and_then(Value::as_i64) == Some(10) {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(team as i64);
return Ok(());
}
}
}
let a = state.scores[0];
let b = state.scores[1];
let reached = a.max(b) >= state.target_score;
if reached && a != b {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(if a > b { 0 } else { 1 });
return Ok(());
}
// Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END.to_string();
state.acked = Vec::new();
let deadline = now() + chrono::Duration::seconds(state.hand_ack_timeout);
state.hand_end_deadline = Some(to_iso(deadline));
Ok(())
}
/// Record that `sub` has read the hand-end summary.
///
/// When all four players have acknowledged, the next hand is dealt.
/// Acknowledging twice is a no-op; acknowledging outside the `hand_end` phase
/// returns an error.
pub fn acknowledge_hand<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_HAND_END {
return Err(
GameError::illegal_move("no hand summary is waiting for acknowledgement").into(),
);
}
let seat = match state.players.iter().find(|player| player.sub == sub) {
Some(player) => player.seat,
None => return Err(GameError::not_your_turn("you are not seated in this game").into()),
};
if state.acked.contains(&seat) {
return Ok(());
}
state.acked.push(seat);
if state.acked.len() < PLAYERS {
return Ok(());
}
state.hand_number += 1;
state.dealer = (state.dealer + 1) % PLAYERS as i64;
state.acked = Vec::new();
state.hand_end_deadline = None;
state.last_move = None;
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Return the primiera value of a capture pile (0 if a suit is absent).
pub fn primiera_score(captured: &[Card]) -> i64 {
let mut best: [Option<i64>; 4] = [None; 4];
for card in captured {
let value = primiera_value(card.rank);
if let Some(index) = SUITS.iter().position(|&suit| suit == card.suit) {
if best[index].is_none_or(|current| value > current) {
best[index] = Some(value);
}
}
}
if best.iter().any(Option::is_none) {
return 0;
}
best.iter().map(|value| value.unwrap_or(0)).sum()
}
/// Return the napola value of a capture pile.
///
/// The longest run of consecutive denari starting from the ace scores one
/// point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, ...),
/// so the whole suit (ace to king) is worth 10. Shorter runs score nothing.
/// Only one team can score a napola: the ace of denari belongs to exactly one
/// capture pile.
pub fn napola_score(captured: &[Card]) -> i64 {
let ranks: HashSet<u32> = captured
.iter()
.filter(|card| card.suit == 'D')
.map(|card| card.rank)
.collect();
let mut run = 0u32;
while ranks.contains(&(run + 1)) {
run += 1;
}
if run >= 3 {
run as i64
} else {
0
}
}
/// Compute the hand points for both teams (index 0 = team A).
pub fn hand_points(state: &ScoponeState) -> (Vec<i64>, Map<String, Value>) {
let mut piles: [Vec<Card>; 2] = [Vec::new(), Vec::new()];
let mut scope: [i64; 2] = [0, 0];
for player in &state.players {
let team = player.team() as usize;
piles[team].extend(player.captured.iter().cloned());
scope[team] += player.scope;
}
let mut points = [0i64, 0i64];
let mut award: Map<String, Value> = Map::new();
// Carte: most captured cards. Ties award nothing.
let cards = [piles[0].len() as i64, piles[1].len() as i64];
if cards[0] != cards[1] {
let winner = if cards[0] > cards[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"carte".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("carte".to_string(), Value::Null);
}
// Denara: most diamond cards. Ties award nothing.
let coins = [
piles[0].iter().filter(|c| c.suit == 'D').count() as i64,
piles[1].iter().filter(|c| c.suit == 'D').count() as i64,
];
if coins[0] != coins[1] {
let winner = if coins[0] > coins[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"denara".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("denara".to_string(), Value::Null);
}
// Settebello: the 7 of diamonds always belongs to someone.
let settebello = [
piles[0].iter().any(|c| c.rank == 7 && c.suit == 'D'),
piles[1].iter().any(|c| c.rank == 7 && c.suit == 'D'),
];
if settebello[0] != settebello[1] {
let winner = if settebello[0] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"settebello".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
}
// Primiera: highest value, only if the team holds all four suits.
let primiera = [primiera_score(&piles[0]), primiera_score(&piles[1])];
if primiera[0] != primiera[1] {
let winner = if primiera[0] > primiera[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"primiera".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("primiera".to_string(), Value::Null);
}
// Scope: one point each.
points[0] += scope[0];
points[1] += scope[1];
// Napola (optional rule): consecutive denari from the ace. A run of 10
// means the team swept the whole suit and wins the match instantly.
let mut napola_details: Option<Value> = None;
if state.napola {
let napola = [napola_score(&piles[0]), napola_score(&piles[1])];
for team in 0..2 {
points[team] += napola[team];
}
let name = (0..2usize)
.find(|&team| napola[team] > 0)
.map(|team| Value::String(TEAM_NAMES[team].to_string()))
.unwrap_or(Value::Null);
award.insert("napola".to_string(), name);
napola_details = Some(json!({"A": napola[0], "B": napola[1]}));
}
let mut details: Map<String, Value> = Map::new();
details.insert("cards".to_string(), json!({"A": cards[0], "B": cards[1]}));
details.insert("denara".to_string(), json!({"A": coins[0], "B": coins[1]}));
details.insert(
"settebello".to_string(),
json!({"A": settebello[0], "B": settebello[1]}),
);
details.insert(
"primiera".to_string(),
json!({"A": primiera[0], "B": primiera[1]}),
);
details.insert("scope".to_string(), json!({"A": scope[0], "B": scope[1]}));
details.insert("award".to_string(), Value::Object(award));
if let Some(napola) = napola_details {
details.insert("napola".to_string(), napola);
}
(points.to_vec(), details)
}
fn optional_string(value: &Option<String>) -> Value {
value
.as_ref()
.map(|text| Value::String(text.clone()))
.unwrap_or(Value::Null)
}
/// Serialize `state` hiding other players' hands.
///
/// Hands are reduced to a count, except for the requesting player's own hand.
/// Non-seated viewers simply see no hand at all.
pub fn state_for_player(state: &ScoponeState, sub: &str) -> Value {
let viewer = state.player_for(sub);
let viewer_seat = viewer.map(|player| player.seat);
let mut players: Vec<Value> = Vec::new();
for player in &state.players {
let mut view: Map<String, Value> = Map::new();
view.insert("sub".to_string(), json!(player.sub));
view.insert("name".to_string(), json!(player.name));
view.insert("seat".to_string(), json!(player.seat));
view.insert(
"team".to_string(),
json!(TEAM_NAMES[player.team() as usize]),
);
view.insert("cards_left".to_string(), json!(player.hand.len()));
view.insert("captured_count".to_string(), json!(player.captured.len()));
view.insert("scope".to_string(), json!(player.scope));
if viewer_seat == Some(player.seat) {
view.insert(
"hand".to_string(),
Value::Array(player.hand.iter().map(Card::to_json).collect()),
);
}
players.push(Value::Object(view));
}
let mut payload: Map<String, Value> = Map::new();
payload.insert("phase".to_string(), json!(state.phase));
payload.insert("target_score".to_string(), json!(state.target_score));
payload.insert("napola".to_string(), json!(state.napola));
payload.insert(
"asso_pigliatutto".to_string(),
json!(state.asso_pigliatutto),
);
payload.insert("hand_number".to_string(), json!(state.hand_number));
payload.insert("dealer".to_string(), json!(state.dealer));
payload.insert("turn".to_string(), json!(state.turn));
payload.insert(
"scores".to_string(),
json!({"A": state.scores[0], "B": state.scores[1]}),
);
payload.insert(
"winner".to_string(),
match state.winner {
Some(winner) => json!(TEAM_NAMES[winner as usize]),
None => Value::Null,
},
);
payload.insert(
"table".to_string(),
Value::Array(state.table.iter().map(Card::to_json).collect()),
);
payload.insert("players".to_string(), Value::Array(players));
payload.insert(
"last_hand".to_string(),
state.hand_scores.last().cloned().unwrap_or(Value::Null),
);
payload.insert(
"last_move".to_string(),
state
.last_move
.as_ref()
.map(Move::to_json)
.unwrap_or(Value::Null),
);
payload.insert("acknowledged".to_string(), json!(state.acked));
payload.insert(
"hand_end_deadline".to_string(),
optional_string(&state.hand_end_deadline),
);
payload.insert(
"turn_deadline".to_string(),
optional_string(&state.turn_deadline),
);
if let Some(seat) = viewer_seat {
if state.phase == PHASE_PLAYING && seat == state.turn {
payload.insert("your_turn".to_string(), Value::Bool(true));
// Only the player on turn receives their legal captures, so all
// the rule logic stays server-side.
let mut legal_moves: Map<String, Value> = Map::new();
if let Some(viewer) = viewer {
for hand_card in &viewer.hand {
let options =
legal_captures(&state.table, hand_card.clone(), state.asso_pigliatutto);
if !options.is_empty() {
let list: Vec<Value> = options
.iter()
.map(|option| {
Value::Array(option.iter().map(|c| json!(c.code())).collect())
})
.collect();
legal_moves.insert(hand_card.code(), Value::Array(list));
}
}
}
payload.insert("legal_moves".to_string(), Value::Object(legal_moves));
}
}
Value::Object(payload)
}
@@ -1,15 +0,0 @@
//! Scopone scientifico errors.
//!
//! Most failure modes are game-independent and live in
//! [`tavolo_platform::errors`]; only genuinely scopone-specific errors are
//! defined here. The Rust platform folds every game error into a single
//! [`GameError`] value carrying an `"illegal_move"` code, so a Python-style
//! exception hierarchy is unnecessary; this module keeps named constructors so
//! call sites (and tests) stay readable.
use tavolo_platform::errors::GameError;
/// The played card is not held by the player.
pub fn card_not_in_hand(code: &str) -> GameError {
GameError::illegal_move(format!("card {code} is not in your hand"))
}
-19
View File
@@ -1,19 +0,0 @@
//! Scopone scientifico rules engine and platform adapter.
//!
//! [`engine`] holds the pure, deterministic rules; [`state`] the JSON-native
//! game representation; [`plugin`] the [`tavolo_platform::engine::GameEngine`]
//! implementation that plugs the game into the platform.
pub mod engine;
pub mod errors;
pub mod plugin;
pub mod state;
pub use engine::{
DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS, HAND_SIZE, PLAYERS,
};
pub use plugin::ScoponeEngine;
pub use state::{
Card, Move, PlayerState, ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END,
PHASE_LOBBY, PHASE_PLAYING, TEAM_NAMES,
};
-428
View File
@@ -1,428 +0,0 @@
//! The platform-facing adapter for scopone scientifico.
//!
//! [`ScoponeEngine`] implements [`GameEngine`] on top of the pure rules in
//! [`crate::engine`]: it translates platform calls (create, join, websocket
//! actions, deadlines) into rules-engine calls and back, keeping all scopone
//! knowledge inside this package. Nothing outside `tavolo_scopone` needs to
//! know about phases, turns or hand scoring.
use std::any::Any;
use rand::thread_rng;
use serde_json::{json, Map, Value};
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{GameError, Result};
use crate::engine::{self, DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS};
use crate::state::{
ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, PHASE_PLAYING,
TEAM_NAMES,
};
/// Downcast a session's opaque state to [`ScoponeState`].
fn state_ref(session: &GameSession) -> Result<&ScoponeState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Downcast a session's opaque state to [`ScoponeState`], mutably.
fn state_mut(session: &mut GameSession) -> Result<&mut ScoponeState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Epoch milliseconds for an ISO-8601 deadline, `None` when absent or
/// unparseable.
fn deadline_ms(iso: Option<&str>) -> Option<i64> {
iso.and_then(tavolo_platform::datetime::from_iso)
.map(|dt| dt.timestamp_millis())
}
/// Scopone scientifico as a platform game engine.
pub struct ScoponeEngine {
turn_timeout_seconds: i64,
hand_ack_timeout_seconds: i64,
}
impl ScoponeEngine {
pub fn new(turn_timeout_seconds: i64, hand_ack_timeout_seconds: i64) -> Self {
Self {
turn_timeout_seconds,
hand_ack_timeout_seconds,
}
}
}
impl Default for ScoponeEngine {
fn default() -> Self {
Self::new(
DEFAULT_TURN_TIMEOUT_SECONDS,
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
)
}
}
impl GameEngine for ScoponeEngine {
fn id(&self) -> &'static str {
"scopone_scientifico"
}
fn name(&self) -> &'static str {
"Scopone scientifico"
}
fn description(&self) -> &'static str {
"Four players in fixed partnerships, ten cards each and an empty table. \
First team to the target score wins."
}
fn min_players(&self) -> usize {
4
}
fn max_players(&self) -> usize {
4
}
fn options_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
"napola": {
"type": "boolean",
"default": true,
"description": "Score the napola rule; a full denari sweep wins the match",
},
"asso_pigliatutto": {
"type": "boolean",
"default": false,
"description": "Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)",
},
},
})
}
// -- lobby ------------------------------------------------------------
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target_score = match options.get("target_score") {
None => DEFAULT_TARGET_SCORE,
Some(Value::Bool(_)) => {
return Err(GameError::illegal_move("target_score must be an integer").into())
}
Some(value) => value
.as_i64()
.ok_or_else(|| GameError::illegal_move("target_score must be an integer"))?,
};
let napola = match options.get("napola") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(GameError::illegal_move("napola must be a boolean").into()),
};
let asso_pigliatutto = match options.get("asso_pigliatutto") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => {
return Err(GameError::illegal_move("asso_pigliatutto must be a boolean").into())
}
};
let creator = session
.players
.first()
.ok_or_else(|| GameError::illegal_move("session has no creator seat"))?;
let creator_sub = creator.user_sub.clone();
let creator_name = creator.display_name.clone();
let state = engine::create_game(
&creator_sub,
&creator_name,
target_score,
self.hand_ack_timeout_seconds,
self.turn_timeout_seconds,
napola,
asso_pigliatutto,
)?;
session.state = Some(Box::new(state));
// The creator takes seat 0, i.e. team A.
if let Some(seat) = session.players.get_mut(0) {
seat.team = Some(TEAM_NAMES[0].to_string());
}
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
let seat = session.players.len();
{
let state = state_mut(session)?;
engine::join_game(state, user_sub, display_name, &mut thread_rng())?;
}
// join_game raises before appending on any violation, so the seat list
// stays in sync with the engine's players.
session.players.push(Seat {
user_sub: user_sub.to_string(),
display_name: display_name.to_string(),
team: Some(TEAM_NAMES[seat % 2].to_string()),
});
Ok(())
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_LOBBY)
.unwrap_or(false)
}
fn lobby_view(&self, session: &GameSession) -> Value {
match state_ref(session) {
Ok(state) => json!({
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
}),
Err(_) => json!({}),
}
}
// -- play -------------------------------------------------------------
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()> {
match action {
"play" => {
let card = payload
.get("card")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("'card' must be a card code string"))?;
let capture: Option<Vec<String>> = match payload.get("capture") {
None | Some(Value::Null) => None,
Some(Value::Array(items)) => {
let mut codes = Vec::with_capacity(items.len());
for item in items {
match item.as_str() {
Some(code) => codes.push(code.to_string()),
None => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
}
}
Some(codes)
}
Some(_) => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
};
let state = state_mut(session)?;
engine::play(state, user_sub, card, capture.as_deref())
}
"ack" => {
let state = state_mut(session)?;
engine::acknowledge_hand(state, user_sub, &mut thread_rng())
}
_ => Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&serde_json::Value::String(action.to_string()))
))
.into()),
}
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => engine::state_for_player(state, user_sub),
Err(_) => Value::Null,
}
}
fn is_finished(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_FINISHED)
.unwrap_or(false)
}
fn game_over_view(&self, session: &GameSession, _user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => json!({
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": state.winner.map(|winner| TEAM_NAMES[winner as usize]),
}),
Err(_) => json!({}),
}
}
// -- (de)serialization -------------------------------------------------
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value> {
state
.downcast_ref::<ScoponeState>()
.map(ScoponeState::to_json)
.ok_or_else(|| GameError::illegal_move("state is not a ScoponeState").into())
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>> {
Ok(Box::new(ScoponeState::from_json(data)?))
}
// -- deadlines ----------------------------------------------------------
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_ref(session)?;
if state.phase == PHASE_PLAYING {
if let Some(iso) = state.turn_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "turn".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("turn:{}:{}:{}", state.hand_number, state.turn, due_ms),
}));
}
}
if state.phase == PHASE_HAND_END {
if let Some(iso) = state.hand_end_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "hand_end".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("hand_end:{}:{}", state.hand_number, due_ms),
}));
}
}
Ok(None)
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
let stale = match &current {
Some(deadline) => deadline.kind != kind || deadline.token != token,
None => true,
};
if stale {
// Overtaken by events (a play landed in time, the hand was
// acknowledged, the deadline moved): nothing to do.
return Err(GameError::illegal_move("stale deadline").into());
}
match kind {
"turn" => {
let state = state_mut(session)?;
engine::auto_play(state, &mut thread_rng())?;
let actor = state.last_move.as_ref().map(|move_| move_.seat);
tracing::info!(
actor,
hand = state.hand_number,
"auto-played for the turn timeout"
);
Ok(())
}
"hand_end" => {
let state = state_mut(session)?;
let subs: Vec<String> = state
.players
.iter()
.map(|player| player.sub.clone())
.collect();
let mut rng = thread_rng();
for sub in subs {
engine::acknowledge_hand(state, &sub, &mut rng)?;
}
tracing::info!(
hand = state.hand_number,
"hand auto-advanced after the acknowledgement timeout"
);
Ok(())
}
// next_deadline never emits other kinds.
_ => Err(GameError::illegal_move(format!("unknown deadline kind: {kind:?}")).into()),
}
}
// -- results -------------------------------------------------------------
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_ref(session)?;
let winner = state
.winner
.ok_or_else(|| GameError::illegal_move("no result: the match is not finished"))?
as usize;
let teams = vec![
state
.players
.iter()
.filter(|player| player.team() == 0)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
state
.players
.iter()
.filter(|player| player.team() == 1)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
];
let players = state
.players
.iter()
.map(|player| {
let team = player.team() as usize;
PlayerResult {
user_sub: player.sub.clone(),
seat: player.seat,
won: team == winner,
team: Some(TEAM_NAMES[team].to_string()),
score: state.scores[team] as f64,
details: json!({"scope": player.scope}),
}
})
.collect();
let summary = json!({
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[winner],
"target_score": state.target_score,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
});
Ok(MatchResult::new(teams, winner, players, summary))
}
}
/// Convert epoch milliseconds back to a UTC timestamp, falling back to now
/// when the value is out of range (it never is for parsed deadlines).
fn timestamp_millis(millis: i64) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
.unwrap_or_else(tavolo_platform::datetime::now)
}
-388
View File
@@ -1,388 +0,0 @@
//! In-memory representation of a scopone scientifico game.
//!
//! The whole mutable game lives in [`ScoponeState`], which is serialized to
//! and from plain JSON for storage inside the platform's session envelope.
//! Keeping the representation JSON-native means the store needs no custom
//! codecs and the state is inspectable.
//!
//! The state contains only game data: the platform owns the session envelope
//! (id, join code, seats, timestamps, stats persistence).
//!
//! 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).
use serde_json::{json, Value};
use tavolo_platform::errors::{GameError, Result};
/// The four suits, in canonical order.
pub const SUITS: [char; 4] = ['D', 'C', 'S', 'B'];
/// Teams are derived from the seat: seats 0 and 2 form team A (index 0),
/// seats 1 and 3 form team B (index 1). Team pairs always sit opposite each
/// other, as in real scopone scientifico.
pub const TEAM_NAMES: [&str; 2] = ["A", "B"];
pub const PHASE_LOBBY: &str = "lobby";
pub const PHASE_PLAYING: &str = "playing";
/// Between hands of an unfinished match: scoring summary shown to every
/// player; the next hand is dealt once all four acknowledge (or the hand-end
/// timeout elapses).
pub const PHASE_HAND_END: &str = "hand_end";
pub const PHASE_FINISHED: &str = "finished";
pub const DEFAULT_TARGET_SCORE: i64 = 11;
/// The team index for a seat (0 or 1).
pub fn team_of(seat: i64) -> i64 {
seat.rem_euclid(2)
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Card {
pub rank: u32,
pub suit: char,
}
impl Card {
/// Build a validated card, or `None` when the suit/rank is out of range.
pub fn new(rank: u32, suit: char) -> Option<Card> {
if SUITS.contains(&suit) && (1..=10).contains(&rank) {
Some(Card { rank, suit })
} else {
None
}
}
/// The `RRSUIT` code (e.g. `07D`).
pub fn code(&self) -> String {
format!("{:02}{}", self.rank, self.suit)
}
/// Parse a card code, uppercasing the input like Python's `Card.parse`.
pub fn parse(code: &str) -> Option<Card> {
let upper = code.to_ascii_uppercase();
let chars: Vec<char> = upper.chars().collect();
if chars.len() != 3 || !chars[0].is_ascii_digit() || !chars[1].is_ascii_digit() {
return None;
}
let rank: u32 = format!("{}{}", chars[0], chars[1]).parse().ok()?;
Card::new(rank, chars[2])
}
pub fn to_json(&self) -> Value {
Value::String(self.code())
}
pub fn from_json(value: &Value) -> Result<Card> {
value
.as_str()
.and_then(Card::parse)
.ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
}
/// Parse a card code, raising an `"invalid card code"` error on malformed
/// input.
pub fn parse_card(code: &str) -> Result<Card> {
Card::parse(code).ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
/// Record of a single play, broadcast so every client can show who played
/// which card and what it captured.
#[derive(Debug, Clone, Default)]
pub struct Move {
pub seat: i64,
pub name: String,
pub card: String,
pub captured: Vec<String>,
pub scopa: bool,
/// The play was an asso pigliatutto capture (the whole table, or the ace
/// itself when the table was empty).
pub pigliatutto: bool,
}
impl Move {
pub fn to_json(&self) -> Value {
json!({
"seat": self.seat,
"name": self.name,
"card": self.card,
"captured": self.captured,
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
})
}
pub fn from_json(data: &Value) -> Move {
Move {
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
card: data
.get("card")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
captured: data
.get("captured")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|c| c.as_str().unwrap_or_default().to_string())
.collect()
})
.unwrap_or_default(),
scopa: data.get("scopa").and_then(Value::as_bool).unwrap_or(false),
pigliatutto: data
.get("pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
}
/// One player's private state within a game.
#[derive(Debug, Clone, Default)]
pub struct PlayerState {
pub sub: String,
pub name: String,
pub seat: i64,
pub hand: Vec<Card>,
pub captured: Vec<Card>,
pub scope: i64,
}
impl PlayerState {
pub fn team(&self) -> i64 {
team_of(self.seat)
}
pub fn to_json(&self) -> Value {
json!({
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": self.hand.iter().map(Card::to_json).collect::<Vec<_>>(),
"captured": self.captured.iter().map(Card::to_json).collect::<Vec<_>>(),
"scope": self.scope,
})
}
pub fn from_json(data: &Value) -> Result<PlayerState> {
let parse_cards = |value: Option<&Value>| -> Result<Vec<Card>> {
let items = value.and_then(Value::as_array).cloned().unwrap_or_default();
items.iter().map(Card::from_json).collect()
};
Ok(PlayerState {
sub: data
.get("sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
hand: parse_cards(data.get("hand"))?,
captured: parse_cards(data.get("captured"))?,
scope: data.get("scope").and_then(Value::as_i64).unwrap_or(0),
})
}
}
/// The whole mutable state of one scopone scientifico game.
#[derive(Debug, Clone)]
pub struct ScoponeState {
pub target_score: i64,
/// Whether the napola rule is scored (denari run from the ace; a full suit
/// wins the match instantly). Default on.
pub napola: bool,
/// Whether the asso pigliatutto rule is scored (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.
pub asso_pigliatutto: bool,
pub phase: String,
pub players: Vec<PlayerState>,
pub table: Vec<Card>,
pub dealer: i64,
pub turn: i64,
pub hand_number: i64,
pub scores: Vec<i64>,
pub winner: Option<i64>,
pub last_taker: Option<i64>,
/// Per-hand points awarded, for a compact audit trail in the API.
pub hand_scores: Vec<Value>,
/// The most recent play in the current hand, for move announcements.
pub last_move: Option<Move>,
/// While `phase == "hand_end"`: seats that acknowledged the summary, and
/// when the auto-continue timeout fires.
pub acked: Vec<i64>,
pub hand_end_deadline: Option<String>,
/// Seconds the hand-end summary waits before dealing anyway.
pub hand_ack_timeout: i64,
/// While `phase == "playing"`: when the server plays a random legal card
/// for the player on turn.
pub turn_deadline: Option<String>,
pub turn_timeout: i64,
}
impl Default for ScoponeState {
fn default() -> Self {
ScoponeState {
target_score: DEFAULT_TARGET_SCORE,
napola: true,
asso_pigliatutto: false,
phase: PHASE_LOBBY.to_string(),
players: Vec::new(),
table: Vec::new(),
dealer: 0,
turn: 0,
hand_number: 1,
scores: vec![0, 0],
winner: None,
last_taker: None,
hand_scores: Vec::new(),
last_move: None,
acked: Vec::new(),
hand_end_deadline: None,
hand_ack_timeout: 30,
turn_deadline: None,
turn_timeout: 30,
}
}
}
impl ScoponeState {
pub fn to_json(&self) -> Value {
json!({
"target_score": self.target_score,
"napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": self.players.iter().map(PlayerState::to_json).collect::<Vec<_>>(),
"table": self.table.iter().map(Card::to_json).collect::<Vec<_>>(),
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"scores": self.scores,
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": self.hand_scores,
"last_move": self.last_move.as_ref().map(Move::to_json),
"acked": 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,
})
}
pub fn from_json(data: &Value) -> Result<ScoponeState> {
let obj = data
.as_object()
.ok_or_else(|| GameError::illegal_move("state must be a JSON object"))?;
let parse_array = |value: Option<&Value>| -> Vec<Value> {
value.and_then(Value::as_array).cloned().unwrap_or_default()
};
let players = parse_array(obj.get("players"))
.iter()
.map(PlayerState::from_json)
.collect::<Result<Vec<_>>>()?;
let table = parse_array(obj.get("table"))
.iter()
.map(Card::from_json)
.collect::<Result<Vec<_>>>()?;
let scores = obj
.get("scores")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![0, 0]);
let last_move = match obj.get("last_move") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(Move::from_json(obj.get("last_move").unwrap()))
}
_ => None,
};
Ok(ScoponeState {
target_score: obj
.get("target_score")
.and_then(Value::as_i64)
.unwrap_or(DEFAULT_TARGET_SCORE),
napola: obj.get("napola").and_then(Value::as_bool).unwrap_or(true),
asso_pigliatutto: obj
.get("asso_pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
phase: obj
.get("phase")
.and_then(Value::as_str)
.unwrap_or(PHASE_LOBBY)
.to_string(),
players,
table,
dealer: obj.get("dealer").and_then(Value::as_i64).unwrap_or(0),
turn: obj.get("turn").and_then(Value::as_i64).unwrap_or(0),
hand_number: obj.get("hand_number").and_then(Value::as_i64).unwrap_or(1),
scores,
winner: obj.get("winner").and_then(Value::as_i64),
last_taker: obj.get("last_taker").and_then(Value::as_i64),
hand_scores: parse_array(obj.get("hand_scores")),
last_move,
acked: obj
.get("acked")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
hand_end_deadline: obj
.get("hand_end_deadline")
.and_then(Value::as_str)
.map(str::to_string),
hand_ack_timeout: obj
.get("hand_ack_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
turn_deadline: obj
.get("turn_deadline")
.and_then(Value::as_str)
.map(str::to_string),
turn_timeout: obj
.get("turn_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
})
}
// -- helpers ----------------------------------------------------------
pub fn player_for(&self, sub: &str) -> Option<&PlayerState> {
self.players.iter().find(|player| player.sub == sub)
}
pub fn seated(&self, sub: &str) -> bool {
self.player_for(sub).is_some()
}
}
File diff suppressed because it is too large Load Diff
@@ -1,483 +0,0 @@
//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
//!
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
use serde_json::{json, Map, Value};
use tavolo_platform::engine::GameSession;
use tavolo_platform::errors::Error;
use tavolo_platform::{GameEngine, Seat};
use tavolo_scopone::engine as rules;
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
use tavolo_scopone::ScoponeEngine;
fn opts(value: Value) -> Map<String, Value> {
value.as_object().unwrap().clone()
}
fn empty() -> Map<String, Value> {
Map::new()
}
fn capitalize(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 base_session(engine: &ScoponeEngine) -> GameSession {
let mut session = GameSession::new("s1", engine.id(), "CODE01");
session.creator_sub = "alice".to_string();
session.players = vec![Seat {
user_sub: "alice".to_string(),
display_name: "Alice".to_string(),
team: None,
}];
session
}
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
let mut session = base_session(engine);
engine.create(&mut session, &options)?;
Ok(session)
}
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
let mut session = make_session(engine, options).unwrap();
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
session
}
fn state(session: &GameSession) -> &ScoponeState {
session
.state
.as_ref()
.unwrap()
.downcast_ref::<ScoponeState>()
.unwrap()
}
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
session
.state
.as_mut()
.unwrap()
.downcast_mut::<ScoponeState>()
.unwrap()
}
fn err_message(error: &Error) -> String {
error
.as_game()
.map(|game| game.message.clone())
.unwrap_or_default()
}
/// Extract the error from a failed `make_session` without requiring
/// `GameSession: Debug` (which `unwrap_err` would).
fn session_error(result: Result<GameSession, Error>) -> Error {
match result {
Ok(_) => panic!("expected an error"),
Err(error) => error,
}
}
/// Play legal moves (and acknowledge hand ends) until the match finishes.
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
let mut moves = 0;
while !engine.is_finished(session) && moves < 200_000 {
let (action, sub, payload): (&str, String, Map<String, Value>) = {
let st = state(session);
if st.phase == PHASE_HAND_END {
let player = st
.players
.iter()
.find(|player| !st.acked.contains(&player.seat))
.unwrap();
("ack", player.sub.clone(), Map::new())
} else {
let player = st
.players
.iter()
.find(|player| player.seat == st.turn)
.unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
("play", player.sub.clone(), payload)
}
};
engine
.handle_action(session, &sub, action, &payload)
.unwrap();
moves += 1;
}
moves
}
// -- create ----------------------------------------------------------------
#[test]
fn create_seats_creator_on_team_a() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert_eq!(Some("A".to_string()), session.players[0].team);
let st = state(&session);
assert_eq!(11, st.target_score);
assert!(st.napola);
}
#[test]
fn create_options() {
let engine = ScoponeEngine::default();
let session =
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
let st = state(&session);
assert_eq!(16, st.target_score);
assert!(!st.napola);
assert!(!st.asso_pigliatutto);
}
#[test]
fn create_asso_pigliatutto_option() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
assert!(state(&session).asso_pigliatutto);
}
#[test]
fn create_rejects_bad_options() {
let engine = ScoponeEngine::default();
assert_eq!(
"target_score must be between 1 and 100",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": 0}))
)))
);
assert_eq!(
"target_score must be an integer",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": "eleven"}))
)))
);
assert_eq!(
"napola must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"napola": "yes"}))
)))
);
assert_eq!(
"asso_pigliatutto must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"asso_pigliatutto": "yes"}))
)))
);
}
#[test]
fn timeouts_come_from_the_engine() {
let engine = ScoponeEngine::new(7, 9);
let session = make_session(&engine, empty()).unwrap();
let st = state(&session);
assert_eq!(7, st.turn_timeout);
assert_eq!(9, st.hand_ack_timeout);
}
// -- join ------------------------------------------------------------------
#[test]
fn join_assigns_teams_and_starts() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert!(engine.in_lobby(&session));
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
}
assert!(!engine.in_lobby(&session));
assert_eq!(PHASE_PLAYING, state(&session).phase);
}
#[test]
fn join_errors() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert_eq!(
"already joined this game",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
// The lobby filled up and the match started: late joins and even re-joins
// are rejected as "already started".
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
);
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
}
// -- actions ---------------------------------------------------------------
#[test]
fn unknown_action_rejected() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "alice", "dance", &Map::new())
.is_err());
}
#[test]
fn play_validates_payload() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
.is_err());
assert!(engine
.handle_action(&mut session, "bob", "play", &Map::new())
.is_err());
assert!(engine
.handle_action(
&mut session,
"bob",
"play",
&opts(json!({"card": "01D", "capture": "02C"}))
)
.is_err());
}
#[test]
fn invalid_card_code_is_illegal_move() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let error = engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
.unwrap_err();
assert_eq!("invalid card code", err_message(&error));
}
#[test]
fn finished_match_rejects_actions() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
assert!(engine.is_finished(&session));
let error = engine
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
.unwrap_err();
assert_eq!("the match is over", err_message(&error));
}
// -- views -----------------------------------------------------------------
#[test]
fn view_for_hides_other_hands() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let view = engine.view_for(&session, "alice");
let players = view["players"].as_array().unwrap();
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
assert!(alice.get("hand").is_some());
assert!(bob.get("hand").is_none());
// The envelope is the platform's job, not the view's.
assert!(view.get("id").is_none());
assert!(view.get("join_code").is_none());
assert!(view.get("game_type").is_none());
}
#[test]
fn lobby_view() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
let lobby = engine.lobby_view(&session);
assert_eq!(json!("lobby"), lobby["phase"]);
assert_eq!(json!(16), lobby["target_score"]);
assert_eq!(json!(true), lobby["napola"]);
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
}
// -- serialization ---------------------------------------------------------
#[test]
fn state_roundtrip() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let state_value = engine
.state_to_json(session.state.as_ref().unwrap().as_ref())
.unwrap();
let restored = engine.state_from_json(&state_value).unwrap();
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
assert_eq!(state(&session).phase, restored.phase);
assert_eq!(state(&session).turn, restored.turn);
assert_eq!(
state(&session)
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>(),
restored
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>()
);
}
// -- deadlines -------------------------------------------------------------
#[test]
fn no_deadline_in_lobby() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert!(engine.next_deadline(&session).unwrap().is_none());
}
#[test]
fn turn_deadline_and_revalidation() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("turn", deadline.kind);
// A forged token is stale.
assert!(engine
.fire_deadline(&mut session, "turn", "turn:1:1:0")
.is_err());
let turn_before = state(&session).turn;
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
assert_ne!(turn_before, state(&session).turn);
}
#[test]
fn stale_deadline_after_play() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
// A play lands in time: the armed deadline is overtaken.
let (sub, payload) = {
let st = state(&session);
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
(player.sub.clone(), payload)
};
engine
.handle_action(&mut session, &sub, "play", &payload)
.unwrap();
assert!(engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.is_err());
}
#[test]
fn hand_end_deadline_advances() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_HAND_END.to_string();
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
));
}
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("hand_end", deadline.kind);
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
let st = state(&session);
assert_eq!(PHASE_PLAYING, st.phase);
assert_eq!(2, st.hand_number);
}
// -- results ---------------------------------------------------------------
#[test]
fn result_of_finished_match() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
let result = engine.result(&session).unwrap();
assert_eq!(2, result.teams.len());
assert!(result.winner_team == 0 || result.winner_team == 1);
assert_eq!(4, result.players.len());
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
for player in &result.players {
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
}
assert!(result.summary.get("team_a_score").is_some());
assert!(result.summary.get("hands_played").is_some());
}
#[test]
fn result_requires_finished_match() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
assert!(engine.result(&session).is_err());
}
#[test]
fn game_over_view() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_FINISHED.to_string();
st.winner = Some(1);
st.scores = vec![3, 11];
}
let over = engine.game_over_view(&session, "alice");
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
assert_eq!(json!("B"), over["winner"]);
}
#[test]
fn registry_metadata() {
let engine = ScoponeEngine::default();
assert_eq!("scopone_scientifico", engine.id());
assert_eq!(4, engine.min_players());
assert_eq!(4, engine.max_players());
let schema = engine.options_schema();
assert!(schema["properties"]["target_score"].is_object());
assert!(schema["properties"]["napola"].is_object());
assert!(schema["properties"]["asso_pigliatutto"].is_object());
// Byte-compatible option schema: descriptions and defaults match Python.
assert_eq!(
json!("Score the napola rule; a full denari sweep wins the match"),
schema["properties"]["napola"]["description"]
);
assert_eq!(
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
schema["properties"]["asso_pigliatutto"]["description"]
);
}
+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,67 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "match" (
"id" UUID NOT NULL PRIMARY KEY,
"team_a_score" SMALLINT NOT NULL,
"team_b_score" SMALLINT NOT NULL,
"winner_team" VARCHAR(1) NOT NULL,
"target_score" SMALLINT NOT NULL,
"hands_played" SMALLINT NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"finished_at" TIMESTAMPTZ NOT NULL
);
COMMENT ON TABLE "match" IS 'A completed scopone scientifico match.';
CREATE TABLE IF NOT EXISTS "match_player" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(200) NOT NULL,
"seat" SMALLINT NOT NULL,
"team" VARCHAR(1) NOT NULL,
"won" BOOL NOT NULL,
"match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_match_playe_match_i_f85e3e" UNIQUE ("match_id", "user_sub")
);
COMMENT ON TABLE "match_player" IS 'Participation of one user in one match.';
CREATE INDEX IF NOT EXISTS "idx_match_playe_user_su_914c0d" ON "match_player" ("user_sub");
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
MODELS_STATE = (
"eJztmW1v2joUx79KlFedtFWFdu10NU0KlGpsBaqS7l5tmiyTGLCa2FnsXIZ6+92v7Tw7Dy"
"3dmErFG0iOfZyTnx2f80/uTJ+6yGOHI8idpfmXcWcS6CNxUG54bZgwCHKzNHA48+KeWZcZ"
"4yF0uDDOoceQMLmIOSEOOKZEdrUMh/qBhzhyDebQgBIk/jEiHM+xQw011KEcy6WOGAyTxW"
"ZuEcE/IgQ4XSC+RKFw/vZdmDFx0U/E0tPgFswx8tzSDWNXDqDsgK8DZbu5GZ5fqJ4ypBlw"
"qBf5JO8drPmSkqx7FGH3UPrItgUiKIQi5AIOEnlegi01xRELAw8jlIXq5gYXzWHkSajm+3"
"lEHMnSUFeSPycfktAK3QAYT2wwHdgAmJU5kCFofBOTQ4mcP0y4BHV3H4+bA1FWU16g/9G6"
"Pjg+faUQUMYXoWpUuMx75Qg5jF0V9JwyR9AHEIhZDFGV99SHnjckvJ657qvRF4E/hXtqyM"
"HnizdFmkLbAmYRr/h7c9w9O30nWlWM8uSshf90ZF1eDse2Yq2xnf0C29mebRPbFSbi1oHE"
"VEXbX8KwHqvmplEVse4iVR/+BB4iCy4zRqeF5RfrWm0VHbVTUJEb4owxThq6skVbwzAUW/"
"cT17Dmu1/DZbZLSFwGAg+uUU2ua2er++7ZltkysfjEfQPIq2TPBRGOfVRPtuypcXUT18P0"
"YAcpt8C0h6PB1LZGV3J4n7EfnuJl2QPZ0lXWtWY9ONV2k2wQ4++h/dGQp8bXyXigVydZP/"
"urKWOCEaeA0BWAbpFJak5NpWmeY4LZ8knzrLnuJ/rZTbSUBvPbQtkqDTPo3K5g6IJSS74i"
"1JYYsupq6CWOF5+vkQcVy+q0F1XWlRppd+c9t5qxeFFAaZc2Ea02+V1ft0ACF+qW5LXllW"
"qQNenWnOgD6jVObOHjROyV2K+xgwM1pwadG1KORgyFBibquEHFbuBXI2O/5Tpb+gAWzczv"
"e237nLRtNi8bCISiz7bUQQX2HxMH3bdvHyEPRK9GgaDayqWWi5l8XIE63wC17vcCxVj36O"
"gxvI+OmnnLNq20RXXFTrtcSH32MqH6imaTNbt/cdCwTisvDlZxfaXVYJR6CJKGdzO1FdlM"
"uOwg2xaYvcnkslR294a2hvVm1BuktEUnzJW5unzjYmmzaqLo8ztriucL/IESoiIzNL5VuB"
"c0RHhBPqO1QjwUgUDi1CUx/avNbkKtKAlhDuEqK2xLa0rcvbhnFC/ZvjXtW+cD875Zum1T"
"lFgoxPXf0ZKW121SBOZ9HhIhzUR/8xewxgxf+0DX5Pdk+n5NHWw/uXc7J2cn745PT7IMn1"
"na0ny6RTaLgn9RyHBdbmpO+gWXF5j3t6IJ5EO1AeGk+wuk23mUAui0KIBOVQGIK3JEakTA"
"p+lkXE+44KK/6cQON/4zPMx2UQu0wJUwSnVWyvRgZP2j4+5fTnp6fSAH6NUVCH8ymd3/D7"
"yE7wc="
)
@@ -0,0 +1,42 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "game_type" VARCHAR(32) NOT NULL DEFAULT 'scopone_scientifico';
CREATE INDEX IF NOT EXISTS "idx_match_game_ty_7d519c" ON "match" ("game_type");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_match_game_ty_7d519c";
ALTER TABLE "match" DROP COLUMN "game_type";"""
MODELS_STATE = (
"eJztmW1P4zgQgP9KlE+ctIdoYGF1Op2UlqLtLW0RLXenXa0sN3FTi8TO2s51K47/frbTNI"
"3zQgsUAeoXaGY8jv2MX2Ymd3ZEfRTywz4U3sz+zbqzCYyQ/FFUfLBsGMe5WAkEnIRpy1WT"
"CRcMekIKpzDkSIp8xD2GY4EpUU1dy6NRHCKBfEubWXRqUYLUPzFDFkMB5gIxqQ7kOCyxiB"
"E/VH371JOdYxI8rZuE4B8JAoIGSDZksrNv36UYEx/9RDx7jG/BFKPQLwDBvupAy4HqUMlu"
"bnrnF7qlGuIEeDRMIpK3jhdiRsmqeZJg/1DZKF2ACGJQTmENF0nCcIk1E6UjlgLBErQaqp"
"8LfDSFSaig279PE+Ip1pZ+k/pz8sdyaGvNABgMx2DUHQNgl3ykhmDwXoo8SpR/MREK1N19"
"2m8OREtt9YLOZ/f64Pj0F42AchEwrdS47HttCAVMTTX0nLLyV8qrBLszg6wadsHIYC6H/B"
"jamaAJN/doLFcd4B5GROAp9uiuYMtN9hOEiARC7dJjpwH+X+51yt/R/Knckek+HSw1jlYp"
"N+TYBYIRgHImlFWQH0UwDHtEVNM3bQ0HyCk8jwPyMyWDm+HbBfBADeLXY+fs9JPU6jGqh7"
"MG8qO+e3nZG4yr2E6ewHayZ1vHdo6JnDpQmLY5MAyzXR0ZL0u1cES0NjghWrUHRKt0PkAm"
"b8xHrmHDdr+Gi2xnkPgcxCFcoIoQo5mtabtnW2TL5eKT8wZQlMmeSyICR6iabNHS4OovTQ"
"+zH2+QcgPMca/fHY3d/pXqPuL8R6h5ueOu0jhaujCkB6fGabLqxPq7N/5sqUfr63DQNYPC"
"VbvxV1uNCSaCAkLnAPrrTDJxJiq4eYoJ5rNH+dkw3Tv61TlaZWTT27VsQQkm0LudQ+aDgi"
"ZfEfpIZLy8GtpLw4sv1yiEmmXZ7evJ75Xu6e36PZfaaRKjgVKH1hEtqyInMiWQwEBPSb1b"
"vakCWV05ISf6QFEhvdjYZrWFK3leYw/H2qdZQSDhiFmY6N+6y3IxYQu7iurBt7z8oWwATy"
"b2931J4TWVFFZ+2SJBWLd5sYLCiyUHzsePG6QHslVtgqB1xVDLx1xtV6Cft0Bt2r3DZMw5"
"OtqE99FRPW+lM0JbVBXsNKcLmc0+TSiXaLZZs/vCQc06LRUO5ml8ZcRglIYIkpraTGVENp"
"Emb5BtA8z2cHhZCLvbvbGB9abf7ma0ZSMstLi8fNNgabtoYt3mOWOK1wv8gRCilGYYfMtw"
"LyhDOCBf0EIj7smBQOJVXWLmx7S3CbWUSUgxg/NVYFtYU3L2cs4oXbIdd9Rxz7v2fX3qts"
"ukxEUMV3/eXGo+NKUiMG/zUBJST/SZPzzW3vCVG7rifl+672nZwe4vd6d1cnby6fj0ZHXD"
"ryRN13x2RNYnBf8ixnHV3VR/6a+ZvMN7fyc5gdpUWxBeNn+HdFsbZQCthgygVc4A5BsFIh"
"VJwJ+j4aCa8JqJWenEnrD+s0LM32Iu0ABXwSjEWRnTg777j4m7czlsm/GB6qBdFSC85GV2"
"/z8H5XI/"
)
@@ -0,0 +1,55 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "player_rating" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"game_type" VARCHAR(32) NOT NULL,
"rating" INT NOT NULL,
"matches_played" INT NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "uid_player_rati_user_su_655b53" UNIQUE ("user_sub", "game_type")
);
CREATE INDEX IF NOT EXISTS "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");
COMMENT ON TABLE "player_rating" IS 'Current Elo rating of one player for one game type.';
ALTER TABLE "match_player" ADD "elo_delta" SMALLINT;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match_player" DROP COLUMN "elo_delta";
DROP TABLE IF EXISTS "player_rating";"""
MODELS_STATE = (
"eJztmltv4jgUgP9KlKeuNFsBvY1Wq5WAUg07pVSF7q6mqiyTGLAa7IztbAd1+9/Xdm7EuR"
"RoYUrFyww59nHsz87xufTJnlEXefywB4UztX+znmwCZ0j+yDZ8smzo+6lYCQQceWHPpMuI"
"CwYdIYVj6HEkRS7iDsO+wJSork3LoTPfQwK5llaz6NiiBKn/xBRZDE0wF4jJ5omchyXmPu"
"KHamyXOnJwTCavGyYg+HuAgKATJDsyOdjdvRRj4qIfiMeP/gMYY+S5GSDYVQNoOVADKtnt"
"bff8QvdUUxwBh3rBjKS9/bmYUpJ0DwLsHiod1TZBBDEol7CAiwSeF2GNReGMpUCwACVTdV"
"OBi8Yw8BR0+/dxQBzF2tJvUv8c/xFNbaEbAFf9IRh0hgDYuT1SUzB4RyKHErW/mAgF6uk5"
"HDcFoqW2ekH7S/Pm4Oj0F42AcjFhulHjsp+1IhQwVNXQU8pqv0JeOdjtKWTFsDNKBnM55X"
"Vox4Iq3Nyhvjx1gDsYEYHH2KGbgi0/sh/AQ2Qi1Fd61KiA/1fzJuTf0Pyp/CLD7/Qqamno"
"JrUNKXaB4AxAuRLKCsgPZtDzukQU0zd1jQ2QS3ibDUhtSgw3xrcJ4BM1iV+PGmenn2Wrnq"
"N6OKsgP+g1Ly+7V8MitqNXsB3t2ZaxfcRELh0oTKsYDENtUyZju1QzJqK+hIWolxqIes4+"
"QCZvzDXPsKG7P8NZtlNIXA58D85RgYtRzdbU3bPNsuXy8Ml1AyjyZM8lEYFnqJhsVtPg6k"
"aqh/GPHaRcAXPY7XUGw2bvWg0/4/y7p3k1hx3V0tDSuSE9ODWsSTKI9Xd3+MVSj9a3/lXH"
"dAqTfsNvtpoTDAQFhD4C6C4yicWxKLPNY0wwn661z4bqfqPf3UariGz8sBAtKMEIOg+PkL"
"kg05KeCG0SGc+fhlakePH1BnlQs8xv+2Lwe61H2t19T6V2GMRooLRBy4jmm2aNmSmBBE70"
"ktS71ZsKkJWlE1KiLyQVwouNLZdbuJb2GjvY13saJwQCjpiFif6th8wnE1bQK8ge3KXpD6"
"UDeDCy7/cphfeUUkj2ZYUAYVFnawmFrQUHjZOTJcID2as0QNBtWVfLxVx9rkA/r4Da1PuA"
"wVijVluGd61Wzlu1Ga4tKnJ2qsOFWGcfJuRTNKuc2X3ioOSc5hIHj6F/ZfhglHoIkpLcTK"
"FHNpIqO8i2Amar37/MuN2t7tDAettrdWLashMWWpw/vsijcuqegKvag4ziWkYhusTeC+83"
"tAmhB7qai7ao85aO2vs9xS/4ZbnYzeCbh3tBGcIT8hXNNeKunAgkTpFnYFYodxNqLjyTYg"
"Yfk2ghc6bk6uWaUWgH2s1Bu3nesZ/L4+FNRnphFHcjwya9yFyol2n/VBXrhVEeYGnXF4O9"
"dsAYIsLqeNQK9eLILRzMGlOmH5P6bz7wW3OMwiBwMV5Iq5FmIHiXrVRGC77fx4f7+PBnW6"
"MtBIjvt7T/E0m/fSk/NaNZxqVOYKqwvbCwflKrbdYFbNSPz44/H50eJ35gIqlyBkv8QFRe"
"oCvlmlfcHt9dgRv4qrCyTt0mq/kByzY2Q9DtE28e3bu7W8ZJig5LVnE26bU2EcPFf+kYtV"
"R6qjDt85KLWr7Pb/w3iKX2p9AfLLA5kVP3Okdw8zH9K21Ouf/3L2IcF6Wpyr2SBZUP6JNs"
"xPtTH9UKhKPuH5BufaliQL2iGFDPFwPkGwUiBZfon4P+VTHhBRXz9sSOsP6zPMx3sSxQAV"
"fByFyRMdODXvMfE3f7st8y7z41QKsorbXNy+z5fy/uTSo="
)
@@ -0,0 +1,73 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "result" JSONB NOT NULL DEFAULT '{}';
UPDATE "match" SET "result" = jsonb_build_object(
'team_a_score', team_a_score,
'team_b_score', team_b_score,
'winner_team', winner_team,
'target_score', target_score,
'hands_played', hands_played);
ALTER TABLE "match" DROP COLUMN "winner_team";
ALTER TABLE "match" DROP COLUMN "team_b_score";
ALTER TABLE "match" DROP COLUMN "team_a_score";
ALTER TABLE "match" DROP COLUMN "target_score";
ALTER TABLE "match" DROP COLUMN "hands_played";
ALTER TABLE "match" ALTER COLUMN "game_type" DROP DEFAULT;
ALTER TABLE "match_player" ADD "details" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "match_player" ADD "score" DOUBLE PRECISION NOT NULL DEFAULT 0;
UPDATE "match_player" mp SET "score" = CASE WHEN mp.team = 'A'
THEN (mp_match.result->>'team_a_score')::float
ELSE (mp_match.result->>'team_b_score')::float END
FROM "match" mp_match WHERE mp_match.id = mp.match_id;
ALTER TABLE "match_player" ALTER COLUMN "team" DROP NOT NULL;
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(32) USING "team"::VARCHAR(32);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "winner_team" VARCHAR(1) NOT NULL;
ALTER TABLE "match" ADD "team_b_score" SMALLINT NOT NULL;
ALTER TABLE "match" ADD "team_a_score" SMALLINT NOT NULL;
ALTER TABLE "match" ADD "target_score" SMALLINT NOT NULL;
ALTER TABLE "match" ADD "hands_played" SMALLINT NOT NULL;
ALTER TABLE "match" DROP COLUMN "result";
ALTER TABLE "match" ALTER COLUMN "game_type" SET DEFAULT 'scopone_scientifico';
ALTER TABLE "match_player" DROP COLUMN "details";
ALTER TABLE "match_player" DROP COLUMN "score";
ALTER TABLE "match_player" ALTER COLUMN "team" SET NOT NULL;
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(1) USING "team"::VARCHAR(1);"""
MODELS_STATE = (
"eJztmm1v4jgQx79KlFc9aa8C+rQ6rU4KlGq5hVIB3TttVUUmMdSqsbOOc13U63c/23l2El"
"ooUKj6piVjj+P8hoz/HvNozqgLsX/YA9y5M/8wHk0CZlB8yDd8MkzgealZGjgY47Bn0mXs"
"cwYcLowTgH0oTC70HYY8jiiRXS3DoTMPQw5dQ7kZdGJQAuU/fgcNBqfI55CJ5qmYh8HnHv"
"QP5dgudcTgiExfN0xA0M8A2pxOoejIxGA3t8KMiAt/QT++9O7tCYLYzQFBrhxA2W05oLRd"
"X3fOL1RPOcWx7VAczEja25vzO0qS7kGA3EPpI9umkEAGxCNkcJEA4whrbApnLAycBTCZqp"
"saXDgBAZbQzS+TgDiStaHuJP8c/xlNLdPNti/7I3vYHtm2WYiRnILGOzI5lMj4IsIlqMen"
"cNwUiLKa8gatr9bg4Oj0N4WA+nzKVKPCZT4pR8BB6Kqgp5RlvEJeBditO8DKYeecNOZiyq"
"vQjg0LcMekNsBWvFO/bAzJlMuX8qixgPV3axDibijcVLyA4Wt5GbU0VJOknlL2OWCChQ14"
"EfO5oMTRDJajzntqrN3I9TD+sB7yaTLZAvoFqEedXns4snpXcviZ7//Eipc1asuWhrLONe"
"vBqRaWZBDj787oqyEvjR/9y7b+riT9Rj9MOScQcGoT+mADN8skNsemXJgniCD/bqU4a64f"
"gd7pQIuhJJtCjP8a9i/L45t66KFFDjf+M7BYQDcV1Mw6NQ4Q5oj4h/K2b7FUSUK5KMdp86"
"Bn/aNn1Fa339TDJwdoivQqdcPkPrOmScMYOPcPgLl2riWNm4fBHDK/GLhm5HjxbQAxUM9b"
"DFVWol2pkfb3NUyt0VKrgNIGrSJabJo1ZroFEDBVjyTvLe9UgqxK9KZEn5G+tpf2fFYBX4"
"nlEznIUzGNZWvgQ2Ygoj6rIYuSdwm/Eo17k4p06WP7wdi8/RC+uyR8k7gsoXuzPu9P9jZO"
"Tl6ge0WvSuGr2vLK10W+fF1tdb0Eat1vU7i3mns13rXaS3jXatW8ZZu204Bl2nM4Axh3CK"
"/YY8BS0SnXhj1kPJWT+P2ocXb6WbSqOcqLswWshz2r2+1cjjSWHILZMt/ZuP9K39Xovd8Z"
"jBveET+ECktTYZRiCEg53YdSTTYWLnv4LV2As9nvd3MKudkZaVyve8324KCucItOiCtz8Q"
"vsO5SVZN0LTEFVKog9NMwT6bIpzrXtQz7vXze7beNq0G51hp1oS5JsMVVjHu6gbXU1uhBT"
"MWfMwbL5Nue4UtLdtWSxvpzrQg4QLtmhVW+tMy4fe+u17K2zAQm3XMvtSbI+69yZ7G7Sfm"
"YjUihWaHxLcrTIw2hKvsG5QtwREwHEKUvN+sHRfkIt1COEmYGHZHuc+06JpxfPDMPM3LKG"
"Leu8bT5VF4A2WdoIyxYDwMOHLNQ2cu2fFhU3wrKGzdKuz1Y3WgFjkHCjjakR+sWlinAwY0"
"KZukyO5YqVjhXHKK16ZDfI6SGRXvm4yR8gRQ98+1EQ+SiIvHU22kJFZHdPXN/VBjNNo3nG"
"lao8ddheHaR+UtvU5ifS5I368dnx56PT40SYJ5ZF6rwozNX6C/2w+F6SkCu5Fh23x3df4A"
"aePNhd5dw47/kOj41NBoHbJ3gerbv7e4ycnLK98Nhyk6rVggyV/wAtalmoVEHa5zmJWh3n"
"Nf80rDL/lOrBkpwTibrXCcHNF1lemXOq9d+/kPmorCpbrUoyLu9Qk2xE/cmXagnCUfd3SL"
"f+otOv+oLTr3rx9EvckUOy1A9zMi7brx6+TbHqLX6Ds/7F7Ol/ruuC3w=="
)
@@ -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"
)
+41
View File
@@ -0,0 +1,41 @@
# tavolo-app
The deployable tavolo application: the composition root wiring
[`tavolo-platform`](../tavolo-platform/README.md) to the
[`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`, `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
`tavolo.active_users`/`tavolo.ongoing_games` gauges (polled from the
stores), process/CPython metrics via
`opentelemetry-instrumentation-system-metrics`, Postgres/Redis query
spans via the asyncpg/redis instrumentations, and OTLP log export.
- `aerich_config.py` — Tortoise ORM configuration consumed by the aerich
CLI (migrations live in `server/migrations/`; aerich itself is
configured via the `[tool.aerich]` section of `server/pyproject.toml`).
- `logging_config.py` — logging setup.
- `static.py` — SPA shell hosting for the compiled frontend.
## Development (from `packages/tavolo-app/`)
```sh
uv run mypy --namespace-packages --explicit-package-bases \
src/tavolo/app.py \
src/tavolo/config.py \
src/tavolo/telemetry.py \
src/tavolo/static.py \
src/tavolo/aerich_config.py \
src/tavolo/logging_config.py
```
End-to-end coverage of the wired stack lives in `server/tests/`.
+54
View File
@@ -0,0 +1,54 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-app"
version = "0.1.0"
description = "Tavolo multiplayer card-game application: platform + games wiring"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
"tavolo-scopone",
"tavolo-scopa",
"tavolo-briscola",
"kaya-core",
"kaya-cors",
"kaya-session",
"kaya-session-redis",
"kaya-oidc",
"kaya-openapi",
"kaya-rsgi",
"granian>=2.0",
"asyncpg",
"aerich",
"httpx",
"PyJWT[crypto]",
"pwo",
"PyYAML",
"redis",
]
[project.optional-dependencies]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0",
]
[dependency-groups]
dev = [
"mypy",
"httpx-ws",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -0,0 +1,23 @@
"""Tortoise ORM configuration consumed by the aerich CLI.
Kept separate from :mod:`tavolo.app` so ``aerich`` can import it without
assembling the whole application (mixins, routes). The database URL comes
from the same :class:`~tavolo.config.Settings` the app uses, so the CLI
and the app always point at the same database.
``aerich.models`` is required alongside the app models: it provides the
table aerich uses to track applied migrations.
"""
from __future__ import annotations
from .config import settings
TORTOISE_ORM = {
"connections": {"default": settings.database_url},
"apps": {
"models": {
"models": ["tavolo.platform.models", "aerich.models"],
"default_connection": "default",
}
},
}
@@ -0,0 +1,288 @@
"""Application entry point.
Assembles the :class:`~kaya.core.KayaApp` with the kaya mixins plus the
platform:
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
otherwise an in-memory store — e.g. for tests)
- :class:`~kaya.oidc.OIDCProviders` (the configured OIDC providers)
- :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` (Postgres match
statistics; skipped for ``/api/health`` and the OpenAPI documentation
endpoints)
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
- :class:`~tavolo.platform.mixin.PlatformMixin` (game lobby, match
history, leaderboards and the live-play websocket, served by the
registered game engines)
- :class:`~tavolo.platform.deadlines.DeadlineSchedulerMixin` (fires the
engines' timeouts)
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
``CORS_*`` environment variables (see :mod:`tavolo.config`). A
:class:`~kaya.otel.OTelMixin` (optional ``otel`` extra) is prepended when
``OTEL_ENABLED`` is set, adding OpenTelemetry traces and metrics.
Live games are kept in :data:`game_store` (Redis when configured,
in-memory otherwise). The SPA shell routes are registered at the bottom;
imports must happen after ``app`` is built.
"""
from __future__ import annotations
from importlib.metadata import version as _pkg_version
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
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 (
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
from .logging_config import configure_logging
from .telemetry import (
CountableSessionStore,
CountingInMemorySessionStore,
CountingRedisSessionStore,
otel_headers,
session_tag_hook,
telemetry_from_settings,
)
configure_logging(settings.logging_config)
log = getLogger(__name__)
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
Returns ``None`` — CORS disabled — unless at least one of
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
Settings left unset fall back to the mixin's own defaults.
"""
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
return None
return CorsMixin(
allow_origins=settings.cors_allow_origins or (),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_methods=settings.cors_allow_methods or ("GET",),
allow_headers=settings.cors_allow_headers or (),
allow_credentials=settings.cors_allow_credentials,
expose_headers=settings.cors_expose_headers or (),
max_age=settings.cors_max_age,
)
def otel_mixin_from_settings(
settings: Settings,
meter_provider: Optional[Any] = None,
tracer_provider: Optional[Any] = None,
) -> Optional[KayaMixin]:
"""Build a :class:`~kaya.otel.OTelMixin` from the OTEL_* settings.
Returns ``None`` — telemetry disabled — unless ``OTEL_ENABLED`` is
truthy. kaya-otel is an optional dependency (the ``otel`` extra), so it
is imported lazily here: default installs and the test suite never need
the OpenTelemetry packages.
``meter_provider``/``tracer_provider`` are the providers built by
:func:`tavolo.telemetry.telemetry_from_settings`; injecting them lets
the custom instruments and the database instrumentations share the same
OTLP pipelines (kaya-otel then leaves their shutdown to the telemetry
mixin).
"""
if not settings.otel_enabled:
return None
try:
from kaya.otel import OTelMixin
except ImportError as exc:
raise RuntimeError(
"OTEL_ENABLED is set but kaya-otel is not installed; "
"install tavolo-app with the 'otel' extra"
) from exc
kwargs: Dict[str, Any] = {
"service_name": settings.otel_service_name,
"endpoint": settings.otel_exporter_endpoint,
"headers": otel_headers(settings),
"excluded_paths": settings.otel_excluded_paths,
# Runs after the session mixin's after hook, so the session id has
# been persisted/minted and the OIDC claims are on the session.
"server_response_hook": session_tag_hook,
"websocket_close_hook": session_tag_hook,
}
if meter_provider is not None:
kwargs["meter_provider"] = meter_provider
if tracer_provider is not None:
kwargs["tracer_provider"] = tracer_provider
return OTelMixin(**kwargs)
registry = GameRegistry()
registry.register(
ScoponeEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
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,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaQuattroEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
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",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
session_store: CountableSessionStore
game_store: GameStore
if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually
# loaded/saved, so importing this module never requires a live Redis.
session_store = CountingRedisSessionStore(Redis.from_url(settings.redis_url))
game_store = RedisGameStore(
Redis.from_url(settings.redis_url, decode_responses=False),
registry,
ttl_seconds=settings.game_ttl_seconds,
)
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
else:
session_store = CountingInMemorySessionStore()
game_store = InMemoryGameStore(registry)
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store)
oidc = OIDCProviders(
[
OIDCConfig(
provider=provider.provider,
issuer=provider.issuer,
client_id=provider.client_id,
client_secret=provider.client_secret,
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
],
session=session_mixin,
)
log.info(
"OIDC providers: %s",
", ".join(provider.provider for provider in settings.oidc_providers),
)
openapi_mixin = OpenAPIMixin(
title="tavolo",
version=_pkg_version("tavolo-app"),
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,
models_modules=["tavolo.platform.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
scheduler = DeadlineScheduler(
game_store,
registry,
heartbeat_ms=settings.deadline_heartbeat_ms,
# The consumer task has no request context; bind the database context
# before it persists a match that a timeout finished.
context_binder=tortoise_mixin.ensure_context,
)
platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc,
)
mixins: list[KayaMixin] = [session_mixin, oidc, tortoise_mixin, openapi_mixin,
PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)]
telemetry = telemetry_from_settings(settings, session_store, game_store)
if telemetry is not None:
otel_mixin = otel_mixin_from_settings(
settings,
meter_provider=telemetry.meter_provider,
tracer_provider=telemetry.tracer_provider,
)
assert otel_mixin is not None # OTEL_ENABLED was checked above
# kaya-otel first: before hooks run in registration order (after hooks
# in reverse), so the span covers session loading, OIDC handling and the
# handler itself. CORS, when enabled, is still prepended before it so
# preflight short-circuits stay untraced. The telemetry mixin carries no
# hooks but owns the metric/logger providers' polling and shutdown, so
# it must be in the list too.
mixins.insert(0, telemetry.mixin)
mixins.insert(0, otel_mixin)
log.info(
"OpenTelemetry enabled: service=%s endpoint=%s (traces, metrics, logs)",
settings.otel_service_name,
settings.otel_exporter_endpoint or "(OTLP default)",
)
cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None:
# First in the list: preflight requests are answered before the session
# and OIDC hooks run.
mixins.insert(0, cors_mixin)
log.info(
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
settings.cors_allow_origins,
settings.cors_allow_origin_regex,
settings.cors_allow_credentials,
)
app = KayaApp(mixins=mixins)
# Register the SPA shell. The catch-all only matches paths no other route
# claimed (asset files under /static are served by Granian itself and never
# reach the app).
from . import static # noqa: E402,F401
@@ -0,0 +1,315 @@
"""Environment-driven configuration for the tavolo application.
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import quote
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
return value
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
"""Parse a comma-separated environment variable into a tuple of values.
Items are stripped and empty items dropped. Unset or empty variables
yield ``None``.
"""
value = os.environ.get(name)
if value is None or value.strip() == "":
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
# Per-provider OIDC settings are indexed with a 0-based suffix:
# OIDC_<n>_PROVIDER, OIDC_<n>_ISSUER, ... Indices must be contiguous from 0.
_OIDC_VARIABLES = (
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
)
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
# The pre-0.0.6 unindexed names, detected only to point the migration out.
_LEGACY_OIDC_VARIABLES = (
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
)
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/tavolo"
_DEFAULT_OIDC_CLIENT_ID = "tavolo"
@dataclass(frozen=True)
class OidcProviderSettings:
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
``provider`` names the provider in URLs (``/auth/<provider>/login``)
and namespaces its session state (``oidc:<provider>:...``); it must
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
"""
provider: str
issuer: str
client_id: str
client_secret: Optional[str]
redirect_uri: str
post_login_redirect: str
post_logout_redirect: str
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
"""Parse the indexed ``OIDC_<n>_*`` provider configuration.
With no indexed variables at all, fall back to a single provider
named ``default`` pointing at the dev mock IdP, mirroring the
pre-0.0.6 defaults. The old unindexed variables are rejected with a
migration hint: they would otherwise be silently ignored.
"""
legacy = [name for name in _LEGACY_OIDC_VARIABLES if os.environ.get(name)]
if legacy:
renamed = ", ".join(
f"{name} -> OIDC_0_{name[len('OIDC_'):]}" for name in legacy
)
raise RuntimeError(
"the OIDC variables are now indexed per provider "
f"({renamed}; add OIDC_0_PROVIDER)"
)
indices: set = set()
for name in os.environ:
match = _OIDC_ENV.match(name)
if match is None:
continue
index, variable = match.groups()
if variable not in _OIDC_VARIABLES:
raise RuntimeError(
f"unknown OIDC environment variable {name!r}; expected one of "
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
)
indices.add(int(index))
if not indices:
return (
OidcProviderSettings(
provider="default",
issuer=_DEFAULT_OIDC_ISSUER,
client_id=_DEFAULT_OIDC_CLIENT_ID,
client_secret=None,
redirect_uri="http://localhost:8080/auth/default/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
)
missing = sorted(set(range(max(indices) + 1)) - indices)
if missing:
raise RuntimeError(
"OIDC provider indices must be contiguous from 0; missing "
+ ", ".join(f"OIDC_{index}_*" for index in missing)
)
providers = []
seen = set()
for index in sorted(indices):
prefix = f"OIDC_{index}_"
provider = _env(prefix + "PROVIDER")
if _PROVIDER_NAME.match(provider) is None:
raise RuntimeError(
f"{prefix}PROVIDER must be a non-empty string of letters, "
f"digits, underscores or hyphens (got {provider!r})"
)
if provider in seen:
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
seen.add(provider)
providers.append(
OidcProviderSettings(
provider=provider,
issuer=_env(prefix + "ISSUER"),
client_id=_env(prefix + "CLIENT_ID"),
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
# kaya-oidc's OIDCProviders derives the callback route from
# the provider name; keep the default in sync with it.
redirect_uri=_env(
prefix + "REDIRECT_URI",
f"http://localhost:8080/auth/{provider}/callback",
),
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
)
)
return tuple(providers)
def _database_url_from_parts(engine: str,
user: str,
password: Optional[str],
host: str,
port: str,
name: str,
options: str) -> str:
"""Assemble a database DSN from individual components.
``user`` and ``password`` are percent-encoded so credentials containing
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
DSN. ``port`` and ``options`` are omitted when empty: a missing port
lets the driver pick its default (5432 for asyncpg). ``options`` is a
raw query string (e.g. ``ssl=require``) appended after a ``?``.
"""
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True)
class Settings:
database_url: str
# The configured OIDC providers, in index order. Each provider
# authenticates users independently and namespaces its session state;
# see OidcProviderSettings above.
oidc_providers: Tuple[OidcProviderSettings, ...]
app_host: str
app_port: int
redis_url: Optional[str]
# How long a live game (and its join-code index) survives in Redis
# without activity, in seconds. Defaults to 24h.
game_ttl_seconds: int
# Directory holding the compiled frontend (trunk's dist output). Only
# used to locate index.html for the SPA shell; the assets themselves
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway (wired into the game engines;
# see tavolo.app).
hand_ack_timeout_seconds: int
# Seconds a player has to play before the server plays a random legal
# card for them, covering disconnects and idle players (wired into the
# game engines; see tavolo.app).
turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls
# when nothing else wakes it. Enqueued deadlines are announced over a
# pub/sub channel, so this is only the backstop recovering lost
# notifications and deadlines orphaned by a dead worker.
deadline_heartbeat_ms: int
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console.
logging_config: Optional[str]
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
# from the same origin, so no CORS headers are needed by default.
cors_allow_origins: Optional[Tuple[str, ...]]
cors_allow_origin_regex: Optional[str]
cors_allow_methods: Optional[Tuple[str, ...]]
cors_allow_headers: Optional[Tuple[str, ...]]
cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int
# OpenTelemetry (kaya-otel's OTelMixin). Disabled unless OTEL_ENABLED is
# truthy; the exporter endpoint falls back to the OTLP/HTTP default
# (localhost:4318) when OTEL_EXPORTER_OTLP_ENDPOINT is unset.
otel_enabled: bool
otel_service_name: str
otel_exporter_endpoint: Optional[str]
otel_exporter_headers: Optional[Tuple[str, ...]]
# Paths excluded from tracing and metrics (exact matches). Defaults to
# the health endpoint, which k8s probes would otherwise spam.
otel_excluded_paths: Tuple[str, ...]
# How often the OTLP metric reader exports (milliseconds) and how often
# the telemetry gauges refresh the session/game counts (seconds).
otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int
@staticmethod
def from_env() -> "Settings":
return Settings(
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
# parts below are ignored (sqlite in tests, managed-DB DSNs).
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "tavolo"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
# Unset: the port segment is omitted and the driver default
# (5432 for asyncpg) applies.
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "tavolo"),
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_providers=_oidc_providers_from_env(),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
# When unset, sessions and live games fall back to in-memory
# stores (tests, ephemeral dev). Set to e.g.
# redis://localhost:6379/0 to persist both in Redis.
redis_url=os.environ.get("REDIS_URL"),
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
static_dir=_env("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "30000")),
logging_config=os.environ.get("LOGGING_CONFIG"),
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
# is set.
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces,
# metrics and logs via OTLP/HTTP (requires the ``otel`` extra).
otel_enabled=_env_bool("OTEL_ENABLED"),
otel_service_name=_env("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
# Comma-separated key=value pairs, e.g. "Authorization=Bearer x".
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
otel_metric_export_interval_millis=int(
_env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000")
),
otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
),
)
settings: Settings = Settings.from_env()
@@ -0,0 +1,83 @@
"""Logging setup for the tavolo application.
Configured once at app import time (see :mod:`tavolo.app`). By default a
single console handler at DEBUG level is installed, formatting records as
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``;
``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG
chatter does not drown the log.
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
over the configuration entirely; the file follows the
:data:`logging.config.dictConfig` schema, e.g.::
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
``disable_existing_loggers`` should stay ``false``: Granian configures its
own loggers before importing the application, and disabling them would
silence the server and Tortoise.
"""
from __future__ import annotations
from logging.config import dictConfig
from typing import Optional
import yaml
DEFAULT_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
},
"loggers": {
# Every pooled HTTP request is a DEBUG record; keep the logger's
# INFO+ output, drop the per-connection noise.
"urllib3.connectionpool": {
"level": "INFO",
},
},
}
def configure_logging(config_path: Optional[str]) -> None:
"""Apply the YAML logging configuration at ``config_path``, or the
built-in default when unset."""
if config_path is None:
dictConfig(DEFAULT_CONFIG)
return
try:
with open(config_path, "rb") as handle:
config = yaml.safe_load(handle)
except OSError as exc:
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
if not isinstance(config, dict):
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
dictConfig(config)
@@ -0,0 +1,89 @@
"""SPA shell hosting for the compiled single-page application.
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``:
- ``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
from .app import app
from .config import settings
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(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("/")
async def index(ctx: HttpContext) -> None:
"""Serve the SPA shell at the site root (the glob below cannot match
an empty path)."""
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
shell. Requests under ``/static`` are answered by Granian before the
app is ever called, so they never arrive here."""
await _send_shell(ctx)
@@ -0,0 +1,375 @@
"""OpenTelemetry metrics and log export beyond kaya-otel.
kaya-otel instruments HTTP requests and websockets with traces and HTTP
metrics; this module adds what it does not cover, all behind the optional
``otel`` extra (OpenTelemetry imports are lazy, so default installs and
the test suite never need them):
* application gauges: ``tavolo.active_users`` (sessions held in the
session store) and ``tavolo.ongoing_games`` (live matches that have not
finished yet);
* process metrics through ``opentelemetry-instrumentation-system-metrics``:
CPU time/utilization, memory usage and CPython garbage-collection
counters;
* OTLP log export through a stdlib :class:`logging.Handler` attached to
the root logger, mirroring what the console sees;
* session/user tagging of request and websocket spans through
:func:`session_tag_hook` (kaya-otel's response/close hooks), so traces
can be correlated with a session (hashed id) and an OIDC user.
The store counts are asynchronous while OpenTelemetry instrument
callbacks are synchronous, so :class:`TelemetryMixin` polls the stores on
a background task and its callbacks report the cached values.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from kaya.core import KayaApp, KayaMixin
from kaya.session import InMemorySessionStore, SessionStore
from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis
from tavolo.platform.auth import session_user
from tavolo.platform.store import GameStore
from .config import Settings
if TYPE_CHECKING:
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
log = getLogger(__name__)
# Session keys live under kaya's default prefix. Keeping it explicit here
# makes the Redis count scan exact and preserves existing sessions.
SESSION_KEY_PREFIX = "kaya:session:"
# The interpreter metrics to export. The instrumentation package can also
# emit host-level ``system.*`` metrics; those are deliberately left off:
# the application cares about its own process, and per-CPU host metrics
# add cardinality without context.
#
# ``process.cpu.utilization`` ignores its configuration value upstream
# (the callback always emits one aggregate observation), so its ``None``
# is only for readability.
PROCESS_METRICS_CONFIG: Dict[str, Optional[list]] = {
"process.cpu.time": ["user", "system"],
"process.cpu.utilization": None,
"process.memory.usage": None,
"process.memory.virtual": None,
"cpython.gc.collections": None,
"cpython.gc.collected_objects": None,
"cpython.gc.uncollectable_objects": None,
}
def otel_headers(settings: Settings) -> Optional[Dict[str, str]]:
"""Parse ``OTEL_EXPORTER_OTLP_HEADERS`` into a mapping (or ``None``)."""
headers = dict(
pair.split("=", 1)
for pair in (settings.otel_exporter_headers or ())
if "=" in pair
)
return headers or None
def _signal_endpoint(base: Optional[str], signal: str) -> Optional[str]:
"""``{base}/v1/<signal>``, or ``None`` for the exporter's own default."""
return f"{base}/{signal}" if base else None
def session_tag_hook(span: Any, carrier: Any) -> None:
"""Tag an HTTP request/websocket span with the request's session/user.
Wired into kaya-otel as its ``server_response_hook`` and
``websocket_close_hook``: those run after the session mixin's after
hook has persisted (and, at login, minted) the session, so the final
session id is visible here.
The session id is the session cookie, i.e. a credential, so only a
truncated SHA-256 of it is exported; session ids are
``secrets.token_urlsafe(32)`` (256 bits), which makes the digest
irreversible in practice. The OIDC ``sub`` is a pseudonymous
identifier already persisted in Postgres and served by the API, so it
is exported verbatim as ``user.id`` (OpenTelemetry's current end-user
attribute; ``enduser.id`` is PII-flagged and being superseded).
"""
session = getattr(carrier, "session", None)
if session is None:
return
session_id = getattr(session, "id", None)
if session_id:
span.set_attribute(
"session.id",
hashlib.sha256(session_id.encode()).hexdigest()[:16],
)
user = session_user(session)
if user is not None:
sub = user.get("sub")
if sub:
span.set_attribute("user.id", str(sub))
class _NoOtelLogs(logging.Filter):
"""Drop OpenTelemetry's own log records.
Export failures are logged by the SDK; forwarding those through the
same exporter would feed an endless retry loop.
"""
def filter(self, record: logging.LogRecord) -> bool:
return not record.name.startswith("opentelemetry")
class CountingInMemorySessionStore(InMemorySessionStore):
"""In-memory session store that can report its live session count."""
async def count(self) -> int:
now = self._clock()
return sum(
1
for session_id in self._data
if session_id not in self._expires
or self._expires[session_id] > now
)
class CountingRedisSessionStore(RedisSessionStore):
"""Redis session store that can report its live session count."""
def __init__(self, client: Redis, prefix: str = SESSION_KEY_PREFIX) -> None:
super().__init__(client, prefix=prefix)
async def count(self) -> int:
total = 0
async for _ in self._client.scan_iter(match=f"{self._prefix}*", count=500):
total += 1
return total
# The session stores the application builds: both expose ``count()`` on top
# of kaya's ``SessionStore`` interface. Game stores need no alias — the
# platform's ``GameStore`` declares ``count_ongoing()`` itself.
CountableSessionStore = Union[CountingInMemorySessionStore, CountingRedisSessionStore]
class TelemetryMixin(KayaMixin):
"""Application gauges, DB instrumentation and log export.
The meter, tracer and logger providers are built by
:func:`telemetry_from_settings` and injected: kaya-otel only shuts
down providers it created itself, so this mixin owns their shutdown
(and uninstruments the database clients it instrumented).
"""
def __init__(
self,
*,
meter_provider: "MeterProvider",
tracer_provider: "TracerProvider",
logger_provider: Optional["LoggerProvider"],
log_handler: Optional[logging.Handler],
session_store: CountableSessionStore,
game_store: GameStore,
poll_interval_seconds: float,
) -> None:
self._meter_provider = meter_provider
self._tracer_provider = tracer_provider
self._logger_provider = logger_provider
self._log_handler = log_handler
self._session_store = session_store
self._game_store = game_store
self._poll_interval = poll_interval_seconds
self._active_users: Optional[int] = None
self._ongoing_games: Optional[int] = None
self._poll_task: Optional[asyncio.Task] = None
self._instrumentor: Optional[Any] = None
self._db_instrumentors: list = []
def apply(self, app: KayaApp) -> None:
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.system_metrics import (
SystemMetricsInstrumentor,
)
from opentelemetry.metrics import CallbackOptions, Observation
instrumentor = SystemMetricsInstrumentor(config=PROCESS_METRICS_CONFIG)
self._instrumentor = instrumentor
instrumentor.instrument(meter_provider=self._meter_provider)
# Query spans for the Postgres (asyncpg, used by Tortoise) and Redis
# clients. Redis command values are redacted by the instrumentation
# (``SET ? ?``); asyncpg spans carry the parameterized SQL only.
self._db_instrumentors = [
AsyncPGInstrumentor(capture_parameters=False),
RedisInstrumentor(),
]
for db_instrumentor in self._db_instrumentors:
db_instrumentor.instrument(tracer_provider=self._tracer_provider)
meter = self._meter_provider.get_meter(__name__)
def active_users(options: CallbackOptions):
if self._active_users is not None:
yield Observation(self._active_users)
def ongoing_games(options: CallbackOptions):
if self._ongoing_games is not None:
yield Observation(self._ongoing_games)
meter.create_observable_gauge(
"tavolo.active_users",
callbacks=[active_users],
unit="{user}",
description="Sessions currently held in the session store.",
)
meter.create_observable_gauge(
"tavolo.ongoing_games",
callbacks=[ongoing_games],
unit="{game}",
description="Live games that have not finished yet.",
)
# -- lifecycle ----------------------------------------------------------
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
self._poll_task = loop.create_task(self._poll())
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
if self._poll_task is not None:
self._poll_task.cancel()
self._poll_task = None
for db_instrumentor in self._db_instrumentors:
db_instrumentor.uninstrument()
self._db_instrumentors = []
if self._log_handler is not None:
logging.getLogger().removeHandler(self._log_handler)
self._log_handler.close()
self._log_handler = None
if self._logger_provider is not None:
self._logger_provider.shutdown()
self._tracer_provider.shutdown()
self._meter_provider.shutdown()
async def _poll(self) -> None:
while True:
await self.refresh_counts()
await asyncio.sleep(self._poll_interval)
async def refresh_counts(self) -> None:
"""Refresh the cached store counts (the poll loop calls this)."""
try:
self._active_users = await self._session_store.count()
except Exception:
log.warning("active_users refresh failed", exc_info=True)
try:
self._ongoing_games = await self._game_store.count_ongoing()
except Exception:
log.warning("ongoing_games refresh failed", exc_info=True)
@dataclass
class Telemetry:
"""The providers and mixin built from the ``OTEL_*`` settings."""
meter_provider: "MeterProvider"
tracer_provider: "TracerProvider"
logger_provider: Optional["LoggerProvider"]
log_handler: Optional[logging.Handler]
mixin: TelemetryMixin
def telemetry_from_settings(
settings: Settings,
session_store: CountableSessionStore,
game_store: GameStore,
) -> Optional[Telemetry]:
"""Build the metric/trace/log providers, or ``None`` when telemetry is off.
Only called when ``OTEL_ENABLED`` is set; the OpenTelemetry imports
are lazy so the default install does not need the ``otel`` extra.
"""
if not settings.otel_enabled:
return None
try:
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
except ImportError as exc:
raise RuntimeError(
"OTEL_ENABLED is set but the OpenTelemetry dependencies are not "
"installed; install tavolo-app with the 'otel' extra"
) from exc
endpoint = settings.otel_exporter_endpoint
headers = otel_headers(settings)
resource = Resource.create({"service.name": settings.otel_service_name})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=_signal_endpoint(endpoint, "v1/traces"),
headers=headers,
)
)
)
meter_provider = MeterProvider(
resource=resource,
metric_readers=[
PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint=_signal_endpoint(endpoint, "v1/metrics"),
headers=headers,
),
export_interval_millis=settings.otel_metric_export_interval_millis,
)
],
)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(
endpoint=_signal_endpoint(endpoint, "v1/logs"),
headers=headers,
)
)
)
handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
handler.addFilter(_NoOtelLogs())
logging.getLogger().addHandler(handler)
mixin = TelemetryMixin(
meter_provider=meter_provider,
tracer_provider=tracer_provider,
logger_provider=logger_provider,
log_handler=handler,
session_store=session_store,
game_store=game_store,
poll_interval_seconds=settings.otel_metrics_poll_interval_seconds,
)
return Telemetry(
meter_provider=meter_provider,
tracer_provider=tracer_provider,
logger_provider=logger_provider,
log_handler=handler,
mixin=mixin,
)
+100
View File
@@ -0,0 +1,100 @@
# tavolo-briscola
Briscola — the Italian trick-taking game — as a
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
Three variants share one rules core:
- `briscola` — the classic two-player game;
- `briscola_a_quattro` — four-player fixed-partnership briscola;
- `briscola_chiamata` — five-player briscola with a calling auction and a
secret partner.
## Contents
- `state.py` — `BriscolaState` (pure game data: phases, players, hands,
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 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`, `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. 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
led suit when no trump was played. The winner takes the trick and leads
the next one.
- After each trick everyone draws back up to three cards, starting with
the trick winner.
- Card points total 120 per deal; a 60–60 deal is a *patta*, its points
are discarded and the deal is replayed. The first team to the target
score wins; a tie at or above the target is broken by another deal.
- 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
uv run python -m unittest discover -s packages/tavolo-briscola/tests
uv run mypy -p tavolo.briscola
```
`test_engine.py`/`test_plugin.py` cover the two classic variants;
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-briscola"
version = "0.1.0"
description = "Briscola 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,31 @@
"""Briscola: tavolo's trick-taking game implementation.
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``);
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
briscola chiamata (``briscola_chiamata``).
The pure rules (:mod:`tavolo.briscola.engine`,
: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 (
BriscolaChiamataEngine,
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
@@ -0,0 +1,469 @@
"""Pure rules engine for briscola.
Every function here is deterministic and I/O-free (the only side effect is
logging): it mutates (or reads)
:class:`~tavolo.briscola.state.BriscolaState` 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.briscola.plugin.BriscolaEngine`.
Rules implemented
-----------------
* 40-card Italian deck (4 suits x ranks 1-10), three cards per player.
* The next card after the deal is turned face up: its suit is the
**briscola** (trump) for the whole deal, and it is the last card drawn
from the stock (the stock is drawn from the bottom up).
* Trick play: the player to the dealer's left leads; each other player
plays one card. The highest trump wins; when no trump was played, the
highest card of the led suit wins. The winner takes the trick and leads
the next one.
* After each trick every player draws back up to three cards, starting
with the trick winner and continuing in turn order; the face-up
briscola card is drawn last, so whoever's turn to draw when only it
remains takes it.
* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else
0 — 120 points per deal. Trick strength is unrelated to points:
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
* Match points accumulate across deals. A 60-60 deal is a **patta**: its
points are discarded and the deal is replayed (the dealer rotates).
The first team to reach the target score (default 61) wins; a tie at or
above the target is broken by another deal.
* Between deals of an unfinished match the game pauses in the
``hand_end`` phase for the deal summary, exactly like scopone: every
player acknowledges (or the hand-end timeout elapses) before the next
deal.
"""
from __future__ import annotations
import random
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 .errors import CardNotInHand
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
DEFAULT_TURN_TIMEOUT_SECONDS,
HAND_SIZE,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
PLAYER_COUNTS,
SUITS,
TEAM_NAMES,
BriscolaState,
Card,
PlayerState,
Trick,
TrickCard,
parse_card,
)
log = getLogger(__name__)
# Trick strength: higher wins. Ace > three > king > horse > jack, then the
# remaining ranks in descending order. All ranks are distinct within a
# suit, so a trick never ties.
TRICK_STRENGTH: Dict[int, int] = {
1: 9,
3: 8,
10: 7,
9: 6,
8: 5,
7: 4,
6: 3,
5: 2,
4: 1,
2: 0,
}
# Card points: 120 per deal in total.
CARD_POINTS: Dict[int, int] = {
1: 11,
3: 10,
10: 4,
9: 3,
8: 2,
}
_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 card_points(card: Card) -> int:
"""The point value of a single card."""
return CARD_POINTS.get(card.rank, 0)
def create_game(
creator_sub: str,
creator_name: str,
player_count: int = 2,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> BriscolaState:
"""Create a lobby game with the creator seated first."""
if player_count not in PLAYER_COUNTS:
raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}")
if (
isinstance(target_score, bool)
or not isinstance(target_score, int)
or target_score < MIN_TARGET_SCORE
or target_score > MAX_TARGET_SCORE
):
raise IllegalMove(
f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}"
)
return BriscolaState(
player_count=player_count,
target_score=target_score,
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: BriscolaState, 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: BriscolaState) -> 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(state)
def _set_turn_deadline(state: BriscolaState) -> 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 _deal(state: BriscolaState) -> None:
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
state.trick = []
state.last_trick = None
state.briscola_card = None
# Dealer rotates each deal; the first card is played by the player to
# the dealer's left.
state.turn = (state.dealer + 1) % state.player_count
state.trick_leader = state.turn
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(deck.pop())
# The turned-up card is the last card drawn: keep it separate from the
# face-down stock and hand it out once the stock is exhausted.
state.briscola_card = deck.pop()
state.briscola_suit = state.briscola_card.suit
state.stock = deck
_set_turn_deadline(state)
log.debug(
"deal %d dealt (dealer seat %d, briscola %s)",
state.hand_number,
state.dealer,
state.briscola_card.code,
)
def _player_at(state: BriscolaState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def play(state: BriscolaState, 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_HAND_END:
raise IllegalMove("the deal 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")
player.hand.remove(card)
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),
state.player_count,
)
if len(state.trick) == state.player_count:
_resolve_trick(state)
else:
state.turn = (state.turn + 1) % state.player_count
_set_turn_deadline(state)
def _resolve_trick(state: BriscolaState) -> None:
"""Score the completed trick, draw and hand the lead to the winner."""
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
_draw(state, winner)
_set_turn_deadline(state)
def _trick_winner(state: BriscolaState) -> 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]
def _draw(state: BriscolaState, winner: int) -> None:
"""Draw back up to ``HAND_SIZE`` cards, winner first, then turn order."""
for offset in range(state.player_count):
player = _player_at(state, (winner + offset) % state.player_count)
if state.stock:
player.hand.append(state.stock.pop())
elif state.briscola_card is not None:
# The face-up briscola is the last card of the stock: the first
# player who needs a card when only it remains takes it.
player.hand.append(state.briscola_card)
state.briscola_card = None
def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None:
"""Play a random card for the player currently on turn.
Every card in hand is legal, so one is drawn at random. Delegates to
:func:`play`, so the move is fully validated and can end the deal 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)
play(state, player.sub, card.code)
def _end_deal(state: BriscolaState) -> None:
"""Score the deal and either finish the match or pause for the summary.
A 60-60 deal is a patta: its points do not count and the deal is
replayed. If the match continues, the game pauses in the ``hand_end``
phase so every player can read the summary; the next deal is dealt by
:func:`acknowledge_hand` once all players have acknowledged (or by the
hand-end timeout fired by the platform's deadline scheduler).
"""
state.turn_deadline = None
points, details = deal_points(state)
patta = points[0] == points[1]
if not patta:
state.scores[0] += points[0]
state.scores[1] += points[1]
entry: Dict[str, Any] = {
"deal": state.hand_number,
"patta": patta,
"points": details["points"],
"cards": details["cards"],
"totals": {"A": state.scores[0], "B": state.scores[1]},
}
state.hand_scores.append(entry)
a, b = state.scores
log.debug(
"deal %d scored A+%d B+%d%s (totals %d-%d)",
state.hand_number,
points[0],
points[1],
" patta" if patta else "",
a,
b,
)
if not patta and max(a, b) >= state.target_score and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
log.info("match ended, team %s wins", TEAM_NAMES[state.winner])
return
# Pause for the deal 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: BriscolaState, sub: str) -> None:
"""Record that ``sub`` has read the deal summary.
When every player has acknowledged, the next deal 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 deal 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.phase = PHASE_PLAYING
_deal(state)
def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the deal points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []]
for player in state.players:
piles[player.team].extend(player.captured)
points = [
sum(card_points(card) for card in pile) for pile in piles
]
details: Dict[str, object] = {
"points": {"A": points[0], "B": points[1]},
"cards": {"A": len(piles[0]), "B": len(piles[1])},
}
return points, details
def state_for_player(state: BriscolaState, 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),
"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)
payload: Dict[str, object] = {
"phase": state.phase,
"target_score": state.target_score,
"player_count": state.player_count,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
"briscola_suit": state.briscola_suit,
"briscola_card": (
state.briscola_card.code if state.briscola_card is not None else None
),
"stock_count": len(state.stock),
"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,
"players": players,
"last_deal": state.hand_scores[-1] if state.hand_scores 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
return payload
@@ -0,0 +1,34 @@
"""Briscola errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely briscola-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,588 @@
"""The platform-facing adapter for briscola.
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.
"""
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 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,
DEFAULT_TURN_TIMEOUT_SECONDS,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
BriscolaState,
)
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
class _BriscolaBase(GameEngine):
"""Shared platform adapter for the two briscola table sizes."""
#: 2 (classic) or 4 (fixed partnerships); set by the subclasses.
player_count: int
options_schema = {
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": MIN_TARGET_SCORE,
"maximum": MAX_TARGET_SCORE,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
},
}
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
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", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
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,
)
# The creator takes seat 0, i.e. team A.
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: BriscolaState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: BriscolaState = session.state
if action == "play":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
engine.play(state, user_sub, card)
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: BriscolaState = 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, BriscolaState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return BriscolaState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: BriscolaState = 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 deal was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: BriscolaState = session.state
if kind == "turn":
seat = state.turn
engine.auto_play(state)
log.info(
"auto-played for seat %s (turn timeout, deal %d)",
seat,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"deal %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: BriscolaState = 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={"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,
"deals_played": state.hand_number,
"deal_scores": state.hand_scores,
},
)
class BriscolaEngine(_BriscolaBase):
"""Classic two-player briscola as a platform game engine."""
id = "briscola"
name = "Briscola"
description = (
"The classic two-player Italian trick-taking game: three cards "
"each, a trump suit and 120 points per deal. First to the target "
"score wins."
)
min_players = 2
max_players = 2
player_count = 2
class BriscolaQuattroEngine(_BriscolaBase):
"""Four-player fixed-partnership briscola as a platform game engine."""
id = "briscola_a_quattro"
name = "Briscola a quattro"
description = (
"Briscola played in two fixed partnerships of two, three cards "
"each and a trump suit. First team to the target score wins."
)
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,279 @@
"""In-memory representation of a briscola game.
The whole mutable game lives in :class:`BriscolaState`, 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 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``).
"""
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: seats 0 and 2 form team A (index 0),
# seats 1 and 3 form team B (index 1). In the two-player variant the teams
# are simply the two players; in briscola a quattro the partners sit
# opposite each other.
TEAM_A = 0
TEAM_B = 1
TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing"
# Between deals of an unfinished match: deal summary shown to every
# player; the next deal is dealt once all players acknowledge (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
# Supported table sizes: classic head-to-head and four-player partnerships.
PLAYER_COUNTS = (2, 4)
DEFAULT_TARGET_SCORE = 61
MIN_TARGET_SCORE = 61
MAX_TARGET_SCORE = 120
# Cards dealt to each player at the start of a deal.
HAND_SIZE = 3
# Default seconds the between-deals summary waits before dealing anyway.
# Games carry their own copy in ``BriscolaState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# card for them. Games carry their own copy in
# ``BriscolaState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
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 TrickCard:
"""One card played in the current trick, in play order."""
seat: int
card: str
def to_json(self) -> Dict[str, Any]:
return {"seat": self.seat, "card": self.card}
@staticmethod
def from_json(data: Dict[str, Any]) -> "TrickCard":
return TrickCard(seat=int(data["seat"]), card=str(data["card"]))
@dataclass
class Trick:
"""A completed trick: the winner's seat and the cards it captured."""
winner: int
cards: List[TrickCard] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"winner": self.winner,
"cards": [c.to_json() for c in self.cards],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Trick":
return Trick(
winner=int(data["winner"]),
cards=[TrickCard.from_json(c) for c in data.get("cards", [])],
)
@dataclass
class PlayerState:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
@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],
}
@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", [])],
)
@dataclass
class BriscolaState:
player_count: int = 2
target_score: int = DEFAULT_TARGET_SCORE
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
# Face-down draw pile; cards are drawn from the end.
stock: List[Card] = field(default_factory=list)
# The face-up card turned at deal time: its suit is the trump suit for
# the whole deal, and it is the last card drawn from the stock.
briscola_card: Optional[Card] = None
briscola_suit: str = ""
dealer: int = 0
turn: int = 0
# 1-based deal counter, used in the audit trail and deadlines.
hand_number: int = 1
# Cards of the trick in progress, in play order.
trick: List[TrickCard] = field(default_factory=list)
trick_leader: int = 0
# The most recently completed trick, for the table announcement.
last_trick: Optional[Trick] = None
# Match points accumulated across deals (patta deals do not count).
scores: List[int] = field(default_factory=lambda: [0, 0])
winner: Optional[int] = None
# Per-deal points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# 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 deal summary waits before dealing anyway.
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS
# While phase == "playing": when the server plays a random card for
# the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"player_count": self.player_count,
"target_score": self.target_score,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"stock": [c.to_json() for c in self.stock],
"briscola_card": (
self.briscola_card.to_json() if self.briscola_card else None
),
"briscola_suit": self.briscola_suit,
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"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,
"scores": list(self.scores),
"winner": self.winner,
"hand_scores": list(self.hand_scores),
"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]) -> "BriscolaState":
return BriscolaState(
player_count=int(data.get("player_count", 2)),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
stock=[Card.from_json(c) for c in data.get("stock", [])],
briscola_card=(
Card.from_json(data["briscola_card"])
if data.get("briscola_card")
else None
),
briscola_suit=str(data.get("briscola_suit", "")),
dealer=int(data.get("dealer", 0)),
turn=int(data.get("turn", 0)),
hand_number=int(data.get("hand_number", 1)),
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
),
scores=[int(x) for x in data.get("scores", [0, 0])],
winner=data.get("winner"),
hand_scores=list(data.get("hand_scores", [])),
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", DEFAULT_HAND_ACK_TIMEOUT_SECONDS)
),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
),
)
# -- 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,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()
@@ -0,0 +1,691 @@
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import engine
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import (
HAND_SIZE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
Card,
PlayerState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
*,
briscola_suit: str = "S",
briscola_card: str | None = None,
stock=None,
captured=None,
turn: int = 0,
dealer: int = 0,
target: int = 61,
scores=None,
phase: str = PHASE_PLAYING,
) -> BriscolaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = BriscolaState(
player_count=len(hands),
target_score=target,
phase=phase,
dealer=dealer,
turn=turn,
briscola_suit=briscola_suit,
briscola_card=card(briscola_card) if briscola_card else None,
stock=[card(c) for c in (stock or [])],
scores=list(scores or [0, 0]),
)
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]
return state
def trick_cards(state: BriscolaState) -> list[str]:
return [t.card for t in state.trick]
def play_all(state: BriscolaState) -> None:
"""Play every card of the current trick in turn order."""
for _ in range(state.player_count):
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
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}))
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),
)
def test_card_points_add_up_to_120(self) -> None:
self.assertEqual(
120, sum(engine.card_points(c) for c in engine.full_deck())
)
self.assertEqual(11, engine.card_points(card("01D")))
self.assertEqual(10, engine.card_points(card("03C")))
self.assertEqual(4, engine.card_points(card("10S")))
self.assertEqual(3, engine.card_points(card("09B")))
self.assertEqual(2, engine.card_points(card("08D")))
self.assertEqual(0, engine.card_points(card("07C")))
class DealTest(unittest.TestCase):
def test_two_player_deal(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
# 40 - 3*2 - 1 face-up = 33 face-down.
self.assertEqual(33, len(state.stock))
self.assertIsNotNone(state.briscola_card)
assert state.briscola_card is not None
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
self.assertEqual(1, state.turn) # dealer is seat 0
self.assertEqual([], state.trick)
def test_four_player_deal(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
self.assertEqual(1, state.turn)
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
dealt = [c.code for p in state.players for c in p.hand]
assert state.briscola_card is not None
dealt.append(state.briscola_card.code)
dealt.extend(c.code for c in state.stock)
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
def test_unsupported_player_count(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=3)
def test_target_score_bounds(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=60)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=121)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=True)
state = engine.create_game("p0", "p0", target_score=101)
self.assertEqual(101, state.target_score)
class TrickTest(unittest.TestCase):
def test_higher_card_of_led_suit_wins(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
self.assertEqual(1, state.last_trick.winner)
self.assertEqual(["02D", "05D"],
[c.code for c in state.players[1].captured])
def test_trump_beats_a_higher_led_card(self) -> None:
state = make_state([["07D"], ["02C"]], briscola_suit="C")
engine.play(state, "p0", "07D")
engine.play(state, "p1", "02C")
self.assertEqual(1, state.last_trick.winner)
def test_higher_trump_wins_among_trumps(self) -> None:
state = make_state([["02C"], ["05C"]], briscola_suit="C")
engine.play(state, "p0", "02C")
engine.play(state, "p1", "05C")
self.assertEqual(1, state.last_trick.winner)
def test_off_suit_non_trump_loses(self) -> None:
state = make_state([["04D"], ["01C"]], briscola_suit="B")
engine.play(state, "p0", "04D")
engine.play(state, "p1", "01C")
self.assertEqual(0, state.last_trick.winner)
def test_rank_strength_order(self) -> None:
# (winner card, loser card) pairs of the led suit, strongest first:
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
pairs = [
("01D", "03D"), # ace beats three
("03D", "10D"), # three beats king
("10D", "09D"), # king beats horse
("09D", "08D"), # horse beats jack
("08D", "07D"), # jack beats seven
("07D", "06D"),
("06D", "05D"),
("05D", "04D"),
("04D", "02D"),
]
for winner, loser in pairs:
state = make_state([["02D"], ["05D"]], briscola_suit="B")
state.players[0].hand = [card(winner)]
state.players[1].hand = [card(loser)]
engine.play(state, "p0", winner)
engine.play(state, "p1", loser)
self.assertEqual(
0, state.last_trick.winner, f"{winner} should beat {loser}"
)
def test_four_player_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.last_trick.winner)
def test_four_player_trump_beats_all(self) -> None:
state = make_state(
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(1, state.last_trick.winner)
def test_winner_leads_the_next_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.turn)
self.assertEqual(3, state.trick_leader)
self.assertEqual([], state.trick)
def test_turn_advances_within_a_trick(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
self.assertEqual(["02D"], trick_cards(state))
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_and_hand_end_reject_plays(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
class DrawTest(unittest.TestCase):
def test_winner_draws_first(self) -> None:
# The stock is drawn from its end: 04B first, then 03B, ...
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
self.assertEqual(["09C", "03B"], hands[0])
self.assertEqual(["09B", "04B"], hands[1])
def test_face_up_briscola_is_drawn_last(self) -> None:
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="C",
briscola_card="05C",
stock=["03B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
# The winner draws first and takes the last face-down card; the
# other player then takes the face-up briscola.
self.assertEqual(["09B", "03B"], hands[1])
self.assertEqual(["09C", "05C"], hands[0])
self.assertIsNone(state.briscola_card)
def test_no_draw_when_stock_is_empty(self) -> None:
state = make_state(
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
# p1 wins the trick; with no stock nobody draws and the hands
# shrink for the rest of the deal.
for player in state.players:
self.assertEqual(1, len(player.hand))
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
while state.stock or state.briscola_card is not None:
play_all(state)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(0, len(state.stock))
self.assertIsNone(state.briscola_card)
class ScoringTest(unittest.TestCase):
def test_deal_points_group_by_team(self) -> None:
state = make_state(
[[], [], [], []],
briscola_suit="S",
captured=[
["01D", "07C"], # seat 0, team A: 11
["03C", "08D"], # seat 1, team B: 10
["10S"], # seat 2, team A: 4
["09B"], # seat 3, team B: 3
],
)
points, details = engine.deal_points(state)
self.assertEqual([15, 15], points)
self.assertEqual({"A": 15, "B": 15}, details["points"])
self.assertEqual({"A": 3, "B": 3}, details["cards"])
def test_full_deal_scores_120(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
while state.phase == PHASE_PLAYING:
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
points, _ = engine.deal_points(state)
self.assertEqual(120, sum(points))
def test_match_ends_when_target_reached(self) -> None:
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], ["10C"]],
scores=[60, 50],
target=61,
)
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
state.players[0].hand = [card("05D")]
state.players[1].hand = [card("02D")]
engine.play(state, "p0", "05D")
engine.play(state, "p1", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertEqual([71, 54], state.scores)
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
# 120 and tie, so another deal must be played.
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
f"{rank:02d}S" for rank in range(1, 11)
] + [f"{rank:02d}B" for rank in range(1, 11)]
state = make_state(
[[], []],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[65, 55],
target=120,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual([120, 120], state.scores)
self.assertIsNone(state.winner)
def test_patta_is_discarded_and_replayed(self) -> None:
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
f"{rank:02d}C" for rank in range(1, 11)
]
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
f"{rank:02d}B" for rank in range(1, 11)
]
state = make_state(
[["02D"], ["02S"]],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[10, 20],
target=61,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
# The 60-60 deal does not count: totals are unchanged.
self.assertEqual([10, 20], state.scores)
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
# The next deal is played at the same cumulative totals.
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual([10, 20], state.scores)
def test_audit_trail_records_each_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
scores=[0, 0],
)
engine._end_deal(state)
entry = state.hand_scores[-1]
self.assertEqual(1, entry["deal"])
self.assertFalse(entry["patta"])
self.assertEqual({"A": 11, "B": 0}, entry["points"])
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 3):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual("lobby", state.phase)
engine.join_game(state, "p3", "p3")
self.assertEqual(PHASE_PLAYING, state.phase)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_full_random_two_player_match(self) -> None:
self._random_match(player_count=2)
def test_full_random_four_player_match(self) -> None:
self._random_match(player_count=4)
def _random_match(self, player_count: int) -> None:
state = engine.create_game("p0", "p0", player_count=player_count)
for i in range(1, player_count):
engine.join_game(state, f"p{i}", f"p{i}")
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, rng.choice(player.hand).code)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
self.assertEqual([], state.trick)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
# Every counted deal totals 120 points, and the winner reached the
# target with a clear lead.
winner_score = state.scores[state.winner]
loser_score = state.scores[1 - state.winner]
self.assertGreaterEqual(winner_score, state.target_score)
self.assertGreater(winner_score, loser_score)
self.assertEqual(0, (winner_score + loser_score) % 120)
class AckTest(unittest.TestCase):
def _hand_end_state(self) -> BriscolaState:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
return state
def test_ack_all_deals_the_next_deal(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
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((dealer_before + 1) % 2, state.dealer)
self.assertEqual((dealer_before + 2) % 2, state.turn)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual([], player.captured)
def test_double_ack_is_idempotent(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
def test_ack_outside_hand_end_is_rejected(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_ack_by_non_player_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(NotYourTurn):
engine.acknowledge_hand(state, "mallory")
class AutoPlayTest(unittest.TestCase):
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"]],
briscola_suit="S",
stock=["01B", "02B"],
briscola_card="03B",
)
engine.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.players[0].hand))
self.assertEqual(1, len(state.trick))
def test_auto_play_can_end_the_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
# Both players are on their last card: two auto-plays complete the
# trick and the deal, scored A=11 / B=0.
engine.auto_play(state)
engine.auto_play(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.turn_deadline)
self.assertTrue(state.hand_end_deadline)
def test_auto_play_requires_playing_phase(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(GameNotStarted):
engine.auto_play(state)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
briscola_suit="D",
briscola_card="07D",
stock=["01B", "02B"],
)
view = engine.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("D", view["briscola_suit"])
self.assertEqual("07D", view["briscola_card"])
self.assertEqual(2, view["stock_count"])
self.assertTrue(view.get("your_turn"))
def test_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
briscola_card="07D",
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual("07D", view["briscola_card"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
captured=[["01C", "03B"], ["10D"]],
)
view = engine.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"])
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
view = engine.state_for_player(state, "p0")
self.assertEqual(PHASE_HAND_END, view["phase"])
self.assertEqual(
{
"deal": 1,
"patta": False,
"points": {"A": 11, "B": 0},
"cards": {"A": 1, "B": 0},
"totals": {"A": 11, "B": 0},
},
view["last_deal"],
)
self.assertTrue(view["hand_end_deadline"])
def test_trick_is_public_in_play_order(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
view = engine.state_for_player(state, None)
self.assertEqual(
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
view["trick"],
)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
restored = BriscolaState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.target_score, restored.target_score)
self.assertEqual(state.briscola_suit, restored.briscola_suit)
self.assertEqual(
state.briscola_card.code if state.briscola_card else None,
restored.briscola_card.code if restored.briscola_card else None,
)
self.assertEqual(
[c.code for c in state.stock], [c.code for c in restored.stock]
)
self.assertEqual(
[(t.seat, t.card) for t in state.trick],
[(t.seat, t.card) for t in restored.trick],
)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
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 = make_state([["02D"], ["05D"]], briscola_suit="S")
data = state.to_json()
del data["player_count"]
del data["briscola_card"]
del data["last_trick"]
restored = BriscolaState.from_json(data)
self.assertEqual(2, restored.player_count)
self.assertIsNone(restored.briscola_card)
self.assertIsNone(restored.last_trick)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,349 @@
"""Adapter tests: the platform contract over the pure briscola 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.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.briscola import engine as rules
from tavolo.briscola.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="b1",
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 i in range(1, engine.max_players):
name = f"p{i}"
engine.join(session, name, name.capitalize())
return session
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_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)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
class MetadataTest(unittest.TestCase):
def test_two_player_metadata(self) -> None:
engine = BriscolaEngine()
self.assertEqual("briscola", engine.id)
self.assertEqual("Briscola", engine.name)
self.assertTrue(engine.description)
self.assertEqual(2, engine.min_players)
self.assertEqual(2, engine.max_players)
target = engine.options_schema["properties"]["target_score"]
self.assertEqual("integer", target["type"])
self.assertEqual(61, target["minimum"])
self.assertEqual(120, target["maximum"])
self.assertEqual(61, target["default"])
def test_four_player_metadata(self) -> None:
engine = BriscolaQuattroEngine()
self.assertEqual("briscola_a_quattro", engine.id)
self.assertEqual("Briscola a quattro", engine.name)
self.assertTrue(engine.description)
self.assertEqual(4, engine.min_players)
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_team_a(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, BriscolaState)
self.assertEqual(61, session.state.target_score)
self.assertEqual(2, session.state.player_count)
def test_create_options(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine, target_score=101)
self.assertEqual(101, session.state.target_score)
self.assertEqual(4, session.state.player_count)
def test_create_rejects_bad_options(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=60)
with self.assertRaises(IllegalMove):
_session(engine, target_score=121)
with self.assertRaises(IllegalMove):
_session(engine, target_score="sixtyone")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
def test_timeouts_come_from_the_engine(self) -> None:
engine = BriscolaEngine(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_two_player_join_starts(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertEqual("B", session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_four_player_teams_alternate(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine)
teams = []
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
teams.append(session.players[-1].team)
self.assertEqual(["B", "A", "B"], teams)
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = BriscolaEngine()
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")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = BriscolaEngine()
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": "nope"})
def test_play_advances_the_trick(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
self.assertEqual(1, state.turn)
card = state.players[1].hand[0].code
engine.handle_action(session, "p1", "play", {"card": card})
self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"])
def test_play_out_of_turn_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
card = state.players[0].hand[0].code
with self.assertRaises(GameError):
engine.handle_action(session, "alice", "play", {"card": card})
def test_ack_outside_hand_end_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "ack", {})
def test_finished_match_rejects_actions(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
def test_ack_all_deals_next(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
# Force the deal-end phase: both players keep 3 cards, so the
# match continues after the summary.
state.phase = PHASE_HAND_END
state.acked = []
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
engine.handle_action(session, "alice", "ack", {})
self.assertEqual(PHASE_HAND_END, state.phase)
engine.handle_action(session, "p1", "ack", {})
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
class ViewTest(unittest.TestCase):
def test_view_for_hides_other_hands(self) -> None:
engine = BriscolaEngine()
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])
# 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)
self.assertIn("briscola_suit", view)
self.assertIn("stock_count", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = BriscolaEngine()
session = _session(engine, target_score=101)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(101, lobby["target_score"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, BriscolaState)
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],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = BriscolaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
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 = BriscolaEngine()
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)
engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code})
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
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_match(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(4, len(result.players))
winner_name = "A" if result.winner_team == 0 else "B"
for player in result.players:
self.assertEqual(player.won, player.team == winner_name)
self.assertIn("captured_cards", player.details)
self.assertIn("team_a_score", result.summary)
self.assertIn("team_b_score", result.summary)
self.assertIn("winner_team", result.summary)
self.assertIn("target_score", result.summary)
self.assertIn("deals_played", result.summary)
self.assertIn("deal_scores", result.summary)
def test_two_player_result(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams)
self.assertEqual(2, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [55, 65]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 55, "B": 65}, over["scores"])
self.assertEqual("B", over["winner"])
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
# tavolo-platform
The game-independent half of tavolo: lobby, live play, match history and
leaderboards. Everything here works for any game that implements the
[`GameEngine`](src/tavolo/platform/engine.py) contract; the package
itself ships no game.
## Contents
- `engine.py` — the platform↔game contract: `GameEngine` (ABC),
`GameSession` (the platform-owned envelope with an opaque `state` blob),
`Seat`, `Deadline` (kind + due time + revalidation token) and the
`MatchResult`/`PlayerResult` outcome types.
- `registry.py` — `GameRegistry`: game id → engine lookup, single source
of truth for which games exist.
- `mixin.py` — `Platform` (registry + store + scheduler + OIDC, the
collaborators every endpoint needs) and `PlatformMixin`, the kaya mixin
that registers all routes and the websocket endpoint on an app.
- `routes/` — `health` (`GET /api/health`), `me` (`GET /api/me`), `games`
(lobby: `GET /api/game-types`, `POST /api/games`, `POST /api/games/join`,
`GET /api/games/{id}`) and `stats` (`GET /api/me/matches`,
`GET /api/leaderboard`, `GET /api/me/ratings`).
- `ws.py` — the live-play websocket (`/ws/games/{id}`): connection
lifecycle, the message envelope and the publish/subscribe fan-out.
Game-specific actions are dispatched to the session's engine.
- `store.py` — live-session persistence: `RedisGameStore` (production)
and `InMemoryGameStore` (tests/dev) over a JSON envelope plus the
engine's opaque state blob, with per-game locks, change signals and the
shared deadline queue.
- `deadlines.py` — `DeadlineScheduler`: enqueue the deadline an engine
declares after each mutation; a per-loop consumer fires due entries
through `engine.fire_deadline` under the per-game lock. Engines
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 and leaderboard aggregates per
`(user_sub, game_type)`).
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
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 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 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),
[`tavolo-scopa`](../tavolo-scopa/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md).
## Development (from `server/`)
```sh
uv run python -m unittest discover -s packages/tavolo-platform/tests
uv run mypy -p tavolo.platform
```
Tests run fully in-process against a `DummyEngine` (a two-player toy game
in `tests/helpers.py`): in-memory sqlite, in-memory stores, a patched
OIDC user and `httpx` / `httpx-ws` ASGI transports. No test in this
package may import a real game.
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-platform"
version = "0.1.0"
description = "Game-independent multiplayer game platform (lobby, live play, match history, leaderboards) built on the kaya framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core>=0.0.7",
"kaya-session",
"kaya-oidc>=0.0.7",
"kaya-openapi",
"tortoise-orm",
"redis",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
# only declare the relation field. These are real attributes, not bugs.
[[tool.mypy.overrides]]
module = "tavolo.platform.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.platform.routes.*"
disable_error_code = ["attr-defined"]
@@ -0,0 +1,53 @@
"""The game-independent half of tavolo: lobby, live play, history, ratings.
This package hosts every concern that does not depend on the rules of a
specific game: the lobby HTTP API, the live-game store, the websocket
endpoint, the deadline scheduler, match persistence and the Elo
leaderboards. A game plugs in by implementing
:class:`~tavolo.platform.engine.GameEngine` and registering it in a
:class:`~tavolo.platform.registry.GameRegistry`; the application then
mounts everything with :class:`~tavolo.platform.mixin.PlatformMixin`.
"""
from __future__ import annotations
from .engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from .errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .mixin import Platform, PlatformMixin
from .registry import GameRegistry, UnknownGameType
__all__ = [
"AlreadyJoined",
"Deadline",
"GameEngine",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"GameRegistry",
"GameSession",
"IllegalMove",
"LobbyFull",
"MatchResult",
"NotYourTurn",
"Platform",
"PlatformMixin",
"PlayerResult",
"Seat",
"UnknownGameType",
]
@@ -0,0 +1,106 @@
"""Authentication helpers on top of the kaya-oidc mixin.
The platform has no application roles: every authenticated user may
create and join games. Authorization beyond login is game membership,
checked against the seats of the live session.
"""
from __future__ import annotations
from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket
from kaya.oidc import OIDCProviders, OIDCUser
from kaya.session import Session
# kaya-oidc namespaces its session state per provider as
# ``oidc:{provider}:user`` (since 0.0.6), so the lookup cannot assume a
# fixed key or provider name.
_OIDC_PREFIX = "oidc:"
_USER_SUFFIX = ":user"
def session_user(session: Session) -> Optional[OIDCUser]:
"""Return the authenticated user stored in a session, if any.
The kaya-oidc mixin writes the userinfo at login under a per-provider
key, so any provider's user is accepted (the configured provider is
``default`` unless the app says otherwise).
"""
for key in session:
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
claims = session.get(key)
if isinstance(claims, Mapping):
return OIDCUser(claims)
return None
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
"""Return the authenticated user of a WebSocket connection, if any.
The session mixin injects ``session`` into the websocket wrapper; the
OIDC mixin stores the userinfo there at login. Patched in tests.
"""
session = getattr(ws, "session", None)
if session is None:
return None
return session_user(session)
def display_name(user: OIDCUser) -> str:
"""Best-effort human-readable name for a user.
``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). The ``sub`` is the last resort.
"""
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.
Sessions are single-identity by default, so at most one provider has
a user; if several do, the first configured provider wins.
"""
return next(iter(oidc.get_users(ctx).values()), None)
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Build a decorator gating a handler on being authenticated.
Responds ``401`` with a JSON error envelope when unauthenticated —
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
the login page (wrong for a JSON API).
"""
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
if current_user(oidc, ctx) is None:
await ctx.send_bytes(
401,
b'{"error":"unauthenticated"}',
{"content-type": ("application/json",)},
)
return
await handler(ctx, *args, **kwargs)
return guarded
return decorator
@@ -0,0 +1,94 @@
"""Recompute every Elo rating and leaderboard aggregate from the history.
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://...
"""
from __future__ import annotations
import argparse
import asyncio
from collections import defaultdict
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .stats import PlayerStats, apply_match_stats
from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__)
async def backfill_elo() -> int:
"""Rebuild all ratings and aggregates; returns the matches replayed."""
from .models import Match, MatchPlayer, PlayerRating
replayed = 0
async with in_transaction():
await PlayerRating.all().delete()
matches = await Match.all().order_by("finished_at", "id")
for match in matches:
players = await MatchPlayer.filter(match_id=match.id)
by_team: Dict[str, List[str]] = defaultdict(list)
for player in players:
if player.team is not None:
by_team[player.team].append(player.user_sub)
# Deterministic team order; the winner is the team of any
# player flagged as having won.
teams = [by_team[team] for team in sorted(by_team)]
winner_index = next(
(
i
for i, members in enumerate(teams)
if any(p.won and p.user_sub in members for p in players)
),
0,
)
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()
replayed += 1
return replayed
async def _main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--database-url",
required=True,
help="Tortoise database URL, e.g. postgres://user:pass@host/db",
)
args = parser.parse_args()
mixin = TortoiseMixin(
database_url=args.database_url,
models_modules=["tavolo.platform.models"],
)
await mixin._bind()
try:
replayed = await backfill_elo()
log.info("elo backfill complete: %d matches replayed", replayed)
print(f"Recomputed ratings from {replayed} matches.")
finally:
await mixin.aclose()
if __name__ == "__main__":
asyncio.run(_main())
@@ -0,0 +1,310 @@
"""Deadline-driven timeouts, independent of player connections.
In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
auto-continue) are driven by absolute deadlines, never by which players
(or whether any players) are connected. The platform owns the scheduling
machinery; the *meaning* of each deadline belongs to the game engine:
* after every mutation the engine's
:meth:`~tavolo.platform.engine.GameEngine.next_deadline` computes the
deadline the new state implies (kind + due time + a revalidation
token), and the scheduler enqueues it in the store's shared deadline
queue (a Redis sorted set in production, see
:mod:`tavolo.platform.store`); enqueueing is idempotent;
* enqueueing a deadline announces it on a shared pub/sub channel, so
every worker's consumer wakes immediately instead of waiting for its
next poll; the consumer also wakes on a slow heartbeat, which is the
backstop recovering notifications lost to a reconnect or a worker
crash. Due entries are handed to the engine's
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
per-game lock; the engine revalidates the token against the live state
and raises :class:`~tavolo.platform.errors.GameError` when the entry
was overtaken by events, so duplicate or stale deliveries are harmless.
Delivery is at-least-once: an entry is removed from the queue only after
it has been processed. If a worker dies mid-processing, the entry stays
in Redis and another worker's consumer picks it up. Entries whose game
has expired are dropped the first time they fire, so the queue is
self-cleaning.
"""
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Awaitable, Callable, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from .engine import GameSession
from .errors import GameError
from .registry import GameRegistry
from .stats import save_match_result
from .store import GameStore
log = getLogger(__name__)
def encode(entry: Dict[str, Any]) -> str:
"""Canonical queue-member encoding for a deadline entry."""
return json.dumps(entry, sort_keys=True)
def _decode(member: Any) -> Optional[Dict[str, Any]]:
if isinstance(member, bytes):
member = member.decode("utf-8")
if not isinstance(member, str):
return None
try:
entry = json.loads(member)
except ValueError:
return None
return entry if isinstance(entry, dict) else None
class DeadlineScheduler:
"""Enqueue and fire the deadlines game engines ask for.
Holds the store and the registry so a single instance serves every
game type. One consumer task, its listener task and its wake-up
event are kept per event loop — tests run each test on a fresh loop.
``context_binder`` is an optional async callable the consumer awaits
before persisting a finished match. The consumer is a long-lived task
of its own, outside any request, so it must bind whatever ambient
context the database layer needs (see
:meth:`~tavolo.platform.tortoise_mixin.TortoiseMixin.ensure_context`).
"""
def __init__(
self,
store: GameStore,
registry: GameRegistry,
heartbeat_ms: int = 1000,
retry_ms: int = 1000,
context_binder: Optional[Callable[[], Awaitable[None]]] = None,
) -> None:
self._store = store
self._registry = registry
self._heartbeat = heartbeat_ms / 1000
# Backoff for entries that failed to process and for listener
# reconnects; deliberately independent of the (slow) heartbeat.
self._retry_delay = retry_ms / 1000
self._context_binder = context_binder
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
self._listeners: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
async def sync_deadline(self, session: GameSession) -> None:
"""Enqueue the deadline the current state implies, if any.
Called after every mutation that can set a deadline and as a
backstop when a client connects. Enqueueing is idempotent: an
identical entry is already queued with the same due time, so
re-adding it changes nothing.
"""
engine = self._registry.require(session.game_type)
deadline = engine.next_deadline(session)
if deadline is None:
return
entry = {
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
}
self.ensure_consumer()
due_at = deadline.due_at.timestamp()
await self._store.add_deadline(encode(entry), due_at)
# Announce the enqueue so consumers on other workers wake up too.
# The entry is already queued, so a lost notification only delays
# their discovery to the next poll backstop.
try:
await self._store.notify_deadline()
except Exception:
log.warning(
"deadline enqueue notification failed; relying on the poll backstop",
exc_info=True,
)
wake = self._wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
async def finalize_mutation(self, session: GameSession) -> None:
"""Persist a successful mutation, notify subscribers and enqueue
the next deadline.
Callers must hold the per-game lock. Handles the terminal
transition: the match result is written to Postgres once
(guarded by ``stats_saved``).
"""
engine = self._registry.require(session.game_type)
if engine.is_finished(session):
if session.finished_at is None:
session.finished_at = datetime.now(timezone.utc)
# A deadline can finish the match in the consumer task, which
# has no request context: bind the database context first.
if self._context_binder is not None:
await self._context_binder()
await save_match_result(session, engine)
log.info("game %s (%s) finished", session.id, session.game_type)
await self._store.save(session)
await self._store.publish(session.id)
await self.sync_deadline(session)
async def process_due(self, member: Any) -> None:
"""Fire a single due deadline entry.
The engine revalidates the entry against the live state under
the per-game lock; stale or foreign entries are discarded
without effect. The entry is removed from the queue once handled
(including "nothing to do"); if handling fails unexpectedly
(e.g. the lock cannot be acquired), the entry is left in the
queue so another consumer retries it.
"""
entry = _decode(member)
if entry is None:
log.warning("deadline consumer: dropping malformed entry %r", member)
await self._store.remove_deadline(member)
return
game_id = entry.get("game_id")
kind = entry.get("kind")
token = entry.get("token")
if not isinstance(game_id, str) or not isinstance(kind, str):
await self._store.remove_deadline(member)
return
async with self._store.lock(game_id):
session = await self._store.load(game_id)
if session is not None:
engine = self._registry.require(session.game_type)
try:
engine.fire_deadline(session, kind, str(token))
except GameError:
# Stale or inapplicable entry: nothing to do.
pass
else:
await self.finalize_mutation(session)
await self._store.remove_deadline(member)
# --- consumer lifecycle -------------------------------------------------
def ensure_consumer(
self, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
"""Start the deadline consumer on the given (or running) loop if
not yet running.
Called lazily whenever a deadline is enqueued (the ASGI test
transport never fires the lifespan hooks, so the mixin's
``setup`` alone is not enough) and on application startup. The
explicit ``loop`` matters at startup: under RSGI granian calls
``setup`` before the loop runs, so ``asyncio.get_running_loop()``
would fail there.
"""
if loop is None:
loop = asyncio.get_running_loop()
for old in list(self._consumers):
if old.is_closed():
self._consumers.pop(old, None)
self._wake_events.pop(old, None)
self._listeners.pop(old, None)
task = self._consumers.get(loop)
if task is None or task.done():
self._wake_events[loop] = asyncio.Event()
self._consumers[loop] = loop.create_task(self._run(loop))
listener = self._listeners.get(loop)
if listener is None or listener.done():
self._listeners[loop] = loop.create_task(self._listen(loop))
log.debug("deadline consumer started")
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
task = self._consumers.pop(loop, None)
self._wake_events.pop(loop, None)
listener = self._listeners.pop(loop, None)
if listener is not None:
listener.cancel()
if task is not None:
task.cancel()
async def _run(self, loop: asyncio.AbstractEventLoop) -> None:
wake = self._wake_events[loop]
while True:
# Clear before polling so an enqueue racing the poll re-wakes us.
wake.clear()
delay = self._heartbeat
failed = False
try:
for member in await self._store.due_deadlines(time.time()):
try:
await self.process_due(member)
except asyncio.CancelledError:
raise
except Exception:
# Left in the queue; retried on the next pass.
log.exception("deadline consumer: failed to process %r", member)
failed = True
next_due = await self._store.next_deadline()
if next_due is not None:
delay = max(0.0, min(self._heartbeat, next_due - time.time()))
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline consumer: poll failed; retrying")
failed = True
if failed:
# A failed entry is still due: back off briefly instead of
# polling it again immediately. Kept independent of the
# heartbeat, which is only a slow backstop.
delay = self._retry_delay
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
async def _listen(self, loop: asyncio.AbstractEventLoop) -> None:
"""Wake the consumer on deadline-enqueue notifications.
The subscription is best effort: a dropped connection or a lost
message is recovered by the consumer's heartbeat backstop, so
this task only has to reconnect and keep forwarding signals.
"""
while True:
try:
async with self._store.subscribe_deadlines() as events:
async for _ in events:
wake = self._wake_events.get(loop)
if wake is None:
# Shut down: the consumer is gone.
return
wake.set()
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline listener failed; reconnecting")
try:
await asyncio.sleep(self._retry_delay)
except asyncio.CancelledError:
raise
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer and listener;
coordination happens exclusively through the shared deadline queue,
its notification channel and the per-game locks, so any worker may
fire any game's deadline.
"""
def __init__(self, scheduler: DeadlineScheduler) -> None:
self._scheduler = scheduler
def apply(self, app: KayaApp) -> None:
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
self._scheduler.ensure_consumer(loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
self._scheduler.stop_consumer(loop)
@@ -0,0 +1,49 @@
"""Chess-style Elo ratings, generalized to two-team matches.
Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean
of its members' current ratings, so the usual chess formula applies
unchanged between the two teams:
* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))``
* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw)
* every member of a team gains/loses the same ``round(K * (S - E))``
Deltas are rounded to integers and ratings are stored as integers, so the
system is exactly zero-sum: what the winners gain the losers lose.
"""
from __future__ import annotations
from typing import Sequence
INITIAL_RATING = 1500
K_FACTOR = 32
def expected_score(rating: float, opponent_rating: float) -> float:
"""Expected score (0..1) of a side rated ``rating`` against
``opponent_rating``."""
return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0))
def team_rating(ratings: Sequence[float]) -> float:
"""A team's rating is the mean of its members' ratings."""
if not ratings:
raise ValueError("a team needs at least one rating")
return sum(ratings) / len(ratings)
def match_delta(
team_a_ratings: Sequence[float],
team_b_ratings: Sequence[float],
winner_team: int,
) -> int:
"""Rating change applied to each member of team A.
``winner_team`` is 0 when team A won, 1 when team B won. Team B
members change by the negation of the returned value (zero-sum).
"""
rating_a = team_rating(team_a_ratings)
rating_b = team_rating(team_b_ratings)
expected = expected_score(rating_a, rating_b)
score = 1.0 if winner_team == 0 else 0.0
return round(K_FACTOR * (score - expected))
@@ -0,0 +1,265 @@
"""The contract between the platform and a game implementation.
The platform owns everything that is game-independent: the lobby, live
game storage, websockets, timeouts, match history and leaderboards. A
game (e.g. scopone scientifico) implements :class:`GameEngine` and plugs
into the platform through :class:`~tavolo.platform.registry.GameRegistry`;
the platform never imports a concrete game module.
The central type is :class:`GameSession`: the platform-owned envelope
carrying the lifecycle metadata (id, join code, seats, timestamps) plus
an opaque ``state`` blob that only the engine interprets. The engine
serializes/deserializes that blob (:meth:`GameEngine.state_to_json` /
:meth:`GameEngine.state_from_json`) so the store stays game-agnostic.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional
from .errors import GameError
__all__ = [
"Deadline",
"GameEngine",
"GameSession",
"MatchResult",
"PlayerResult",
"Seat",
]
@dataclass
class Seat:
"""One occupied chair in a game, owned by the platform.
Games may keep their own per-player state internally; the seat is
what the platform needs for the lobby, for access control and for
persisting participations.
"""
user_sub: str
display_name: str
# Game-defined team label ("A"/"B", ...); ``None`` for games without
# fixed teams.
team: Optional[str] = None
def to_json(self) -> Dict[str, Any]:
return {
"user_sub": self.user_sub,
"display_name": self.display_name,
"team": self.team,
}
@staticmethod
def from_json(data: Mapping[str, Any]) -> "Seat":
return Seat(
user_sub=str(data["user_sub"]),
display_name=str(data["display_name"]),
team=data.get("team"),
)
@dataclass
class GameSession:
"""The platform-owned envelope of a live game.
``state`` is opaque to the platform: only the engine registered for
``game_type`` reads or writes it.
"""
id: str
game_type: str
join_code: str
creator_sub: str
players: List[Seat] = field(default_factory=list)
created_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# Set once the finished match has been copied to Postgres.
stats_saved: bool = False
state: Any = None
def seated(self, sub: str) -> bool:
"""Whether ``sub`` occupies a seat in this game."""
return self.seat_of(sub) is not None
def seat_of(self, sub: str) -> Optional[Seat]:
for seat in self.players:
if seat.user_sub == sub:
return seat
return None
@dataclass(frozen=True)
class Deadline:
"""A timeout the engine wants the platform to fire.
``kind`` is an engine-defined string (e.g. ``"turn"``); ``token`` is
an opaque revalidation token: when the deadline fires, the engine must
recompute it from the live session and refuse to act (raising
:class:`~tavolo.platform.errors.GameError`) if it no longer matches,
which makes duplicate or overtaken deliveries harmless.
"""
kind: str
due_at: datetime
token: str
@dataclass
class PlayerResult:
"""The outcome of a finished match for one participant."""
user_sub: str
seat: int
won: bool
team: Optional[str] = None
# Points scored, aggregated by the leaderboard.
score: float = 0.0
# Game-specific extras persisted alongside the participation.
details: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MatchResult:
"""The game-independent outcome of a finished match.
``teams`` groups the participants' subs into the sides the Elo rating
treats as opponents (exactly two for now); ``winner_team`` is the
index of the winning side. ``summary`` is persisted verbatim as the
match's JSON ``result`` column.
"""
teams: List[List[str]]
winner_team: int
players: List[PlayerResult]
summary: Dict[str, Any] = field(default_factory=dict)
class GameEngine(ABC):
"""The interface every playable game implements.
Implementations must be deterministic and I/O-free apart from
logging: all persistence, locking and transport concerns belong to
the platform. Rule violations are reported by raising
:class:`~tavolo.platform.errors.GameError` subclasses.
"""
#: Unique id carried by sessions and persisted matches.
id: str
#: Human-readable name and description, for the lobby UI.
name: str
description: str
min_players: int
max_players: int
#: JSON-schema fragment describing the game-specific creation options
#: accepted by :meth:`create` (exposed via ``GET /api/game-types``).
options_schema: Mapping[str, Any] = {}
@abstractmethod
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
"""Initialize ``session.state`` for a fresh lobby game.
The platform has already filled the session envelope and seated
the creator (``session.players[0]``, without a team label yet).
Assign the creator's team here if the game has fixed teams, then
validate ``options`` (the opaque object from the create request;
raise :class:`~tavolo.platform.errors.GameError` on invalid
values) and initialize ``session.state``.
"""
@abstractmethod
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
"""Seat a player, starting the match when the lobby fills up.
Must append a :class:`Seat` to ``session.players`` and mirror
whatever per-player state the game keeps in ``session.state``.
Raises ``AlreadyJoined`` / ``LobbyFull`` / ``GameNotStarted``.
"""
@abstractmethod
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
"""Apply one player action received over the websocket.
``payload`` is the client message minus its ``action`` field.
Unknown actions and rule violations raise
:class:`~tavolo.platform.errors.GameError`.
"""
@abstractmethod
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
"""The personalized game view for one viewer.
Must hide information the viewer is not entitled to (e.g. other
players' hands). ``user_sub`` is ``None`` for a spectator — an
anonymous user or an authenticated user who is not seated — and
the view must then contain no privileged information either (no
hand, no legal moves: only what everyone at the table can see).
The platform merges in the envelope fields (``id``,
``join_code`` for seated players, ``game_type``, ``spectator``)
itself.
"""
@abstractmethod
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
"""Game-specific fields of the lobby payload (options echo, phase).
Echo the creation options under the same keys as
:attr:`options_schema`, plus ``phase``, so clients can render a
generic pre-join preview (``GET /api/games/preview``) from the
schema without game-specific code.
"""
@abstractmethod
def in_lobby(self, session: GameSession) -> bool:
"""Whether the game is still waiting for players."""
@abstractmethod
def is_finished(self, session: GameSession) -> bool:
"""Whether the match is over and a result can be extracted."""
@abstractmethod
def result(self, session: GameSession) -> MatchResult:
"""The outcome of the match. Only called when finished."""
@abstractmethod
def state_to_json(self, state: Any) -> Dict[str, Any]:
"""Serialize the opaque game state to plain JSON."""
@abstractmethod
def state_from_json(self, data: Mapping[str, Any]) -> Any:
"""Rebuild the opaque game state from its JSON form."""
@abstractmethod
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
"""The deadline the current state implies, if any.
Called after every mutation; the platform enqueues the returned
deadline (enqueueing is idempotent) and never removes previously
enqueued ones — stale entries are discarded at fire time by
:meth:`fire_deadline` revalidation.
"""
@abstractmethod
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
"""Apply the timeout action for a due deadline.
Must recompute the current deadline and raise
:class:`~tavolo.platform.errors.GameError` without acting when
``kind``/``token`` no longer match the live state.
"""
def game_over_view(
self, session: GameSession, user_sub: Optional[str]
) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message.
``user_sub`` is ``None`` for a spectator (see :meth:`view_for`);
the default fields are public, so the default implementation
ignores it.
"""
return {}
@@ -0,0 +1,43 @@
"""Typed errors shared between the platform and game implementations.
Game engines raise :class:`GameError` subclasses on rule/validation
failures; the platform translates them into 4xx HTTP responses or
``error`` WebSocket messages, using :attr:`GameError.code` as the
machine-readable error code. Engines themselves stay transport-agnostic.
"""
from __future__ import annotations
class GameError(Exception):
"""Base class for every rule/validation failure a game can raise."""
#: Machine-readable code carried by WebSocket ``error`` messages.
code = "illegal_move"
class IllegalMove(GameError):
"""The requested action violates the rules of the game."""
class NotYourTurn(GameError):
"""A player attempted to act out of turn."""
class GameNotStarted(GameError):
"""An action was attempted before the game left the lobby."""
class GameFinished(GameError):
"""An action was attempted after the match ended."""
class LobbyFull(GameError):
"""A game already has its full complement of players."""
class AlreadyJoined(GameError):
"""A player tried to join a game they are already seated in."""
class GameNotFound(GameError):
"""No live game exists for the given id or join code."""
@@ -0,0 +1,73 @@
"""JSON helpers for kaya HTTP handlers.
Kaya has no built-in request/response JSON helpers: the request body is an
async byte stream on ``ctx.request_body`` and responses are sent with
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
parsing JSON, and sending JSON responses.
"""
from __future__ import annotations
import json
from typing import Any, List, Mapping
from kaya.core import HttpContext
JSON_HEADERS = {"content-type": ("application/json",)}
class JsonRequestError(ValueError):
"""Raised by :func:`read_json` when the request body is not valid JSON
or is not a JSON object."""
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
"""Parse a raw query string into a mapping of param name to list of
values.
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
incantation; always returns a mapping (never None).
"""
from urllib.parse import parse_qs
return parse_qs(query_string, keep_blank_values=True)
async def read_json(ctx: HttpContext) -> dict:
"""Drain and parse the request body as JSON.
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
with a short human-readable reason on failure.
"""
body = b""
async for chunk in ctx.request_body:
body += chunk
if not body:
raise JsonRequestError("empty body")
try:
parsed = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise JsonRequestError("invalid JSON") from exc
if not isinstance(parsed, dict):
raise JsonRequestError("JSON body must be an object")
return parsed
async def read_json_optional(ctx: HttpContext) -> dict:
"""Like :func:`read_json` but treats an empty body as ``{}``."""
try:
return await read_json(ctx)
except JsonRequestError as exc:
if str(exc) == "empty body":
return {}
raise
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
"""Send ``payload`` as a JSON response."""
body = json.dumps(payload).encode("utf-8")
await ctx.send_bytes(status, body, JSON_HEADERS)
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
"""Send a JSON error envelope."""
await send_json(ctx, status, {"error": message})
@@ -0,0 +1,51 @@
"""The :class:`PlatformMixin`: mounts the whole platform on a kaya app.
``Platform`` bundles the collaborators every platform endpoint needs —
the game registry, the live-game store, the deadline scheduler and the
OIDC providers used for authentication — so route and websocket modules
never reach for module-global state. :class:`PlatformMixin` is a plain
:class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats
and health HTTP routes plus the live-play websocket endpoint, and the
app stays a :class:`~kaya.core.KayaApp` (both ASGI and RSGI keep
working).
"""
from __future__ import annotations
from dataclasses import dataclass
from kaya.core import KayaApp, KayaMixin
from kaya.oidc import OIDCProviders
from .deadlines import DeadlineScheduler
from .registry import GameRegistry
from .store import GameStore
@dataclass(frozen=True)
class Platform:
"""The collaborators shared by all platform endpoints."""
registry: GameRegistry
game_store: GameStore
scheduler: DeadlineScheduler
oidc: OIDCProviders
class PlatformMixin(KayaMixin):
"""Register the game-independent routes and the websocket endpoint."""
def __init__(self, platform: Platform) -> None:
self.platform = platform
def apply(self, app: KayaApp) -> None:
# Imported here so module import order stays acyclic: the route
# modules reference Platform only for typing.
from . import ws
from .routes import auth, games, health, me, stats
health.register(app, self.platform)
auth.register(app, self.platform)
me.register(app, self.platform)
games.register(app, self.platform)
stats.register(app, self.platform)
ws.register(app, self.platform)

Some files were not shown because too many files have changed in this diff Show More