Compare commits

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

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

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

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

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

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

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

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

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

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 14:45:20 +08:00
woggioni 9eea293471 Give the page background a casino felt texture
CI / Build and push docker image (push) Successful in 59s
Replace the flat radial green with a layered body background: a soft
overhead light and an edge vignette over a fine grain tiled across the
green gradient.

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

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

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

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

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

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

Recreate the option inputs on every game switch instead, so each one is
initialized from the new game's defaults and schema constraints.
2026-10-06 18:08:58 +08:00
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
woggioni adc3d340b1 Upgrade kaya to 0.0.7 2026-10-01 15:39:32 +00:00
woggioni 915a294dc8 Support multiple OIDC providers
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
2026-10-01 12:38:29 +00:00
woggioni 3abbbc6b68 Upgrade kaya to 0.0.6
kaya-oidc 0.0.6 namespaces its session state per provider as
oidc:{provider}:user (the default provider is "default", so the key is
oidc:default:user). The unprefixed oidc_user key is no longer written:
left unadapted, every websocket upgrade would be rejected with 401 and
traced requests would silently lose their user.id attribute. Adopt it
with a session_user helper on tavolo.platform.auth that finds any
provider's user, used by both get_ws_user and telemetry's
session_tag_hook. Websocket and route handlers keep their signatures.

kaya-otel 0.0.6 only enriches websocket spans with standard URL, network
and peer attributes (plus error.type), which needs no application change.

Regenerate the lock: the eight kaya pins move to 0.0.6 and, the wheels'
declared metadata being unchanged, nothing else moves. tavolo-platform
now floors kaya-core>=0.0.6 (it reads the new session layout) and
tavolo-app floors kaya-otel>=0.0.6.

As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
2026-10-01 01:18:05 +00:00
woggioni b43e074728 Wake deadline consumers on enqueue instead of polling every second 2026-09-29 23:39:42 +00:00
woggioni e344ce2e30 Sort the player's hand by rank then suit 2026-09-29 16:57:48 +00:00
woggioni e9909cb317 Upgrade kaya to 0.0.5
kaya 0.0.5 surfaces a vanished websocket peer as
kaya.core.WebSocketDisconnect instead of leaking granian's
RSGIProtocolClosed: receive() reports a close message so iteration ends
normally, sends raise the new exception, and KayaApp.handle_websocket
swallows it as normal termination. Adopt it: drop the granian-specific
workaround from the platform websocket handler, catch
WebSocketDisconnect in the send helper, and model the new semantics in
the abrupt-disconnect test (with coverage that a send on a vanished
peer is ignored).

Regenerate the lock: the eight kaya pins move to 0.0.5 (declared
metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a
security release with no breaking changes for kaya-oidc's jwt.decode
usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports
WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5.
2026-09-29 15:43:19 +00:00
woggioni 2910fc4600 Refuse websocket upgrades with HTTP statuses, not close codes
A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.

Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
2026-09-29 22:31:41 +08:00
woggioni 21911caf9e Poll deadlines with ZRANGE BYSCORE instead of the deprecated ZRANGEBYSCORE 2026-09-28 05:50:54 +08:00
woggioni 08f158ef28 Fetch match-history participants in one query per page
CI / Build and push docker image (push) Successful in 53s
2026-09-28 05:21:34 +08:00
woggioni 834f1f26a9 Tag traced requests with the session id and OIDC sub 2026-09-28 05:11:22 +08:00
woggioni bcd1371019 Cap urllib3.connectionpool at INFO in the default logging config
The root logger runs at DEBUG, so urllib3.connectionpool emitted a
record per pooled HTTP request. Keep its INFO+ output while dropping
the per-connection chatter.
2026-09-28 04:38:28 +08:00
woggioni 3cf7e2e257 Show match finish times in the browser timezone
CI / Build and push docker image (push) Successful in 2m18s
The history table truncated finished_at, which the API serializes in
UTC, so the Finished column always showed UTC wall time. Parse the
timestamp with the browser's Date and render its local components,
keeping the existing YYYY-MM-DD HH:MM layout.
2026-09-28 04:26:07 +08:00
woggioni 2bdf247cb3 Restore cursor value types before keyset filtering
Cursors are JSON, so a datetime sort key reached the next request as a
string. Tortoise's DatetimeField.to_db_value does not parse strings, so
asyncpg rejected the string where a timestamptz parameter was expected;
sqlite tolerated it, hiding the bug in tests. Coerce decoded cursor
values through their model field's to_python_value before building the
keyset filter.
2026-09-28 04:26:00 +08:00
woggioni a3e8ff3206 Trace Postgres and Redis queries with the contrib instrumentations
CI / Build and push docker image (push) Successful in 2m42s
Builds the TracerProvider in tavolo.telemetry (kaya-otel's is private) and
injects it into kaya-otel, then instruments asyncpg and redis through
opentelemetry-instrumentation-asyncpg/redis. Redis command values are
redacted and asyncpg spans carry the parameterized SQL only.
2026-09-28 03:38:59 +08:00
woggioni 98d4485375 Export app, process and log telemetry when OTEL_ENABLED is set
Adds tavolo.active_users and tavolo.ongoing_games gauges, process/CPython
metrics via opentelemetry-instrumentation-system-metrics and OTLP log
export. The game store tracks ongoing matches (Redis sorted set, in-memory
set) and the session stores expose live counts for the gauges; a poller
refreshes them since metric callbacks cannot await the stores.
2026-09-28 03:38:59 +08:00
woggioni 5be4926885 Bundle kaya-otel in the Docker image
CI / Build and push docker image (push) Successful in 1m57s
The tavolo-app otel extra was never installed: requirements.txt was
locked without it and the Dockerfile installs the local distributions
with --no-deps, so any container with OTEL_ENABLED set died at import
with "kaya-otel is not installed".

Add the extra's full closure (kaya-otel plus its OpenTelemetry/protobuf
dependencies) to the pinned lock and refresh the comments that claimed
the extra had to be added separately.
2026-09-26 15:47:16 +08:00
woggioni a3566f2083 Preview a match by join code before joining
CI / Build and push docker image (push) Successful in 1m22s
GET /api/games/preview returns the lobby behind a code plus the game's
name and engine-echoed options. The lobby debounces the lookup as a
complete code is typed and shows the game, seated players, open seats
and option badges rendered from the game's schema, disabling Join when
the lobby is full or the match has already started.
2026-09-24 09:03:31 +08:00
woggioni cc0b31ce6b Let the asso pigliatutto ace capture itself on a bare table
With the rule enabled, an ace played on an empty table goes straight to
the player's capture pile instead of staying on the table: it counts as
the last capture but never scores a scopa. An empty capture request now
matches only that self-capture option, so requesting nothing still can't
satisfy a real capture.
2026-09-23 23:53:40 +08:00
woggioni 8f0e974842 Add optional asso pigliatutto rule with a lobby toggle
CI / Build and push docker image (push) Successful in 3m16s
An ace played when no equal rank is on the table sweeps the whole
table; the sweep never scores a scopa, and an ace already on the table
still forces the mandatory equal-rank capture. The rule is off by
default, selectable per match via the schema-driven creation form, and
announced in the game view.
2026-09-23 23:22:37 +08:00
woggioni abc55479e4 Rename the playing-card CSS class away from Bootstrap's .card-img
CI / Build and push docker image (push) Successful in 1m10s
Bootstrap 5.3 ships .card-img{width:100%} for images inside cards, which
collided with the similarly named class emitted by the card helpers:
every card image stretched to its container width, so the table cards
wrapped one per flex line and pushed the player's hand below the
viewport. Emit 'playing-card' instead and rename the six selectors in
style.css; callers keep passing only the size modifiers.
2026-09-22 08:36:14 +08:00
woggioni b867dd1619 Copy the vendored Bootstrap theme into the web builder stage
CI / Build and push docker image (push) Successful in 1m24s
The Dockerfile copies the frontend inputs explicitly, so the new
theme.css and vendor/ directory were missing: trunk failed to resolve
them and the image build stopped in the web-builder stage. Copy both
into the build context; the runtime image is unchanged.
2026-09-21 23:50:56 +08:00
woggioni 12fa3f5aae Rebuild the web UI on Bootstrap 5 with the dark felt theme
CI / Build and push docker image (push) Failing after 39s
The lobby was hand-rolled CSS and looked plain. Vendor Bootstrap 5.3
(MIT) under web/vendor so builds stay offline, load it in dark mode and
bridge the felt/gold palette onto Bootstrap's CSS variables in a new
theme.css; style.css now only owns the game table, cards and scoring
summary.

Rebuild the lobby, waiting room and shared chrome with Bootstrap
components: a shared navbar, creation form as cards with schema-driven
controls, roster as a list group with seat/team badges, a seats progress
bar and a copy-to-clipboard button on the join code. History,
leaderboard, connection/error states and the 404 page use cards, tables
and buttons too; the play table itself is unchanged.

The copy feedback timer is cancelled when the waiting room is rebuilt by
another player joining, fixing a disposed-signal panic that froze the
page mid-match.
2026-09-21 23:40:44 +08:00
woggioni ed8a004ec8 Persist matches finished by deadline timeouts
CI / Build and push docker image (push) Successful in 1m15s
The deadline consumer runs in a long-lived task outside any request, so
a timeout that ended the match raised 'No TortoiseContext is currently
active' in save_match_result before the state was saved: the game stayed
stuck on the last turn and the entry retried forever. It only surfaced
on the match-deciding turn; ordinary timeouts and human plays were fine.

Bind the Tortoise context before persisting a finished match (optional
context_binder wired to TortoiseMixin.ensure_context), and back off to
the heartbeat when a due entry fails instead of hot-looping on it.
2026-09-21 17:00:08 +08:00
woggioni 2b738ea2fe Move tavolo-app under packages/ for a uniform monorepo layout
CI / Build and push docker image (push) Successful in 2m29s
Relocate the composition root (app, config, static, logging_config,
aerich_config) from server/src/tavolo/ to
packages/tavolo-app/src/tavolo/, with its own pyproject.toml and README
like the sibling distributions. Module paths and the granian entrypoint
are unchanged.

server/pyproject.toml remains as a tooling shim ([tool.aerich] next to
./migrations, [tool.mypy] for runs from server/); the Dockerfile installs
all three distributions from ./packages. Verified: all suites green,
mypy clean per package, aerich resolves the moved config identically,
wheel builds.
2026-09-21 13:04:09 +08:00
woggioni 83ecf9bed3 Make the web client game-generic against the platform API
Send creation options as the nested options object the lobby expects,
drive the creation form from each game's options_schema, and render
history/leaderboard from the generic result payload (nullable teams,
float points). The lobby uses the payload's seats_open, and the
scopone-specific table, hand-end and game-over views are gated on its
game_type with an unsupported-game fallback for future games. The
websocket protocol is unchanged.
2026-09-21 13:04:08 +08:00
woggioni 4b66407fa4 Split backend into tavolo-platform, tavolo-scopone and tavolo-app packages
Move the game-independent machinery (lobby, live-game store, websocket,
deadline scheduler, match history, leaderboards) into a new
tavolo-platform distribution behind a GameEngine contract, the scopone
scientifico rules plus a platform adapter into tavolo-scopone, and keep
only the composition root in tavolo-app. The three distributions share
the tavolo namespace (PEP 420, kaya-style monorepo).

Match history becomes fully generic: Match carries the engine's result
JSON and MatchPlayer points/details instead of scopone-shaped team
columns (migration 3 backfills existing rows). Lobby creation takes an
opaque per-game options object and websocket actions dispatch to the
session's engine.

Tests: platform suite runs against a DummyEngine toy game, scopone
keeps the rules tests plus new adapter tests, server/tests covers the
wired stack end to end (194 tests, was 143).
2026-09-21 13:04:08 +08:00
woggioni 7690251760 Rename Denara label to Denari and drop em-dash from summary button 2026-09-21 13:04:08 +08:00
woggioni d68dffaf2f Fix hanging test suite by closing per-loop Tortoise contexts
pwo.async_test runs every test in a fresh event loop and TortoiseMixin
builds one TortoiseContext per loop. Dropping the previous context when
the loop changed orphaned its aiosqlite connections; their non-daemon
worker threads then blocked threading._shutdown forever, so the suite
printed OK but the interpreter never exited.

Add TortoiseMixin.aclose() and a tests.helpers.async_test wrapper that
closes the context in a finally on the test's own loop, and switch the
test modules over to it.
2026-09-21 13:04:08 +08:00
169 changed files with 25412 additions and 3562 deletions
+89 -17
View File
@@ -1,14 +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: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres.
See [server/README.md](server/README.md).
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). Card images are the
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
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`. 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
@@ -31,19 +44,69 @@ 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 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
@@ -53,9 +116,12 @@ Backend (from `server/`):
cd server
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e '.[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/mypy src
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
```
Frontend (from `web/`), with the backend running on :8080:
@@ -70,10 +136,16 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
run the backend with:
```sh
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
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/ \
.venv/bin/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.
+22 -12
View File
@@ -21,7 +21,7 @@
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
# reach the app with:
# kubectl port-forward -n tavolo svc/tavolo 8080:80
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
---
apiVersion: v1
@@ -71,22 +71,32 @@ data:
# CORS_ALLOW_CREDENTIALS: "false"
# CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600"
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set. Requires the otel extra in the image.
# 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"
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
# OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: /
OIDC_POST_LOGOUT_REDIRECT: /
# OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000"
# OTEL_METRICS_POLL_INTERVAL_SECONDS: "15"
# OIDC (the provider lives in another namespace). Each provider is an
# indexed block: OIDC_<n>_PROVIDER names it in the login URL
# (/auth/<provider>/login) and indices must be contiguous from 0. Add
# OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets
# to tavolo-secrets as well.
OIDC_0_PROVIDER: default
OIDC_0_CLIENT_ID: tavolo
OIDC_0_POST_LOGIN_REDIRECT: /
OIDC_0_POST_LOGOUT_REDIRECT: /
# Issuer URL as seen by the browser AND the pods (see notes above).
OIDC_ISSUER: REPLACE_ME
OIDC_0_ISSUER: REPLACE_ME
# Public callback URL of this deployment, e.g.
# http://<host>:<port>/auth/callback (must be allowed at the provider).
OIDC_REDIRECT_URI: REPLACE_ME
# http://<host>:<port>/auth/default/callback (must be allowed at the
# provider). When unset it defaults to
# http://localhost:8080/auth/<provider>/callback.
OIDC_0_REDIRECT_URI: REPLACE_ME
---
apiVersion: v1
@@ -103,8 +113,8 @@ stringData:
# The only Postgres secret: the password for DATABASE_USER at
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
DATABASE_PASSWORD: REPLACE_ME
# Client secret for OIDC_CLIENT_ID at the provider.
OIDC_CLIENT_SECRET: REPLACE_ME
# Client secret for OIDC_0_CLIENT_ID at the provider.
OIDC_0_CLIENT_SECRET: REPLACE_ME
---
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
+13 -8
View File
@@ -103,13 +103,15 @@ services:
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
# By default the app and browsers reach the mock IdP under the same
# name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port.
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
# name (see README /etc/hosts note); override the OIDC_0_* values to
# use a real provider or a different host port. Additional providers
# take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0).
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo}
# The mock OIDC server does not validate clients: any id/secret works.
OIDC_CLIENT_ID: tavolo
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
OIDC_0_CLIENT_ID: tavolo
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback}
REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
@@ -122,8 +124,11 @@ services:
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
# Requires the otel extra in the image (see server/pyproject.toml).
# 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/requirements.txt).
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+40 -6
View File
@@ -13,12 +13,29 @@ DATABASE_OPTIONS=
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
# client id/secret works. For a real IdP like Keycloak, use its values here.)
OIDC_ISSUER=http://localhost:8180/tavolo
OIDC_CLIENT_ID=tavolo
OIDC_CLIENT_SECRET=dev-secret
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
# (required; names the provider in the login URL /auth/<provider>/login and
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
# OIDC_<n>_REDIRECT_URI (defaults to
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
# and OIDC_<n>_POST_LOGOUT_REDIRECT (default /). Indices must be contiguous
# from 0; add a second provider with an OIDC_1_* block (and so on).
#
# The values below target the dev mock-oauth2-server (it does not validate
# clients, so any client id/secret works). For a real IdP like Keycloak,
# use its values and register the redirect URI there.
OIDC_0_PROVIDER=default
OIDC_0_ISSUER=http://localhost:8180/tavolo
OIDC_0_CLIENT_ID=tavolo
OIDC_0_CLIENT_SECRET=dev-secret
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
# A second provider, e.g. Google:
#OIDC_1_PROVIDER=google
#OIDC_1_ISSUER=https://accounts.google.com
#OIDC_1_CLIENT_ID=...
#OIDC_1_CLIENT_SECRET=...
#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback
# Redis (session and live-game storage). Unset to fall back to in-memory
# stores (only sensible for local development with a single worker).
@@ -61,6 +78,23 @@ TURN_TIMEOUT_SECONDS=30
# Seconds browsers may cache the preflight response (default 600):
#CORS_MAX_AGE=600
# 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
#OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer token,X-Tenant=one
# Paths excluded from tracing and metrics (default /api/health).
#OTEL_EXCLUDED_PATHS=/api/health
# Metric export interval (ms, default 60000) and session/game count poll
# interval (seconds, default 15).
#OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# App server
APP_HOST=0.0.0.0
APP_PORT=8080
+17 -4
View File
@@ -29,8 +29,14 @@ WORKDIR /web
# Card images are committed in the repository (CC0 woodcut napoletane deck);
# web/fetch-cards.sh can regenerate them.
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/Trunk.toml ./
COPY web/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 Granian
@@ -55,10 +61,17 @@ RUN --mount=type=cache,target=/root/.cache/pip \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt
COPY server/pyproject.toml server/README.md ./
COPY server/src/ ./src/
# The five sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency — including the tavolo-app `otel` extra — is
# already pinned in requirements.txt.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install .
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-scopa ./packages/tavolo-briscola ./packages/tavolo-app
# server/pyproject.toml only carries the [tool.aerich] section (the
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
# next to ./migrations).
COPY server/pyproject.toml ./
# 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/
+401 -104
View File
@@ -1,14 +1,38 @@
# tavolo
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. The first game implemented is **scopone
scientifico**, the four-player, fixed-partnership variant of the classic
Italian card game.
[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)):
Players authenticate with the configured **OIDC** provider. Live game state
is kept in **Redis** (with real-time play over WebSocket), and completed
matches — every participant and the final score — are persisted to
**Postgres** for history and leaderboard statistics.
- **`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.
## Quick start (Docker Compose)
@@ -31,12 +55,15 @@ Because the browser and the app both talk to the OIDC issuer at
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
```
Then log in: `GET http://localhost:8080/auth/login`, type any username
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
alias that redirects to the first configured provider), type any username
(e.g. `alice`) in the interactive login, and you are redirected back with a
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
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
@@ -53,14 +80,17 @@ All configuration comes from environment variables (see `.env.example`):
| `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 (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_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `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 |
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
| `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 between-hands scoring summary waits for acknowledgements |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
| `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 | 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` |
@@ -69,13 +99,53 @@ 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 and metrics (requires the `otel` extra, i.e. `pip install tavolo[otel]`) |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `pip install tavolo-app[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) |
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
| `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 |
¹ 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
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:
```sh
OIDC_0_PROVIDER=google
OIDC_0_ISSUER=https://accounts.google.com
OIDC_0_CLIENT_ID=...
OIDC_0_CLIENT_SECRET=...
OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback
OIDC_1_PROVIDER=github
OIDC_1_ISSUER=https://github.com/login/oauth
OIDC_1_CLIENT_ID=...
OIDC_1_CLIENT_SECRET=...
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. 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 application logs through the Python stdlib `logging` module, one
@@ -111,88 +181,149 @@ loggers:
level: INFO
```
## OpenTelemetry
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP (kaya-otel plus
`tavolo.telemetry`):
- **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, 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). Note that with
multiple workers or replicas each process reports the same global counts:
aggregate with `max`, not `sum`.
## Data model
### Redis (live games)
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
(`GAME_TTL_SECONDS`).
- `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`).
- `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 state and pushes the
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: turn auto-plays and hand-end auto-continues. Every worker runs
a consumer that fires due entries under the per-game lock, so timeouts
do not depend on any player being connected and survive the death of
any worker (delivery is at-least-once; entries are revalidated 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.
### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `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), both teams' final scores, winner, target score, hands
played, start/finish timestamps.
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, team, whether they won and the Elo change the match produced
(`elo_delta`). 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 result is written transactionally to Postgres
(once, guarded by a flag on the Redis state); the finished state stays in
Redis until its TTL expires so clients can still fetch the final board.
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.
### Elo ratings
Players carry a chess-style Elo rating per game type (`tavolo.elo`):
everyone starts at 1500, a team's rating is the mean of its two 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 both members of a team, zero-sum between teams. Ratings update
in the same transaction as the match result. To recompute every rating
from the recorded match history (e.g. to backfill matches recorded before
ratings existed):
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 \
.venv/bin/python -m tavolo.backfill_elo
.venv/bin/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 card games the platform can host (for the creation dropdown) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `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": 21, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` (target default 11) or `{"game_type": "scopa_a_quattro"}` (target default 21) with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team 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; unauthenticated, unknown-game and non-player
connections are closed with codes `4401`, `4404` and `4403` respectively.
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.
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:
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"]}
@@ -211,7 +342,39 @@ Client → server messages:
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
@@ -222,20 +385,26 @@ is required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected.
match keeps progressing even with every player disconnected. Briscola
chiamata uses the same timeout for its auction turns (an absent bidder
passes; the last player standing with no bid is made to bid the ace) and
for the called-card choice (a random suit of the won rank).
### Hand-end summary
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries
`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**.
@@ -244,34 +413,134 @@ must dismiss. A play attempted in this phase is rejected with an
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
required), plus one point per scopa. Ties award nothing.
- Optional *napola* rule (per-game `napola` flag on `POST /api/games`,
default on): 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, …). A team that captures the whole denari suit (ace to
king) wins the match instantly, regardless of the score.
- The match ends when a team reaches the target score (default 11,
configurable per game) with a clear lead; a tie at or above the target is
- Optional *napola* rule (per-game `napola` creation option, default on):
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, …).
A team that captures the whole denari suit (ace to king) wins the match
instantly, regardless of the score.
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
option, default off): 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 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
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e '.[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/mypy src
# per-package suites (each package is self-contained):
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
# type checks (see packages/tavolo-app/README.md for the app modules):
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
.venv/bin/python -m mypy -p tavolo.scopa
.venv/bin/python -m mypy -p tavolo.briscola
```
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.
`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 aerich migrations in `migrations/`. The
`db-migrate` compose service runs `aerich upgrade` before the app starts.
To add a migration after changing `src/tavolo/models.py`:
To add a migration after changing
`packages/tavolo-platform/src/tavolo/platform/models.py`:
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
@@ -284,31 +553,59 @@ baseline.)
## Layout
Everything lives under `server/`:
```
src/tavolo/
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
├── config.py # env -> frozen Settings
├── auth.py # auth helpers (HTTP + WebSocket)
├── http.py # JSON request/response helpers
├── pagination.py # keyset (cursor) pagination
├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
├── aerich_config.py # aerich CLI configuration
├── models.py # Match, MatchPlayer, PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # finished match -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── store.py # Redis / in-memory live-game store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint
├── game/
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
│ ├── engine.py # pure scopone scientifico rules
│ └── errors.py # typed rule violations
└── routes/
├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot
└── stats.py # match history + leaderboard + Elo ratings
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 # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + games wired together)
```
+4 -3
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
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)
+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,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
.venv/bin/python -m 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/`.
+52
View File
@@ -0,0 +1,52 @@
[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]
dev = [
"mypy",
"httpx-ws",
]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -16,7 +16,7 @@ TORTOISE_ORM = {
"connections": {"default": settings.database_url},
"apps": {
"models": {
"models": ["tavolo.models", "aerich.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
@@ -6,6 +6,7 @@ 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
@@ -39,6 +40,135 @@ def _env_bool(name: str, default: bool = False) -> bool:
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],
@@ -70,15 +200,10 @@ def _database_url_from_parts(engine: str,
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: Optional[str]
oidc_redirect_uri: str
# Where the browser is sent after login/logout. In production the SPA is
# served by this app ("/"); in development point these at the trunk dev
# server (e.g. "http://localhost:8000/").
oidc_post_login_redirect: str
oidc_post_logout_redirect: str
# 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]
@@ -90,15 +215,17 @@ class Settings:
# 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.
# 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).
# 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.
# Locally enqueued deadlines wake the consumer immediately; the
# heartbeat only bounds the discovery delay for deadlines enqueued by
# other workers.
# 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.
@@ -123,6 +250,10 @@ class Settings:
# 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":
@@ -141,12 +272,7 @@ class Settings:
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
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
@@ -157,7 +283,7 @@ class Settings:
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", "1000")),
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
@@ -169,14 +295,20 @@ class Settings:
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
# and metrics via OTLP/HTTP (requires the ``otel`` extra).
# 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")
),
)
@@ -2,7 +2,9 @@
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}``.
``{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.::
@@ -55,6 +57,13 @@ DEFAULT_CONFIG = {
"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",
},
},
}
@@ -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
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
.venv/bin/python -m 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
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m 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,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,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)
@@ -0,0 +1,95 @@
"""Tortoise ORM models: match statistics persisted in Postgres.
Live game state lives in Redis (see :mod:`tavolo.platform.store`); only
completed matches are written here. The schema is game-independent:
* :class:`Match` — one row per finished match; everything game-specific
(scores, hands played, options, per-hand audit trail) lives in the
JSON ``result`` column produced by the game's engine.
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat, an optional team label, whether they won, the
points they scored and their Elo delta.
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
totals (wins, points, latest display name) of a player for one game
type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
"""
from __future__ import annotations
from tortoise import fields
from tortoise.models import Model
from .elo import INITIAL_RATING
class Match(Model):
"""A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True)
# Which game was played (an id from the game registry).
game_type = fields.CharField(max_length=32, db_index=True)
started_at = fields.DatetimeField()
finished_at = fields.DatetimeField()
# Game-specific outcome summary as reported by the engine's
# ``MatchResult.summary`` (scores, hands played, options, ...).
result: dict = fields.JSONField(default=dict)
players: fields.ReverseRelation["MatchPlayer"]
class Meta:
table = "match"
ordering = ["-finished_at"]
class MatchPlayer(Model):
"""Participation of one user in one match."""
id = fields.UUIDField(pk=True)
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
"models.Match", related_name="players", on_delete=fields.CASCADE
)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255, db_index=True)
display_name = fields.CharField(max_length=200)
seat = fields.SmallIntField()
# Game-defined team label; null for games without fixed teams.
team = fields.CharField(max_length=32, null=True)
won = fields.BooleanField()
# Points the player scored, aggregated by the leaderboard.
score = fields.FloatField(default=0)
# Elo change this match produced for the player (see
# tavolo.platform.elo); null for matches recorded before ratings
# existed.
elo_delta = fields.SmallIntField(null=True)
# Game-specific extras reported by the engine's PlayerResult.
details: dict = fields.JSONField(default=dict)
class Meta:
table = "match_player"
unique_together = (("match", "user_sub"),)
class PlayerRating(Model):
"""Current Elo rating and leaderboard totals of one player per game."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Which game the rating applies to (an id from the game registry).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
# Leaderboard aggregates, updated with every finished match.
wins = fields.IntField(default=0)
points = fields.FloatField(default=0)
# Most recent display name seen for the player.
display_name = fields.CharField(max_length=200, default="")
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
# Composite index matching the leaderboard ordering within a game.
indexes = (
("game_type", "rating", "wins", "points", "matches_played", "user_sub"),
)
@@ -0,0 +1,375 @@
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.
Two kinds of fragment live here:
* parameter lists (``PAGINATION_PARAMETERS``, ``PAGE_PARAMETERS``), used
directly in the route decorators, and
* reusable response schemas, registered as ``components.schemas`` by the
application (and the test fixtures) through ``SCHEMAS`` and referenced
from the routes with :func:`kaya.openapi.schema_ref`.
Engine-defined payload parts (lobby options echo, game views, match
results) are intentionally typed as open objects: the platform is
game-agnostic, so their exact shape belongs to each engine, not to this
document.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence
from kaya.openapi import schema_ref
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
{
"name": "cursor",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Opaque pagination cursor from a previous response's next_cursor.",
},
]
# Page-number pagination (used by the leaderboard, which supports jumping
# to a specific page).
PAGE_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "page",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "default": 1},
"description": "1-based page number (clamped to the last page).",
},
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
]
# ---------------------------------------------------------------------------
# Schema builders
# ---------------------------------------------------------------------------
def _object(
properties: Dict[str, Any],
required: Sequence[str] = (),
*,
description: Optional[str] = None,
additional_properties: bool = False,
) -> Dict[str, Any]:
"""An object schema with the given properties (JSON Schema 2020-12)."""
schema: Dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = list(required)
if description is not None:
schema["description"] = description
if additional_properties:
schema["additionalProperties"] = True
return schema
def _free_object(description: Optional[str] = None) -> Dict[str, Any]:
"""An open object: used for engine-specific payload parts."""
schema: Dict[str, Any] = {"type": "object", "additionalProperties": True}
if description is not None:
schema["description"] = description
return schema
def _array(items: Dict[str, Any]) -> Dict[str, Any]:
return {"type": "array", "items": items}
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
nullable = dict(schema)
kind = nullable.get("type")
if isinstance(kind, str):
nullable["type"] = [kind, "null"]
return nullable
def json_response(description: str, schema: Dict[str, Any]) -> Dict[str, Any]:
"""A JSON response object carrying ``schema``, for ``@operation``."""
return {
"description": description,
"content": {"application/json": {"schema": schema}},
}
def error_response(description: str) -> Dict[str, Any]:
"""The standard JSON error envelope (``{"error": <message>}``)."""
return json_response(description, schema_ref("Error"))
# ---------------------------------------------------------------------------
# Component schemas
# ---------------------------------------------------------------------------
ERROR_SCHEMA = _object({"error": {"type": "string"}}, ("error",))
HEALTH_SCHEMA = _object(
{"status": {"type": "string", "enum": ["ok"]}}, ("status",)
)
ME_SCHEMA = _object(
{
"sub": {"type": "string"},
"name": {"type": "string"},
"picture": _nullable({"type": "string"}),
},
("sub", "name", "picture"),
)
AUTH_PROVIDER_SCHEMA = _object(
{
"name": {"type": "string"},
"login_url": {"type": "string"},
},
("name", "login_url"),
)
AUTH_PROVIDERS_PAGE_SCHEMA = _object(
{"providers": _array(schema_ref("AuthProvider"))}, ("providers",)
)
GAME_TYPE_SCHEMA = _object(
{
"id": {"type": "string"},
"name": {"type": "string"},
"description": {"type": "string"},
"min_players": {"type": "integer"},
"max_players": {"type": "integer"},
"options_schema": _free_object(
"JSON-schema fragment describing the game-specific creation "
"options accepted by POST /api/games."
),
},
("id", "name", "description", "min_players", "max_players", "options_schema"),
)
GAME_TYPES_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("GameType"))}, ("results",)
)
LOBBY_PLAYER_SCHEMA = _object(
{
"sub": {"type": "string"},
"name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({
"type": "string",
"description": "Game-defined team label; null for games without fixed teams.",
}),
},
("sub", "name", "seat", "team"),
)
_LOBBY_PROPERTIES: Dict[str, Any] = {
"id": {"type": "string"},
"join_code": {"type": "string"},
"game_type": {"type": "string"},
"players": _array(schema_ref("LobbyPlayer")),
"seats_open": {"type": "integer"},
}
_LOBBY_REQUIRED = ("id", "join_code", "game_type", "players", "seats_open")
LOBBY_SCHEMA = _object(
dict(_LOBBY_PROPERTIES),
_LOBBY_REQUIRED,
description="The lobby payload: the platform envelope plus the engine's "
"lobby_view fields (the creation-options echo and the phase).",
additional_properties=True,
)
GAME_PREVIEW_SCHEMA = _object(
{
**_LOBBY_PROPERTIES,
"game_name": {"type": "string"},
"game_description": {"type": "string"},
},
(*_LOBBY_REQUIRED, "game_name", "game_description"),
description="The join-code preview: the lobby payload plus the game's "
"display name and description.",
additional_properties=True,
)
GAME_VIEW_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"spectator": {"type": "boolean"},
"seats_open": {"type": "integer"},
"join_code": {
"type": "string",
"description": "The join code is a credential: present only in a "
"seated player's view, never to spectators.",
},
},
("id", "game_type", "spectator", "seats_open"),
description="A game snapshot: the platform envelope plus the engine's "
"view_for fields. Seated players get their personalized view "
"(own hand, join code); everyone else the read-only spectator "
"view (no hands, no join code).",
additional_properties=True,
)
ONGOING_GAME_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"players": _array(schema_ref("LobbyPlayer")),
"seats_open": {"type": "integer"},
"game_name": {"type": "string"},
"game_description": {"type": "string"},
"your_seat": _nullable({"type": "integer"}),
"created_at": _nullable({"type": "string", "format": "date-time"}),
},
(
"id",
"game_type",
"players",
"seats_open",
"game_name",
"game_description",
"your_seat",
"created_at",
),
description="One entry of the caller's ongoing-games list: the lobby "
"payload without the join code, plus the game name and the "
"caller's seat.",
additional_properties=True,
)
ONGOING_GAMES_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("OngoingGame"))}, ("results",)
)
MATCH_PLAYER_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"display_name": {"type": "string"},
"seat": {"type": "integer"},
"team": _nullable({"type": "string"}),
"won": {"type": "boolean"},
"score": {"type": "number"},
"elo_delta": _nullable({"type": "integer"}),
"details": _nullable(_free_object("Game-specific extras reported by the engine.")),
},
(
"user_sub",
"display_name",
"seat",
"team",
"won",
"score",
"elo_delta",
"details",
),
)
MATCH_SCHEMA = _object(
{
"id": {"type": "string"},
"game_type": {"type": "string"},
"result": _nullable(_free_object(
"The game-specific outcome reported by the engine."
)),
"started_at": {"type": "string", "format": "date-time"},
"finished_at": {"type": "string", "format": "date-time"},
"you_won": {"type": "boolean"},
"your_elo_delta": _nullable({"type": "integer"}),
"players": _array(schema_ref("MatchPlayer")),
},
(
"id",
"game_type",
"result",
"started_at",
"finished_at",
"you_won",
"your_elo_delta",
"players",
),
)
MATCHES_PAGE_SCHEMA = _object(
{
"results": _array(schema_ref("Match")),
"next_cursor": _nullable({"type": "string"}),
},
("results", "next_cursor"),
)
LEADERBOARD_ENTRY_SCHEMA = _object(
{
"user_sub": {"type": "string"},
"display_name": {"type": "string"},
"elo": {"type": "integer"},
"matches": {"type": "integer"},
"wins": {"type": "integer"},
"points": {"type": "number"},
},
("user_sub", "display_name", "elo", "matches", "wins", "points"),
)
LEADERBOARD_PAGE_SCHEMA = _object(
{
"results": _array(schema_ref("LeaderboardEntry")),
"page": {"type": "integer"},
"pages": {"type": "integer"},
"total": {"type": "integer"},
"your_rank": _nullable({"type": "integer"}),
"your_page": _nullable({"type": "integer"}),
},
("results", "page", "pages", "total", "your_rank", "your_page"),
)
RATING_SCHEMA = _object(
{
"game_type": {"type": "string"},
"rating": {"type": "integer"},
"matches_played": {"type": "integer"},
},
("game_type", "rating", "matches_played"),
)
RATINGS_PAGE_SCHEMA = _object(
{"results": _array(schema_ref("Rating"))}, ("results",)
)
#: Component name -> schema, passed to
#: ``OpenAPIMixin(components={"schemas": SCHEMAS})``.
SCHEMAS: Dict[str, Dict[str, Any]] = {
"Error": ERROR_SCHEMA,
"Health": HEALTH_SCHEMA,
"Me": ME_SCHEMA,
"AuthProvider": AUTH_PROVIDER_SCHEMA,
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
"GameType": GAME_TYPE_SCHEMA,
"GameTypesPage": GAME_TYPES_PAGE_SCHEMA,
"LobbyPlayer": LOBBY_PLAYER_SCHEMA,
"Lobby": LOBBY_SCHEMA,
"GamePreview": GAME_PREVIEW_SCHEMA,
"GameView": GAME_VIEW_SCHEMA,
"OngoingGame": ONGOING_GAME_SCHEMA,
"OngoingGamesPage": ONGOING_GAMES_PAGE_SCHEMA,
"MatchPlayer": MATCH_PLAYER_SCHEMA,
"Match": MATCH_SCHEMA,
"MatchesPage": MATCHES_PAGE_SCHEMA,
"LeaderboardEntry": LEADERBOARD_ENTRY_SCHEMA,
"LeaderboardPage": LEADERBOARD_PAGE_SCHEMA,
"Rating": RATING_SCHEMA,
"RatingsPage": RATINGS_PAGE_SCHEMA,
}
@@ -15,24 +15,32 @@ from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from tortoise.queryset import QuerySet
from .http import extract_query_params
if TYPE_CHECKING:
from tortoise.models import Model
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MIN_LIMIT = 1
CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit"
PAGE_PARAM = "page"
class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded."""
class PageParamsError(ValueError):
"""Raised when the ``page``/``limit`` query params are invalid."""
def encode_cursor(values: Dict[str, Any]) -> str:
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
# datetime-typed sort fields (e.g. finished_at for match history).
@@ -76,11 +84,63 @@ def parse_cursor_params(query_string: str) -> Cursor:
return Cursor(limit=limit, after=after)
def parse_page_params(query_string: str) -> Tuple[int, int]:
"""Parse the ``page``/``limit`` params: a 1-based page and a page size.
``limit`` is clamped to [1, 100] (default 20); ``page`` must be a
positive integer (default 1). Clamping ``page`` to the last existing
page is left to the endpoint, which knows how many pages there are.
"""
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise PageParamsError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
page_raw = params.get(PAGE_PARAM)
if page_raw:
try:
page = int(page_raw[0])
except ValueError as exc:
raise PageParamsError("invalid page") from exc
else:
page = 1
if page < 1:
raise PageParamsError("invalid page")
return page, limit
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]]
def _coerce_sort_values(
model: "type[Model]", sort: Sort, after: Dict[str, Any]
) -> Dict[str, Any]:
"""Restore the Python type of every decoded cursor value.
The cursor is JSON, so field values travel as strings (datetimes via
``default=str``). asyncpg rejects strings where a ``timestamp``
parameter is expected, and sqlite only tolerates them by accident, so
each value is run through its model field's ``to_python_value`` before
it reaches the keyset filter.
"""
fields = model._meta.fields_map
coerced: Dict[str, Any] = {}
for field, _ in sort:
value = after.get(field)
field_obj = fields.get(field)
if value is not None and field_obj is not None:
value = field_obj.to_python_value(value)
coerced[field] = value
return coerced
def _keyset_where(sort: Sort, after: Dict[str, Any]):
"""Build a Tortoise ``Q`` filter from a cursor."""
from tortoise.queryset import Q # local to keep import edge narrow
@@ -116,7 +176,8 @@ async def paginate(
order_by.append(field if direction == "ASC" else f"-{field}")
qs = queryset.order_by(*order_by)
if cursor.after:
qs = qs.filter(_keyset_where(sort, cursor.after))
after = _coerce_sort_values(queryset.model, sort, cursor.after)
qs = qs.filter(_keyset_where(sort, after))
rows = await qs.limit(cursor.limit + 1)
if len(rows) <= cursor.limit:
return rows, None
@@ -0,0 +1,60 @@
"""Registry of the games the platform can host.
The registry maps a ``game_type`` id to its
:class:`~tavolo.platform.engine.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 (see the application entry point); the platform itself
ships no game.
"""
from __future__ import annotations
from typing import Dict, Iterable, List, Optional
from .engine import GameEngine
from .errors import GameError
class UnknownGameType(GameError):
"""A request named a ``game_type`` no registered engine provides."""
code = "unknown_game_type"
class GameRegistry:
"""An ordered collection of game engines, keyed by their id."""
def __init__(self, engines: Iterable[GameEngine] = ()) -> None:
self._engines: Dict[str, GameEngine] = {}
for engine in engines:
self.register(engine)
def register(self, engine: GameEngine) -> GameEngine:
"""Add ``engine``; the first registered becomes the default."""
if engine.id in self._engines:
raise ValueError(f"duplicate game engine id: {engine.id!r}")
self._engines[engine.id] = engine
return engine
def get(self, game_type: str) -> Optional[GameEngine]:
"""Return the engine for ``game_type``, or ``None``."""
return self._engines.get(game_type)
def require(self, game_type: str) -> GameEngine:
"""Return the engine for ``game_type`` or raise."""
engine = self.get(game_type)
if engine is None:
raise UnknownGameType(f"unknown game_type: {game_type!r}")
return engine
@property
def default(self) -> GameEngine:
"""The first registered engine, used when no type is requested."""
try:
return next(iter(self._engines.values()))
except StopIteration:
raise RuntimeError("no game engines registered") from None
def all(self) -> List[GameEngine]:
return list(self._engines.values())
@@ -0,0 +1 @@
"""HTTP endpoints of the platform: health, identity, lobby, statistics."""
@@ -0,0 +1,64 @@
"""Authentication plumbing for the single-page app.
kaya-oidc registers one login/callback/logout route per provider
(``/auth/<provider>/...``); 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), so the UI can keep a
single logout link.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation, schema_ref
from ..http import send_json
from ..openapi import json_response
if TYPE_CHECKING:
from ..mixin import Platform
def _login_url(platform: "Platform", provider: str) -> str:
return f"{platform.oidc.base_path}/{provider}/login"
def register(app: KayaApp, platform: "Platform") -> None:
providers = list(platform.oidc.providers)
@app.GET("/api/auth/providers")
@operation(summary="Configured login providers",
description="Names and login URLs of the configured OIDC "
"providers, in configuration order.",
tags=["auth"],
responses={200: json_response("The configured providers",
schema_ref("AuthProvidersPage"))})
async def auth_providers(ctx: HttpContext) -> None:
await send_json(
ctx,
200,
{
"providers": [
{"name": name, "login_url": _login_url(platform, name)}
for name in providers
]
},
)
@app.GET("/auth/login")
async def login(ctx: HttpContext) -> None:
await ctx.send_empty(302, {"Location": _login_url(platform, providers[0])})
@app.GET("/auth/logout")
async def logout(ctx: HttpContext) -> None:
logged_in = platform.oidc.logged_in_providers(ctx)
location = (
f"{platform.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/"
)
await ctx.send_empty(302, {"Location": location})
@@ -0,0 +1,405 @@
"""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
(see :mod:`tavolo.platform.ws`); 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``.
"""
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation, schema_ref
from ..auth import current_user, display_name, require_auth
from ..engine import GameSession, Seat
from ..errors import GameError
from ..http import (
JsonRequestError,
extract_query_params,
read_json,
read_json_optional,
send_error,
send_json,
)
from ..openapi import error_response, json_response
if TYPE_CHECKING:
from ..mixin import Platform
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
_MAX_CODE_ATTEMPTS = 20
def _now_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
async def _unique_code(platform: "Platform") -> str:
for _ in range(_MAX_CODE_ATTEMPTS):
code = _now_code()
if await platform.game_store.find_by_code(code) is None:
return code
raise RuntimeError("could not allocate a unique join code")
def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
"players": [
{
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
}
for index, seat in enumerate(session.players)
],
"seats_open": max(0, engine.max_players - len(session.players)),
**engine.lobby_view(session),
}
def _view_payload(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
payload["join_code"] = session.join_code
payload.update(engine.view_for(session, sub))
return payload
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
"""The join-code preview: the lobby payload plus the game's name and
description, so a client can render a pre-join summary without a
second lookup."""
engine = platform.registry.require(session.game_type)
return {
**_lobby_payload(platform, session),
"game_name": engine.name,
"game_description": engine.description,
}
def _ongoing_game_payload(
platform: "Platform", session: GameSession, sub: str
) -> Dict[str, Any]:
"""One entry of the caller's ongoing-games list.
Reuses the lobby payload (players, open seats, option echo) but drops
the join code — the Continue link only needs the id, and the game page
shows the code to its players anyway.
"""
engine = platform.registry.require(session.game_type)
payload = _lobby_payload(platform, session)
payload.pop("join_code", None)
payload["game_name"] = engine.name
payload["game_description"] = engine.description
payload["your_seat"] = next(
(
index
for index, seat in enumerate(session.players)
if seat.user_sub == sub
),
None,
)
payload["created_at"] = (
session.created_at.isoformat() if session.created_at else None
)
return payload
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/game-types")
@operation(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: json_response("The available game types",
schema_ref("GameTypesPage"))})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{
"id": engine.id,
"name": engine.name,
"description": engine.description,
"min_players": engine.min_players,
"max_players": engine.max_players,
"options_schema": engine.options_schema,
}
for engine in platform.registry.all()
]
})
@app.POST("/api/games")
@operation(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"],
request_body={
"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: json_response("The created lobby", schema_ref("Lobby")),
400: error_response("Invalid game_type, options or body"),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def create_game(ctx: HttpContext) -> None:
body: dict = {}
try:
body = await read_json_optional(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
game_type: Any = body.get("game_type")
if game_type is None:
engine = platform.registry.default
elif isinstance(game_type, str) and platform.registry.get(game_type) is not None:
engine = platform.registry.require(game_type)
else:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
options: Any = body.get("options", {})
if not isinstance(options, dict):
await send_error(ctx, 400, "options must be an object")
return
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
session = GameSession(
id=str(uuid.uuid4()),
game_type=engine.id,
join_code=await _unique_code(platform),
creator_sub=user.sub,
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
created_at=datetime.now(timezone.utc),
)
try:
engine.create(session, options)
except GameError as exc:
await send_error(ctx, 400, str(exc))
return
await platform.game_store.save(session)
log.info(
"game %s created by %s (%s, options %r)",
session.id,
user.sub,
engine.id,
options,
)
await send_json(ctx, 201, _lobby_payload(platform, session))
@app.POST("/api/games/join")
@operation(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"],
request_body={
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
},
},
responses={
200: json_response(
"Seated: the lobby, or the personalized game state "
"when the join started the match",
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]},
),
400: error_response("Missing code"),
401: error_response("Authentication required"),
404: error_response("Unknown join code"),
409: error_response("Already joined or lobby full"),
})
@require_auth(platform.oidc)
async def join_game(ctx: HttpContext) -> None:
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
code = body.get("code")
if not isinstance(code, str) or not code:
await send_error(ctx, 400, "code is required")
return
user = current_user(platform.oidc, ctx)
assert user is not None
existing = await platform.game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code")
return
async with platform.game_store.lock(existing.id):
session = await platform.game_store.load(existing.id)
if session is None:
await send_error(ctx, 404, "unknown join code")
return
engine = platform.registry.require(session.game_type)
try:
engine.join(session, user.sub, display_name(user))
except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
await send_error(ctx, 409, str(exc))
return
await platform.game_store.save(session)
await platform.game_store.publish(session.id)
# When the last join started the match, the engine may have
# armed a deadline; queue it so it fires even if nobody ever
# connects.
await platform.scheduler.sync_deadline(session)
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
if engine.in_lobby(session):
log.info(
"%s joined game %s (seat %d, %d/%d players)",
user.sub, session.id, seat, len(session.players), engine.max_players,
)
await send_json(ctx, 200, _lobby_payload(platform, session))
return
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@app.GET("/api/games/preview")
@operation(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: json_response("The lobby preview",
schema_ref("GamePreview")),
400: error_response("Missing code"),
401: error_response("Authentication required"),
404: error_response("Unknown join code"),
})
@require_auth(platform.oidc)
async def preview_game(ctx: HttpContext) -> None:
values = extract_query_params(ctx.query_string).get("code")
code = values[0].strip() if values else ""
if not code:
await send_error(ctx, 400, "code is required")
return
session = await platform.game_store.find_by_code(code)
if session is None:
await send_error(ctx, 404, "unknown join code")
return
await send_json(ctx, 200, _preview_payload(platform, session))
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Anyone who knows the game id may read a game. "
"Seated players get the personalized view (their "
"own hand plus the join code); everyone else — "
"anonymous users and authenticated non-players — "
"gets the read-only spectator view: no hands, no "
"join code, just what is public at the table.",
tags=["games"],
responses={
200: json_response(
"The personalized (player) or read-only (spectator) game state",
schema_ref("GameView"),
),
404: error_response("Game not found"),
})
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
if session is None:
await send_error(ctx, 404, "game not found")
return
user = current_user(platform.oidc, ctx)
await send_json(
ctx, 200, _view_payload(platform, session, user.sub if user else None)
)
@app.GET("/api/me/games")
@operation(summary="List my ongoing games",
description="Live games the caller is seated in — waiting "
"lobbies and matches in progress or between "
"hands — newest first, for the lobby's Continue "
"list. Finished matches are not included.",
tags=["games"],
responses={
200: json_response("The caller's ongoing games",
schema_ref("OngoingGamesPage")),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def my_games(ctx: HttpContext) -> None:
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
sessions = await platform.game_store.list_ongoing()
await send_json(ctx, 200, {
"results": [
_ongoing_game_payload(platform, session, user.sub)
for session in sessions
if session.seated(user.sub)
]
})
@@ -0,0 +1,25 @@
"""Liveness probe."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation, schema_ref
from ..openapi import json_response
if TYPE_CHECKING:
from ..mixin import Platform
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/health")
@operation(summary="Health check",
tags=["health"],
responses={200: json_response("The service is up", schema_ref("Health"))})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(
200,
b'{"status":"ok"}',
{"content-type": ("application/json",)},
)
@@ -0,0 +1,41 @@
"""Whoami endpoint: lets the single-page app detect the login state."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation, schema_ref
from ..auth import current_user, display_name, picture, require_auth
from ..http import send_json
from ..openapi import error_response, json_response
if TYPE_CHECKING:
from ..mixin import Platform
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me")
@operation(summary="Current user",
description="Returns the authenticated user's identity from the "
"session, including the profile picture URL when "
"the provider supplies one; 401 when not logged "
"in.",
tags=["auth"],
responses={
200: json_response("The current user", schema_ref("Me")),
401: error_response("Not logged in"),
})
@require_auth(platform.oidc)
async def me(ctx: HttpContext) -> None:
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
await send_json(
ctx,
200,
{
"sub": user.sub,
"name": display_name(user),
"picture": picture(user),
},
)
@@ -0,0 +1,289 @@
"""Player statistics endpoints, served from Postgres.
Every finished match is persisted by
:func:`tavolo.platform.stats.save_match_result`. These endpoints expose a
player's own match history and a global leaderboard aggregated from the
same two tables. The game-specific outcome of each match is exposed
verbatim through the ``result`` JSON column.
"""
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from uuid import UUID
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation, schema_ref
from ..auth import current_user, require_auth
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating
from ..openapi import (
PAGE_PARAMETERS,
PAGINATION_PARAMETERS,
error_response,
json_response,
)
from ..pagination import (
CursorDecodeError,
PageParamsError,
paginate,
parse_cursor_params,
parse_page_params,
)
from tortoise.expressions import Q
if TYPE_CHECKING:
from ..mixin import Platform
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
LEADERBOARD_FOCUS_PARAMETER: Dict[str, Any] = {
"name": "focus",
"in": "query",
"required": False,
"schema": {"type": "string", "enum": ["me"]},
"description": "Set to 'me' to return the page containing the caller's "
"own ranking (page 1 for unrated or anonymous callers).",
}
def _parse_game_type(
platform: "Platform", query_string: str
) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if platform.registry.get(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
def _ranks_before(rating: PlayerRating) -> Q:
"""Keyset condition selecting the rows ranked above ``rating``.
Mirrors the leaderboard ordering ``-rating, -wins, -points,
matches_played, user_sub``: exactly the rows sorting before the given
one.
"""
return (
Q(rating__gt=rating.rating)
| Q(rating=rating.rating, wins__gt=rating.wins)
| Q(rating=rating.rating, wins=rating.wins, points__gt=rating.points)
| Q(
rating=rating.rating,
wins=rating.wins,
points=rating.points,
matches_played__lt=rating.matches_played,
)
| Q(
rating=rating.rating,
wins=rating.wins,
points=rating.points,
matches_played=rating.matches_played,
user_sub__lt=rating.user_sub,
)
)
def _serialize_match(
match: Match, viewer: str, participants: List[MatchPlayer]
) -> Dict[str, Any]:
return {
"id": str(match.id),
"game_type": match.game_type,
# The engine's MatchResult.summary: for scopone, the teams' final
# scores, the winner, the target score and the per-hand audit.
"result": match.result,
"started_at": match.started_at.isoformat(),
"finished_at": match.finished_at.isoformat(),
"you_won": any(p.user_sub == viewer and p.won for p in participants),
"your_elo_delta": next(
(p.elo_delta for p in participants if p.user_sub == viewer), None
),
"players": [
{
"user_sub": p.user_sub,
"display_name": p.display_name,
"seat": p.seat,
"team": p.team,
"won": p.won,
"score": p.score,
"elo_delta": p.elo_delta,
"details": p.details,
}
for p in participants
],
}
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me/matches")
@operation(summary="List my matches",
description="Cursor-paginated history of finished matches the "
"caller played, newest first, with the result.",
tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={
200: json_response("A page of matches",
schema_ref("MatchesPage")),
400: error_response("Invalid pagination cursor or game_type"),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def my_matches(ctx: HttpContext) -> None:
try:
cursor = parse_cursor_params(ctx.query_string)
except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc))
return
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = current_user(platform.oidc, ctx)
assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
)
# One participants query for the whole page instead of one per
# match; the grouping keeps each match's players seat-ordered.
by_match: Dict[UUID, List[MatchPlayer]] = defaultdict(list)
if matches:
participants = (
await MatchPlayer.filter(match_id__in=[m.id for m in matches])
.order_by("match_id", "seat")
)
for player in participants:
# Tortoise sets the FK's raw id on instances but does not
# declare it on the model class, hence the getattr.
by_match[getattr(player, "match_id")].append(player)
results = [
_serialize_match(m, user.sub, by_match.get(m.id, [])) for m in matches
]
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
@app.GET("/api/leaderboard")
@operation(summary="Global leaderboard",
description="Elo rating, wins, matches played and points for "
"every rated player of a game, sorted by Elo (the "
"requested game_type, or the default game when "
"the filter is absent). Page-number paginated; "
"the response carries the caller's own rank when "
"authenticated.",
tags=["stats"],
parameters=[
GAME_TYPE_PARAMETER,
*PAGE_PARAMETERS,
LEADERBOARD_FOCUS_PARAMETER,
],
responses={
200: json_response("A page of the leaderboard",
schema_ref("LeaderboardPage")),
400: error_response("Unknown game_type or invalid parameters"),
})
async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
try:
page, limit = parse_page_params(ctx.query_string)
except PageParamsError as exc:
await send_error(ctx, 400, str(exc))
return
focus_values = extract_query_params(ctx.query_string).get("focus")
focus = focus_values[0] if focus_values else None
if focus is not None and focus != "me":
await send_error(ctx, 400, "invalid focus")
return
# The board is the requested game's, or the default game's when the
# filter is absent (ratings are per game type).
rating_game = game_type or platform.registry.default.id
board = PlayerRating.filter(game_type=rating_game)
total = await board.count()
pages = (total + limit - 1) // limit
current = min(page, pages) if pages else 1
# The caller's own ranking, when authenticated and rated: ranks
# above theirs plus one. `focus=me` jumps to their page.
user = current_user(platform.oidc, ctx)
yours: Optional[PlayerRating] = None
if user is not None:
yours = await PlayerRating.get_or_none(
user_sub=user.sub, game_type=rating_game
)
your_rank: Optional[int] = None
your_page: Optional[int] = None
if yours is not None:
your_rank = 1 + await board.filter(_ranks_before(yours)).count()
your_page = (your_rank - 1) // limit + 1
if focus == "me":
current = your_page
rows = await (
board.order_by(
"-rating", "-wins", "-points", "matches_played", "user_sub"
)
.offset((current - 1) * limit)
.limit(limit)
)
await send_json(ctx, 200, {
"results": [
{
"user_sub": row.user_sub,
"display_name": row.display_name or row.user_sub,
"matches": row.matches_played,
"wins": row.wins,
"points": row.points,
"elo": row.rating,
}
for row in rows
],
"page": current,
"pages": pages,
"total": total,
"your_rank": your_rank,
"your_page": your_page,
})
@app.GET("/api/me/ratings")
@operation(summary="My Elo ratings",
description="The caller's current Elo rating for every game "
"type they have played.",
tags=["stats"],
responses={
200: json_response("The caller's ratings",
schema_ref("RatingsPage")),
401: error_response("Authentication required"),
})
@require_auth(platform.oidc)
async def my_ratings(ctx: HttpContext) -> None:
user = current_user(platform.oidc, ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
await send_json(ctx, 200, {
"results": [
{
"game_type": row.game_type,
"rating": row.rating,
"matches_played": row.matches_played,
}
for row in rows
]
})
@@ -0,0 +1,145 @@
"""Copy finished match results from the live store into Postgres.
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 and leaderboard aggregates
(see :mod:`tavolo.platform.elo`).
This module is game-independent: everything it persists comes from the
engine's :class:`~tavolo.platform.engine.MatchResult`.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from logging import getLogger
from typing import Dict, List, Sequence
from tortoise.transactions import in_transaction
from .elo import match_delta
from .engine import GameEngine, GameSession
log = getLogger(__name__)
@dataclass(frozen=True)
class PlayerStats:
"""One participant's data feeding a ``player_rating`` row update."""
user_sub: str
won: bool
score: float
display_name: str
async def apply_match_stats(
game_type: str,
teams: List[List[str]],
winner_team: int,
players: Sequence[PlayerStats],
) -> Dict[str, int]:
"""Update the ratings and leaderboard aggregates of ``teams``.
``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Alongside the Elo delta the
transaction records each participant's wins, accumulated points and
latest display name, so the leaderboard is a single-table query.
Returns the per-player delta. Must be called inside a transaction.
"""
if len(teams) != 2:
raise ValueError("Elo rating requires exactly two teams")
from .models import PlayerRating
stats = {player.user_sub: player for player in players}
ratings: Dict[str, "PlayerRating"] = {}
for subs in teams:
for sub in subs:
rating = await PlayerRating.get_or_none(
user_sub=sub, game_type=game_type
)
if rating is None:
rating = await PlayerRating.create(
id=uuid.uuid4(), user_sub=sub, game_type=game_type
)
ratings[sub] = rating
delta_a = match_delta(
[ratings[sub].rating for sub in teams[0]],
[ratings[sub].rating for sub in teams[1]],
winner_team,
)
deltas: Dict[str, int] = {
**{sub: delta_a for sub in teams[0]},
**{sub: -delta_a for sub in teams[1]},
}
for sub, delta in deltas.items():
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
player = stats.get(sub)
if player is not None:
if player.won:
rating.wins += 1
rating.points += player.score
if player.display_name:
rating.display_name = player.display_name
await rating.save()
return deltas
async def save_match_result(session: GameSession, engine: GameEngine) -> None:
"""Persist ``session`` to Postgres if it is finished and not yet saved."""
if session.stats_saved or not engine.is_finished(session):
return
result = engine.result(session)
from .models import Match, MatchPlayer
started_at = session.created_at or datetime.now(timezone.utc)
finished_at = session.finished_at or datetime.now(timezone.utc)
display_names = {seat.user_sub: seat.display_name for seat in session.players}
player_stats = [
PlayerStats(
user_sub=player.user_sub,
won=player.won,
score=player.score,
display_name=display_names.get(player.user_sub, player.user_sub),
)
for player in result.players
]
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
game_type=session.game_type,
started_at=started_at,
finished_at=finished_at,
result=result.summary,
)
deltas = await apply_match_stats(
session.game_type, result.teams, result.winner_team, player_stats
)
for player in result.players:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=player.user_sub,
display_name=display_names.get(player.user_sub, player.user_sub),
seat=player.seat,
team=player.team,
won=player.won,
score=player.score,
elo_delta=deltas[player.user_sub],
details=player.details,
)
session.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %d of %d won",
session.id,
session.game_type,
result.winner_team,
len(result.teams),
)
@@ -0,0 +1,454 @@
"""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 :mod:`tavolo.platform.models`); Redis keeps serving the
finished session until it expires.
Two implementations satisfy the same interface:
* :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set.
* :class:`InMemoryGameStore` — tests and ephemeral dev, used otherwise.
The store is game-agnostic: a session is serialized as a platform-owned
envelope (id, join code, seats, timestamps) plus an opaque ``state``
blob produced by the game's engine (resolved through the
:class:`~tavolo.platform.registry.GameRegistry` both stores are
constructed with).
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. Publishing only a
signal (never the state) means updated state reaches connections on
every worker without leaking hidden information into the channel.
Timeouts are driven by a shared delayed-deadline queue: producers
enqueue an opaque ``member`` string with a due timestamp and announce it
on a shared "deadline enqueued" channel, and a consumer on every worker
wakes on that signal to process due entries (see
:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries
are removed only after they are processed — so a worker dying
mid-processing cannot lose a deadline; engines revalidate entries
against the live state, which makes duplicate deliveries harmless.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import time
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
from redis.asyncio import Redis
from .engine import GameSession, Seat
from .registry import GameRegistry
log = getLogger(__name__)
GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "tavolo:game:"
DEADLINES_KEY = "tavolo:deadlines"
# Channel announcing newly enqueued deadline entries, so every worker's
# consumer can wake immediately instead of polling on its heartbeat.
DEADLINES_CHANNEL = "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.
ONGOING_GAMES_KEY = "tavolo:games:ongoing"
# Sentinel pushed into in-memory subscriber queues to signal a change.
_BUMP = b"update"
def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
return value.isoformat() if value is not None else None
def _created_at_key(session: GameSession) -> datetime:
"""Sort key for :meth:`GameStore.list_ongoing` (newest first).
Sessions without a creation timestamp sort last; the epoch sentinel is
timezone-aware so it never mixes with the stored UTC datetimes.
"""
return session.created_at or datetime.min.replace(tzinfo=timezone.utc)
def _dt_from_json(value: Any) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def session_to_json(session: GameSession, registry: GameRegistry) -> Dict[str, Any]:
"""Serialize a session: platform envelope plus the engine's state blob."""
engine = registry.require(session.game_type)
return {
"id": session.id,
"game_type": session.game_type,
"join_code": session.join_code,
"creator_sub": session.creator_sub,
"players": [seat.to_json() for seat in session.players],
"created_at": _dt_to_json(session.created_at),
"finished_at": _dt_to_json(session.finished_at),
"stats_saved": session.stats_saved,
"state": engine.state_to_json(session.state),
}
def session_from_json(data: Mapping[str, Any], registry: GameRegistry) -> GameSession:
"""Rebuild a session, delegating the state blob to its game engine."""
game_type = str(data["game_type"])
engine = registry.require(game_type)
return GameSession(
id=str(data["id"]),
game_type=game_type,
join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")),
players=[Seat.from_json(p) for p in data.get("players", [])],
created_at=_dt_from_json(data.get("created_at")),
finished_at=_dt_from_json(data.get("finished_at")),
stats_saved=bool(data.get("stats_saved", False)),
state=engine.state_from_json(data.get("state") or {}),
)
class GameStore(ABC):
"""Abstract persistence + notification layer for live games."""
@abstractmethod
async def load(self, game_id: str) -> Optional[GameSession]:
"""Return the live session for ``game_id`` or ``None``."""
@abstractmethod
async def save(self, session: GameSession) -> None:
"""Persist ``session``, refreshing its TTL and code index."""
@abstractmethod
async def find_by_code(self, code: str) -> Optional[GameSession]:
"""Return the live session for a join ``code`` or ``None``."""
@abstractmethod
async def count_ongoing(self) -> int:
"""Return the number of live games that have not finished yet.
Lobbies and matches in progress count; matches that are over but
still retained for the final-board view do not.
"""
@abstractmethod
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
"""Return up to ``limit`` live games that have not finished yet.
Newest ``created_at`` first. Sessions whose key has expired are
skipped, and so are matches that are over but still retained for
the final-board view.
"""
@abstractmethod
def lock(self, game_id: str) -> AsyncContextManager[None]:
"""Async context manager serializing mutations of one game."""
@abstractmethod
def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]:
"""Async context manager yielding an async iterator of change signals."""
@abstractmethod
async def publish(self, game_id: str) -> None:
"""Signal that the session of ``game_id`` changed."""
@abstractmethod
async def add_deadline(self, member: str, due_at: float) -> None:
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
Idempotent for identical members: re-adding an existing member only
updates its due time.
"""
@abstractmethod
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
"""Return up to ``limit`` enqueued members due at or before ``now``."""
@abstractmethod
async def next_deadline(self) -> Optional[float]:
"""Return the earliest pending due time (epoch seconds), if any."""
@abstractmethod
async def remove_deadline(self, member: str) -> None:
"""Remove ``member`` from the queue; a no-op when absent."""
@abstractmethod
async def notify_deadline(self) -> None:
"""Wake every worker's deadline consumer (best effort).
Notification is advisory: consumers stay correct when a signal
is lost, because their poll backstop recovers it.
"""
@abstractmethod
def subscribe_deadlines(self) -> AsyncContextManager[AsyncIterator[None]]:
"""Async context manager yielding deadline-enqueue signals."""
def _channel(game_id: str) -> str:
return f"{CHANNEL_PREFIX}{game_id}:events"
class RedisGameStore(GameStore):
def __init__(self, redis: Redis, registry: GameRegistry, ttl_seconds: int = 86400) -> None:
self._redis = redis
self._registry = registry
self._ttl = ttl_seconds
def lock(self, game_id: str):
# Lock and state use distinct key names; the lock expires on its own
# if a worker dies mid-mutation.
return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock",
timeout=10, blocking_timeout=10)
async def load(self, game_id: str) -> Optional[GameSession]:
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None:
log.debug("redis load %s: miss", game_id)
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
log.debug("redis load %s: hit", game_id)
return session_from_json(json.loads(raw), self._registry)
async def save(self, session: GameSession) -> None:
payload = json.dumps(session_to_json(session, self._registry))
engine = self._registry.require(session.game_type)
async with self._redis.pipeline(transaction=True) as pipe:
pipe.set(f"{GAME_KEY_PREFIX}{session.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{session.join_code}", session.id, ex=self._ttl)
if engine.is_finished(session):
pipe.zrem(ONGOING_GAMES_KEY, session.id)
else:
pipe.zadd(ONGOING_GAMES_KEY, {session.id: time.time() + self._ttl})
pipe.expire(ONGOING_GAMES_KEY, self._ttl)
await pipe.execute()
log.debug("redis save %s (game %s, ttl %ds)", session.id, session.game_type, self._ttl)
async def find_by_code(self, code: str) -> Optional[GameSession]:
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
if game_id is None:
return None
if isinstance(game_id, bytes):
game_id = game_id.decode("utf-8")
return await self.load(str(game_id))
async def count_ongoing(self) -> int:
# Prune entries whose game has expired since the last poll, then
# count what is left. Both commands run in one transaction so the
# count cannot see a half-pruned set.
now = time.time()
async with self._redis.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(ONGOING_GAMES_KEY, "-inf", now)
pipe.zcard(ONGOING_GAMES_KEY)
_, count = await pipe.execute()
return int(count)
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
await self._redis.zremrangebyscore(
ONGOING_GAMES_KEY, "-inf", time.time()
)
members = await self._redis.zrange(ONGOING_GAMES_KEY, 0, -1)
sessions: List[GameSession] = []
for member in members:
game_id = (
member.decode("utf-8") if isinstance(member, bytes) else str(member)
)
session = await self.load(game_id)
if session is None:
# The game key expired but the ongoing entry had not been
# pruned yet: skip it.
continue
if self._registry.require(session.game_type).is_finished(session):
continue
sessions.append(session)
sessions.sort(key=_created_at_key, reverse=True)
return sessions[:limit]
@contextlib.asynccontextmanager
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
pubsub = self._redis.pubsub()
await pubsub.subscribe(_channel(game_id))
try:
yield _redis_events(pubsub)
finally:
with contextlib.suppress(Exception):
await pubsub.unsubscribe(_channel(game_id))
await pubsub.aclose()
async def publish(self, game_id: str) -> None:
await self._redis.publish(_channel(game_id), "update")
log.debug("redis publish %s", game_id)
async def add_deadline(self, member: str, due_at: float) -> None:
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
# ``ZRANGE ... BYSCORE`` is the non-deprecated spelling of the
# old ``ZRANGEBYSCORE``.
members = cast(
list,
await self._redis.zrange(
DEADLINES_KEY,
"-inf",
now,
byscore=True,
offset=0,
num=limit,
),
)
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
async def next_deadline(self) -> Optional[float]:
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
return float(earliest[0][1]) if earliest else None
async def remove_deadline(self, member: str) -> None:
await self._redis.zrem(DEADLINES_KEY, member)
async def notify_deadline(self) -> None:
await self._redis.publish(DEADLINES_CHANNEL, "deadline")
@contextlib.asynccontextmanager
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
pubsub = self._redis.pubsub()
await pubsub.subscribe(DEADLINES_CHANNEL)
try:
yield _redis_events(pubsub)
finally:
with contextlib.suppress(Exception):
await pubsub.unsubscribe(DEADLINES_CHANNEL)
await pubsub.aclose()
async def _redis_events(pubsub) -> AsyncIterator[None]:
async for message in pubsub.listen():
if message.get("type") == "message":
yield None
class InMemoryGameStore(GameStore):
"""Process-local store used by tests and when Redis is not configured."""
def __init__(self, registry: GameRegistry) -> None:
self._registry = registry
self._games: Dict[str, str] = {}
self._codes: Dict[str, str] = {}
self._ongoing: Set[str] = set()
self._locks: Dict[str, asyncio.Lock] = {}
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
self._deadlines: Dict[str, float] = {}
self._deadline_subscribers: Set[asyncio.Queue] = set()
def _lock_for(self, game_id: str) -> asyncio.Lock:
lock = self._locks.get(game_id)
if lock is None:
lock = asyncio.Lock()
self._locks[game_id] = lock
return lock
@contextlib.asynccontextmanager
async def lock(self, game_id: str) -> AsyncIterator[None]:
async with self._lock_for(game_id):
yield
async def load(self, game_id: str) -> Optional[GameSession]:
raw = self._games.get(game_id)
if raw is None:
return None
return session_from_json(json.loads(raw), self._registry)
async def save(self, session: GameSession) -> None:
self._games[session.id] = json.dumps(session_to_json(session, self._registry))
self._codes[session.join_code] = session.id
engine = self._registry.require(session.game_type)
if engine.is_finished(session):
self._ongoing.discard(session.id)
else:
self._ongoing.add(session.id)
async def find_by_code(self, code: str) -> Optional[GameSession]:
game_id = self._codes.get(code.upper())
if game_id is None:
return None
return await self.load(game_id)
async def count_ongoing(self) -> int:
return len(self._ongoing)
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
sessions: List[GameSession] = []
for game_id in list(self._ongoing):
session = await self.load(game_id)
if session is None:
continue
if self._registry.require(session.game_type).is_finished(session):
continue
sessions.append(session)
sessions.sort(key=_created_at_key, reverse=True)
return sessions[:limit]
@contextlib.asynccontextmanager
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
queue: asyncio.Queue = asyncio.Queue()
self._subscribers.setdefault(game_id, set()).add(queue)
try:
yield _queue_events(queue)
finally:
subscribers = self._subscribers.get(game_id)
if subscribers is not None:
subscribers.discard(queue)
if not subscribers:
self._subscribers.pop(game_id, None)
async def publish(self, game_id: str) -> None:
for queue in list(self._subscribers.get(game_id, ())):
queue.put_nowait(_BUMP)
async def add_deadline(self, member: str, due_at: float) -> None:
self._deadlines[member] = due_at
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
due.sort(key=self._deadlines.__getitem__)
return due[:limit]
async def next_deadline(self) -> Optional[float]:
return min(self._deadlines.values(), default=None)
async def remove_deadline(self, member: str) -> None:
self._deadlines.pop(member, None)
async def notify_deadline(self) -> None:
for queue in list(self._deadline_subscribers):
queue.put_nowait(_BUMP)
@contextlib.asynccontextmanager
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
queue: asyncio.Queue = asyncio.Queue()
self._deadline_subscribers.add(queue)
try:
yield _queue_events(queue)
finally:
self._deadline_subscribers.discard(queue)
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
while True:
await queue.get()
yield None
@@ -19,6 +19,9 @@ This mixin therefore:
(``before_websocket`` hook), because the match-result write happens at
the end of a WebSocket match. The long-lived connection task keeps the
context for its whole lifetime.
4. Exposes :meth:`ensure_context` for background work that belongs to no
request: the deadline scheduler calls it before persisting a match that
a timeout finished, binding the context to its own long-lived task.
It deliberately avoids the global-fallback singleton
(``_enable_global_fallback``), which Tortoise only allows to be set once
@@ -83,6 +86,22 @@ class TortoiseMixin(KayaMixin):
self._ctx = None
self._init_loop = None
async def aclose(self) -> None:
"""Close the current context's connections and forget it.
Must be called from the event loop that owns the context. When a
loop goes away without this, its aiosqlite connections are orphaned;
their non-daemon worker threads then block interpreter shutdown
forever. The test suite calls this at the end of every test because
each test runs in a fresh event loop.
"""
ctx = self._ctx
self._ctx = None
self._init_loop = None
if ctx is not None:
log.info("closing database connections")
await ctx.close_connections()
async def _build_context(self) -> TortoiseContext:
ctx = TortoiseContext()
with ctx:
@@ -99,6 +118,17 @@ class TortoiseMixin(KayaMixin):
log.info("sqlite schemas generated")
return ctx
async def ensure_context(self) -> None:
"""Bind this mixin's context to the calling task.
Background work with no request of its own (the deadline
scheduler's finished-match write) calls this before touching the
database. It is cheap when the context is already built for the
running loop and idempotent for callers that already have it
bound (HTTP requests, WebSocket connections).
"""
await self._bind()
async def _bind(self) -> None:
loop = get_running_loop()
if self._init_loop is not loop:
@@ -0,0 +1,250 @@
"""WebSocket endpoint for live play.
Clients connect to ``/ws/games/{game_id}`` using their session cookie
(the OIDC login stores the user in the session, which the session mixin
loads onto the websocket). Anyone who knows the game id is accepted:
seated players get their personalized view, everyone else — anonymous
users and authenticated users who are not seated — is a read-only
**spectator** receiving the view without privileged information (no
hands, no legal moves). The game view carries ``spectator: true`` for
them and never carries the join code. Spectator actions other than
``state``/``sync`` are rejected with a ``forbidden`` error; seating is
re-checked under the game lock so a viewer who joins mid-connection
immediately gains (and, if they somehow left, would lose) the right to
act.
The platform owns the connection lifecycle — authentication, the
subscription/publish fan-out and the message envelope — and delegates
the game-specific actions to the engine registered for the session's
``game_type``.
Protocol
--------
Server -> client messages are JSON objects with a ``type``:
* ``state`` — the personalized game view (the engine's
:meth:`~tavolo.platform.engine.GameEngine.view_for` output merged with
the session envelope: ``id``, ``join_code``, ``game_type``).
* ``game_over`` — sent once when the match ends, carrying the engine's
:meth:`~tavolo.platform.engine.GameEngine.game_over_view` fields.
* ``error`` — a rejected action or malformed message.
Client -> server messages are JSON objects::
{"action": "state"} # platform: resend the view
{"action": "<game action>", ...fields} # dispatched to the engine
For scopone scientifico the game actions are ``play`` (with ``card``
and optionally ``capture``) and ``ack`` — see
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
message (minus ``action``) is handed to the engine as the payload, games
define their own fields freely.
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, so all
players see the move immediately (and consistently across workers).
Timeouts do not depend on anyone being connected: they are driven by
the absolute deadlines the engine declares, via the shared deadline
queue drained by a consumer on every worker (see
:mod:`tavolo.platform.deadlines`). A disconnected or idle player
therefore cannot stall the match, and a worker dying cannot either.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
from . import auth
from .engine import GameSession
from .errors import GameError
if TYPE_CHECKING: # avoid the import cycle: mixin imports this module
from .mixin import Platform
log = getLogger(__name__)
Send = Callable[[Dict[str, Any]], Awaitable[None]]
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
async def _reject(ws: WebSocket, status: int) -> None:
"""Refuse a websocket upgrade with the HTTP ``status``.
Rejections happen before :meth:`WebSocket.accept`, so ``close()``
carries the HTTP status of the refused upgrade rather than a
WebSocket close code: ASGI servers answer ``403`` per the spec, and
RSGI (granian) uses the value verbatim as the response status.
Passing a 4xxx close code here would panic granian's RSGI layer on
the invalid status — and since granian is built with
``panic = "abort"``, that kills the worker and takes the whole app
down.
"""
await ws.close(status)
def _state_message(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
payload["join_code"] = session.join_code
payload.update(engine.view_for(session, sub))
return {"type": "state", "game": payload}
def register(app: KayaApp, platform: "Platform") -> None:
"""Register the live-play websocket endpoint on ``app``."""
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
# Anonymous and non-seated viewers are welcome as read-only
# spectators; only an unknown game id is refused.
user = auth.get_ws_user(ws)
sub: Optional[str] = user.sub if user is not None else None
session = await platform.game_store.load(game_id)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await _reject(ws, 404)
return
await ws.accept()
log.info("%s connected to game %s", sub or "spectator", game_id)
send_lock = asyncio.Lock()
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
try:
await ws.send_text(json.dumps(payload))
except WebSocketDisconnect:
# The peer went away without a close handshake: kaya
# raises this from the transport, and the receive loop
# ends momentarily.
pass
await send(_state_message(platform, session, sub))
# Backstop: make sure the current deadline is queued even if its
# entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
await platform.scheduler.sync_deadline(session)
async with platform.game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(platform, events, game_id, sub, send)
)
try:
async for message in ws:
if message.kind == "close":
break
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(platform, send, game_id, sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", sub or "spectator", game_id)
async def _forward(
platform: "Platform",
events,
game_id: str,
sub: Optional[str],
send: Send,
) -> None:
async for _ in events:
session = await platform.game_store.load(game_id)
if session is None:
return
engine = platform.registry.require(session.game_type)
await send(_state_message(platform, session, sub))
if engine.is_finished(session):
await send(
{
"type": "game_over",
**engine.game_over_view(session, sub),
}
)
return
async def _handle_message(
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
) -> None:
try:
data = json.loads(raw)
except (ValueError, TypeError):
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
await send(_error("invalid JSON"))
return
if not isinstance(data, dict):
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
await send(_error("message must be a JSON object"))
return
action = data.get("action")
if action in ("state", "sync"):
session = await platform.game_store.load(game_id)
if session is not None:
await send(_state_message(platform, session, sub))
return
if not isinstance(action, str):
await send(_error(f"unknown action: {action!r}"))
return
await _handle_action(platform, send, game_id, sub, action, data)
async def _handle_action(
platform: "Platform",
send: Send,
game_id: str,
sub: Optional[str],
action: str,
data: Dict[str, Any],
) -> None:
async with platform.game_store.lock(game_id):
session = await platform.game_store.load(game_id)
if session is None:
await send(_error("game not found", code="not_found"))
return
# Re-check seating under the lock: an anonymous spectator stays a
# spectator forever, while a logged-in viewer who joins the game
# mid-connection becomes a player and may act from then on.
if sub is None or not session.seated(sub):
log.debug("game %s: rejected %r from a spectator", game_id, action)
await send(_error("spectators cannot act on the game", code="forbidden"))
return
engine = platform.registry.require(session.game_type)
payload = {key: value for key, value in data.items() if key != "action"}
try:
engine.handle_action(session, sub, action, payload)
except GameError as exc:
log.debug("game %s: rejected %r by %s: %s", game_id, action, sub, exc)
await send(_error(str(exc), code=exc.code))
return
log.debug("game %s: %s performed %r", game_id, sub, action)
await platform.scheduler.finalize_mutation(session)
@@ -0,0 +1,363 @@
"""Shared fixtures for the tavolo-platform test suite.
The centerpiece is :class:`DummyEngine`: a tiny two-player game
implementing the platform's
:class:`~tavolo.platform.engine.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 the match.
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
wired with in-memory stores, dummy OIDC providers (patched per test) and
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
tests construct their own app instead of importing a global one.
"""
from __future__ import annotations
import asyncio
import contextlib
import unittest.mock as _mock
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin
from tavolo.platform import (
AlreadyJoined,
Deadline,
GameEngine,
GameError,
GameFinished,
GameNotStarted,
GameSession,
IllegalMove,
LobbyFull,
MatchResult,
NotYourTurn,
Platform,
PlatformMixin,
PlayerResult,
Seat,
)
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin
class DummyEngine(GameEngine):
"""A two-player toy game: first to ``target`` plays wins.
Team labels are "A"/"B" (one player per team) so Elo paths are
exercised too. A ``deadline_in_seconds`` creation option arms a
``tick`` deadline that plays for the first player when it fires.
"""
id = "dummy"
name = "Dummy game"
description = "A two-player toy game for testing the platform."
min_players = 2
max_players = 2
options_schema = {
"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.",
},
},
}
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target = options.get("target", 3)
if isinstance(target, bool) or not isinstance(target, int) or target < 1:
raise IllegalMove("target must be a positive integer")
# The creator takes team A.
creator = session.players[0]
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team="A",
)
session.state = {
"target": target,
"plays": [],
"started": False,
"finished": False,
"winner": None,
"deadline_in_seconds": options.get("deadline_in_seconds"),
}
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
state = session.state
if state["started"]:
raise GameNotStarted("game has already started")
if session.seated(user_sub):
raise AlreadyJoined("already joined this game")
if len(session.players) >= 2:
raise LobbyFull("game is full")
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team="A" if not session.players else "B",
)
)
if len(session.players) == 2:
state["started"] = True
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state = session.state
if not state["started"]:
raise GameNotStarted("the game has not started yet")
if state["finished"]:
raise GameFinished("the match is over")
if not session.seated(user_sub):
raise NotYourTurn("you are not seated in this game")
if action != "play":
raise IllegalMove(f"unknown action: {action!r}")
self._play(session, user_sub)
def _play(self, session: GameSession, user_sub: str) -> None:
state = session.state
state["plays"].append(user_sub)
if len(state["plays"]) >= state["target"]:
state["finished"] = True
state["winner"] = user_sub
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state = session.state
return {
"started": state["started"],
"finished": state["finished"],
"winner": state["winner"],
"plays": len(state["plays"]),
"target": state["target"],
"viewer_seated": user_sub is not None and session.seated(user_sub),
}
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
return {
"phase": "playing" if session.state["started"] else "lobby",
"target": session.state["target"],
}
def in_lobby(self, session: GameSession) -> bool:
return not session.state["started"]
def is_finished(self, session: GameSession) -> bool:
return bool(session.state["finished"])
def result(self, session: GameSession) -> MatchResult:
state = session.state
winner = state["winner"]
if winner is None:
raise GameError("no result: the match is not finished")
subs = [seat.user_sub for seat in session.players]
plays: List[str] = state["plays"]
return MatchResult(
teams=[[subs[0]], [subs[1]]],
winner_team=subs.index(winner),
players=[
PlayerResult(
user_sub=seat.user_sub,
seat=index,
won=seat.user_sub == winner,
team=seat.team,
score=float(plays.count(seat.user_sub)),
details={"plays": plays.count(seat.user_sub)},
)
for index, seat in enumerate(session.players)
],
summary={
"target": state["target"],
"plays": len(plays),
"winner": winner,
},
)
def state_to_json(self, state: Any) -> Dict[str, Any]:
return dict(state)
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return dict(data)
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
seconds = session.state.get("deadline_in_seconds")
if (
seconds is None
or not session.state["started"]
or session.state["finished"]
):
return None
due_at = datetime.now(timezone.utc) + timedelta(seconds=float(seconds))
return Deadline(
kind="tick",
due_at=due_at,
token=f"tick:{len(session.state['plays'])}",
)
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:
raise GameError("stale deadline")
# The house plays for the first player.
self._play(session, session.players[0].user_sub)
def make_platform(
engines: Sequence[GameEngine] = (DummyEngine(),),
heartbeat_ms: int = 50,
oidc_providers: Sequence[str] = ("default",),
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
"""Build a throwaway app + platform wired with in-memory stores."""
registry = GameRegistry(engines)
game_store = InMemoryGameStore(registry)
session_mixin = SessionMixin(InMemorySessionStore())
oidc = OIDCProviders(
[
OIDCConfig(
provider=name,
issuer=f"http://localhost:8180/{name}",
client_id="tavolo",
client_secret=None,
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
post_login_redirect="/",
post_logout_redirect="/",
)
for name in oidc_providers
],
session=session_mixin,
)
tortoise_mixin = TortoiseMixin(
database_url="sqlite://:memory:",
models_modules=["tavolo.platform.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
openapi_mixin = OpenAPIMixin(
title="tavolo-platform-tests",
version="0.1.0",
description="test app",
spec_path="/api/openapi.json",
docs_path="/api/docs",
components={"schemas": SCHEMAS},
)
scheduler = DeadlineScheduler(
game_store,
registry,
heartbeat_ms=heartbeat_ms,
context_binder=tortoise_mixin.ensure_context,
)
platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc,
)
app = KayaApp(
mixins=[
session_mixin,
oidc,
tortoise_mixin,
openapi_mixin,
PlatformMixin(platform),
]
)
_tortoise_mixins.append(tortoise_mixin)
_schedulers.append(scheduler)
return app, platform, tortoise_mixin
_tortoise_mixins: List[TortoiseMixin] = []
_schedulers: List[DeadlineScheduler] = []
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
"""Like ``pwo.async_test`` (fresh loop per test), but tear down the
platform pieces afterwards: close Tortoise contexts (otherwise
orphaned aiosqlite threads block interpreter shutdown) and stop
deadline consumers."""
@wraps(coro)
def wrapper(*args: Any, **kwargs: Any) -> None:
async def run() -> None:
loop = asyncio.get_running_loop()
try:
await coro(*args, **kwargs)
finally:
for scheduler in _schedulers:
scheduler.stop_consumer(loop)
_schedulers.clear()
for mixin in _tortoise_mixins:
await mixin.aclose()
_tortoise_mixins.clear()
with asyncio.Runner() as runner:
runner.run(run())
return wrapper
async def use_db(tortoise_mixin: TortoiseMixin):
"""Bind the app's Tortoise context for this loop, for seeding rows."""
from tortoise.context import TortoiseContext
await tortoise_mixin._bind()
ctx: Optional[TortoiseContext] = tortoise_mixin._ctx
assert ctx is not None
return ctx
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
@contextlib.contextmanager
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user for the
first configured provider."""
user = make_user(sub, name)
provider = next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
patcher.start()
try:
yield user
finally:
patcher.stop()
@contextlib.contextmanager
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
from tavolo.platform import auth
remaining = list(users)
last = remaining[-1] if remaining else None
def _next(_ws):
if remaining:
return remaining.pop(0)
return last
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
patcher.start()
try:
yield
finally:
patcher.stop()
@@ -0,0 +1,204 @@
"""Tests for the auth helpers' OIDC user lookup and gating.
kaya-oidc (0.0.6) namespaces session state per provider as
``oidc:{provider}:user`` and tavolo wires an :class:`OIDCProviders`; these
tests pin both contracts so a kaya upgrade or a provider change cannot
silently break HTTP or websocket authentication.
"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from typing import Any
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
from kaya.session import InMemorySessionStore, Session, SessionMixin
from helpers import async_test
from tavolo.platform.auth import (
current_user,
display_name,
get_ws_user,
picture,
require_auth,
session_user,
)
def _providers(*names: str) -> OIDCProviders:
session = SessionMixin(InMemorySessionStore())
return OIDCProviders(
[
OIDCConfig(
provider=name,
issuer=f"http://localhost:8180/{name}",
client_id="tavolo",
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
)
for name in names
],
session=session,
)
def _ctx(data: Any) -> Any:
return SimpleNamespace(session=Session("sid", data))
def _ws(session: Any) -> Any:
return SimpleNamespace(session=session)
class SessionUserTest(unittest.TestCase):
def test_finds_default_provider_user(self) -> None:
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
def test_finds_user_of_any_provider(self) -> None:
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
def test_ignores_flow_state_and_tokens(self) -> None:
session = Session(
"sid",
{
"oidc:default:state": "state",
"oidc:default:id_token": "token",
"oidc:default:access_token": "token",
},
)
self.assertIsNone(session_user(session))
def test_ignores_legacy_unprefixed_user_key(self) -> None:
session = Session("sid", {"oidc_user": {"sub": "alice"}})
self.assertIsNone(session_user(session))
def test_ignores_malformed_user_claims(self) -> None:
session = Session("sid", {"oidc:default:user": "alice"})
self.assertIsNone(session_user(session))
class GetWsUserTest(unittest.TestCase):
def test_returns_user_from_provider_namespaced_session(self) -> None:
session = Session(
"sid",
{"oidc:default:user": {"sub": "alice", "preferred_username": "Alice"}},
)
user = get_ws_user(_ws(session))
assert user is not None
self.assertEqual("alice", user.sub)
def test_missing_session_returns_none(self) -> None:
self.assertIsNone(get_ws_user(SimpleNamespace()))
class DisplayNameTest(unittest.TestCase):
"""The claim priority used for the name shown at the table."""
def test_prefers_preferred_username_over_name(self) -> None:
user = OIDCUser(
{"sub": "alice", "preferred_username": "alice", "name": "Alice"}
)
self.assertEqual("alice", display_name(user))
def test_falls_back_to_name(self) -> None:
user = OIDCUser({"sub": "alice", "name": "Alice"})
self.assertEqual("Alice", display_name(user))
def test_ignores_email(self) -> None:
# The app requests only the openid and profile scopes; an email
# claim the provider still returns must not leak into the name.
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
self.assertEqual("alice", display_name(user))
def test_falls_back_to_sub(self) -> None:
user = OIDCUser({"sub": "alice"})
self.assertEqual("alice", display_name(user))
def test_ignores_empty_and_non_string_claims(self) -> None:
user = OIDCUser({"sub": "alice", "preferred_username": "", "name": 42})
self.assertEqual("alice", display_name(user))
class PictureTest(unittest.TestCase):
"""Only absolute http(s) picture claims are exported to the SPA."""
def test_uses_an_absolute_http_url(self) -> None:
user = OIDCUser({"sub": "alice", "picture": "https://cdn.example.com/a.png"})
self.assertEqual("https://cdn.example.com/a.png", picture(user))
def test_rejects_relative_and_script_schemes(self) -> None:
for value in ("/relative.png", "javascript:alert(1)", "data:image/png;base64,x"):
with self.subTest(picture=value):
user = OIDCUser({"sub": "alice", "picture": value})
self.assertIsNone(picture(user))
def test_missing_claim_is_none(self) -> None:
self.assertIsNone(picture(OIDCUser({"sub": "alice"})))
class CurrentUserTest(unittest.TestCase):
def test_anonymous_session_has_no_user(self) -> None:
self.assertIsNone(current_user(_providers("default"), _ctx({})))
def test_returns_the_authenticated_provider_user(self) -> None:
user = current_user(
_providers("default", "google"),
_ctx({"oidc:google:user": {"sub": "alice"}}),
)
assert user is not None
self.assertEqual("alice", user.sub)
def test_first_configured_provider_wins(self) -> None:
# Single-identity sessions never hold two users; this only pins the
# tie-break should they ever coexist.
user = current_user(
_providers("default", "google"),
_ctx(
{
"oidc:default:user": {"sub": "alice"},
"oidc:google:user": {"sub": "bob"},
}
),
)
assert user is not None
self.assertEqual("alice", user.sub)
class RequireAuthTest(unittest.TestCase):
@async_test
async def test_rejects_anonymous_requests(self) -> None:
oidc = _providers("default")
ctx = _ctx({})
sent = {}
async def send_bytes(*args: Any) -> None:
sent["status"] = args[0]
ctx.send_bytes = send_bytes
handled = []
@require_auth(oidc)
async def handler(_ctx: Any) -> None:
handled.append(True)
await handler(ctx)
self.assertEqual(401, sent["status"])
self.assertEqual([], handled)
@async_test
async def test_allows_authenticated_requests(self) -> None:
oidc = _providers("default")
ctx = _ctx({"oidc:default:user": {"sub": "alice"}})
handled = []
@require_auth(oidc)
async def handler(request_ctx: Any) -> None:
user = current_user(oidc, request_ctx)
assert user is not None
handled.append(user.sub)
await handler(ctx)
self.assertEqual(["alice"], handled)
@@ -0,0 +1,250 @@
"""Deadline-scheduler tests, driven by the DummyEngine.
Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed sessions, enqueue their
deadlines and let the background consumer fire them without a single
websocket. The engine owns the meaning of each deadline; the scheduler
owns enqueueing, delivery and removal.
"""
from __future__ import annotations
import asyncio
import unittest
from typing import Any, Dict, Optional
from tavolo.platform import GameSession, Seat
from tavolo.platform.deadlines import encode
from helpers import DummyEngine, async_test, make_platform, use_db
def _started_session(
game_id: str = "dl-1",
code: str = "DL0001",
target: int = 3,
deadline_in_seconds: Optional[float] = None,
) -> GameSession:
engine = DummyEngine()
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
options: Dict[str, Any] = {"target": target}
if deadline_in_seconds is not None:
options["deadline_in_seconds"] = deadline_in_seconds
engine.create(session, options)
engine.join(session, "bob", "bob")
return session
async def _wait_for(predicate, timeout: float = 5.0):
"""Poll the store until ``predicate`` returns a truthy value."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
result = await predicate()
if result:
return result
await asyncio.sleep(0.05)
return None
class ConnectionIndependenceTest(unittest.TestCase):
@async_test
async def test_tick_fires_with_no_connections(self) -> None:
_, platform, _ = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=0.05)
await store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must still fire the tick,
# which plays for the first player.
result = await _wait_for(
lambda: _plays_is(store, session.id, 1),
)
self.assertIsNotNone(result, "deadline never fired")
@async_test
async def test_no_deadline_nothing_enqueued(self) -> None:
_, platform, _ = make_platform()
session = _started_session() # no deadline_in_seconds option
await platform.game_store.save(session)
await platform.scheduler.sync_deadline(session)
self.assertIsNone(await platform.game_store.next_deadline())
@async_test
async def test_tick_finishing_match_persists_result_with_no_connections(self) -> None:
# A deadline that ends the match must persist the result from the
# consumer task, which has no request context of its own.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(target=1, deadline_in_seconds=0.05)
await store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must finish the match and
# write it to Postgres.
result = await _wait_for(lambda: _finished(store, session.id))
self.assertIsNotNone(result, "deadline never finished the match")
ctx = await use_db(tortoise_mixin)
with ctx:
self.assertEqual(1, await Match.all().count())
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
session = await store.load(game_id)
if session is not None and len(session.state["plays"]) == count:
return session
return None
async def _finished(store, game_id: str) -> Optional[GameSession]:
session = await store.load(game_id)
if session is not None and session.state["finished"]:
return session
return None
class NotificationTest(unittest.TestCase):
"""Deadline-enqueue notifications wake consumers on other workers."""
@async_test
async def test_notification_wakes_a_long_heartbeat_consumer(self) -> None:
# The consumer's heartbeat is a minute away and nothing sets its
# local wake event: only the pub/sub notification can deliver the
# deadline in time. This is the cross-worker path.
_, platform, _ = make_platform(heartbeat_ms=60_000)
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=0.1)
await store.save(session)
scheduler.ensure_consumer()
# Let the listener task subscribe before announcing.
await asyncio.sleep(0.05)
engine = platform.registry.require(session.game_type)
deadline = engine.next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
await store.add_deadline(member, deadline.due_at.timestamp())
await store.notify_deadline()
result = await _wait_for(lambda: _plays_is(store, session.id, 1))
self.assertIsNotNone(result, "notification did not wake the consumer")
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@async_test
async def test_processing_twice_is_a_no_op(self) -> None:
# Simulates a worker dying after firing but before removing the
# entry: another worker re-delivers the same entry. The engine's
# token has moved on, so the second delivery is stale.
_, platform, _ = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=3600)
await store.save(session)
deadline = DummyEngine().next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
await scheduler.process_due(member)
await scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
# Fired exactly once: one play, not two.
self.assertEqual(["alice"], result.state["plays"])
@async_test
async def test_stale_entry_is_discarded(self) -> None:
# A tick enqueued before a play landed in time: the token has
# moved, so the entry must not fire.
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(deadline_in_seconds=3600)
session.state["plays"].append("alice") # a play landed in time
await store.save(session)
member = encode({
"game_id": session.id,
"kind": "tick",
"token": "tick:0", # not the live token ("tick:1")
})
await store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
self.assertEqual(["alice"], result.state["plays"])
# The entry was removed after processing.
self.assertNotIn(member, await store.due_deadlines(float("inf")))
@async_test
async def test_entry_for_expired_game_is_dropped(self) -> None:
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
member = encode({
"game_id": "dl-gone",
"kind": "tick",
"token": "tick:0",
})
await store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
self.assertNotIn(member, await store.due_deadlines(float("inf")))
@async_test
async def test_malformed_entry_is_dropped(self) -> None:
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
await store.add_deadline("not json", due_at=0.0)
await scheduler.process_due("not json")
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
@async_test
async def test_finished_match_is_persisted_on_tick(self) -> None:
# A tick that completes the match writes the result to Postgres.
# No context is bound in this task: the scheduler must bind its
# own, exactly like its consumer task at app startup.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(target=1, deadline_in_seconds=3600)
await store.save(session)
deadline = DummyEngine().next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
await scheduler.process_due(member)
ctx = await use_db(tortoise_mixin)
with ctx:
self.assertEqual(1, await Match.all().count())
if __name__ == "__main__":
unittest.main()
@@ -1,9 +1,9 @@
"""Unit tests for the chess-style Elo math in :mod:`tavolo.elo`."""
"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`."""
from __future__ import annotations
import unittest
from tavolo.elo import (
from tavolo.platform.elo import (
INITIAL_RATING,
K_FACTOR,
expected_score,
@@ -0,0 +1,105 @@
"""Keyset pagination tests: cursor values are restored to Python types.
Cursors are JSON, so a cursor carrying a datetime reaches the next
request as a string. Postgres/asyncpg rejects strings for timestamp
parameters (sqlite silently coerces them), so ``paginate`` must run the
decoded values through their model fields before building the WHERE
clause.
"""
from __future__ import annotations
import unittest
import uuid
from datetime import datetime, timezone
from typing import Any, List
from tavolo.platform.models import Match
from tavolo.platform.pagination import (
Cursor,
PageParamsError,
decode_cursor,
encode_cursor,
paginate,
parse_page_params,
)
from helpers import async_test
SORT = [("finished_at", "DESC"), ("id", "ASC")]
class _CapturingQuerySet:
"""Minimal stand-in for a Tortoise QuerySet, recording its filter."""
model = Match
def __init__(self) -> None:
self.queryset_filter: Any = None
def order_by(self, *orderings: str) -> "_CapturingQuerySet":
return self
def filter(self, q: Any) -> "_CapturingQuerySet":
self.queryset_filter = q
return self
async def limit(self, count: int) -> List[Any]:
return []
def _flatten_q(q: Any) -> List[Any]:
nodes = [q]
for child in q.children:
nodes.extend(_flatten_q(child))
return nodes
def _filter_values(q: Any, key: str) -> List[Any]:
return [node.filters[key] for node in _flatten_q(q) if key in node.filters]
class PaginateCursorTypesTest(unittest.TestCase):
@async_test
async def test_cursor_values_are_restored_before_filtering(self) -> None:
match_id = uuid.uuid4()
finished_at = datetime(2026, 9, 19, 5, 2, 50, 547904, tzinfo=timezone.utc)
# Round-trip through the wire format, exactly as a request does.
after = decode_cursor(
encode_cursor({"finished_at": finished_at, "id": match_id})
)
assert after is not None
qs = _CapturingQuerySet()
await paginate(qs, SORT, Cursor(limit=1, after=after))
assert qs.queryset_filter is not None
for key in ("finished_at", "finished_at__lt"):
values = _filter_values(qs.queryset_filter, key)
self.assertEqual(1, len(values))
self.assertIsInstance(values[0], datetime)
self.assertEqual(finished_at, values[0])
id_values = _filter_values(qs.queryset_filter, "id__gt")
self.assertEqual(1, len(id_values))
self.assertIsInstance(id_values[0], uuid.UUID)
self.assertEqual(match_id, id_values[0])
class PageParamsTest(unittest.TestCase):
def test_defaults(self) -> None:
self.assertEqual((1, 20), parse_page_params(""))
def test_page_and_limit(self) -> None:
self.assertEqual((3, 5), parse_page_params("page=3&limit=5"))
def test_limit_is_clamped(self) -> None:
self.assertEqual((1, 100), parse_page_params("limit=500"))
self.assertEqual((1, 1), parse_page_params("limit=0"))
def test_invalid_values_raise(self) -> None:
for query in ("page=0", "page=-2", "page=abc", "limit=abc"):
with self.assertRaises(PageParamsError, msg=query):
parse_page_params(query)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,328 @@
"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform, oidc_user
class GamesRouteTest(unittest.TestCase):
@async_test
async def test_create_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.post("/api/games", json={})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "unauthenticated"}, response.json())
@async_test
async def test_create_and_read_lobby(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": 5}}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("lobby", body["phase"])
self.assertEqual(1, body["seats_open"])
self.assertEqual(5, body["target"])
self.assertEqual("dummy", body["game_type"])
self.assertEqual("A", body["players"][0]["team"])
self.assertEqual(6, len(body["join_code"]))
game_id = body["id"]
with oidc_user(platform.oidc, "alice"):
snapshot = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, snapshot.status_code)
snap = snapshot.json()
self.assertEqual(game_id, snap["id"])
self.assertEqual("dummy", snap["game_type"])
self.assertTrue(snap["viewer_seated"])
self.assertFalse(snap["spectator"])
self.assertIn("join_code", snap)
# A logged-in non-player is a read-only spectator: the view
# marks it and the join code is withheld.
with oidc_user(platform.oidc, "mallory"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertFalse(spect["viewer_seated"])
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
@async_test
async def test_join_starts_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "bob"):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
# The second join started the match, so the response is the
# personalized view rather than the lobby payload.
self.assertTrue(state["started"])
self.assertFalse(state["finished"])
self.assertEqual(0, state["plays"])
self.assertTrue(state["viewer_seated"])
@async_test
async def test_preview_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/games/preview?code=ABC123")
self.assertEqual(401, response.status_code)
@async_test
async def test_preview_lobby_and_started_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": 5}}
)
code = created.json()["join_code"]
with oidc_user(platform.oidc, "mallory"):
missing = await client.get("/api/games/preview")
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
self.assertEqual(400, missing.status_code)
self.assertEqual(404, unknown.status_code)
with oidc_user(platform.oidc, "mallory"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("dummy", body["game_type"])
self.assertEqual("Dummy game", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(5, body["target"])
self.assertEqual(1, body["seats_open"])
self.assertEqual("alice", body["players"][0]["sub"])
# Join codes are case-insensitive.
with oidc_user(platform.oidc, "mallory"):
lower = await client.get(f"/api/games/preview?code={code.lower()}")
self.assertEqual(200, lower.status_code)
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user(platform.oidc, "mallory"):
started = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, started.status_code)
self.assertEqual("playing", started.json()["phase"])
self.assertEqual(0, started.json()["seats_open"])
@async_test
async def test_create_rejects_bad_options(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
zero = await client.post(
"/api/games", json={"options": {"target": 0}}
)
text = await client.post(
"/api/games", json={"options": {"target": "three"}}
)
non_object = await client.post(
"/api/games", json={"options": [1, 2]}
)
self.assertEqual(400, zero.status_code)
self.assertEqual(400, text.status_code)
self.assertEqual(400, non_object.status_code)
@async_test
async def test_join_errors(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "bob"):
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
self.assertEqual(404, unknown.status_code)
with oidc_user(platform.oidc, "alice"):
duplicate = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, duplicate.status_code)
with oidc_user(platform.oidc, "bob"):
missing = await client.post("/api/games/join", json={})
self.assertEqual(400, missing.status_code)
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user(platform.oidc, "erin"):
late = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, late.status_code)
@async_test
async def test_get_unknown_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/games/does-not-exist")
self.assertEqual(404, response.status_code)
@async_test
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
anonymous = await client.get("/api/me/games")
self.assertEqual(401, anonymous.status_code)
with oidc_user(platform.oidc, "alice"):
mine = await client.post(
"/api/games", json={"options": {"target": 5}}
)
with oidc_user(platform.oidc, "bob"):
theirs = await client.post("/api/games", json={})
self.assertEqual(201, mine.status_code)
self.assertEqual(201, theirs.status_code)
with oidc_user(platform.oidc, "alice"):
listed = await client.get("/api/me/games")
self.assertEqual(200, listed.status_code)
results = listed.json()["results"]
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
entry = results[0]
self.assertEqual("dummy", entry["game_type"])
self.assertEqual("Dummy game", entry["game_name"])
self.assertTrue(entry["game_description"])
self.assertEqual("lobby", entry["phase"])
self.assertEqual(5, entry["target"])
self.assertEqual(1, entry["seats_open"])
self.assertEqual(0, entry["your_seat"])
self.assertNotIn("join_code", entry)
self.assertEqual("alice", entry["players"][0]["sub"])
# Bob's own game is the only one he sees; Alice's is not his.
with oidc_user(platform.oidc, "bob"):
bobs = await client.get("/api/me/games")
self.assertEqual(
[theirs.json()["id"]],
[g["id"] for g in bobs.json()["results"]],
)
# Once Bob joins Alice's game it appears for both, now in the
# playing phase.
with oidc_user(platform.oidc, "bob"):
await client.post(
"/api/games/join", json={"code": mine.json()["join_code"]}
)
with oidc_user(platform.oidc, "bob"):
bobs = await client.get("/api/me/games")
by_id = {g["id"]: g for g in bobs.json()["results"]}
self.assertEqual(
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
)
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(["dummy"], [g["id"] for g in results])
self.assertEqual("Dummy game", results[0]["name"])
self.assertTrue(results[0]["description"])
self.assertEqual(2, results[0]["min_players"])
self.assertEqual(2, results[0]["max_players"])
self.assertIn("target", results[0]["options_schema"]["properties"])
@async_test
async def test_create_defaults_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
self.assertEqual(201, created.status_code)
self.assertEqual("dummy", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"game_type": "dummy"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("dummy", body["game_type"])
with oidc_user(platform.oidc, "alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("dummy", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
class MeRouteTest(unittest.TestCase):
@async_test
async def test_me_authenticated(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
response.json(),
)
@async_test
async def test_me_unauthenticated(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me")
self.assertEqual(401, response.status_code)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,67 @@
"""Tests for the provider-agnostic auth routes.
``GET /api/auth/providers`` feeds the SPA's login buttons; ``/auth/login``
and ``/auth/logout`` are the generic aliases that redirect to the right
per-provider kaya route.
"""
from __future__ import annotations
import unittest
from unittest import mock
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform
class AuthProvidersRouteTest(unittest.TestCase):
@async_test
async def test_lists_providers_in_configuration_order(self) -> None:
app, _platform, _tortoise = make_platform(oidc_providers=("default", "google"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/auth/providers")
self.assertEqual(200, response.status_code)
self.assertEqual(
{
"providers": [
{"name": "default", "login_url": "/auth/default/login"},
{"name": "google", "login_url": "/auth/google/login"},
]
},
response.json(),
)
class GenericAuthRedirectTest(unittest.TestCase):
@async_test
async def test_login_redirects_to_the_first_provider(self) -> None:
app, _platform, _tortoise = make_platform(oidc_providers=("google", "github"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/login", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/google/login", response.headers["location"])
@async_test
async def test_logout_redirects_to_the_logged_in_provider(self) -> None:
app, platform, _tortoise = make_platform()
with mock.patch.object(
platform.oidc, "logged_in_providers", return_value=["default"]
):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://127.0.0.1"
) as client:
response = await client.get("/auth/logout", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/default/logout", response.headers["location"])
@async_test
async def test_logout_without_a_session_redirects_home(self) -> None:
app, _platform, _tortoise = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/logout", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/", response.headers["location"])
@@ -0,0 +1,526 @@
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
from __future__ import annotations
import unittest
import uuid
from datetime import datetime, timezone
from typing import Any, Dict
from unittest import mock
from httpx import ASGITransport, AsyncClient
from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
def _finished_session(target: int = 2):
"""A started dummy session one play short of completion."""
from tavolo.platform import GameSession, Seat
engine = DummyEngine()
session = GameSession(
id="stats-game",
game_type=engine.id,
join_code="STATS1",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine.create(session, {"target": target})
engine.join(session, "bob", "bob")
engine.handle_action(session, "alice", "play", {})
return engine, session
class SaveMatchResultTest(unittest.TestCase):
@async_test
async def test_finished_match_is_persisted_once(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
self.assertTrue(engine.is_finished(session))
with ctx:
await save_match_result(session, engine)
await save_match_result(session, engine) # idempotent
self.assertEqual(1, await Match.all().count())
self.assertEqual(2, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
# The game type travels from the session onto the row; the
# engine's summary is stored verbatim as the result.
self.assertEqual("dummy", match.game_type)
self.assertEqual("alice", match.result["winner"])
self.assertEqual(2, match.result["plays"])
winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice"}, {p.user_sub for p in winners})
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
with ctx:
await save_match_result(session, engine)
ratings = {
row.user_sub: row for row in await PlayerRating.all()
}
self.assertEqual(2, len(ratings))
# Two players at 1500: winner gains K/2, loser loses it.
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played)
# The leaderboard aggregates ride along in the same transaction.
self.assertEqual(1, ratings["alice"].wins)
self.assertEqual(0, ratings["bob"].wins)
self.assertEqual(2.0, ratings["alice"].points)
self.assertEqual(0.0, ratings["bob"].points)
self.assertEqual("alice", ratings["alice"].display_name)
# The per-match delta is recorded on each participation row.
deltas = {
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
}
self.assertEqual({"alice": 16, "bob": -16}, deltas)
@async_test
async def test_elo_ratings_accumulate_across_matches(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
from tavolo.platform import GameSession, Seat
engine2, session2 = DummyEngine(), GameSession(
id="stats-game-2",
game_type="dummy",
join_code="STATS2",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine2.create(session2, {"target": 2})
engine2.join(session2, "bob", "bob")
# Bob wins the second match.
engine2.handle_action(session2, "bob", "play", {})
engine2.handle_action(session2, "bob", "play", {})
with ctx:
await save_match_result(session, engine)
await save_match_result(session2, engine2)
ratings = {
row.user_sub: row.rating for row in await PlayerRating.all()
}
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
# is now the favourite (1516 vs 1484), so losing costs 17.
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
bob = await PlayerRating.get(user_sub="bob")
self.assertEqual(2, bob.matches_played)
@async_test
async def test_unfinished_match_is_not_persisted(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
with ctx:
await save_match_result(session, engine)
self.assertEqual(0, await Match.all().count())
async def _seed_two_matches(
tortoise_mixin, game_types: tuple = ("dummy", "dummy")
) -> None:
ctx = await use_db(tortoise_mixin)
with ctx:
# The leaderboard reads the player_rating aggregates, so the seeded
# participations are mirrored there (as save_match_result would).
aggregates: Dict[tuple, Dict[str, Any]] = {}
for index, (winner, finished) in enumerate(
[
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
]
):
match = await Match.create(
id=uuid.uuid4(),
game_type=game_types[index],
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
finished_at=finished,
result={"winner": winner, "plays": 3 + index},
)
for seat, sub in enumerate(("alice", "bob")):
score = 2.0 + index if sub == winner else 1.0
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
display_name=sub,
seat=seat,
team="A" if seat == 0 else "B",
won=(sub == winner),
score=score,
)
aggregate = aggregates.setdefault(
(sub, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0},
)
aggregate["matches"] += 1
aggregate["wins"] += 1 if sub == winner else 0
aggregate["points"] += score
for (sub, game_type), aggregate in aggregates.items():
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type=game_type,
rating=INITIAL_RATING,
matches_played=aggregate["matches"],
wins=aggregate["wins"],
points=aggregate["points"],
display_name=sub,
)
class StatsRouteTest(unittest.TestCase):
@async_test
async def test_my_matches_newest_first(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(2, len(results))
self.assertEqual("bob", results[0]["result"]["winner"]) # newest first
self.assertFalse(results[0]["you_won"])
self.assertTrue(results[1]["you_won"])
self.assertEqual(2, len(results[0]["players"]))
self.assertIn("next_cursor", response.json())
@async_test
async def test_my_matches_pagination(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
first = await client.get("/api/me/matches?limit=1")
cursor = first.json()["next_cursor"]
self.assertIsNotNone(cursor)
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
self.assertEqual(1, len(first.json()["results"]))
self.assertEqual(1, len(second.json()["results"]))
self.assertNotEqual(
first.json()["results"][0]["id"],
second.json()["results"][0]["id"],
)
@async_test
async def test_my_matches_fetches_participants_in_one_query(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
with mock.patch.object(
MatchPlayer, "filter", wraps=MatchPlayer.filter
) as spy:
async with AsyncClient(
transport=transport, base_url="http://127.0.0.1"
) as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
# One query for all matches, not one per match row.
self.assertEqual(1, spy.call_count)
results = response.json()["results"]
self.assertEqual(2, len(results))
for result in results:
seats = [player["seat"] for player in result["players"]]
self.assertEqual([0, 1], seats)
@async_test
async def test_my_matches_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/matches")
self.assertEqual(401, response.status_code)
@async_test
async def test_leaderboard_aggregates(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
# Bob leads on points after tying Alice on wins.
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
@async_test
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
# Alice outranks everyone despite Bob leading on points.
await PlayerRating.filter(
user_sub="alice", game_type="dummy"
).update(rating=1600)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
# Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"])
@async_test
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
app, platform, tortoise_mixin = make_platform(
engines=(DummyEngine(), SecondEngine())
)
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.filter(
user_sub="bob", game_type="dummy"
).update(rating=1516)
# Bob's rating in another game must not leak into the
# dummy leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="second",
rating=1800,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard?game_type=dummy")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["bob"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual("second", platform.registry.all()[1].id)
@async_test
async def test_leaderboard_pagination(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type="dummy",
rating=1600 + index,
matches_played=1,
wins=1,
points=2.0,
display_name=sub,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
first = await client.get("/api/leaderboard?limit=2")
second = await client.get("/api/leaderboard?limit=2&page=2")
third = await client.get("/api/leaderboard?limit=2&page=3")
beyond = await client.get("/api/leaderboard?limit=2&page=99")
body = first.json()
self.assertEqual(1, body["page"])
self.assertEqual(3, body["pages"])
self.assertEqual(6, body["total"])
self.assertEqual(2, len(body["results"]))
self.assertEqual(
["frank", "erin"], [row["user_sub"] for row in body["results"]]
)
self.assertEqual(
["dave", "carol"],
[row["user_sub"] for row in second.json()["results"]],
)
self.assertEqual(
["bob", "alice"],
[row["user_sub"] for row in third.json()["results"]],
)
# Out-of-range pages clamp to the last page.
self.assertEqual(3, beyond.json()["page"])
self.assertEqual(
["bob", "alice"],
[row["user_sub"] for row in beyond.json()["results"]],
)
@async_test
async def test_leaderboard_focus_me(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
for index, sub in enumerate(("carol", "dave")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type="dummy",
rating=1600 + index,
matches_played=1,
wins=1,
points=2.0,
display_name=sub,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
mine = await client.get("/api/leaderboard?limit=1&focus=me")
anonymous = await client.get("/api/leaderboard?limit=1&focus=me")
# dave, carol, bob, alice: the caller lands on their own page.
body = mine.json()
self.assertEqual(4, body["page"])
self.assertEqual(4, body["your_rank"])
self.assertEqual(4, body["your_page"])
self.assertEqual("alice", body["results"][0]["user_sub"])
anonymous_body = anonymous.json()
self.assertEqual(1, anonymous_body["page"])
self.assertIsNone(anonymous_body["your_rank"])
self.assertIsNone(anonymous_body["your_page"])
@async_test
async def test_leaderboard_rejects_bad_page_params(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
for query in ("page=0", "page=abc", "focus=elsewhere"):
response = await client.get(f"/api/leaderboard?{query}")
self.assertEqual(400, response.status_code, query)
# The limit is clamped, not rejected.
clamped = await client.get("/api/leaderboard?limit=500")
self.assertEqual(200, clamped.status_code)
@async_test
async def test_my_matches_include_elo_delta(self) -> None:
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
players = {
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
@async_test
async def test_my_ratings_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/ratings")
self.assertEqual(401, response.status_code)
@async_test
async def test_my_ratings_returns_only_own_rows(self) -> None:
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="dummy",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="dummy",
rating=1484,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/ratings")
self.assertEqual(200, response.status_code)
self.assertEqual(
[{"game_type": "dummy", "rating": 1516, "matches_played": 1}],
response.json()["results"],
)
class SecondEngine(DummyEngine):
id = "second"
name = "Second game"
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=dummy")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"dummy", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("dummy", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=dummy")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, alice won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,392 @@
"""In-memory game store behaviour (the Redis store shares this interface)."""
from __future__ import annotations
import asyncio
import time
import unittest
from datetime import datetime, timezone
from typing import Any, Optional, cast
from tavolo.platform import GameSession, Seat
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import (
DEADLINES_KEY,
ONGOING_GAMES_KEY,
InMemoryGameStore,
RedisGameStore,
)
from helpers import DummyEngine, async_test
def _registry() -> GameRegistry:
return GameRegistry([DummyEngine()])
def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
engine = DummyEngine()
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine.create(session, {"target": 5})
return session
class _FakePipeline:
"""Records commands and applies them to the fake Redis on execute."""
def __init__(self, redis: "_FakeRedis") -> None:
self._redis = redis
self._ops: list = []
async def __aenter__(self) -> "_FakePipeline":
return self
async def __aexit__(self, *exc: object) -> bool:
return False
def set(self, key: str, value: str, ex: object = None) -> None:
self._ops.append(("set", key, value))
def zadd(self, key: str, mapping: dict) -> None:
self._ops.append(("zadd", key, mapping))
def zrem(self, key: str, member: str) -> None:
self._ops.append(("zrem", key, member))
def expire(self, key: str, ttl: int) -> None:
self._ops.append(("expire", key))
def zremrangebyscore(self, key: str, minimum: object, maximum: object) -> None:
self._ops.append(("zremrangebyscore", key))
def zcard(self, key: str) -> None:
self._ops.append(("zcard", key))
async def execute(self) -> list:
results = [self._redis.apply(op) for op in self._ops]
self._ops = []
return results
class _FakeRedis:
"""The slice of the Redis client the game store uses."""
def __init__(self) -> None:
self.keys: dict = {}
self.zsets: dict = {}
self.zrange_calls: list = []
def pipeline(self, transaction: bool = True) -> _FakePipeline:
return _FakePipeline(self)
async def zadd(self, key: str, mapping: dict) -> int:
return cast(int, self.apply(("zadd", key, mapping)))
async def zrem(self, key: str, member: str) -> int:
return cast(int, self.apply(("zrem", key, member)))
async def get(self, key: str) -> object:
return self.keys.get(key)
async def zremrangebyscore(
self, key: str, minimum: object, maximum: object
) -> int:
return cast(int, self.apply(("zremrangebyscore", key)))
async def zrange(
self,
name: str,
start: Any,
end: Any,
byscore: bool = False,
offset: Optional[int] = None,
num: Optional[int] = None,
**kwargs: object,
) -> list:
self.zrange_calls.append((name, start, end, byscore, offset, num))
members = sorted(
self.zsets.get(name, {}).items(), key=lambda item: item[1]
)
if byscore:
low = float("-inf") if start == "-inf" else float(start)
high = float("inf") if end == "+inf" else float(end)
members = [item for item in members if low <= item[1] <= high]
if offset is not None and num is not None:
members = members[offset : offset + num]
return [member for member, _ in members]
def apply(self, op: tuple) -> object:
name, *args = op
if name == "set":
self.keys[args[0]] = args[1]
return True
if name == "zadd":
self.zsets.setdefault(args[0], {}).update(args[1])
return 1
if name == "zrem":
self.zsets.get(args[0], {}).pop(args[1], None)
return 1
if name == "expire":
return True
if name == "zremrangebyscore":
zset = self.zsets.get(args[0], {})
for member in [
member
for member, score in zset.items()
if score <= time.time()
]:
del zset[member]
return 0
if name == "zcard":
return len(self.zsets.get(args[0], {}))
raise AssertionError(f"unexpected command: {name}")
class InMemoryGameStoreTest(unittest.TestCase):
@async_test
async def test_save_load_roundtrip(self) -> None:
store = InMemoryGameStore(_registry())
session = _session()
await store.save(session)
loaded = await store.load("g1")
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual("CODE01", loaded.join_code)
self.assertEqual("dummy", loaded.game_type)
self.assertEqual(5, loaded.state["target"])
self.assertEqual(["alice"], [p.user_sub for p in loaded.players])
# The loaded state is a deserialized copy, not the same object.
self.assertIsNot(loaded.state, session.state)
@async_test
async def test_unknown_game_type_rejected(self) -> None:
store = InMemoryGameStore(_registry())
session = _session()
session.game_type = "nope"
with self.assertRaises(Exception):
await store.save(session)
@async_test
async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore(_registry())
self.assertIsNone(await store.load("nope"))
self.assertIsNone(await store.find_by_code("NOPE01"))
@async_test
async def test_find_by_code(self) -> None:
store = InMemoryGameStore(_registry())
await store.save(_session())
found = await store.find_by_code("code01") # case-insensitive
self.assertIsNotNone(found)
assert found is not None
self.assertEqual("g1", found.id)
@async_test
async def test_load_returns_a_copy(self) -> None:
store = InMemoryGameStore(_registry())
await store.save(_session())
first = await store.load("g1")
assert first is not None
first.state["target"] = 999
second = await store.load("g1")
assert second is not None
self.assertEqual(5, second.state["target"])
@async_test
async def test_publish_reaches_subscriber(self) -> None:
store = InMemoryGameStore(_registry())
await store.save(_session())
received = []
async with store.subscribe("g1") as events:
await store.publish("g1")
async for _ in events:
received.append(True)
break
self.assertEqual([True], received)
@async_test
async def test_lock_serializes_concurrent_mutations(self) -> None:
store = InMemoryGameStore(_registry())
order = []
async def holder() -> None:
async with store.lock("g5"):
order.append("holder-enter")
await asyncio.sleep(0.05)
order.append("holder-exit")
async def contender() -> None:
await asyncio.sleep(0.01)
async with store.lock("g5"):
order.append("contender")
await asyncio.gather(holder(), contender())
self.assertEqual(
["holder-enter", "holder-exit", "contender"], order
)
@async_test
async def test_count_ongoing_tracks_unfinished_games(self) -> None:
store = InMemoryGameStore(_registry())
self.assertEqual(0, await store.count_ongoing())
session = _session()
await store.save(session)
self.assertEqual(1, await store.count_ongoing())
other = _session(game_id="g2", code="CODE02")
await store.save(other)
self.assertEqual(2, await store.count_ongoing())
# A finished match is retained for the final board but no longer
# counts as ongoing.
session.state["finished"] = True
await store.save(session)
self.assertEqual(1, await store.count_ongoing())
other.state["finished"] = True
await store.save(other)
self.assertEqual(0, await store.count_ongoing())
@async_test
async def test_list_ongoing_newest_first_and_unfinished_only(self) -> None:
store = InMemoryGameStore(_registry())
old = _session("old", "CODE01")
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
new = _session("new", "CODE02")
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
await store.save(old)
await store.save(new)
self.assertEqual(
["new", "old"], [s.id for s in await store.list_ongoing()]
)
self.assertEqual(
["new"], [s.id for s in await store.list_ongoing(limit=1)]
)
# A finished match retained for the final board is not listed.
new.state["finished"] = True
await store.save(new)
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
@async_test
async def test_deadline_queue(self) -> None:
store = InMemoryGameStore(_registry())
self.assertIsNone(await store.next_deadline())
self.assertEqual([], await store.due_deadlines(now=100.0))
await store.add_deadline("b", due_at=50.0)
await store.add_deadline("a", due_at=10.0)
await store.add_deadline("c", due_at=200.0)
# Re-adding an existing member only updates its due time.
await store.add_deadline("b", due_at=60.0)
self.assertEqual(10.0, await store.next_deadline())
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
# Due entries come out in due-time order and stay queued until removed.
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
await store.remove_deadline("a")
await store.remove_deadline("a") # removing twice is a no-op
self.assertEqual(60.0, await store.next_deadline())
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
@async_test
async def test_deadline_notifications_reach_subscribers(self) -> None:
store = InMemoryGameStore(_registry())
async with store.subscribe_deadlines() as events:
await store.notify_deadline()
signal = await asyncio.wait_for(anext(events), timeout=1.0)
self.assertIsNone(signal)
# The subscription is gone after the context exits: notifying
# again is a no-op rather than an error.
await store.notify_deadline()
class RedisGameStoreTest(unittest.TestCase):
@async_test
async def test_save_marks_games_ongoing_and_finished_ones_stop_counting(self) -> None:
redis = _FakeRedis()
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
session = _session()
await store.save(session)
self.assertEqual(1, await store.count_ongoing())
score = redis.zsets[ONGOING_GAMES_KEY][session.id]
self.assertGreater(score, time.time())
self.assertLessEqual(score, time.time() + 3600)
session.state["finished"] = True
await store.save(session)
self.assertNotIn(session.id, redis.zsets[ONGOING_GAMES_KEY])
self.assertEqual(0, await store.count_ongoing())
@async_test
async def test_count_ongoing_prunes_expired_entries(self) -> None:
redis = _FakeRedis()
redis.zsets[ONGOING_GAMES_KEY] = {
"stale": time.time() - 10,
"live": time.time() + 100,
}
store = RedisGameStore(redis, _registry()) # type: ignore[arg-type]
self.assertEqual(1, await store.count_ongoing())
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
@async_test
async def test_list_ongoing_loads_unfinished_sessions(self) -> None:
redis = _FakeRedis()
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
old = _session("old", "CODE01")
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
new = _session("new", "CODE02")
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
await store.save(old)
await store.save(new)
self.assertEqual(
["new", "old"], [s.id for s in await store.list_ongoing()]
)
self.assertEqual(
["new"], [s.id for s in await store.list_ongoing(limit=1)]
)
# A finished match retained for the final board is not listed.
new.state["finished"] = True
await store.save(new)
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
# An ongoing entry whose game key expired is skipped.
redis.zsets[ONGOING_GAMES_KEY]["gone"] = time.time() + 100
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
@async_test
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
redis = _FakeRedis()
store = RedisGameStore(redis, _registry()) # type: ignore[arg-type]
await store.add_deadline("b", due_at=50.0)
await store.add_deadline("a", due_at=10.0)
await store.add_deadline("c", due_at=200.0)
# Re-adding an existing member only updates its due time.
await store.add_deadline("b", due_at=60.0)
self.assertEqual(["a"], await store.due_deadlines(now=20.0))
self.assertEqual(
[(DEADLINES_KEY, "-inf", 20.0, True, 0, 32)], redis.zrange_calls
)
# Matches come back in due-time order and honour the limit.
self.assertEqual(["a"], await store.due_deadlines(now=100.0, limit=1))
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,338 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations
import json
import unittest
from typing import Any, Dict, Mapping, Sequence
from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
class AbruptCloseSocket(WebSocket):
"""A socket whose peer vanished without a close handshake.
kaya surfaces this from the transport: ``receive()`` reports a
``close`` message so iteration ends normally, while sends raise
``WebSocketDisconnect``.
"""
def __init__(self, path: str, gone: bool = False) -> None:
self.path = path
self.query_string = ""
self.scheme = "ws"
self.headers: Mapping[str, Sequence[str]] = {}
self.client = ("127.0.0.1", 12345)
self.server = ("127.0.0.1", 80)
self.sent: list[Dict[str, Any]] = []
self.close_codes: list[int] = []
self.gone = gone
async def accept(self, headers: Any = None) -> None:
pass
async def receive(self) -> WebSocketMessage:
self.gone = True
return WebSocketMessage(kind="close")
async def send_text(self, data: str) -> None:
if self.gone:
raise WebSocketDisconnect(code=1006)
self.sent.append(json.loads(data))
async def send_bytes(self, data: bytes) -> None:
if self.gone:
raise WebSocketDisconnect(code=1006)
async def close(self, code: int = 1000) -> None:
self.close_codes.append(code)
async def __anext__(self) -> WebSocketMessage:
await self.receive()
raise StopAsyncIteration
class RecordingSocket(WebSocket):
"""A socket that only records pre-accept rejections."""
def __init__(self, path: str) -> None:
self.path = path
self.query_string = ""
self.scheme = "ws"
self.headers: Mapping[str, Sequence[str]] = {}
self.client = ("127.0.0.1", 12345)
self.server = ("127.0.0.1", 80)
self.close_codes: list[int] = []
async def accept(self, headers: Any = None) -> None:
raise AssertionError("a rejected socket must not be accepted")
async def receive(self) -> WebSocketMessage:
raise AssertionError("a rejected socket must not receive")
async def send_text(self, data: str) -> None:
raise AssertionError("a rejected socket must not send")
async def send_bytes(self, data: bytes) -> None:
raise AssertionError("a rejected socket must not send")
async def close(self, code: int = 1000) -> None:
self.close_codes.append(code)
async def __anext__(self) -> WebSocketMessage:
raise AssertionError("a rejected socket must not receive")
class WebSocketTest(unittest.TestCase):
async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict:
"""Create a game and seat both players; return the started state."""
with oidc_user(oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": target}}
)
code = created.json()["join_code"]
with oidc_user(oidc, "bob"):
response = await client.post("/api/games/join", json={"code": code})
return response.json()
@async_test
async def test_move_updates_all_connections(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob"), make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
self.assertEqual("dummy", first["game"]["game_type"])
self.assertEqual(0, first["game"]["plays"])
self.assertEqual(game_id, first["game"]["id"])
# Seated players get the join code; spectators don't.
self.assertIn("join_code", first["game"])
self.assertFalse(first["game"]["spectator"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
self.assertEqual("state", alice_first["type"])
await bob_ws.send_json({"action": "play"})
bob_update = await bob_ws.receive_json()
alice_update = await alice_ws.receive_json()
for update in (bob_update, alice_update):
self.assertEqual("state", update["type"])
self.assertEqual(1, update["game"]["plays"])
@async_test
async def test_state_reports_open_seats(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
game_id = created.json()["id"]
code = created.json()["join_code"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(1, first["game"]["seats_open"])
# Once the second player joins the match starts and no seat is
# left open.
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(0, first["game"]["seats_open"])
@async_test
async def test_unknown_action_returns_error(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "dance"})
error = await bob_ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("illegal_move", error["code"])
@async_test
async def test_state_action_resyncs(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "state"})
resent = await bob_ws.receive_json()
self.assertEqual("state", resent["type"])
@async_test
async def test_abrupt_disconnect_is_not_an_error(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws = AbruptCloseSocket(f"/ws/games/{game_id}")
with ws_users([make_user("bob")]):
# kaya reports a peer that vanished without a close handshake as
# a close message; the handler must exit cleanly.
await app.handle_websocket(ws)
self.assertEqual("state", ws.sent[0]["type"])
self.assertEqual([], ws.close_codes)
@async_test
async def test_send_on_vanished_peer_is_ignored(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws = AbruptCloseSocket(f"/ws/games/{game_id}", gone=True)
with ws_users([make_user("bob")]):
# Sending raises WebSocketDisconnect; the handler swallows it
# rather than letting it escape the forward task.
await app.handle_websocket(ws)
self.assertEqual([], ws.sent)
self.assertEqual([], ws.close_codes)
@async_test
async def test_game_over_broadcast(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
# target 1: the first play ends the match.
state = await self._started_game(client, platform.oidc, target=1)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice"), make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "play"})
# Both connections see the final state...
alice_final = await alice_ws.receive_json()
bob_final = await bob_ws.receive_json()
self.assertTrue(alice_final["game"]["finished"])
self.assertTrue(bob_final["game"]["finished"])
# ...followed by the game_over announcement.
alice_over = await alice_ws.receive_json()
bob_over = await bob_ws.receive_json()
self.assertEqual("game_over", alice_over["type"])
self.assertEqual("game_over", bob_over["type"])
@async_test
async def test_unknown_game_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertFalse(first["game"]["viewer_seated"])
self.assertNotIn("join_code", first["game"])
# Spectators may not act.
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
@async_test
async def test_anonymous_user_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
class RejectionStatusTest(unittest.TestCase):
"""Pre-accept rejections must carry HTTP statuses, not close codes.
Granian's RSGI layer uses the value passed to ``close()`` before
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
close code like 4401 is not a valid status and panics the worker
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
dies). Drive the rejection through the app with a recording socket
so the invariant is checked without a real server. Spectators are
accepted, so the only pre-accept rejection left is an unknown game.
"""
@async_test
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
app, _, _ = make_platform()
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
ws = RecordingSocket("/ws/games/no-such-game")
with ws_users(users):
await app.handle_websocket(ws)
self.assertEqual([404], ws.close_codes, label)
for code in ws.close_codes:
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
# tavolo-scopa
Scopa — the classic Italian fishing game — in its two-player form and in
the four-player fixed-partnership variant (*scopa a quattro*), as
[`tavolo-platform`](../tavolo-platform/README.md) game implementations.
The optional *asso pigliatutto* creation option turns either variant into
**scopa d'assi**.
## Contents
- `state.py` — `ScopaState` (pure game data: phases, players, hands,
table, the deck persisting between rounds, scores, deadlines),
`PlayerState`, `Card`, `Move`, with JSON (de)serialization. No session
envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, round re-dealing, hand scoring (carte, denara, settebello,
primiera, scope), the optional asso pigliatutto capture, hand-end
acknowledgements. Deterministic and I/O-free apart from logging, so the
whole rule set is unit-testable.
- `errors.py` — scopa-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `ScopaEngine` (two players) and `ScopaQuattroEngine`
(four players in fixed partnerships): the platform-facing adapters.
They translate create/join/websocket actions/deadlines into
rules-engine calls and back, validate the
`target_score`/`asso_pigliatutto` creation options (the match target
defaults to the traditional 11 points for two-player scopa and 21 for
scopa a quattro), and extract the `MatchResult` (teams, winner,
per-player scores and the match summary persisted as the match's JSON
`result`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
.venv/bin/python -m mypy -p tavolo.scopa
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-scopa"
version = "0.1.0"
description = "Scopa game implementation for the tavolo platform"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -0,0 +1,15 @@
"""Scopa: the classic Italian fishing game.
The pure rules (:mod:`tavolo.scopa.engine`, :mod:`tavolo.scopa.state`)
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopa.plugin`
adapts them to the platform's
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them. Two variants are provided:
:class:`ScopaEngine` (two players) and :class:`ScopaQuattroEngine` (four
players in fixed partnerships).
"""
from __future__ import annotations
from .plugin import ScopaEngine, ScopaQuattroEngine
__all__ = ["ScopaEngine", "ScopaQuattroEngine"]
@@ -0,0 +1,595 @@
"""Pure rules engine for scopa.
Every function here is deterministic and I/O-free (the only side effect is
debug logging): it mutates (or reads) :class:`~tavolo.scopa.state.ScopaState`
and raises :class:`~tavolo.platform.errors.GameError` subclasses on rule
violations. This makes the whole rule set unit-testable without Redis,
Postgres or HTTP. The platform-facing adapter is
:class:`~tavolo.scopa.plugin.ScopaEngine`.
Rules implemented
-----------------
* 40-card Italian deck; three cards per player and four cards face up on
the table at the start of every hand; the rest of the deck stays out of
play until the next round of the hand.
* When every hand is empty the dealer deals three more cards to each
player (no new table cards) and play resumes; this repeats until the
deck is exhausted. Two players play six rounds per hand, four players
three.
* A card captures either a **single card of equal rank** or a **combination
of cards whose ranks sum to its own**. When an equal-ranked card is on the
table that capture is mandatory; the player may not take an alternative
combination instead. When any capture exists it must be taken.
* Emptying the table with a capture is a **scopa** (+1), except on the very
last play of a hand — a sweep on the last play of a round still scores
when the deck has cards left.
* At the end of a hand the remaining table cards go to the player who made
the last capture.
* Hand points: ``carte`` (most captured cards), ``denara`` (most diamond
cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best
seven/five/four/three card of each suit, all four suits required), plus
one point per ``scopa``. Ties on carte/denara/primiera award nothing.
With four players the partners' piles and scope are pooled.
* Optional ``asso_pigliatutto`` rule (disabled by default; scopa d'assi):
an ace played when no equal rank is on the table sweeps the whole table
— or captures itself, leaving the table empty, when the table is bare.
The sweep never scores a scopa, even when cards remain in hand or in the
deck, but it counts as a capture for the last-taker rule. When another
ace is on the table the mandatory equal-rank capture still applies.
* The match ends when a side reaches the target score with a clear lead; a
tie at or above the target is broken by playing another hand.
"""
from __future__ import annotations
import random
from datetime import datetime, timedelta, timezone
from itertools import combinations
from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple
from tavolo.platform.errors import (
AlreadyJoined,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .errors import CardNotInHand
from .state import (
DEFAULT_PLAYER_COUNT,
DEFAULT_TARGET_SCORES,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
PLAYER_COUNTS,
SUITS,
TEAM_NAMES,
Card,
Move,
PlayerState,
ScopaState,
parse_card,
)
log = getLogger(__name__)
# Number of cards dealt to each player at a time.
HAND_SIZE = 3
# Cards dealt face up on the table at the start of every hand.
TABLE_CARDS = 4
# Default seconds the hand-end summary waits before dealing anyway. Games
# carry their own copy in ``ScopaState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# legal card for them. Games carry their own copy in
# ``ScopaState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the
# remaining ranks in descending order. All of 8/9/10 are worth 10.
PRIMIERA_VALUES: Dict[int, int] = {
7: 21,
6: 18,
1: 16,
5: 15,
4: 14,
3: 13,
2: 12,
8: 10,
9: 10,
10: 10,
}
_rng = random.SystemRandom()
def full_deck() -> List[Card]:
"""Return the 40 cards of the Italian deck in canonical order."""
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
deck = full_deck()
(rng or _rng).shuffle(deck)
return deck
def legal_captures(
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
) -> List[List[Card]]:
"""Return every legal capture (a list of card sets) for ``card``.
If an equal-ranked card is on the table, only those single-card
captures are returned (the rule forbids taking a combination instead).
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
the only capture is the whole table — or an empty capture when the
table is bare, the ace taking itself. Without the option every subset
of the table whose ranks sum to ``card.rank`` is returned.
"""
equal = [c for c in table if c.rank == card.rank]
if equal:
return [[c] for c in equal]
if asso_pigliatutto and card.rank == 1:
return [list(table)]
candidates = [c for c in table if c.rank <= card.rank]
captures: List[List[Card]] = []
# A sum-equal capture needs at least two cards (single non-equal cards
# cannot sum to the played card).
for size in range(2, len(candidates) + 1):
for combo in combinations(candidates, size):
if sum(c.rank for c in combo) == card.rank:
captures.append(list(combo))
return captures
def create_game(
creator_sub: str,
creator_name: str,
player_count: int = DEFAULT_PLAYER_COUNT,
target_score: Optional[int] = None,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
asso_pigliatutto: bool = False,
) -> ScopaState:
"""Create a lobby game with the creator seated first.
``target_score`` defaults to the variant's traditional target: 11
points for two-player scopa, 21 for the four-player partnership game.
"""
if player_count not in PLAYER_COUNTS:
raise IllegalMove(
"player_count must be one of: "
+ ", ".join(str(count) for count in PLAYER_COUNTS)
)
if target_score is None:
target_score = DEFAULT_TARGET_SCORES[player_count]
if target_score < 1 or target_score > 100:
raise IllegalMove("target_score must be between 1 and 100")
return ScopaState(
player_count=player_count,
target_score=target_score,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
)
def join_game(state: ScopaState, sub: str, name: str) -> None:
"""Seat ``sub`` in the next free chair, starting the match when full."""
if state.phase != PHASE_LOBBY:
raise GameNotStarted("game has already started")
if state.seated(sub):
raise AlreadyJoined("already joined this game")
if len(state.players) >= state.player_count:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
if len(state.players) == state.player_count:
start_game(state)
def start_game(state: ScopaState) -> None:
"""Deal the first hand and switch the game to playing."""
if len(state.players) != state.player_count:
raise GameNotStarted(
f"need exactly {state.player_count} players to start"
)
state.phase = PHASE_PLAYING
_deal_hand(state)
def _set_turn_deadline(state: ScopaState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _player_at(state: ScopaState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def _deal_hand(state: ScopaState) -> None:
"""Start a new hand: shuffle, lay the table, deal the first round."""
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
player.scope = 0
state.table = []
state.last_taker = None
for _ in range(TABLE_CARDS):
state.table.append(deck.pop())
state.deck = deck
_deal_round(state)
log.debug(
"hand %d dealt (dealer seat %d, %d players, %d on the table)",
state.hand_number,
state.dealer,
state.player_count,
len(state.table),
)
def _deal_round(state: ScopaState) -> None:
"""Deal three cards to every player and give the lead to the player
after the dealer."""
for player in state.players:
player.hand = []
for _ in range(HAND_SIZE):
for offset in range(state.player_count):
player = _player_at(state, (state.dealer + 1 + offset) % state.player_count)
player.hand.append(state.deck.pop())
state.turn = (state.dealer + 1) % state.player_count
_set_turn_deadline(state)
def play(
state: ScopaState,
sub: str,
card_code: str,
capture_codes: Optional[Sequence[str]] = None,
) -> None:
"""Apply one move by the player identified by ``sub``.
``capture_codes`` selects which table cards to capture; it must be a
legal capture (see :func:`legal_captures`) when one exists and empty
otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass
on any violation.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase == PHASE_HAND_END:
raise IllegalMove("the hand is over; acknowledge the summary to continue")
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = state.player_for(sub)
if player is None or player.seat != state.turn:
raise NotYourTurn("it is not your turn")
card = parse_card(card_code)
if card not in player.hand:
raise CardNotInHand(f"card {card.code} is not in your hand")
# Remove the card now so the scopa check below can tell whether this
# was the last play of the hand.
player.hand.remove(card)
requested = [parse_card(c) for c in (capture_codes or [])]
table_before = list(state.table)
options = legal_captures(
table_before, card, asso_pigliatutto=state.asso_pigliatutto
)
# An asso pigliatutto capture: the ace takes the whole table because no
# equal rank was on it, or takes itself when the table is bare. Such a
# capture never scores a scopa.
pigliatutto = (
state.asso_pigliatutto
and card.rank == 1
and all(c.rank != card.rank for c in table_before)
)
taken: List[Card] = []
scopa = False
if not options:
if requested:
raise IllegalMove("no capture is possible with that card")
state.table.append(card)
else:
chosen = _match_option(options, requested)
if chosen is None:
raise IllegalMove("the requested capture is not legal")
taken = chosen
for captured in chosen:
state.table.remove(captured)
player.captured.append(captured)
player.captured.append(card)
state.last_taker = player.seat
# A scopa scores only if cards remain to be played this hand: a
# sweep on the last play of a round still scores when the deck has
# cards left to deal.
cards_left = bool(state.deck) or any(p.hand for p in state.players)
if not state.table and cards_left and not pigliatutto:
player.scope += 1
scopa = True
state.last_move = Move(
seat=player.seat,
name=player.name,
card=card.code,
captured=[c.code for c in taken],
scopa=scopa,
pigliatutto=pigliatutto,
)
if all(not p.hand for p in state.players):
if state.deck:
# Same hand continues with a fresh round of three cards each.
_deal_round(state)
else:
_end_hand(state)
else:
state.turn = (state.turn + 1) % state.player_count
_set_turn_deadline(state)
def _match_option(
options: Sequence[Sequence[Card]], requested: Sequence[Card]
) -> Optional[List[Card]]:
"""Return the option matching ``requested`` exactly, if any.
An empty request matches only an empty option — the asso pigliatutto
self-capture — so requesting nothing never satisfies a real capture.
"""
wanted = sorted(c.code for c in requested)
for option in options:
if sorted(c.code for c in option) == wanted:
return list(option)
return None
def auto_play(state: ScopaState, rng: Optional[random.Random] = None) -> None:
"""Play a random legal move for the player currently on turn.
A card is drawn at random from that player's hand; if it can capture,
one of the legal captures is chosen at random (the rules require a
capture when one exists). Delegates to :func:`play`, so the move is
fully validated and can end the hand or the match. Pass ``rng`` for
deterministic tests.
"""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
options = legal_captures(
state.table, card, asso_pigliatutto=state.asso_pigliatutto
)
capture = chooser.choice(options) if options else None
play(
state,
player.sub,
card.code,
[c.code for c in capture] if capture is not None else None,
)
def _end_hand(state: ScopaState) -> None:
"""Sweep the table and score the hand.
If the match continues, the game pauses in the ``hand_end`` phase so
every player can read the scoring summary; the next hand is dealt by
:func:`acknowledge_hand` once every player has acknowledged (or by the
hand-end timeout fired by the platform's deadline scheduler). If the
match is over the game goes to ``finished`` immediately.
"""
state.turn_deadline = None
if state.table and state.last_taker is not None:
taker = _player_at(state, state.last_taker)
taker.captured.extend(state.table)
state.table = []
points, details = hand_points(state)
for team in (0, 1):
state.scores[team] += points[team]
details["hand"] = state.hand_number
details["team_a_points"] = points[0]
details["team_b_points"] = points[1]
state.hand_scores.append(details)
a, b = state.scores
log.debug(
"hand %d scored A+%d B+%d (totals %d-%d)",
state.hand_number,
points[0],
points[1],
a,
b,
)
reached = max(a, b) >= state.target_score
if reached and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
log.debug("match ended, team %s wins", TEAM_NAMES[state.winner])
return
# Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: ScopaState, sub: str) -> None:
"""Record that ``sub`` has read the hand-end summary.
When every player has acknowledged, the next hand is dealt. Acknowledging
twice is a no-op; acknowledging outside the ``hand_end`` phase raises an
error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no hand summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < state.player_count:
return
state.hand_number += 1
state.dealer = (state.dealer + 1) % state.player_count
state.acked = []
state.hand_end_deadline = None
state.last_move = None
state.phase = PHASE_PLAYING
_deal_hand(state)
def primiera_score(captured: Sequence[Card]) -> int:
"""Return the primiera value of a capture pile (0 if a suit is absent)."""
best: Dict[str, int] = {}
for card in captured:
value = PRIMIERA_VALUES[card.rank]
if card.suit not in best or value > best[card.suit]:
best[card.suit] = value
if len(best) < len(SUITS):
return 0
return sum(best.values())
def hand_points(state: ScopaState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the hand points for both sides (index 0 = team A).
With four players the partners' piles and scopa counts are pooled; with
two each player is their own side.
"""
piles: List[List[Card]] = [[], []]
scope: List[int] = [0, 0]
for player in state.players:
piles[player.team].extend(player.captured)
scope[player.team] += player.scope
points = [0, 0]
award: Dict[str, Optional[str]] = {}
# Carte: most captured cards. Ties award nothing.
cards = [len(piles[0]), len(piles[1])]
if cards[0] != cards[1]:
winner = 0 if cards[0] > cards[1] else 1
points[winner] += 1
award["carte"] = TEAM_NAMES[winner]
else:
award["carte"] = None
# Denara: most diamond cards. Ties award nothing.
coins = [
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
]
if coins[0] != coins[1]:
winner = 0 if coins[0] > coins[1] else 1
points[winner] += 1
award["denara"] = TEAM_NAMES[winner]
else:
award["denara"] = None
# Settebello: the 7 of diamonds always belongs to someone.
settebello = [
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
]
if settebello[0] != settebello[1]:
winner = 0 if settebello[0] else 1
points[winner] += 1
award["settebello"] = TEAM_NAMES[winner]
# Primiera: highest value, only if the side holds all four suits.
primiera = [primiera_score(piles[t]) for t in (0, 1)]
if primiera[0] != primiera[1]:
winner = 0 if primiera[0] > primiera[1] else 1
points[winner] += 1
award["primiera"] = TEAM_NAMES[winner]
else:
award["primiera"] = None
# Scope: one point each.
points[0] += scope[0]
points[1] += scope[1]
return points, {
"cards": {"A": cards[0], "B": cards[1]},
"denara": {"A": coins[0], "B": coins[1]},
"settebello": {"A": settebello[0], "B": settebello[1]},
"primiera": {"A": primiera[0], "B": primiera[1]},
"scope": {"A": scope[0], "B": scope[1]},
"award": award,
}
def state_for_player(state: ScopaState, sub: Optional[str]) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
for player in state.players:
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": TEAM_NAMES[player.team],
"cards_left": len(player.hand),
"captured_count": len(player.captured),
"scope": player.scope,
}
if viewer is not None and viewer.seat == player.seat:
view["hand"] = [c.code for c in player.hand]
players.append(view)
payload: Dict[str, object] = {
"phase": state.phase,
"player_count": state.player_count,
"target_score": state.target_score,
"asso_pigliatutto": state.asso_pigliatutto,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
"deck_count": len(state.deck),
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
"table": [c.code for c in state.table],
"players": players,
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
"last_move": state.last_move.to_json() if state.last_move else None,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
}
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
payload["your_turn"] = True
# Only the player on turn receives their legal captures, so all the
# rule logic stays server-side.
legal_moves: Dict[str, List[List[str]]] = {}
for hand_card in viewer.hand:
options = legal_captures(
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
)
if options:
legal_moves[hand_card.code] = [
[c.code for c in option] for option in options
]
payload["legal_moves"] = legal_moves
return payload
@@ -0,0 +1,34 @@
"""Scopa errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely scopa-specific errors
are defined here.
"""
from __future__ import annotations
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
__all__ = [
"AlreadyJoined",
"CardNotInHand",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"IllegalMove",
"LobbyFull",
"NotYourTurn",
]
class CardNotInHand(GameError):
"""The played card is not held by the player."""
@@ -0,0 +1,305 @@
"""The platform-facing adapter for scopa.
:class:`ScopaEngine` (classic two-player) and
:class:`ScopaQuattroEngine` (four-player fixed partnerships) implement
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
:mod:`tavolo.scopa.engine`: they translate platform calls (create, join,
websocket actions, deadlines) into rules-engine calls and back, keeping
all scopa knowledge inside this package. Nothing outside ``tavolo.scopa``
needs to know about phases, turns or hand scoring.
"""
from __future__ import annotations
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Mapping, Optional
from tavolo.platform.engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from .state import (
DEFAULT_TARGET_SCORE,
DEFAULT_TARGET_SCORE_QUATTRO,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
ScopaState,
)
log = getLogger(__name__)
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
def _options_schema(default_target: int) -> Dict[str, Any]:
"""The creation options schema with this variant's target default."""
return {
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": default_target,
"description": "Match points the winning side must reach.",
},
"asso_pigliatutto": {
"type": "boolean",
"default": False,
"description": "Scopa d'assi: an ace sweeps the whole "
"table (no scopa)",
},
},
}
class _ScopaBase(GameEngine):
"""Shared platform adapter for the two-player and four-player games."""
player_count: int
#: Traditional match target of the variant; also the schema default.
default_target_score: int
def __init__(
self,
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target_score = options.get("target_score", self.default_target_score)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
asso_pigliatutto = options.get("asso_pigliatutto", False)
if not isinstance(asso_pigliatutto, bool):
raise IllegalMove("asso_pigliatutto must be a boolean")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
player_count=self.player_count,
target_score=target_score,
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
asso_pigliatutto=asso_pigliatutto,
)
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team=TEAM_NAMES[0],
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
seat = len(session.players)
engine.join_game(session.state, user_sub, display_name)
# join_game raises before appending on any violation, so the seat
# list stays in sync with the engine's players.
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team=TEAM_NAMES[seat % 2],
)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: ScopaState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
"asso_pigliatutto": state.asso_pigliatutto,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: ScopaState = session.state
if action == "play":
card = payload.get("card")
capture = payload.get("capture")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
raise IllegalMove("'capture' must be a list of card codes")
try:
engine.play(state, user_sub, card, capture)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "ack":
engine.acknowledge_hand(state, user_sub)
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return engine.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state: ScopaState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, ScopaState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return ScopaState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: ScopaState = session.state
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
if due_ms is None:
return None
return Deadline(
kind="turn",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
if due_ms is None:
return None
return Deadline(
kind="hand_end",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"hand_end:{state.hand_number}:{due_ms}",
)
return None
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
# Overtaken by events (a play landed in time, the hand was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: ScopaState = session.state
if kind == "turn":
engine.auto_play(state)
actor = state.last_move.seat if state.last_move is not None else None
log.info(
"auto-played for seat %s (turn timeout, hand %d)",
actor,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"hand %d auto-advanced after the acknowledgement timeout",
state.hand_number,
)
else: # pragma: no cover - next_deadline never emits other kinds
raise GameError(f"unknown deadline kind: {kind!r}")
# -- results -------------------------------------------------------------
def result(self, session: GameSession) -> MatchResult:
state: ScopaState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
teams = [
[p.sub for p in state.players if p.team == 0],
[p.sub for p in state.players if p.team == 1],
]
return MatchResult(
teams=teams,
winner_team=state.winner,
players=[
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=player.team == state.winner,
team=TEAM_NAMES[player.team],
score=float(state.scores[player.team]),
details={
"scope": player.scope,
"captured_cards": len(player.captured),
},
)
for player in state.players
],
summary={
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[state.winner],
"target_score": state.target_score,
"asso_pigliatutto": state.asso_pigliatutto,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
},
)
class ScopaEngine(_ScopaBase):
"""Classic two-player scopa as a platform game engine."""
id = "scopa"
name = "Scopa"
description = (
"The classic two-player Italian fishing game: three cards each, "
"four on the table, capture by rank or sum and sweep for scope. "
"First to the target score wins."
)
min_players = 2
max_players = 2
player_count = 2
default_target_score = DEFAULT_TARGET_SCORE
options_schema = _options_schema(DEFAULT_TARGET_SCORE)
class ScopaQuattroEngine(_ScopaBase):
"""Four-player partnership scopa as a platform game engine."""
id = "scopa_a_quattro"
name = "Scopa a quattro"
description = (
"Scopa for four players in fixed partnerships: three cards each, "
"four on the table, capture by rank or sum and sweep for scope. "
"First side to the target score wins."
)
min_players = 4
max_players = 4
player_count = 4
default_target_score = DEFAULT_TARGET_SCORE_QUATTRO
options_schema = _options_schema(DEFAULT_TARGET_SCORE_QUATTRO)
@@ -0,0 +1,265 @@
"""In-memory representation of a scopa game.
The whole mutable game lives in :class:`ScopaState`, which is serialized
to and from plain JSON for storage inside the platform's session envelope
(see :mod:`tavolo.platform.store`). Keeping the representation JSON-native
means the store needs no custom codecs and the state is inspectable with
``redis-cli``.
The same state serves the classic two-player game and the four-player
partnership variant (``player_count``): with two players the two seats
are individual sides, with four they are fixed partnerships (0/2 vs 1/3),
and either way the side is the seat's parity. Within a hand the deck is
dealt in rounds of three cards per player and persists across rounds in
:attr:`ScopaState.deck`.
The state contains only game data: the platform owns the session envelope
(id, join code, seats, timestamps, stats persistence) — see
:class:`tavolo.platform.engine.GameSession`.
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
SUITS = ("D", "C", "S", "B")
RANKS = tuple(range(1, 11))
# Teams are derived from the seat parity: seats 0 and 2 form team A
# (index 0), seats 1 and 3 form team B (index 1). In the two-player game
# that makes the two players their own sides (seats 0 and 1); in the
# four-player game partners sit opposite each other.
TEAM_A = 0
TEAM_B = 1
TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing"
# Between hands of an unfinished match: scoring summary shown to every
# player; the next hand is dealt once everyone acknowledges (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
# Traditional match targets: two-player scopa is played to 11 points,
# while the four-player partnership game (like scopone) goes to 21.
DEFAULT_TARGET_SCORE = 11
DEFAULT_TARGET_SCORE_QUATTRO = 21
DEFAULT_TARGET_SCORES = {2: DEFAULT_TARGET_SCORE, 4: DEFAULT_TARGET_SCORE_QUATTRO}
DEFAULT_PLAYER_COUNT = 2
PLAYER_COUNTS = (2, 4)
def team_of(seat: int) -> int:
return seat % 2
@dataclass(frozen=True)
class Card:
rank: int
suit: str
def __post_init__(self) -> None:
if self.suit not in SUITS:
raise ValueError(f"invalid suit: {self.suit!r}")
if self.rank not in RANKS:
raise ValueError(f"invalid rank: {self.rank!r}")
@property
def code(self) -> str:
return f"{self.rank:02d}{self.suit}"
@staticmethod
def parse(code: str) -> "Card":
code = str(code).upper()
if len(code) != 3 or not code[:2].isdigit():
raise ValueError(f"invalid card code: {code!r}")
return Card(rank=int(code[:2]), suit=code[2])
def to_json(self) -> str:
return self.code
@staticmethod
def from_json(value: Any) -> "Card":
return Card.parse(str(value))
def parse_card(code: Any) -> Card:
"""Parse a card code, raising :class:`ValueError` on malformed input."""
return Card.parse(str(code))
@dataclass
class Move:
"""Record of a single play, broadcast so every client can show who
played which card and what it captured."""
seat: int
name: str
card: str
captured: List[str] = field(default_factory=list)
scopa: bool = False
# The play was an asso pigliatutto capture (the whole table, or the
# ace itself when the table was empty).
pigliatutto: bool = False
def to_json(self) -> Dict[str, Any]:
return {
"seat": self.seat,
"name": self.name,
"card": self.card,
"captured": list(self.captured),
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Move":
return Move(
seat=int(data["seat"]),
name=str(data["name"]),
card=str(data["card"]),
captured=[str(c) for c in data.get("captured", [])],
scopa=bool(data.get("scopa", False)),
pigliatutto=bool(data.get("pigliatutto", False)),
)
@dataclass
class PlayerState:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
scope: int = 0
@property
def team(self) -> int:
return team_of(self.seat)
def to_json(self) -> Dict[str, Any]:
return {
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": [c.to_json() for c in self.hand],
"captured": [c.to_json() for c in self.captured],
"scope": self.scope,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "PlayerState":
return PlayerState(
sub=str(data["sub"]),
name=str(data["name"]),
seat=int(data["seat"]),
hand=[Card.from_json(c) for c in data.get("hand", [])],
captured=[Card.from_json(c) for c in data.get("captured", [])],
scope=int(data.get("scope", 0)),
)
@dataclass
class ScopaState:
# 2 (individual) or 4 (fixed partnerships). Fixed for the session.
player_count: int = DEFAULT_PLAYER_COUNT
target_score: int = DEFAULT_TARGET_SCORE
# Whether the asso pigliatutto rule is scored (scopa d'assi: an ace
# sweeps the whole table when no equal rank is on it, or captures
# itself when the table is empty; the capture never scores a scopa).
# Default off, also for states serialized before the option existed.
asso_pigliatutto: bool = False
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list)
# Cards of the current hand not dealt yet: scopa deals three cards per
# player at a time, and re-deals when every hand is empty until the
# deck is exhausted.
deck: List[Card] = field(default_factory=list)
dealer: int = 0
turn: int = 0
hand_number: int = 1
scores: List[int] = field(default_factory=lambda: [0, 0])
winner: Optional[int] = None
last_taker: Optional[int] = None
# Per-hand points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# The most recent play in the current hand, for move announcements.
last_move: Optional[Move] = None
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30
# While phase == "playing": when the server plays a random legal card
# for the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = 30
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"player_count": self.player_count,
"target_score": self.target_score,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table],
"deck": [c.to_json() for c in self.deck],
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"scores": list(self.scores),
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": list(self.hand_scores),
"last_move": self.last_move.to_json() if self.last_move else None,
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "ScopaState":
return ScopaState(
player_count=int(data.get("player_count", DEFAULT_PLAYER_COUNT)),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])],
deck=[Card.from_json(c) for c in data.get("deck", [])],
dealer=int(data.get("dealer", 0)),
turn=int(data.get("turn", 0)),
hand_number=int(data.get("hand_number", 1)),
scores=[int(x) for x in data.get("scores", [0, 0])],
winner=data.get("winner"),
last_taker=data.get("last_taker"),
hand_scores=list(data.get("hand_scores", [])),
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(data.get("turn_timeout", 30)),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
return None
def seated(self, sub: str) -> bool:
return self.player_for(sub) is not None
@@ -0,0 +1,625 @@
"""Rule engine tests: deals, captures, scope, scoring and full matches."""
from __future__ import annotations
import random
import unittest
from tavolo.scopa import engine
from tavolo.scopa.errors import (
AlreadyJoined,
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
Card,
PlayerState,
ScopaState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
table,
turn: int = 0,
*,
dealer: int = 0,
captured=None,
scope=None,
target: int = 11,
last_taker=None,
deck=None,
asso_pigliatutto: bool = False,
) -> ScopaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = ScopaState(
player_count=len(hands),
target_score=target,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_PLAYING,
dealer=dealer,
turn=turn,
last_taker=last_taker,
)
for seat, hand in enumerate(hands):
state.players.append(
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand])
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
if scope is not None:
for player, value in zip(state.players, scope):
player.scope = value
state.table = [card(c) for c in table]
state.deck = [card(c) for c in (deck or [])]
return state
class DeckTest(unittest.TestCase):
def test_full_deck_has_40_unique_cards(self) -> None:
deck = engine.full_deck()
self.assertEqual(40, len(deck))
self.assertEqual(40, len({c.code for c in deck}))
self.assertEqual(4, len({c.suit for c in deck}))
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
def test_shuffled_deck_is_permutation(self) -> None:
deck = engine.shuffled_deck()
self.assertEqual(
sorted(c.code for c in engine.full_deck()),
sorted(c.code for c in deck),
)
class CaptureTest(unittest.TestCase):
def test_equal_card_is_mandatory(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
def test_sum_combination(self) -> None:
table = [card("01C"), card("03C"), card("02S")]
options = engine.legal_captures(table, card("04D"))
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
table = [card("05C"), card("05S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual(
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
)
def test_no_capture(self) -> None:
table = [card("09C"), card("08S")]
self.assertEqual([], engine.legal_captures(table, card("02D")))
def test_play_without_capture_places_card_on_table(self) -> None:
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
engine.play(state, "p0", "02D")
self.assertIn("02D", [c.code for c in state.table])
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
self.assertEqual(1, state.turn)
def test_play_capture_and_scopa(self) -> None:
state = make_state([["02D", "09C"], ["01C"]], table=["02C"])
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(1, state.players[0].scope)
self.assertEqual([], state.table)
self.assertEqual(
["02C", "02D"], [c.code for c in state.players[0].captured]
)
# The move is recorded for the "who played what" announcement.
assert state.last_move is not None
self.assertEqual(0, state.last_move.seat)
self.assertEqual("p0", state.last_move.name)
self.assertEqual("02D", state.last_move.card)
self.assertEqual(["02C"], state.last_move.captured)
self.assertTrue(state.last_move.scopa)
def test_play_without_capture_records_move(self) -> None:
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
engine.play(state, "p0", "02D")
assert state.last_move is not None
self.assertEqual("02D", state.last_move.card)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_illegal_combination_when_equal_card_present(self) -> None:
state = make_state([["05D"], ["01C"]],
table=["05C", "02D", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D", ["02D", "03S"])
def test_illegal_capture_rejected(self) -> None:
state = make_state([["04D"], ["01C"]], table=["02C", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "04D", ["02C"])
def test_no_capture_requested_when_capture_possible(self) -> None:
state = make_state([["05D"], ["01C"]], table=["05C"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D")
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_game_rejects_moves(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
class DealTest(unittest.TestCase):
def test_create_game_defaults(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(2, state.player_count)
# Two-player scopa is traditionally played to 11 points.
self.assertEqual(11, state.target_score)
self.assertFalse(state.asso_pigliatutto)
self.assertEqual("p0", state.players[0].sub)
self.assertEqual("lobby", state.phase)
def test_create_game_four_player_default_target(self) -> None:
# The four-player partnership game (like scopone) goes to 21.
state = engine.create_game("p0", "p0", player_count=4)
self.assertEqual(21, state.target_score)
# An explicit target still wins over the default.
explicit = engine.create_game("p0", "p0", player_count=4, target_score=16)
self.assertEqual(16, explicit.target_score)
def test_create_game_player_count_validation(self) -> None:
for bad in (1, 3, 5, 0):
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=bad)
def test_join_starts_two_player_game(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
self.assertEqual(4, len({c.code for c in state.table}))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 6 in hand.
self.assertEqual(30, len(state.deck))
self.assertEqual(1, state.turn)
def test_join_starts_four_player_game(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 12 in hand.
self.assertEqual(24, len(state.deck))
self.assertEqual(1, state.turn)
for seat in range(4):
self.assertEqual(seat % 2, engine._player_at(state, seat).team)
def test_join_errors(self) -> None:
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
# The lobby filled up and the match started.
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_start_requires_full_table(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.start_game(state)
class RoundTest(unittest.TestCase):
def test_redeal_when_hands_empty_and_deck_has_cards(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=["09C"],
deck=deck, turn=0, dealer=0)
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
# The last card of the round: the next three cards are dealt.
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(3, len(player.hand))
self.assertEqual([], state.deck)
# The table is never topped up.
self.assertEqual(
{"09C", "02D", "03D"}, {c.code for c in state.table}
)
# The player after the dealer leads every round again.
self.assertEqual(1, state.turn)
def test_redeal_gives_the_lead_back_to_the_non_dealer(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=[], deck=deck,
turn=0, dealer=1)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
# Dealer seat 1: the lead is seat 0 again.
self.assertEqual(0, state.turn)
def test_hand_ends_when_deck_is_exhausted(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], deck=[], target=100)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_HAND_END, state.phase)
class ScopaTest(unittest.TestCase):
def test_scopa_on_the_last_play_of_a_round_with_deck_left(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], []], table=["02C"], deck=deck, turn=0)
engine.play(state, "p0", "02D", ["02C"])
# Cards remain in the deck: the sweep is a scopa, and the round is
# re-dealt immediately.
self.assertEqual(1, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.scopa)
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_no_scopa_on_the_last_play_of_the_hand(self) -> None:
state = make_state([["02D"], []], table=["02C"], deck=[],
turn=0, target=100)
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertFalse(state.last_move.scopa)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# p1 still holds cards, so emptying the table would normally score
# a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"]],
table=["05C", "02D", "03S"],
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
self.assertEqual([], state.table)
self.assertEqual(
{"05C", "02D", "03S", "01D"},
{c.code for c in state.players[0].captured},
)
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertFalse(state.last_move.scopa)
def test_legal_captures_offer_only_the_whole_table(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(
table, card("01D"), asso_pigliatutto=True
)
self.assertEqual(
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
)
# A bare table leaves only the self-capture option; without the
# rule the ace stays on the table.
self.assertEqual(
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
)
self.assertEqual(
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
)
# Non-ace cards keep the normal rules.
self.assertEqual(
[],
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
)
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
asso_pigliatutto=True,
)
options = engine.legal_captures(
state.table, card("01D"), asso_pigliatutto=True
)
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "01D", ["01C", "02D"])
# A rejected play consumes the card, so retry on a fresh state.
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D", ["01C"])
self.assertEqual(["02D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_disabled_by_default(self) -> None:
state = make_state([["01D", "09C"], ["04D"]], table=["05C", "02D"])
engine.play(state, "p0", "01D")
# Without the rule an ace captures nothing here and stays on the
# table.
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
# ... and on a bare table it stays there too.
state = make_state([["01D", "09C"], ["04D"]], table=[])
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
def test_ace_on_empty_table_captures_itself(self) -> None:
# The self-capture makes p0 the last taker and scores no scopa.
state = make_state(
[["01D", "09C"], ["04D"]],
table=[],
last_taker=1,
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D")
self.assertEqual([], state.table)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual(0, state.players[0].scope)
self.assertEqual(0, state.last_taker)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_non_ace_on_empty_table_stays(self) -> None:
state = make_state(
[["02D", "09C"], ["04D"]], table=[], asso_pigliatutto=True
)
engine.play(state, "p0", "02D")
self.assertEqual(["02D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
class HandEndTest(unittest.TestCase):
def test_table_swept_to_last_taker(self) -> None:
state = make_state([["02D"], []], table=["05C", "04D"],
last_taker=1, deck=[], target=100)
engine.play(state, "p0", "02D")
captured = {c.code for c in state.players[1].captured}
self.assertEqual({"05C", "04D", "02D"}, captured)
self.assertEqual(PHASE_HAND_END, state.phase)
def test_match_ends_at_target_with_clear_lead(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], turn=0,
target=1, captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
def test_tie_at_target_plays_another_hand(self) -> None:
state = make_state(
[["02D"], []],
table=[],
deck=[],
target=1,
captured=[["01D", "02C"], ["02D", "01C"]],
)
state.scores = [1, 1]
engine.play(state, "p0", "02D")
# A 0-0 hand at the target is not a win: another hand is played.
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.winner)
def test_acknowledge_deals_next_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
state.phase = PHASE_HAND_END
engine.acknowledge_hand(state, "p0")
self.assertEqual(PHASE_HAND_END, state.phase)
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual(1, state.dealer)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_acknowledge_rejects_outside_hand_end(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_hand_summary_has_no_napola(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], target=1,
captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertNotIn("napola", state.hand_scores[-1])
class ScoringTest(unittest.TestCase):
def test_primiera_values_and_all_suits_requirement(self) -> None:
self.assertEqual(70, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
self.assertEqual(0, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S"]]))
self.assertEqual(40, engine.primiera_score(
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
def test_two_player_hand_points(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"],
["03D", "04C", "07S", "02B"],
],
scope=[1, 0],
)
points, details = engine.hand_points(state)
# Carte and denara tie; A takes settebello and primiera, plus one
# scopa: A+3.
self.assertEqual({"A": 4, "B": 4}, details["cards"])
self.assertEqual({"A": 1, "B": 1}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 0}, details["scope"])
self.assertEqual([3, 0], points)
def test_ties_award_nothing(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["06C", "01S", "05B", "02D"],
["06S", "01B", "05D", "02C"],
],
scope=[0, 0],
)
points, _ = engine.hand_points(state)
# Equal cards, equal denara, equal primiera and no settebello:
# everything ties, so no points at all.
self.assertEqual([0, 0], points)
def test_four_player_pools_the_partners(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"], # seat 0, team A
["03D", "04C", "07S", "02B"], # seat 1, team B
["02D"], # seat 2, team A
["10D", "10C", "10S", "10B"], # seat 3, team B
],
scope=[1, 0, 0, 2],
)
points, details = engine.hand_points(state)
self.assertEqual([3, 3], points)
self.assertEqual({"A": 5, "B": 8}, details["cards"])
self.assertEqual({"A": 2, "B": 2}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 2}, details["scope"])
self.assertNotIn("napola", details)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game(
"p0", "p0", player_count=4, target_score=16,
asso_pigliatutto=True,
)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
restored = ScopaState.from_json(state.to_json())
self.assertEqual(4, restored.player_count)
self.assertEqual(16, restored.target_score)
self.assertTrue(restored.asso_pigliatutto)
self.assertEqual(
[c.code for c in state.deck], [c.code for c in restored.deck]
)
self.assertEqual(
[c.code for c in state.table], [c.code for c in restored.table]
)
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
def test_asso_pigliatutto_defaults_off(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScopaState.from_json(data).asso_pigliatutto)
def test_player_count_defaults_to_two(self) -> None:
data = ScopaState().to_json()
del data["player_count"]
self.assertEqual(2, ScopaState.from_json(data).player_count)
class AutoPlayTest(unittest.TestCase):
def test_auto_play_applies_a_legal_move(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(7)
player = engine._player_at(state, state.turn)
hand_size = len(player.hand)
engine.auto_play(state, rng)
self.assertEqual(hand_size - 1, len(player.hand))
assert state.last_move is not None
self.assertEqual(player.seat, state.last_move.seat)
class FullMatchTest(unittest.TestCase):
def _play_out(self, player_count: int, target: int) -> ScopaState:
state = engine.create_game("p0", "p0", player_count=player_count,
target_score=target)
for seat in range(1, player_count):
engine.join_game(state, f"p{seat}", f"p{seat}")
rng = random.Random(1234 + player_count)
steps = 0
while state.phase != PHASE_FINISHED and steps < 100_000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
engine.auto_play(state, rng)
steps += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIsNotNone(state.winner)
self.assertGreaterEqual(max(state.scores), target)
self.assertNotEqual(state.scores[0], state.scores[1])
return state
def test_random_two_player_match_reaches_a_winner(self) -> None:
state = self._play_out(2, 5)
for hand in state.hand_scores:
self.assertIn("cards", hand)
self.assertIn("scope", hand)
def test_random_four_player_match_reaches_a_winner(self) -> None:
state = self._play_out(4, 5)
self.assertEqual(4, len(state.players))
def test_all_cards_are_captured_by_the_end_of_a_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(99)
# Finish exactly one hand (target 100 keeps the match alive).
state.target_score = 100
while state.phase != PHASE_HAND_END:
engine.auto_play(state, rng)
self.assertIsNotNone(state.last_taker)
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,345 @@
"""Adapter tests: the platform contract over the pure scopa rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotStarted,
IllegalMove,
)
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
from tavolo.scopa import engine as rules
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
ScopaState,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="s1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for seat in range(1, engine.max_players):
engine.join(session, f"p{seat}", f"P{seat}")
return session
def _drive_to_end(engine, session: GameSession) -> None:
"""Play the match out with the first legal move each turn."""
moves = 0
while not engine.is_finished(session) and moves < 200_000:
state = session.state
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(
state.table, played, asso_pigliatutto=state.asso_pigliatutto
)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_side_a(self) -> None:
for engine_type, target in ((ScopaEngine, 11), (ScopaQuattroEngine, 21)):
engine = engine_type()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, ScopaState)
# Two-player scopa is played to 11, the partnership game to 21.
self.assertEqual(target, session.state.target_score)
self.assertFalse(session.state.asso_pigliatutto)
def test_schema_defaults_follow_the_variant(self) -> None:
self.assertEqual(
11, ScopaEngine().options_schema["properties"]["target_score"]["default"]
)
self.assertEqual(
21,
ScopaQuattroEngine().options_schema["properties"]["target_score"][
"default"
],
)
def test_create_options(self) -> None:
engine = ScopaEngine()
session = _session(engine, target_score=21, asso_pigliatutto=True)
self.assertEqual(21, session.state.target_score)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None:
engine = ScopaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=0)
with self.assertRaises(IllegalMove):
_session(engine, target_score=101)
with self.assertRaises(IllegalMove):
_session(engine, target_score="eleven")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None:
engine = ScopaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_join_starts_two_player_game(self) -> None:
engine = ScopaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(["A", "B"], [p.team for p in session.players])
def test_join_starts_four_player_game_with_teams(self) -> None:
engine = ScopaQuattroEngine()
session = _session(engine)
for seat, team in ((1, "B"), (2, "A"), (3, "B")):
engine.join(session, f"p{seat}", f"P{seat}")
self.assertEqual(team, session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = ScopaEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
engine.join(session, "bob", "Bob")
with self.assertRaises(GameNotStarted):
engine.join(session, "carol", "Carol")
with self.assertRaises(GameNotStarted):
engine.join(session, "alice", "Alice")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(
session, "p1", "play", {"card": "01D", "capture": "02C"}
)
def test_invalid_card_code_is_illegal_move(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": "nope"})
def test_finished_match_rejects_actions(self) -> None:
engine = ScopaEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
class ViewTest(unittest.TestCase):
def test_view_hides_other_hands_and_reports_the_deck(self) -> None:
engine = ScopaEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
self.assertEqual(2, view["player_count"])
self.assertEqual(30, view["deck_count"])
self.assertEqual(4, len(view["table"]))
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual(4, view["player_count"])
def test_lobby_view(self) -> None:
engine = ScopaEngine()
session = _session(engine, target_score=16, asso_pigliatutto=True)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ScopaState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
self.assertEqual(
[c.code for c in session.state.deck],
[c.code for c in restored.deck],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = ScopaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = ScopaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
# A forged token is stale.
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
engine = ScopaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = ScopaEngine()
session = _started(engine)
state = session.state
# Force the hand-end phase with an imminent deadline.
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_two_player_match(self) -> None:
engine = ScopaEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual(1, len(result.teams[0]))
self.assertEqual(1, len(result.teams[1]))
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(2, len(result.players))
for player in result.players:
self.assertEqual(
player.won,
player.team == ("A" if result.winner_team == 0 else "B"),
)
self.assertIn("team_a_score", result.summary)
self.assertIn("hands_played", result.summary)
self.assertIn("asso_pigliatutto", result.summary)
def test_result_of_finished_four_player_match(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
result = engine.result(session)
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertEqual(4, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = ScopaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = ScopaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [3, 11]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 3, "B": 11}, over["scores"])
self.assertEqual("B", over["winner"])
def test_registry_metadata(self) -> None:
two = ScopaEngine()
self.assertEqual("scopa", two.id)
self.assertEqual("Scopa", two.name)
self.assertEqual(2, two.min_players)
self.assertEqual(2, two.max_players)
four = ScopaQuattroEngine()
self.assertEqual("scopa_a_quattro", four.id)
self.assertEqual("Scopa a quattro", four.name)
self.assertEqual(4, four.min_players)
self.assertEqual(4, four.max_players)
for engine in (two, four):
self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn(
"asso_pigliatutto", engine.options_schema["properties"]
)
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
# tavolo-scopone
Scopone scientifico — the four-player, fixed-partnership Italian card
game — as a [`tavolo-platform`](../tavolo-platform/README.md) game
implementation.
## Contents
- `state.py` — `ScoponeState` (pure game data: phases, players, hands,
table, scores, deadlines), `PlayerState`, `Card`, `Move`, with JSON
(de)serialization. No session envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
napola), the optional asso pigliatutto capture, hand-end
acknowledgements. Deterministic and I/O-free apart from logging, so the
whole rule set is unit-testable.
- `errors.py` — scopone-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
It translates create/join/websocket actions/deadlines into rules-engine
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
creation options (the match target defaults to the traditional 21
points), and extracts the `MatchResult` (teams, winner,
per-player scores and the match summary persisted as the match's JSON
`result`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m mypy -p tavolo.scopone
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-scopone"
version = "0.1.0"
description = "Scopone scientifico 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,13 @@
"""Scopone scientifico: tavolo's first game implementation.
The pure rules (:mod:`tavolo.scopone.engine`, :mod:`tavolo.scopone.state`)
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopone.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 ScoponeEngine
__all__ = ["ScoponeEngine"]
@@ -2,9 +2,11 @@
Every function here is deterministic and I/O-free (the only side effect is
debug logging): it mutates (or reads)
:class:`~tavolo.game.state.GameState` and raises
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
:class:`~tavolo.scopone.state.ScoponeState` 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.scopone.plugin.ScoponeEngine`.
Rules implemented
-----------------
@@ -26,6 +28,12 @@ Rules implemented
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.
"""
@@ -37,15 +45,17 @@ from itertools import combinations
from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple
from .errors import (
from tavolo.platform.errors import (
AlreadyJoined,
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .errors import CardNotInHand
from .state import (
DEFAULT_TARGET_SCORE,
PHASE_FINISHED,
@@ -55,9 +65,9 @@ from .state import (
SUITS,
TEAM_NAMES,
Card,
GameState,
Move,
PlayerState,
ScoponeState,
parse_card,
)
@@ -68,13 +78,12 @@ HAND_SIZE = 10
PLAYERS = 4
# Default seconds the hand-end summary waits before dealing anyway. Games
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
# carry their own copy in ``ScoponeState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# legal card for them. Games carry their own copy in
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS).
# ``ScoponeState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the
@@ -107,17 +116,23 @@ def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
return deck
def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]:
def legal_captures(
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
) -> List[List[Card]]:
"""Return every legal capture (a list of card sets) for ``card``.
If an equal-ranked card is on the table, only those single-card
captures are returned (the rule forbids taking a combination instead).
Otherwise every subset of the table whose ranks sum to ``card.rank`` is
returned.
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
the only capture is the whole table — or an empty capture when the
table is bare, the ace taking itself. Without the option every subset
of the table whose ranks sum to ``card.rank`` is returned.
"""
equal = [c for c in table if c.rank == card.rank]
if equal:
return [[c] for c in equal]
if asso_pigliatutto and card.rank == 1:
return [list(table)]
candidates = [c for c in table if c.rank <= card.rank]
captures: List[List[Card]] = []
@@ -131,35 +146,29 @@ def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]:
def create_game(
game_id: str,
join_code: str,
creator_sub: str,
creator_name: str,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
game_type: str = "scopone_scientifico",
napola: bool = True,
) -> GameState:
asso_pigliatutto: bool = False,
) -> ScoponeState:
"""Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100:
raise IllegalMove("target_score must be between 1 and 100")
return GameState(
id=game_id,
join_code=join_code,
creator_sub=creator_sub,
game_type=game_type,
return ScoponeState(
target_score=target_score,
napola=napola,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
created_at=datetime.now(timezone.utc).isoformat(),
)
def join_game(state: GameState, sub: str, name: str) -> None:
def join_game(state: ScoponeState, 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")
@@ -173,7 +182,7 @@ def join_game(state: GameState, sub: str, name: str) -> None:
start_game(state)
def start_game(state: GameState) -> None:
def start_game(state: ScoponeState) -> None:
"""Deal the first hand and switch the game to playing."""
if len(state.players) != PLAYERS:
raise GameNotStarted("need exactly four players to start")
@@ -181,13 +190,13 @@ def start_game(state: GameState) -> None:
_deal_hand(state)
def _set_turn_deadline(state: GameState) -> None:
def _set_turn_deadline(state: ScoponeState) -> 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_hand(state: GameState) -> None:
def _deal_hand(state: ScoponeState) -> None:
deck = shuffled_deck()
for player in state.players:
player.hand = []
@@ -203,10 +212,10 @@ def _deal_hand(state: GameState) -> None:
for seat in range(PLAYERS):
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
player.hand.append(deck.pop())
log.debug("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer)
log.debug("hand %d dealt (dealer seat %d)", state.hand_number, state.dealer)
def _player_at(state: GameState, seat: int) -> PlayerState:
def _player_at(state: ScoponeState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
@@ -214,7 +223,7 @@ def _player_at(state: GameState, seat: int) -> PlayerState:
def play(
state: GameState,
state: ScoponeState,
sub: str,
card_code: str,
capture_codes: Optional[Sequence[str]] = None,
@@ -223,8 +232,8 @@ def play(
``capture_codes`` selects which table cards to capture; it must be a
legal capture (see :func:`legal_captures`) when one exists and empty
otherwise. Raises a :class:`~tavolo.game.errors.GameError` subclass on
any violation.
otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass
on any violation.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
@@ -245,7 +254,18 @@ def play(
player.hand.remove(card)
requested = [parse_card(c) for c in (capture_codes or [])]
options = legal_captures(state.table, card)
table_before = list(state.table)
options = legal_captures(
table_before, card, asso_pigliatutto=state.asso_pigliatutto
)
# An asso pigliatutto capture: the ace takes the whole table because no
# equal rank was on it, or takes itself when the table is bare. Such a
# capture never scores a scopa.
pigliatutto = (
state.asso_pigliatutto
and card.rank == 1
and all(c.rank != card.rank for c in table_before)
)
taken: List[Card] = []
scopa = False
@@ -265,7 +285,7 @@ def play(
state.last_taker = player.seat
# A scopa scores only if cards remain to be played this hand.
hands_empty = all(not p.hand for p in state.players)
if not state.table and not hands_empty:
if not state.table and not hands_empty and not pigliatutto:
player.scope += 1
scopa = True
@@ -275,6 +295,7 @@ def play(
card=card.code,
captured=[c.code for c in taken],
scopa=scopa,
pigliatutto=pigliatutto,
)
if all(not p.hand for p in state.players):
@@ -287,17 +308,19 @@ def play(
def _match_option(
options: Sequence[Sequence[Card]], requested: Sequence[Card]
) -> Optional[List[Card]]:
"""Return the option matching ``requested`` exactly, if any."""
"""Return the option matching ``requested`` exactly, if any.
An empty request matches only an empty option — the asso pigliatutto
self-capture — so requesting nothing never satisfies a real capture.
"""
wanted = sorted(c.code for c in requested)
if not wanted:
return None
for option in options:
if sorted(c.code for c in option) == wanted:
return list(option)
return None
def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None:
"""Play a random legal move for the player currently on turn.
A card is drawn at random from that player's hand; if it can capture,
@@ -313,24 +336,26 @@ def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
options = legal_captures(state.table, card)
options = legal_captures(
state.table, card, asso_pigliatutto=state.asso_pigliatutto
)
capture = chooser.choice(options) if options else None
play(
state,
player.sub,
card.code,
[c.code for c in capture] if capture else None,
[c.code for c in capture] if capture is not None else None,
)
def _end_hand(state: GameState) -> None:
def _end_hand(state: ScoponeState) -> None:
"""Sweep the table and score the hand.
If the match continues, the game pauses in the ``hand_end`` phase so
every player can read the scoring summary; the next hand is dealt by
:func:`acknowledge_hand` once all four players have acknowledged (or by
the hand-end timeout in the websocket layer). If the match is over the
game goes to ``finished`` immediately.
:func:`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.
"""
state.turn_deadline = None
if state.table and state.last_taker is not None:
@@ -348,8 +373,7 @@ def _end_hand(state: GameState) -> None:
a, b = state.scores
log.debug(
"game %s: hand %d scored A+%d B+%d (totals %d-%d)",
state.id,
"hand %d scored A+%d B+%d (totals %d-%d)",
state.hand_number,
points[0],
points[1],
@@ -364,15 +388,13 @@ def _end_hand(state: GameState) -> None:
if napola.get(name) == 10:
state.phase = PHASE_FINISHED
state.winner = team
state.finished_at = datetime.now(timezone.utc).isoformat()
log.info("game %s: team %s swept the denari (napola) and wins", state.id, name)
log.info("team %s swept the denari (napola) and wins", name)
return
reached = max(a, b) >= state.target_score
if reached and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
state.finished_at = datetime.now(timezone.utc).isoformat()
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
log.debug("match ended, team %s wins", "A" if state.winner == 0 else "B")
return
# Pause for the scoring summary instead of dealing immediately.
@@ -382,7 +404,7 @@ def _end_hand(state: GameState) -> None:
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: GameState, sub: str) -> None:
def acknowledge_hand(state: ScoponeState, sub: str) -> None:
"""Record that ``sub`` has read the hand-end summary.
When all four players have acknowledged, the next hand is dealt.
@@ -437,7 +459,7 @@ def napola_score(captured: Sequence[Card]) -> int:
return run if run >= 3 else 0
def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the hand points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []]
scope: List[int] = [0, 0]
@@ -506,13 +528,14 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
return points, details
def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
def state_for_player(state: ScoponeState, 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. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via
the caller; this function assumes ``sub`` may or may not be seated and
simply omits the hand for non-seated viewers.
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]] = []
@@ -531,12 +554,10 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
players.append(view)
payload: Dict[str, object] = {
"id": state.id,
"join_code": state.join_code,
"game_type": state.game_type,
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
@@ -556,7 +577,9 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
# rule logic stays server-side.
legal_moves: Dict[str, List[List[str]]] = {}
for hand_card in viewer.hand:
options = legal_captures(state.table, hand_card)
options = legal_captures(
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
)
if options:
legal_moves[hand_card.code] = [
[c.code for c in option] for option in options
@@ -0,0 +1,34 @@
"""Scopone scientifico errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely scopone-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,277 @@
"""The platform-facing adapter for scopone scientifico.
:class:`ScoponeEngine` implements
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
:mod:`tavolo.scopone.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.
"""
from __future__ import annotations
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Mapping, Optional
from tavolo.platform.engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from .state import (
DEFAULT_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
ScoponeState,
)
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 ScoponeEngine(GameEngine):
"""Scopone scientifico as a platform game engine."""
id = "scopone_scientifico"
name = "Scopone scientifico"
description = (
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
)
min_players = 4
max_players = 4
options_schema = {
"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)",
},
},
}
def __init__(
self,
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
napola = options.get("napola", True)
if not isinstance(napola, bool):
raise IllegalMove("napola must be a boolean")
asso_pigliatutto = options.get("asso_pigliatutto", False)
if not isinstance(asso_pigliatutto, bool):
raise IllegalMove("asso_pigliatutto must be a boolean")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
target_score=target_score,
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
napola=napola,
asso_pigliatutto=asso_pigliatutto,
)
# 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: ScoponeState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: ScoponeState = session.state
if action == "play":
card = payload.get("card")
capture = payload.get("capture")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
raise IllegalMove("'capture' must be a list of card codes")
try:
engine.play(state, user_sub, card, capture)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "ack":
engine.acknowledge_hand(state, user_sub)
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return engine.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state: ScoponeState = 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, ScoponeState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return ScoponeState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: ScoponeState = session.state
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
if due_ms is None:
return None
return Deadline(
kind="turn",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
if due_ms is None:
return None
return Deadline(
kind="hand_end",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"hand_end:{state.hand_number}:{due_ms}",
)
return None
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
# Overtaken by events (a play landed in time, the hand was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: ScoponeState = session.state
if kind == "turn":
engine.auto_play(state)
actor = state.last_move.seat if state.last_move is not None else None
log.info(
"auto-played for seat %s (turn timeout, hand %d)",
actor,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"hand %d auto-advanced after the acknowledgement timeout",
state.hand_number,
)
else: # pragma: no cover - next_deadline never emits other kinds
raise GameError(f"unknown deadline kind: {kind!r}")
# -- results -------------------------------------------------------------
def result(self, session: GameSession) -> MatchResult:
state: ScoponeState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
teams = [
[p.sub for p in state.players if p.team == 0],
[p.sub for p in state.players if p.team == 1],
]
return MatchResult(
teams=teams,
winner_team=state.winner,
players=[
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=player.team == state.winner,
team=TEAM_NAMES[player.team],
score=float(state.scores[player.team]),
details={"scope": player.scope},
)
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,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
},
)
@@ -1,9 +1,14 @@
"""In-memory representation of a scopone scientifico game.
The whole mutable game lives in :class:`GameState`, which is serialized to
and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping
the representation JSON-native means the store needs no custom codecs and
the state is inspectable with ``redis-cli``.
The whole mutable game lives in :class:`ScoponeState`, 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``.
@@ -32,7 +37,9 @@ PHASE_PLAYING = "playing"
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
DEFAULT_TARGET_SCORE = 11
# The traditional match target for scopone scientifico: the partnership
# game is played to 21 points.
DEFAULT_TARGET_SCORE = 21
def team_of(seat: int) -> int:
@@ -87,6 +94,9 @@ class Move:
card: str
captured: List[str] = field(default_factory=list)
scopa: bool = False
# The play was an asso pigliatutto capture (the whole table, or the
# ace itself when the table was empty).
pigliatutto: bool = False
def to_json(self) -> Dict[str, Any]:
return {
@@ -95,6 +105,7 @@ class Move:
"card": self.card,
"captured": list(self.captured),
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
}
@staticmethod
@@ -105,6 +116,7 @@ class Move:
card=str(data["card"]),
captured=[str(c) for c in data.get("captured", [])],
scopa=bool(data.get("scopa", False)),
pigliatutto=bool(data.get("pigliatutto", False)),
)
@@ -144,18 +156,16 @@ class PlayerState:
@dataclass
class GameState:
id: str
join_code: str
creator_sub: str
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
# Defaults so states serialized before game types existed still load.
game_type: str = "scopone_scientifico"
class ScoponeState:
target_score: int = DEFAULT_TARGET_SCORE
# Whether the napola rule is scored (denari run from the ace; a full
# suit wins the match instantly). Default on, also for states
# serialized before the option existed.
# suit wins the match instantly). Default on.
napola: bool = True
# 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.
asso_pigliatutto: bool = False
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list)
@@ -167,10 +177,6 @@ class GameState:
last_taker: Optional[int] = None
# Per-hand points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
stats_saved: bool = False
# ISO-8601 timestamps, used when the match result is written to Postgres.
created_at: Optional[str] = None
finished_at: Optional[str] = None
# The most recent play in the current hand, for move announcements.
last_move: Optional[Move] = None
# While phase == "hand_end": seats that acknowledged the summary, and
@@ -180,7 +186,7 @@ class GameState:
# Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30
# While phase == "playing": when the server plays a random legal card
# for the player on turn. Copied from settings at creation.
# for the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = 30
@@ -188,12 +194,9 @@ class GameState:
def to_json(self) -> Dict[str, Any]:
return {
"id": self.id,
"join_code": self.join_code,
"creator_sub": self.creator_sub,
"game_type": self.game_type,
"target_score": self.target_score,
"napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table],
@@ -204,9 +207,6 @@ class GameState:
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": list(self.hand_scores),
"stats_saved": self.stats_saved,
"created_at": self.created_at,
"finished_at": self.finished_at,
"last_move": self.last_move.to_json() if self.last_move else None,
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
@@ -216,14 +216,11 @@ class GameState:
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "GameState":
return GameState(
id=str(data["id"]),
join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")),
game_type=str(data.get("game_type", "scopone_scientifico")),
def from_json(data: Dict[str, Any]) -> "ScoponeState":
return ScoponeState(
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
napola=bool(data.get("napola", True)),
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])],
@@ -234,9 +231,6 @@ class GameState:
winner=data.get("winner"),
last_taker=data.get("last_taker"),
hand_scores=list(data.get("hand_scores", [])),
stats_saved=bool(data.get("stats_saved", False)),
created_at=data.get("created_at"),
finished_at=data.get("finished_at"),
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
@@ -247,7 +241,7 @@ class GameState:
# -- helpers ----------------------------------------------------------
def player_for(self, sub: str) -> Optional[PlayerState]:
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
@@ -4,19 +4,19 @@ from __future__ import annotations
import random
import unittest
from tavolo.game import engine
from tavolo.game.errors import (
from tavolo.scopone import engine
from tavolo.scopone.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.game.state import (
from tavolo.scopone.state import (
PHASE_FINISHED,
PHASE_PLAYING,
Card,
GameState,
ScoponeState,
PlayerState,
)
@@ -34,12 +34,9 @@ def make_state(
scope=None,
target: int = 11,
last_taker=None,
) -> GameState:
) -> ScoponeState:
"""Build a controlled game state directly (bypassing the deal)."""
state = GameState(
id="game-1",
join_code="ABC123",
creator_sub="p0",
state = ScoponeState(
target_score=target,
phase=PHASE_PLAYING,
turn=turn,
@@ -315,22 +312,237 @@ class NapolaTest(unittest.TestCase):
state = make_state([["02D"], [], [], []], table=[])
self.assertTrue(state.napola)
state.napola = False
self.assertFalse(GameState.from_json(state.to_json()).napola)
self.assertFalse(ScoponeState.from_json(state.to_json()).napola)
# States serialized before the option existed default to enabled.
data = state.to_json()
del data["napola"]
self.assertTrue(GameState.from_json(data).napola)
self.assertTrue(ScoponeState.from_json(data).napola)
def test_create_game_napola_default_and_override(self) -> None:
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
self.assertTrue(engine.create_game("p0", "p0").napola)
self.assertFalse(
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
engine.create_game("p0", "p0", napola=False).napola
)
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# The other players still hold cards, so emptying the table would
# normally score a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D", "03S"],
)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
self.assertEqual([], state.table)
self.assertEqual(
{"05C", "02D", "03S", "01D"},
{c.code for c in state.players[0].captured},
)
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertFalse(state.last_move.scopa)
def test_legal_captures_offer_only_the_whole_table(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(
table, card("01D"), asso_pigliatutto=True
)
self.assertEqual(
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
)
# A bare table leaves only the self-capture option; without the
# rule the ace stays on the table.
self.assertEqual(
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
)
self.assertEqual(
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
)
# Non-ace cards keep the normal rules.
self.assertEqual(
[],
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
)
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
table = ["01C", "02D"]
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
state.asso_pigliatutto = True
options = engine.legal_captures(
state.table, card("01D"), asso_pigliatutto=True
)
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "01D", ["01C", "02D"])
# A rejected play consumes the card, so retry on a fresh state.
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D", ["01C"])
self.assertEqual(["02D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_disabled_by_default(self) -> None:
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D"],
)
engine.play(state, "p0", "01D")
# Without the rule an ace captures nothing here and stays on the
# table.
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
# ... and on a bare table it stays there too.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
)
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
self.assertEqual(
[],
engine.legal_captures(
[card("05C")], card("01D"), asso_pigliatutto=False
),
)
def test_ace_on_empty_table_captures_itself(self) -> None:
# Other players still hold cards, so the bare table would trigger a
# (nonsensical) scopa without the pigliatutto suppression. The
# self-capture makes p0 the last taker, replacing seat 1.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=[],
last_taker=1,
)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D")
self.assertEqual([], state.table)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual(0, state.players[0].scope)
self.assertEqual(0, state.last_taker)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_non_ace_on_empty_table_stays(self) -> None:
state = make_state(
[["02D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
engine.play(state, "p0", "02D")
self.assertEqual(["02D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_move_pigliatutto_serialization(self) -> None:
move = engine.Move(
seat=0, name="p0", card="01D", captured=["05C"], pigliatutto=True
)
restored = engine.Move.from_json(move.to_json())
self.assertTrue(restored.pigliatutto)
legacy = move.to_json()
del legacy["pigliatutto"]
self.assertFalse(engine.Move.from_json(legacy).pigliatutto)
def test_state_serialization_roundtrip(self) -> None:
state = make_state([["02D"], [], [], []], table=[])
self.assertFalse(state.asso_pigliatutto)
state.asso_pigliatutto = True
self.assertTrue(
ScoponeState.from_json(state.to_json()).asso_pigliatutto
)
# States serialized before the option existed default to disabled.
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScoponeState.from_json(data).asso_pigliatutto)
def test_create_game_default_and_override(self) -> None:
self.assertFalse(engine.create_game("p0", "p0").asso_pigliatutto)
self.assertTrue(
engine.create_game("p0", "p0", asso_pigliatutto=True).asso_pigliatutto
)
def test_state_for_player_exposes_rule_and_legal_moves(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D", "03S"],
)
state.asso_pigliatutto = True
view = engine.state_for_player(state, "p0")
self.assertTrue(view["asso_pigliatutto"])
self.assertEqual(
{"01D": [["05C", "02D", "03S"]]}, view["legal_moves"]
)
# On a bare table the ace advertises the self-capture.
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
view = engine.state_for_player(state, "p0")
self.assertEqual({"01D": [[]]}, view["legal_moves"])
def test_auto_play_sweeps_with_the_rule(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D"],
)
state.asso_pigliatutto = True
engine.auto_play(state)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual(["05C", "02D"], state.last_move.captured)
def test_auto_play_self_captures_on_bare_table(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
engine.auto_play(state)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual([], state.table)
def test_full_random_match_with_rule_keeps_all_cards(self) -> None:
state = engine.create_game(
"p0", "p0", target_score=11, asso_pigliatutto=True
)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == "hand_end":
for p in state.players:
engine.acknowledge_hand(state, p.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = engine.legal_captures(
state.table, played, asso_pigliatutto=state.asso_pigliatutto
)
capture = [c.code for c in options[0]] if options else None
engine.play(state, player.sub, played.code, capture)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
# Self-captures included, all 40 cards end up in capture piles.
self.assertEqual([], state.table)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(40, sum(len(p.captured) for p in state.players))
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
state = engine.create_game("p0", "p0", target_score=11)
self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1")
engine.join_game(state, "p2", "p2")
@@ -362,6 +574,27 @@ class MatchFlowTest(unittest.TestCase):
self.assertEqual(["07C"], view["table"])
self.assertTrue(view.get("your_turn"))
def test_state_for_player_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"],
captured=[["01D"], [], ["02C"], ["03B"]],
scope=[1, 0, 2, 0],
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
# The public table state is still fully visible.
self.assertEqual(["07C"], view["table"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(1, players[0]["captured_count"])
self.assertEqual(1, players[0]["scope"])
self.assertEqual(2, players[2]["scope"])
self.assertEqual(1, players[1]["cards_left"])
def test_legal_moves_only_for_player_on_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"])
@@ -382,7 +615,7 @@ class MatchFlowTest(unittest.TestCase):
self.assertNotIn("your_turn", other)
def test_full_random_match_reaches_completion(self) -> None:
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
state = engine.create_game("p0", "p0", target_score=11)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
@@ -405,11 +638,10 @@ class MatchFlowTest(unittest.TestCase):
self.assertEqual([], state.table)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(40, sum(len(p.captured) for p in state.players))
self.assertTrue(state.finished_at)
class HandEndAckTest(unittest.TestCase):
def _hand_end_state(self) -> GameState:
def _hand_end_state(self) -> ScoponeState:
"""Drive a game into the hand_end phase with a one-card hand."""
state = make_state([["02D"], [], [], []],
table=["02C"], target=11)
@@ -525,7 +757,7 @@ class AutoPlayTest(unittest.TestCase):
engine.auto_play(state)
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
state = engine.create_game("p0", "p0", turn_timeout=7)
self.assertEqual(7, state.turn_timeout)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
@@ -0,0 +1,311 @@
"""ScoponeEngine adapter tests: the platform contract over the pure rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import GameError, IllegalMove
from tavolo.scopone import ScoponeEngine
from tavolo.scopone.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, ScoponeState
def _session(engine: ScoponeEngine, **options) -> GameSession:
session = GameSession(
id="s1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine: ScoponeEngine, **options) -> GameSession:
session = _session(engine, **options)
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
return session
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_team_a(self) -> None:
engine = ScoponeEngine()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, ScoponeState)
# The traditional partnership target.
self.assertEqual(21, session.state.target_score)
self.assertTrue(session.state.napola)
def test_create_options(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16, napola=False)
self.assertEqual(16, session.state.target_score)
self.assertFalse(session.state.napola)
self.assertFalse(session.state.asso_pigliatutto)
def test_create_asso_pigliatutto_option(self) -> None:
engine = ScoponeEngine()
session = _session(engine, asso_pigliatutto=True)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None:
engine = ScoponeEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=0)
with self.assertRaises(IllegalMove):
_session(engine, target_score="eleven")
with self.assertRaises(IllegalMove):
_session(engine, napola="yes")
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None:
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_join_assigns_teams_and_starts(self) -> None:
engine = ScoponeEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
for name, team in (("bob", "B"), ("carol", "A"), ("dave", "B")):
engine.join(session, name, name.capitalize())
self.assertEqual(team, session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
engine = ScoponeEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
# The lobby filled up and the match started: late joins and even
# re-joins are rejected as "already started".
with self.assertRaises(GameNotStarted):
engine.join(session, "erin", "Erin")
with self.assertRaises(GameNotStarted):
engine.join(session, "alice", "Alice")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {"card": "01D", "capture": "02C"})
def test_invalid_card_code_is_illegal_move(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {"card": "nope"})
def test_finished_match_rejects_actions(self) -> None:
from tavolo.platform.errors import GameFinished
engine = ScoponeEngine()
session = _started(engine, target_score=1)
# Drive to completion: keep playing legal moves until finished.
from tavolo.scopone import engine as rules
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == "hand_end":
for p in state.players:
engine.handle_action(session, p.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
class ViewTest(unittest.TestCase):
def test_view_for_hides_other_hands(self) -> None:
engine = ScoponeEngine()
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)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["napola"])
self.assertFalse(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ScoponeState)
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 = ScoponeEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
# A forged token is stale.
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
from tavolo.scopone import engine as rules
engine = ScoponeEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
# A play lands in time: the armed deadline is overtaken.
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
state = session.state
# Force the hand-end phase with an imminent deadline.
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
from tavolo.scopone import engine as rules
engine = ScoponeEngine()
session = _started(engine, target_score=1)
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == "hand_end":
for p in state.players:
engine.handle_action(session, p.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(4, len(result.players))
for player in result.players:
self.assertEqual(
player.won, player.team == ("A" if result.winner_team == 0 else "B")
)
self.assertIn("team_a_score", result.summary)
self.assertIn("hands_played", result.summary)
def test_result_requires_finished_match(self) -> None:
engine = ScoponeEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [3, 11]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 3, "B": 11}, over["scores"])
self.assertEqual("B", over["winner"])
def test_registry_metadata(self) -> None:
engine = ScoponeEngine()
self.assertEqual("scopone_scientifico", engine.id)
self.assertEqual(4, engine.min_players)
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn("napola", engine.options_schema["properties"])
self.assertIn("asso_pigliatutto", engine.options_schema["properties"])
if __name__ == "__main__":
unittest.main()
+8 -58
View File
@@ -1,46 +1,11 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo"
version = "0.1.0"
description = "Multiplayer card-game platform backend built on the kaya framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core",
"kaya-cors",
"kaya-session",
"kaya-session-redis",
"kaya-oidc",
"kaya-openapi",
"kaya-rsgi",
"granian>=2.0",
"tortoise-orm",
"aerich",
"asyncpg",
"httpx",
"PyJWT[crypto]",
"pwo",
"PyYAML",
"redis",
]
[project.optional-dependencies]
dev = [
"mypy",
"httpx-ws",
]
otel = [
"kaya-otel>=0.0.4",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = false
# Database migrations (aerich). See the Migrations section in README.md.
# NOTE: the tavolo-app distribution lives in packages/tavolo-app/ (same
# kaya-style layout as tavolo-platform and tavolo-scopone). This file
# exists only for tooling that reads configuration from the working
# directory:
# - aerich reads [tool.aerich] from here (server/ locally, /app in the
# db-migrate compose service), next to ./migrations;
# - mypy reads [tool.mypy] from here when run from server/.
# See the Migrations section in README.md.
[tool.aerich]
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
location = "./migrations"
@@ -49,18 +14,3 @@ location = "./migrations"
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.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.routes.*"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.game.*"
disable_error_code = ["attr-defined"]
+109 -29
View File
@@ -1,14 +1,28 @@
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
# by the following command (run from server/packages/tavolo-app/):
#
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml
# pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml
#
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-scopa, tavolo-briscola
# and tavolo-app sibling packages are NOT pinned here: they are installed
# from ./packages in
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
# -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa
# -e ./packages/tavolo-briscola
# -e ./packages/tavolo-app). All of their
# third-party dependencies are direct dependencies of tavolo-app, so this
# lock covers the full closure. The otel extra (kaya-otel and its
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
# Docker image; pip-compile cannot resolve the unpublishable sibling
# packages, so regeneration drops them from a copy of the input file (the
# existing pins constrain the rest).
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
aerich==0.10.1
# via tavolo (pyproject.toml)
# via tavolo-app (pyproject.toml)
aiosqlite==0.22.1
# via tortoise-orm
anyio==4.15.1
@@ -19,7 +33,7 @@ anyio==4.15.1
asyncclick==8.4.2.1
# via aerich
asyncpg==0.31.0
# via tavolo (pyproject.toml)
# via tavolo-app (pyproject.toml)
certifi==2026.7.22
# via
# httpcore
@@ -32,10 +46,12 @@ cryptography==50.0.1
# via pyjwt
dictdiffer==0.10.0
# via aerich
googleapis-common-protos==1.75.4
# via opentelemetry-exporter-otlp-proto-http
granian==2.8.3
# via
# kaya-rsgi
# tavolo (pyproject.toml)
# tavolo-app (pyproject.toml)
h11==0.16.0
# via httpcore
httpcore==1.0.9
@@ -43,62 +59,126 @@ httpcore==1.0.9
httpx==0.28.1
# via
# kaya-oidc
# tavolo (pyproject.toml)
idna==3.19
# tavolo-app (pyproject.toml)
idna==3.20
# via
# anyio
# httpx
iso8601==2.1.0
# via tortoise-orm
kaya-core==0.0.4
kaya-core==0.0.7
# via
# kaya-cors
# kaya-oidc
# kaya-openapi
# kaya-otel
# kaya-rsgi
# kaya-session
# tavolo (pyproject.toml)
kaya-cors==0.0.4
# via tavolo (pyproject.toml)
kaya-oidc==0.0.4
# via tavolo (pyproject.toml)
kaya-openapi==0.0.4
# via tavolo (pyproject.toml)
kaya-rsgi==0.0.4
# via tavolo (pyproject.toml)
kaya-session==0.0.4
# tavolo-app (pyproject.toml)
kaya-cors==0.0.7
# via tavolo-app (pyproject.toml)
kaya-oidc==0.0.7
# via tavolo-app (pyproject.toml)
kaya-openapi==0.0.8
# via tavolo-app (pyproject.toml)
kaya-otel==0.0.7
# via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.7
# via tavolo-app (pyproject.toml)
kaya-session==0.0.7
# via
# kaya-oidc
# kaya-session-redis
# tavolo (pyproject.toml)
kaya-session-redis==0.0.4
# via tavolo (pyproject.toml)
# tavolo-app (pyproject.toml)
kaya-session-redis==0.0.7
# via tavolo-app (pyproject.toml)
opentelemetry-api==1.45.0
# via
# opentelemetry-exporter-http-transport
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-instrumentation
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-system-metrics
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-http-transport[urllib3]==0.66b0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-common==0.66b0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-common==1.45.0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.45.0
# via kaya-otel
opentelemetry-instrumentation==0.66b0
# via
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-system-metrics
opentelemetry-instrumentation-asyncpg==0.66b0
# via tavolo-app (pyproject.toml)
opentelemetry-instrumentation-redis==0.66b0
# via tavolo-app (pyproject.toml)
opentelemetry-instrumentation-system-metrics==0.66b0
# via tavolo-app (pyproject.toml)
opentelemetry-proto==1.45.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.45.0
# via
# kaya-otel
# opentelemetry-exporter-otlp-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.66b0
# via
# opentelemetry-instrumentation
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-system-metrics
# opentelemetry-sdk
packaging==26.3
# via opentelemetry-instrumentation
protobuf==7.36.2
# via
# googleapis-common-protos
# opentelemetry-proto
psutil==7.2.2
# via opentelemetry-instrumentation-system-metrics
pwo==0.1.2
# via
# kaya-core
# kaya-rsgi
# kaya-session
# tavolo (pyproject.toml)
# tavolo-app (pyproject.toml)
pycparser==3.0
# via cffi
pyjwt[crypto]==2.14.0
pyjwt[crypto]==2.15.1
# via
# kaya-oidc
# tavolo (pyproject.toml)
# tavolo-app (pyproject.toml)
pypika-tortoise==0.6.5
# via tortoise-orm
pyyaml==6.0.3
# via tavolo (pyproject.toml)
# via tavolo-app (pyproject.toml)
redis==8.1.0
# via
# kaya-session-redis
# tavolo (pyproject.toml)
# tavolo-app (pyproject.toml)
tortoise-orm==1.1.8
# via
# aerich
# tavolo (pyproject.toml)
# via aerich
typing-extensions==4.16.0
# via
# anyio
# kaya-core
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pwo
urllib3==2.8.0
# via opentelemetry-exporter-http-transport
wrapt==2.5.0
# via
# opentelemetry-instrumentation
# opentelemetry-instrumentation-redis
-1
View File
@@ -1 +0,0 @@
"""Scopone scientifico backend built on the kaya framework."""
-179
View File
@@ -1,179 +0,0 @@
"""Application entry point.
Assembles the :class:`~kaya.core.KayaApp` with four mixins:
- :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.OIDCMixin` (OIDC login)
- :class:`~tavolo.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``)
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). Routes and the websocket handlers are registered by importing
their modules 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 Optional
from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis
from .config import Settings, settings
from .deadlines import DeadlineSchedulerMixin
from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore
from .tortoise_mixin import TortoiseMixin
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) -> 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.
"""
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 with the 'otel' extra"
) from exc
headers = dict(
pair.split("=", 1)
for pair in (settings.otel_exporter_headers or ())
if "=" in pair
)
return OTelMixin(
service_name=settings.otel_service_name,
endpoint=settings.otel_exporter_endpoint,
headers=headers or None,
excluded_paths=settings.otel_excluded_paths,
)
session_store: SessionStore
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 = RedisSessionStore(Redis.from_url(settings.redis_url))
game_store: GameStore = RedisGameStore(
Redis.from_url(settings.redis_url, decode_responses=False),
ttl_seconds=settings.game_ttl_seconds,
)
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
else:
session_store = InMemorySessionStore()
game_store = InMemoryGameStore()
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store)
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer=settings.oidc_issuer,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
redirect_uri=settings.oidc_redirect_uri,
post_login_redirect=settings.oidc_post_login_redirect,
post_logout_redirect=settings.oidc_post_logout_redirect,
fetch_userinfo=True,
),
session=session_mixin,
)
openapi_mixin = OpenAPIMixin(
title="tavolo",
version=_pkg_version("tavolo"),
description="Scopone scientifico multiplayer API",
spec_path="/api/openapi.json",
docs_path="/api/docs",
)
tortoise_mixin = TortoiseMixin(
database_url=settings.database_url,
models_modules=["tavolo.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
DeadlineSchedulerMixin(game_store)]
otel_mixin = otel_mixin_from_settings(settings)
if otel_mixin is not None:
# First in the list: 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.
mixins.insert(0, otel_mixin)
log.info(
"OpenTelemetry enabled: service=%s endpoint=%s",
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)
log.debug(
"timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
# Register routes by importing modules. Order does not matter; each module
# pulls ``app`` from here and decorates its handlers at import time. The
# static SPA-shell catch-all is registered last and only matches paths no
# other route claimed (asset files under /static are served by Granian
# itself and never reach the app).
from .routes import games, health, me, stats # noqa: E402,F401
from . import ws # noqa: E402,F401
from .routes import static # noqa: E402,F401
-59
View File
@@ -1,59 +0,0 @@
"""Authentication helpers on top of the kaya-oidc mixin.
Tavolo has no application roles: every authenticated user may create and
join games. Authorization beyond login is game membership, checked against
the live game state in Redis.
"""
from __future__ import annotations
from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket
from kaya.oidc import OIDCUser
from .app import oidc_mixin
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
claims = session.get("oidc_user")
if not isinstance(claims, Mapping):
return None
return OIDCUser(claims)
def display_name(user: OIDCUser) -> str:
"""Best-effort human-readable name for a user."""
for key in ("name", "preferred_username", "email"):
value = user.get(key)
if isinstance(value, str) and value:
return value
return user.sub
def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator: gate a handler on being authenticated (any OIDC user).
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).
"""
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
if oidc_mixin.get_user(ctx) is None:
await ctx.send_bytes(
401,
b'{"error":"unauthenticated"}',
{"content-type": ("application/json",)},
)
return
await handler(ctx, *args, **kwargs)
return guarded
-66
View File
@@ -1,66 +0,0 @@
"""Recompute every Elo rating from the recorded match history.
Ratings are deterministic given the finished matches, so this replays all
matches in chronological order and rewrites the ``player_rating`` table
and each ``match_player.elo_delta`` from scratch. Run once after
deploying the ratings feature to backfill pre-existing matches, or any
time ratings need to be rebuilt::
python -m tavolo.backfill_elo
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .config import settings
from .stats import apply_elo
from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__)
async def backfill_elo() -> int:
"""Rebuild all ratings; returns the number of 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)
team_members: Dict[str, List[str]] = defaultdict(list)
for player in players:
team_members[player.team].append(player.user_sub)
deltas = await apply_elo(
match.game_type, match.winner_team, team_members
)
for player in players:
player.elo_delta = deltas[player.user_sub]
await player.save()
replayed += 1
return replayed
async def _main() -> None:
mixin = TortoiseMixin(
database_url=settings.database_url,
models_modules=["tavolo.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:
if mixin._ctx is not None:
await mixin._ctx.close_connections()
if __name__ == "__main__":
asyncio.run(_main())
-296
View File
@@ -1,296 +0,0 @@
"""Deadline-driven timeouts, independent of player connections.
Both in-match timeouts — the per-turn auto-play (``turn_deadline``) and
the hand-end summary auto-continue (``hand_end_deadline``) — are driven by
the absolute deadlines persisted on the game state, never by which players
(or whether any players) are connected.
Every mutation that sets a deadline enqueues an entry in the store's
shared deadline queue (a Redis sorted set in production, see
:mod:`tavolo.store`), and a background consumer running on **every**
worker polls the queue for due entries. An entry records the phase, hand,
turn and deadline (as integer epoch milliseconds) it was enqueued for;
before acting, the consumer revalidates all of it against the live state
under the per-game lock, so entries that were overtaken by events (a play
landed in time, the hand was acknowledged, the deadline moved) are simply
discarded.
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 — the lock plus
revalidation make the duplicate delivery a no-op. 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
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from .config import settings
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_HAND_END, PHASE_PLAYING, PHASE_FINISHED, GameState
from .stats import save_match_result
from .store import GameStore
log = getLogger(__name__)
# Entry kinds enqueued in the deadline queue.
KIND_TURN = "turn"
KIND_HAND_END = "hand_end"
# One consumer task and its wake-up event per event loop (tests run each
# test on a fresh loop).
_consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
_wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
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
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable. Queue entries carry this integer (never the ISO
string) as their revalidation token."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
async def sync_deadline(store: GameStore, state: GameState) -> None:
"""Enqueue the deadline the current state carries, if any.
Called after every mutation that can set a deadline (plays, acks, game
start) 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.
"""
entry: Optional[Dict[str, Any]] = None
due_ms: Optional[int] = None
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
entry = {
"game_id": state.id,
"kind": KIND_TURN,
"hand": state.hand_number,
"turn": state.turn,
"deadline": due_ms,
}
elif state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
entry = {
"game_id": state.id,
"kind": KIND_HAND_END,
"hand": state.hand_number,
"deadline": due_ms,
}
if entry is None or due_ms is None:
if entry is not None:
log.warning("game %s: unparseable deadline", state.id)
return
ensure_consumer(store)
# The score derives from the same value carried in the member, so the
# two can never disagree.
await store.add_deadline(encode(entry), due_ms / 1000)
wake = _wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
async def finalize_mutation(store: GameStore, state: GameState) -> 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``).
"""
if state.phase == PHASE_FINISHED:
await save_match_result(state)
log.info(
"game %s finished: team %s wins %d-%d",
state.id,
"A" if state.winner == 0 else "B",
state.scores[0],
state.scores[1],
)
await store.save(state)
await store.publish(state.id)
await sync_deadline(store, state)
async def process_due(store: GameStore, member: Any) -> None:
"""Fire a single due deadline entry.
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 (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 store.remove_deadline(member)
return
game_id = entry.get("game_id")
kind = entry.get("kind")
if not isinstance(game_id, str):
await store.remove_deadline(member)
return
async with store.lock(game_id):
state = await store.load(game_id)
if state is not None:
if kind == KIND_TURN:
await _fire_turn(store, state, entry)
elif kind == KIND_HAND_END:
await _fire_hand_end(store, state, entry)
await store.remove_deadline(member)
async def _fire_turn(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
if (
state.phase != PHASE_PLAYING
or state.hand_number != entry.get("hand")
or state.turn != entry.get("turn")
or _deadline_ms(state.turn_deadline) != entry.get("deadline")
):
return
seat = state.turn
try:
engine.auto_play(state)
except GameError:
return
log.info(
"game %s: auto-played for %s (turn timeout, hand %d)",
state.id,
state.players[seat].sub if seat < len(state.players) else "?",
entry.get("hand"),
)
await finalize_mutation(store, state)
async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
if (
state.phase != PHASE_HAND_END
or state.hand_number != entry.get("hand")
or _deadline_ms(state.hand_end_deadline) != entry.get("deadline")
):
return
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"game %s: hand %d auto-advanced after the acknowledgement timeout",
state.id,
entry.get("hand"),
)
await finalize_mutation(store, state)
# --- consumer lifecycle -------------------------------------------------------
def ensure_consumer(
store: GameStore, 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(_consumers):
if old.is_closed():
_consumers.pop(old, None)
_wake_events.pop(old, None)
task = _consumers.get(loop)
if task is None or task.done():
_wake_events[loop] = asyncio.Event()
_consumers[loop] = loop.create_task(_run(store, loop))
log.debug("deadline consumer started")
def stop_consumer(loop: asyncio.AbstractEventLoop) -> None:
task = _consumers.pop(loop, None)
_wake_events.pop(loop, None)
if task is not None:
task.cancel()
async def _run(store: GameStore, loop: asyncio.AbstractEventLoop) -> None:
wake = _wake_events[loop]
heartbeat = settings.deadline_heartbeat_ms / 1000
while True:
# Clear before polling so an enqueue racing the poll re-wakes us.
wake.clear()
delay = heartbeat
try:
for member in await store.due_deadlines(time.time()):
try:
await process_due(store, 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)
next_due = await store.next_deadline()
if next_due is not None:
delay = max(0.0, min(heartbeat, next_due - time.time()))
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline consumer: poll failed; retrying")
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer; coordination
happens exclusively through the shared deadline queue and the per-game
locks, so any worker may fire any game's deadline.
"""
def __init__(self, store: GameStore) -> None:
self._store = store
def apply(self, app: KayaApp) -> None:
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
ensure_consumer(self._store, loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
stop_consumer(loop)
-1
View File
@@ -1 +0,0 @@
"""Scopone scientifico domain package."""
-42
View File
@@ -1,42 +0,0 @@
"""Typed errors raised by the scopone engine.
Route/WebSocket handlers translate these into 4xx responses or ``error``
WebSocket messages; the engine itself stays transport-agnostic.
"""
from __future__ import annotations
class GameError(Exception):
"""Base class for every rule/validation failure."""
class IllegalMove(GameError):
"""The requested play violates the rules of scopone scientifico."""
class NotYourTurn(GameError):
"""A player attempted to play out of turn."""
class CardNotInHand(GameError):
"""The played card is not held by the player."""
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 four 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."""
-42
View File
@@ -1,42 +0,0 @@
"""Registry of the card games the platform can host.
Only *scopone scientifico* is implemented for now; adding a game means a
new entry here plus its engine. The registry is the single source of truth
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
and persisted on each finished match (:mod:`tavolo.models`), which is what
makes match statistics game-scoped.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class GameType:
"""Metadata describing one playable card game."""
id: str
name: str
description: str
SCOPONE_SCIENTIFICO = "scopone_scientifico"
GAME_TYPES: Dict[str, GameType] = {
SCOPONE_SCIENTIFICO: GameType(
id=SCOPONE_SCIENTIFICO,
name="Scopone scientifico",
description=(
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
),
),
}
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
def get_game_type(game_type_id: str) -> Optional[GameType]:
"""Return the registered game type with id ``game_type_id``, if any."""
return GAME_TYPES.get(game_type_id)
-82
View File
@@ -1,82 +0,0 @@
"""Tortoise ORM models: match statistics persisted in Postgres.
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
matches are written here. The two tables answer the question "every match
a player took part in, with the final score":
* :class:`Match` — one row per finished match with both teams' scores.
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat/team and whether they won.
* :class:`PlayerRating` — current chess-style Elo rating of a player for
one game type, updated transactionally with every finished match (see
:mod:`tavolo.elo`).
"""
from __future__ import annotations
from tortoise import fields
from tortoise.models import Model
from .elo import INITIAL_RATING
class Match(Model):
"""A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True)
# Which card game was played (tavolo.games.GAME_TYPES); the default
# backfills matches recorded before game types existed.
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
team_a_score = fields.SmallIntField()
team_b_score = fields.SmallIntField()
# "A" or "B".
winner_team = fields.CharField(max_length=1)
target_score = fields.SmallIntField()
hands_played = fields.SmallIntField()
started_at = fields.DatetimeField()
finished_at = fields.DatetimeField()
players: fields.ReverseRelation["MatchPlayer"]
class Meta:
table = "match"
ordering = ["-finished_at"]
class MatchPlayer(Model):
"""Participation of one user in one match."""
id = fields.UUIDField(pk=True)
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
"models.Match", related_name="players", on_delete=fields.CASCADE
)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255, db_index=True)
display_name = fields.CharField(max_length=200)
seat = fields.SmallIntField()
team = fields.CharField(max_length=1)
won = fields.BooleanField()
# Elo change this match produced for the player (see tavolo.elo);
# null for matches recorded before ratings existed.
elo_delta = fields.SmallIntField(null=True)
class Meta:
table = "match_player"
unique_together = (("match", "user_sub"),)
class PlayerRating(Model):
"""Current Elo rating of one player for one game type."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Which card game the rating applies to (tavolo.games.GAME_TYPES).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),)
-21
View File
@@ -1,21 +0,0 @@
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``."""
from __future__ import annotations
from typing import Any, Dict, List
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
{
"name": "cursor",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Opaque pagination cursor from a previous response's next_cursor.",
},
]
-1
View File
@@ -1 +0,0 @@
"""HTTP route modules."""

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