Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565e927a9b | ||
|
|
71493f720b | ||
|
|
4d940add1c | ||
|
|
7d67eccca2 | ||
|
|
8c6ad10bf6 | ||
|
|
532cb16f94 | ||
|
|
3fe29ba740 | ||
|
|
25af0bf3fe | ||
|
|
551c4c9c28 | ||
|
|
bf613eb1c4 | ||
|
|
d4d437c1a1 | ||
|
|
7883d0c458 | ||
|
|
637b711fa8 | ||
|
|
e0030ee94a | ||
|
|
c746a88cfb | ||
|
|
99aa81d682 | ||
|
|
43d5a6e2f9 | ||
|
|
6427afd12b
|
||
|
|
d2dae476f0
|
||
|
|
18877b8109
|
||
|
|
b821274f0b
|
||
|
|
6222e38da3
|
||
|
|
97d4b3f217
|
@@ -7,11 +7,11 @@
|
||||
.env.example
|
||||
**/.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.egg-info/
|
||||
**/.mypy_cache/
|
||||
**/.pytest_cache/
|
||||
web/target/
|
||||
web/dist/
|
||||
server/tests/
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
# tavolo
|
||||
|
||||
A platform for multiplayer card games. Two games are included:
|
||||
A platform for multiplayer card games. Four games are included:
|
||||
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||
game — and **briscola**, in its classic two-player and four-player
|
||||
partnership (*briscola a quattro*) variants:
|
||||
game — **scopa**, in its classic two-player and four-player partnership
|
||||
(*scopa a quattro*) variants, with the *asso pigliatutto* option turning
|
||||
either into **scopa d'assi** — **briscola**, in its classic two-player
|
||||
and four-player partnership (*briscola a quattro*) variants, and
|
||||
**briscola chiamata** — the five-player variant with a calling auction
|
||||
and a secret partner:
|
||||
|
||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||
A monorepo of four distributions sharing the `tavolo` namespace: the
|
||||
game-independent `tavolo-platform`, the `tavolo-scopone` and
|
||||
`tavolo-briscola` games, and the `tavolo-app` composition root wiring
|
||||
them together. See [server/README.md](server/README.md).
|
||||
A monorepo of five distributions sharing the `tavolo` namespace: the
|
||||
game-independent `tavolo-platform`, the `tavolo-scopone`,
|
||||
`tavolo-scopa` and `tavolo-briscola` games, and the `tavolo-app`
|
||||
composition root wiring them together. See
|
||||
[server/README.md](server/README.md).
|
||||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||||
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
||||
`web/vendor/` so builds work offline) plus the custom felt theme in
|
||||
`web/theme.css`. Card images are the
|
||||
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||
`web/theme.css`. Also an installable PWA — see [PWA](#pwa). Card images
|
||||
are the CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||
|
||||
## Run the whole stack
|
||||
@@ -39,13 +44,16 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||
## Between hands
|
||||
|
||||
When a hand ends but the match is not decided, the game pauses on a
|
||||
**scoring summary screen**: scopone players see how each category was won
|
||||
(carte, denara, settebello, primiera, scope — plus napola and asso
|
||||
pigliatutto when enabled), briscola players see the deal's points and any
|
||||
*patta*; both see the running totals and must click "Understood" before
|
||||
the next hand is dealt. If someone is away the next hand is dealt
|
||||
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
|
||||
match-ending hand is explained on the final screen.
|
||||
**scoring summary screen**: scopone and scopa players see how each
|
||||
category was won (carte, denara, settebello, primiera, scope — plus
|
||||
napola and asso pigliatutto when enabled), briscola players see the
|
||||
deal's points and any *patta*; both see the running totals and must click
|
||||
"Understood" before the next hand is dealt. If someone is away the next
|
||||
hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` (default
|
||||
30s). The match-ending hand is explained on the final screen. Briscola
|
||||
chiamata plays a single deal per match, so it goes straight from the last
|
||||
trick to the final scoring screen; a 60–60 *patta* is replayed
|
||||
immediately.
|
||||
|
||||
## On your turn
|
||||
|
||||
@@ -53,31 +61,71 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
||||
player does not move — disconnected or fallen asleep — the server plays a
|
||||
random legal card for them (choosing among the legal captures when the
|
||||
rules require a capture), so one absent player cannot stall the table.
|
||||
Briscola chiamata arms the same countdown for its auction turns and for
|
||||
the auction winner's called-card choice: an absent bidder passes, the
|
||||
last player standing with no bid is made to bid the ace, and a timed-out
|
||||
call names a random suit of the won rank.
|
||||
|
||||
## Watching
|
||||
|
||||
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||||
spectators see the table, the score and every player's captured cards
|
||||
(and scope for scopone), but never a hand and never the join code. The
|
||||
(and scope for scopone), but never a hand and never the join code. In
|
||||
briscola chiamata they also see no role labels and no side totals until
|
||||
the called card is played, so the secret partner stays secret. The
|
||||
"Copy watch link" button in the waiting room and in the game HUD copies
|
||||
the URL to share. A spectator who opens the URL before the match starts
|
||||
sees a "game not started yet" waiting room.
|
||||
|
||||
## PWA
|
||||
|
||||
The frontend is an installable PWA: `web/manifest.webmanifest` describes
|
||||
the app (standalone display, felt theme colors, PNG icons), and `web/sw.js`
|
||||
is a caching-free service worker — it deliberately caches nothing, since
|
||||
live state always comes from the server and hashed asset names change every
|
||||
build — that also handles clicks on move notifications. Browsers offer to
|
||||
install Tavolo over HTTPS (or on localhost in development).
|
||||
|
||||
Both files must be served from the site root, or the worker's scope and the
|
||||
manifest's `start_url` could not cover the app: Granian only serves
|
||||
`/static` and `/assets`, so `tavolo/static.py` answers
|
||||
`/manifest.webmanifest` and `/sw.js` (revalidated on every load) from the
|
||||
same build directory as the shell. Icons are committed under
|
||||
`web/assets/icons/`; `web/make-icons.sh` regenerates them with
|
||||
`rsvg-convert` after editing the vector sources.
|
||||
|
||||
## Move notifications
|
||||
|
||||
Players can opt into desktop notifications for the other players' card
|
||||
plays with the bell in the game HUD (the first click asks for the browser
|
||||
permission; the preference is stored per device and applies to every
|
||||
game). Notifications only appear while the game tab is in the background
|
||||
or its window is unfocused — never while the player is watching the table
|
||||
— and only for games the viewer is seated in, never for their own plays.
|
||||
They are delivered through the service worker (so installed PWAs get them
|
||||
too) under a per-game tag, which replaces the previous move instead of
|
||||
stacking one notification per play; clicking one focuses the game tab.
|
||||
The game page must stay open to receive them, since that is where the
|
||||
live-play websocket lives.
|
||||
|
||||
## Development
|
||||
|
||||
Backend (from `server/`):
|
||||
Backend (from `server/`), using [uv](https://docs.astral.sh/uv/):
|
||||
|
||||
```sh
|
||||
cd server
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
|
||||
.venv/bin/python -m unittest discover -s tests -t .
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
uv sync --package tavolo-app --extra otel
|
||||
uv run python -m unittest discover -s tests -t .
|
||||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
```
|
||||
|
||||
The `otel` extra is optional; without it the telemetry integration tests
|
||||
skip. Development tools (`mypy`, `httpx-ws`) are installed by default from
|
||||
the workspace's `dev` dependency group.
|
||||
|
||||
Frontend (from `web/`), with the backend running on :8080:
|
||||
|
||||
```sh
|
||||
@@ -96,8 +144,10 @@ OIDC_0_CLIENT_ID=tavolo \
|
||||
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
|
||||
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||
uv run granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||
```
|
||||
|
||||
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||
re-downloads them if needed.
|
||||
re-downloads them if needed. PWA icons are committed under
|
||||
`web/assets/icons/`; `web/make-icons.sh` regenerates them from the SVG
|
||||
sources.
|
||||
|
||||
@@ -128,7 +128,7 @@ services:
|
||||
# (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).
|
||||
# and server/uv.lock).
|
||||
OTEL_ENABLED: ${OTEL_ENABLED:-}
|
||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -7,7 +7,7 @@
|
||||
# builder alpine python -> python venv with the backend + deps
|
||||
# runtime alpine python + the venv + the compiled frontend
|
||||
#
|
||||
# apk and pip both use BuildKit cache mounts (type=cache): the package
|
||||
# apk and uv both use BuildKit cache mounts (type=cache): the package
|
||||
# caches persist in the builder's cache across builds instead of being
|
||||
# re-downloaded, and never land in the image layers.
|
||||
|
||||
@@ -30,6 +30,9 @@ WORKDIR /web
|
||||
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
||||
# web/fetch-cards.sh can regenerate them.
|
||||
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/theme.css web/Trunk.toml ./
|
||||
# PWA manifest and service worker: trunk's copy-file entries need them at
|
||||
# the build root, and the runtime serves both from the site root (static.py).
|
||||
COPY web/manifest.webmanifest web/sw.js ./
|
||||
COPY web/assets ./assets
|
||||
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
|
||||
# image builds and runs without network access.
|
||||
@@ -46,29 +49,43 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
# --- Python builder ----------------------------------------------------------
|
||||
FROM alpine:3.24 AS builder
|
||||
|
||||
# The official uv image ships a statically linked musl binary, so it works
|
||||
# on Alpine. Pin the version so image builds do not change implicitly.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add python3 py3-pip build-base python3-dev
|
||||
apk add python3 build-base python3-dev
|
||||
|
||||
# Use Alpine's Python instead of downloading a managed interpreter, install
|
||||
# the venv at the same path the runtime stage uses, and copy rather than
|
||||
# hardlink from the cache mount (cache and target are different filesystems).
|
||||
ENV UV_PYTHON_DOWNLOADS=0 \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||
UV_LINK_MODE=copy
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# uv needs the workspace root plus the member pyprojects to know which
|
||||
# package to sync. Copying only the manifests lets the third-party
|
||||
# dependency layer stay cached while application sources change.
|
||||
# server/pyproject.toml also carries [tool.aerich], read by the db-migrate
|
||||
# compose service with working_dir=/app, next to ./migrations.
|
||||
COPY server/uv.lock server/pyproject.toml ./
|
||||
COPY server/packages/tavolo-platform/pyproject.toml packages/tavolo-platform/
|
||||
COPY server/packages/tavolo-scopone/pyproject.toml packages/tavolo-scopone/
|
||||
COPY server/packages/tavolo-scopa/pyproject.toml packages/tavolo-scopa/
|
||||
COPY server/packages/tavolo-briscola/pyproject.toml packages/tavolo-briscola/
|
||||
COPY server/packages/tavolo-app/pyproject.toml packages/tavolo-app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-workspace --package tavolo-app --extra otel --no-dev
|
||||
|
||||
COPY server/requirements.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --upgrade pip \
|
||||
&& /opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
# The four 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.
|
||||
# The five sibling distributions are workspace members (PEP 420 namespace
|
||||
# packages sharing the `tavolo` namespace). --no-editable installs them as
|
||||
# wheels, so the runtime image does not need their source tree.
|
||||
COPY server/packages/ ./packages/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./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 ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-editable --package tavolo-app --extra otel --no-dev
|
||||
|
||||
# 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/
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# tavolo (backend)
|
||||
|
||||
The backend for a multiplayer card-game platform, built on the
|
||||
[kaya](../kaya) framework. It is a monorepo of four distributions
|
||||
[kaya](../kaya) framework. It is a monorepo of five distributions
|
||||
sharing the `tavolo` namespace (see
|
||||
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
|
||||
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)
|
||||
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md),
|
||||
[`packages/tavolo-scopa/README.md`](packages/tavolo-scopa/README.md)
|
||||
and
|
||||
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
|
||||
|
||||
@@ -15,10 +16,15 @@ and
|
||||
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
|
||||
scientifico, the four-player, fixed-partnership variant of the classic
|
||||
Italian card game: pure rules plus the platform adapter.
|
||||
- **`tavolo-scopa`** (`packages/tavolo-scopa/`) — scopa, the classic
|
||||
Italian fishing game, in its two-player (`scopa`) and four-player
|
||||
fixed-partnership (`scopa_a_quattro`) variants, with the optional asso
|
||||
pigliatutto rule (scopa d'assi): pure rules plus the platform adapters.
|
||||
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
||||
Italian trick-taking game, in its classic two-player (`briscola`) and
|
||||
four-player partnership (`briscola_a_quattro`) variants: pure rules
|
||||
plus the platform adapter.
|
||||
Italian trick-taking game, in its classic two-player (`briscola`),
|
||||
four-player partnership (`briscola_a_quattro`) and five-player
|
||||
called-partner (`briscola_chiamata`) variants: pure rules plus the
|
||||
platform adapter.
|
||||
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||
composition root wiring the platform to the games, with the
|
||||
environment-driven settings and the SPA shell.
|
||||
@@ -55,7 +61,9 @@ alias that redirects to the first configured provider), type any username
|
||||
session cookie. That cookie authenticates both the REST API and the
|
||||
WebSocket endpoint.
|
||||
|
||||
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
|
||||
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`); every
|
||||
endpoint documents its request body and its response bodies as named
|
||||
component schemas.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -91,7 +99,7 @@ All configuration comes from environment variables (see `.env.example`):
|
||||
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
|
||||
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
||||
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `pip install tavolo-app[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 `uv sync --package tavolo-app --extra otel`) |
|
||||
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
||||
@@ -130,9 +138,12 @@ 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
|
||||
replaces the previous one. Accounts are keyed by the canonical principal
|
||||
`oidc:v1:<provider>:<sub>`, not by `sub` alone, so equal subjects at two
|
||||
providers cannot share games, history, or ratings (someone using two
|
||||
providers has two accounts). Provider names are permanent security
|
||||
identifiers: do not reuse a name for a different issuer, and treat renaming
|
||||
one as an identity migration. 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
|
||||
@@ -235,14 +246,21 @@ aggregate with `max`, not `sum`.
|
||||
scoped per game), start/finish timestamps, and the game-specific outcome
|
||||
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||
final scores, winner, target score, hands played and the per-hand
|
||||
audit; for briscola: the final scores, winner, target score, deals
|
||||
played and the per-deal audit).
|
||||
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||
seat, game-defined team label (nullable), whether they won, the points
|
||||
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 canonical provider-qualified
|
||||
principal (`user_sub`), display name, seat, game-defined team label
|
||||
(nullable), whether they won, the points
|
||||
they scored, the Elo change the match produced (`elo_delta`) and
|
||||
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
||||
the number of rated matches played.
|
||||
- `player_rating` — current Elo rating and leaderboard totals per
|
||||
`(user_sub, game_type)`: the rating, matches played, wins, accumulated
|
||||
points and the most recent display name. Kept in sync with the match
|
||||
transaction, so the leaderboard is a single indexed table query.
|
||||
|
||||
When a match ends, the engine's result is written transactionally to
|
||||
Postgres (once, guarded by a flag on the session); the finished session
|
||||
@@ -256,13 +274,13 @@ Players carry a chess-style Elo rating per game type
|
||||
mean of its members, and the standard formula
|
||||
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
|
||||
many points the match result moves — the same delta for every member of
|
||||
a team, zero-sum between the two teams. Ratings update in the same
|
||||
transaction as the match result. To recompute every rating from the
|
||||
recorded match history (e.g. to backfill matches recorded before ratings
|
||||
existed):
|
||||
a team, zero-sum between the two teams. Ratings and the leaderboard
|
||||
aggregates update in the same transaction as the match result. To
|
||||
rebuild every rating and aggregate from the recorded match history (e.g.
|
||||
to backfill matches recorded before ratings existed):
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m tavolo.platform.backfill_elo \
|
||||
uv run python -m tavolo.platform.backfill_elo \
|
||||
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||
```
|
||||
|
||||
@@ -275,13 +293,14 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 21, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` (target default 11) or `{"game_type": "scopa_a_quattro"}` (target default 21) with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
||||
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
||||
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
|
||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||||
| `GET` | `/api/leaderboard` | Elo rating, wins / matches / points per rated player, sorted by Elo (`?game_type=`, `?page=`, `?limit=`, `?focus=me`). The response carries the requested page, the page count and — when authenticated — the caller's own rank and page; `focus=me` returns the caller's page directly |
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
@@ -308,7 +327,7 @@ platform handles `state` (resend the snapshot) itself; every other action
|
||||
is dispatched to the session's game engine with the rest of the message
|
||||
as its payload. Spectators may only send `state`/`sync`: any other
|
||||
action is answered with an `error` whose `code` is `forbidden`. For
|
||||
scopone scientifico:
|
||||
scopone scientifico and scopa (both variants):
|
||||
|
||||
```json
|
||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||
@@ -340,6 +359,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
|
||||
every player has acknowledged, or automatically after
|
||||
`HAND_ACK_TIMEOUT_SECONDS`.
|
||||
|
||||
For briscola chiamata the game actions are the calling auction plus play:
|
||||
|
||||
```json
|
||||
{"action": "bid", "rank": 3}
|
||||
{"action": "bid", "points": 64}
|
||||
{"action": "pass"}
|
||||
{"action": "call", "card": "03D"}
|
||||
{"action": "play", "card": "04C"}
|
||||
```
|
||||
|
||||
- `bid` with a `rank` calls a lower rank than the standing bid
|
||||
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
|
||||
target once the standing bid is the two.
|
||||
- `pass` leaves the auction; the last player standing must bid.
|
||||
- `call` names any card of the won rank; naming one of your own cards
|
||||
means playing alone.
|
||||
- `play` follows briscola: any card in hand is legal.
|
||||
|
||||
After every accepted move the new state is broadcast to every connected
|
||||
client, spectators included.
|
||||
|
||||
@@ -352,18 +389,21 @@ is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||
queue (see above), not from timers tied to client connections, so the
|
||||
match keeps progressing even with every player disconnected.
|
||||
match keeps progressing even with every player disconnected. Briscola
|
||||
chiamata uses the same timeout for its auction turns (an absent bidder
|
||||
passes; the last player standing with no bid is made to bid the ace) and
|
||||
for the called-card choice (a random suit of the won rank).
|
||||
|
||||
### Hand-end summary
|
||||
|
||||
When a hand finishes but the match continues, the game enters the
|
||||
`hand_end` phase instead of dealing immediately: the state carries the
|
||||
scoring breakdown of the hand just played (`last_hand` with an `award`
|
||||
map naming the team that won each scopone category; `last_deal` with the
|
||||
deal's points and running totals for briscola), the `acknowledged` seats
|
||||
and a `hand_end_deadline`. The frontend renders this as a screen every
|
||||
player must dismiss. A play attempted in this phase is rejected with an
|
||||
`illegal_move` error.
|
||||
map naming the side that won each scopone/scopa category; `last_deal`
|
||||
with the deal's points and running totals for briscola), the
|
||||
`acknowledged` seats and a `hand_end_deadline`. The frontend renders this
|
||||
as a screen every player must dismiss. A play attempted in this phase is
|
||||
rejected with an `illegal_move` error.
|
||||
|
||||
## Rules implemented
|
||||
|
||||
@@ -388,9 +428,42 @@ player must dismiss. A play attempted in this phase is rejected with an
|
||||
empty. The capture never scores a scopa, but it counts as the last
|
||||
capture, and the mandatory equal-rank capture still applies when another
|
||||
ace is on the table.
|
||||
- The match ends when a team reaches the target score (default 11,
|
||||
configurable per game via the `target_score` creation option) with a
|
||||
clear lead; a tie at or above the target is broken by another hand.
|
||||
- The match ends when a team reaches the target score (default 21,
|
||||
the traditional partnership target, configurable per game via the
|
||||
`target_score` creation option) with a clear lead; a tie at or above
|
||||
the target is broken by another hand.
|
||||
|
||||
### Scopa
|
||||
|
||||
- 40-card Italian deck; three cards per player and four cards face up on
|
||||
the table at the start of every hand. When every hand is empty the
|
||||
dealer deals three more cards to each player (no new table cards) until
|
||||
the deck is exhausted: two players play six rounds per hand, four
|
||||
players three.
|
||||
- A card captures a **single card of equal rank** (mandatory when present)
|
||||
or a **combination of table cards whose ranks sum to its own**; when any
|
||||
capture exists it must be taken. Remaining table cards at the end of the
|
||||
hand go to the player who made the last capture.
|
||||
- Emptying the table scores a *scopa* (+1), except on the last play of the
|
||||
hand (a sweep on the last play of a round still scores when the deck has
|
||||
cards left).
|
||||
- Hand points: `carte`, `denara`, `settebello` and `primiera` (same values
|
||||
and all-four-suits requirement as scopone), plus one point per scopa.
|
||||
Ties award nothing. There is no napola.
|
||||
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
|
||||
option, default off; scopa d'assi): an ace played when no equal rank is
|
||||
on the table sweeps every card on the table — or captures itself when
|
||||
the table is empty. The capture never scores a scopa, but it counts as
|
||||
the last capture, and the mandatory equal-rank capture still applies
|
||||
when another ace is on the table.
|
||||
- The match ends when a side reaches the target score (default 11 points
|
||||
for two-player scopa and 21 for scopa a quattro, the traditional
|
||||
partnership target; configurable per game via the `target_score`
|
||||
creation option) with a clear lead; a tie at or above the target is
|
||||
broken by another hand.
|
||||
- `scopa` is the classic two-player game; `scopa_a_quattro` seats four
|
||||
players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||
opposite).
|
||||
|
||||
### Briscola
|
||||
|
||||
@@ -415,24 +488,60 @@ player must dismiss. A play attempted in this phase is rejected with an
|
||||
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||
opposite).
|
||||
|
||||
### Briscola chiamata
|
||||
|
||||
- 40-card Italian deck, eight cards each for five players: the whole deck
|
||||
is dealt, so there is no stock and no face-up card.
|
||||
- The calling auction starts with the player after the dealer: each
|
||||
player either passes (leaving the auction) or bids a lower rank than
|
||||
the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the
|
||||
standing bid is the two, the auction continues on points (62, 64, …).
|
||||
The last player standing must bid, so every deal gets a caller.
|
||||
- The winner names any card of the won rank. If the winner holds it, the
|
||||
caller plays alone against the other four; otherwise its holder is the
|
||||
secret partner and the two form the calling side. The called card's
|
||||
suit is the trump; the partner's identity stays hidden until the
|
||||
called card is played.
|
||||
- Play and trick resolution follow briscola; eight tricks exhaust every
|
||||
hand. The calling side must reach the declared target (61 unless
|
||||
raised); with the plain target a 60–60 deal is a *patta* and is
|
||||
replayed with the next dealer. A match is a single deal, so the caller
|
||||
and partner (or the lone caller) against the other players map onto
|
||||
the platform's two-team result, Elo and history.
|
||||
- Auction turns, the called-card choice and the plays all have their own
|
||||
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
|
||||
player standing with no bid is made to bid the ace, an absent winner's
|
||||
call names a random suit of the won rank, and plays are auto-played.
|
||||
|
||||
## Development
|
||||
|
||||
The backend is a [uv](https://docs.astral.sh/uv/) workspace: `pyproject.toml`
|
||||
declares the five packages under `packages/` as members, and `uv.lock` pins
|
||||
their full dependency closure. The checked-in `.python-version` selects
|
||||
Python 3.14, matching the Docker image. Install uv, then from `server/`:
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
|
||||
uv sync --package tavolo-app --extra otel
|
||||
# app integration suite:
|
||||
.venv/bin/python -m unittest discover -s tests -t .
|
||||
uv run python -m unittest discover -s tests -t .
|
||||
# per-package suites (each package is self-contained):
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
# type checks (see packages/tavolo-app/README.md for the app modules):
|
||||
.venv/bin/python -m mypy -p tavolo.platform
|
||||
.venv/bin/python -m mypy -p tavolo.scopone
|
||||
.venv/bin/python -m mypy -p tavolo.briscola
|
||||
uv run mypy -p tavolo.platform
|
||||
uv run mypy -p tavolo.scopone
|
||||
uv run mypy -p tavolo.scopa
|
||||
uv run mypy -p tavolo.briscola
|
||||
```
|
||||
|
||||
`uv sync` installs the workspace's `dev` dependency group (`mypy`,
|
||||
`httpx-ws`) automatically; pass `--no-dev` for a runtime-only environment.
|
||||
The `otel` extra is optional, but without it the telemetry integration tests
|
||||
skip. The lockfile is committed: use `uv lock --upgrade` (or
|
||||
`uv lock --upgrade-package <name>`) to update dependencies deliberately.
|
||||
|
||||
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
|
||||
for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
|
||||
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
|
||||
@@ -447,8 +556,8 @@ 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
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich migrate
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich upgrade
|
||||
```
|
||||
|
||||
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
||||
@@ -468,7 +577,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
|
||||
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
||||
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||
├── stats.py # MatchResult -> Postgres persistence + Elo update
|
||||
├── backfill_elo.py # recompute all ratings from the match history
|
||||
├── backfill_elo.py # recompute all ratings and aggregates from match history
|
||||
├── store.py # Redis / in-memory live-session store (+ deadline queue)
|
||||
├── deadlines.py # connection-independent timeout scheduler
|
||||
├── ws.py # WebSocket live-play endpoint (generic envelope)
|
||||
@@ -488,12 +597,19 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
|
||||
├── engine.py # pure scopone scientifico rules
|
||||
├── errors.py # scopone-specific rule violations
|
||||
└── plugin.py # ScoponeEngine: the GameEngine adapter
|
||||
packages/tavolo-scopa/ # scopa game (own pyproject + tests)
|
||||
src/tavolo/scopa/
|
||||
├── state.py # ScopaState / PlayerState / Card / Move, JSON (de)serialization
|
||||
├── engine.py # pure scopa rules (two- and four-player)
|
||||
├── errors.py # scopa-specific rule violations
|
||||
└── plugin.py # ScopaEngine + ScopaQuattroEngine adapters
|
||||
packages/tavolo-briscola/ # briscola game (own pyproject + tests)
|
||||
src/tavolo/briscola/
|
||||
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
||||
├── engine.py # pure briscola rules (2- and 4-player)
|
||||
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
|
||||
├── errors.py # briscola-specific rule violations
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
|
||||
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||
src/tavolo/
|
||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||
@@ -502,7 +618,8 @@ packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||
├── 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
|
||||
pyproject.toml # uv workspace root + [tool.aerich] / [tool.mypy]
|
||||
uv.lock # locked dependency set for the whole workspace
|
||||
migrations/ # aerich migrations for the platform models
|
||||
tests/ # app integration suite (platform + games wired together)
|
||||
```
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"claims": {
|
||||
"sub": "alice",
|
||||
"preferred_username": "alice",
|
||||
"name": "Alice"
|
||||
"name": "Alice",
|
||||
"picture": "https://i.pravatar.cc/128?u=alice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -20,7 +21,8 @@
|
||||
"claims": {
|
||||
"sub": "bob",
|
||||
"preferred_username": "bob",
|
||||
"name": "Bob"
|
||||
"name": "Bob",
|
||||
"picture": "https://i.pravatar.cc/128?u=bob"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_player_rati_game_ty_3326d1";
|
||||
ALTER TABLE "player_rating" ADD "points" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "player_rating" ADD "display_name" VARCHAR(200) NOT NULL DEFAULT '';
|
||||
ALTER TABLE "player_rating" ADD "wins" INT NOT NULL DEFAULT 0;
|
||||
-- Backfill the leaderboard aggregates from the match history:
|
||||
-- existing rows keep their rating and matches, while players from
|
||||
-- matches recorded before ratings existed are inserted at the
|
||||
-- initial rating (1500) so they stay on the board.
|
||||
INSERT INTO "player_rating" (
|
||||
"id", "user_sub", "game_type", "rating", "matches_played",
|
||||
"wins", "points", "display_name", "updated_at"
|
||||
)
|
||||
SELECT gen_random_uuid(), agg.user_sub, agg.game_type, 1500,
|
||||
agg.matches, agg.wins, agg.points, agg.display_name, NOW()
|
||||
FROM (
|
||||
SELECT mp.user_sub AS user_sub,
|
||||
m.game_type AS game_type,
|
||||
COUNT(*) AS matches,
|
||||
SUM(CASE WHEN mp.won THEN 1 ELSE 0 END) AS wins,
|
||||
SUM(mp.score) AS points,
|
||||
(ARRAY_AGG(mp.display_name ORDER BY m.finished_at DESC, m.id DESC))[1]
|
||||
AS display_name
|
||||
FROM "match_player" mp
|
||||
JOIN "match" m ON m.id = mp.match_id
|
||||
GROUP BY mp.user_sub, m.game_type
|
||||
) agg
|
||||
ON CONFLICT ("user_sub", "game_type") DO UPDATE
|
||||
SET "wins" = EXCLUDED."wins",
|
||||
"points" = EXCLUDED."points",
|
||||
"display_name" = EXCLUDED."display_name";
|
||||
CREATE INDEX "idx_player_rati_game_ty_abcb75" ON "player_rating" ("game_type", "rating", "wins", "points", "matches_played", "user_sub");"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_player_rati_game_ty_abcb75";
|
||||
ALTER TABLE "player_rating" DROP COLUMN "points";
|
||||
ALTER TABLE "player_rating" DROP COLUMN "display_name";
|
||||
ALTER TABLE "player_rating" DROP COLUMN "wins";
|
||||
CREATE INDEX "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"eJztmm1v4jgQgP9KlE89qVcBfdnV6XRSoFTLLZQK6N1pq1VkEkOtGjubOMeiXv/72c67k/"
|
||||
"DSEgoVH7ZLxh7HeSYez4zzrM+oDbF31gPMetR/0551AmaQ/8g2nGo6cJxELAQMjHHQM+4y"
|
||||
"9pgLLMaFE4A9yEU29CwXOQxRIroamkVnDoYM2ppU0+hEowSK/9gj1Fw4RR6DLm+e8nlobO"
|
||||
"FA70yMbVOLD47I9G3D+AT98KHJ6BTyji4f7OE7FyNiw5/Qiy6dJ3OCILYzQJAtBpByUwwo"
|
||||
"ZPf3nesb2VNMcWxaFPszkvR2FuyRkri77yP7TOiItikk0AX8EVK4iI9xiDUSBTPmAub6MJ"
|
||||
"6qnQhsOAE+FtD13yc+sQRrTd5J/Ln4I5xaqptp3vZH5rA9Mk09ZyMxBYV3KLIoEfZFhAlQ"
|
||||
"zy/BuAkQKdXFDVpfjMHJ+dUvEgH12NSVjRKX/iIVAQOBqoSeUBb2CnjlYLcegVsMO6OkMO"
|
||||
"dTfg3tSLAEd0SqArZ8Tf00MSRTJhbleWMJ67+MQYC7IXFTvgCDZXkbtjRkk6CeUPYYcDkL"
|
||||
"E7A85mtOiaEZLEad1VRY26HqWfRjO+QTZ7ID9EtQjzq99nBk9O7E8DPP+4ElL2PUFi0NKV"
|
||||
"0o0pMrxSzxINrfndEXTVxq3/q3bXWtxP1G33QxJ+AzahI6N4GdZhKJI1HGzBNEkPf4Kjsr"
|
||||
"qkdD77Wh+VCCTc7Gfw77t8X2TTRU0yKLaf9pmG+gVRk1tU+NfYQZIt6ZuO17bFWCUMbKkd"
|
||||
"s86Rn/qB611e03VfOJAZrcvYq4YfKU2tOEYAyspzlwbTPTktjNwWABXS9vuGaoePN1ADGQ"
|
||||
"z5s3VTpEu5MjHe4yTKThViuB0gYtI5pvmjVmqgQQMJWPJO4t7lSArCzoTYiuCH1NJ+m5Mg"
|
||||
"K+49snspAjbRqFrb4HXQ0R+VsOmQ95N9AriHEfkiBd6JieP9a/HwPffQp8Y7tsEPemdT5e"
|
||||
"2Nu4vFwj7uW9SgNf2ZaNfG3kieVqyusNUKt6VeHeqe9VeNdq6/Cu1cp5izYl04BFsedwBj"
|
||||
"DuEFaSY8DCoFPsDQfIeCom8et549PVZ94q5yguPi1hPewZ3W7ndqSwZBDMNnlno/6velfD"
|
||||
"db83GCvOiOdBhKVEYZRiCEgx3XlhTDbmKgf4li7B2ez3u5kIudkZKVzve8324KQucfNOiE"
|
||||
"lx/gX2LOoWeN0bTEGZK4g0FMwToVIV59ruIV/375vdtnY3aLc6w06YksQppmzMwh20ja5C"
|
||||
"F2LK54wZ2NTfZhRf5XT3zVlsz+fakAGECzK08tQ6pXLMrbeSW6cNEqRcm+UkaZ1tZib767"
|
||||
"RXJCK5YoXCt8BHcz+MpuQrXEjEHT4RQKwi16weHB0m1Fw9gotdMI/T48w7xZ+ePzMMPHPL"
|
||||
"GLaM67b+Ul4AqrK0EZQtBoAFD5mrbWTaT5cVN4KyhukmXVdWN1q+60LCtDamWqCnAWJrPI"
|
||||
"iyoTum/OE0RhnXjSoYwT00h/8Thzj5usdWRiysiKST5+QASa2KPGQPlxIYc+5Zg9Ul35zT"
|
||||
"8I2AXlAOsvO1lmNx5Vhc2Z8UqpLqyv6e3n6oZDXxQlnGpRF+orC7mkr9slZVIhXG9436xa"
|
||||
"eLz+dXF3GQH0uWRfr5ID/vudfkmlfcHd9DgRttlGsijffVI0gFZBJprF0+SVSO9ZOV9ZPD"
|
||||
"OxrQq4rCdnAw4Dvi25HXfJqS1fyAX6boLs9u+gQv9PAovBz+nn+pEh/kr/llRJWJsQFdVP"
|
||||
"yNa9hyuiwZBkmfVVlwuZ23/PVp6R5amCYW7KBhrve2/LD6Ou4bd9DytPBf6Hqo6OCn3Nmn"
|
||||
"VD5gqlJJUigW1QaEw+4fkG59rX20vmQfref3UX5HBslG3/6lVHZ/QPE+9fD3+Mxv+5vZy/"
|
||||
"+g2+dS"
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "match_player" ALTER COLUMN "user_sub" TYPE VARCHAR(1024) USING "user_sub"::VARCHAR(1024);
|
||||
ALTER TABLE "player_rating" ALTER COLUMN "user_sub" TYPE VARCHAR(1024) USING "user_sub"::VARCHAR(1024);"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "match_player" ALTER COLUMN "user_sub" TYPE VARCHAR(255) USING "user_sub"::VARCHAR(255);
|
||||
ALTER TABLE "player_rating" ALTER COLUMN "user_sub" TYPE VARCHAR(255) USING "user_sub"::VARCHAR(255);"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"eJztmutv4jgQwP+VKJ96Uq8q6Wt1Wp0UKNVyC6UCunfaahWZxFCrxs46zrGo1//9bOf94t"
|
||||
"EChYoP2yVjj+P8Jh7PjPOsT6gDsXfSAdx+1P/QnnUCJlD8yDYcazpw3UQsBRwMcdAz7jL0"
|
||||
"OAM2F8IRwB4UIgd6NkMuR5TIrqZm04mLIYeOptQ0OtIogfI//gg1BsfI45CJ5rGYh8ZnLv"
|
||||
"RO5NgOtcXgiIzfNoxP0E8fWpyOoejIxGAPP4QYEQf+gl506T5ZIwSxkwGCHDmAkltyQCm7"
|
||||
"v29d36iecopDy6bYn5Cktzvjj5TE3X0fOSdSR7aNIYEMiEdI4SI+xiHWSBTMWAg482E8VS"
|
||||
"cROHAEfCyh659HPrEla03dSf45/zOcWqqbZd12B1a/ObAsvWAjOYUc71BkUyLtiwiXoJ5f"
|
||||
"gnETIEqqyxs0vpi9o7PL3xQC6vExU40Kl/6iFAEHgaqCnlCW9gp4FWA3HgErh51RyjEXU3"
|
||||
"4N7UgwB3dEagNsxZr6ZWFIxlwuyjNjDutvZi/AbSjcVCzAYFnehi2GapLUE8oeB0ywsAAv"
|
||||
"Yr4WlDiawHLUWc0caydUPYl+rId84ky2gH4O6kGr0+wPzM6dHH7ieT+x4mUOmrLFUNJZTn"
|
||||
"p0mTNLPIj2d2vwRZOX2vfubTO/VuJ+g++6nBPwObUInVrASTOJxJEoY+YRIsh7fJWdc6oH"
|
||||
"Q++0ocVQkk3Bxn/1u7fl9k008qZFNtf+07DYQDdl1NQ+NfQR5oh4J/K277FVSUIZK0du86"
|
||||
"hj/pP3qI12t543nxygLtyrjBtGT6k9TQqGwH6aAuZYmZbEbi4GM8i8ouHqoeLN1x7EQD1v"
|
||||
"0VTpEO1OjbS/yzCRhlutAkoNWkW02DQxJnkJIGCsHkneW96pBFlV0JsQXRD6Wm7Sc2EEfC"
|
||||
"e2T2QjV9k0Clt9DzINEfVbDVkMeVfQK4lxH5IgXepYnj/UfxwC310KfGO7rBD3pnU+Xthb"
|
||||
"OzXOlwh8ZbfK0DdozAa/DvLkirXU9Qq083qbIr5V95tFbpyeLkFc9KoErtpyyQYsCz/7E4"
|
||||
"Bxi/CKNAOWxp1ye9hDxmM5id/PjKvLT6JVzVFeXM1h3e+Y7XbrdpBjySGYrPLORv1f9a6G"
|
||||
"S39nMG44KZ4GQVYuEKMUQ0DK6U5Lw7KhUNnDt3QOznq3284EyfXWIMf1vlNvCl+scItOiC"
|
||||
"tx8QX2bMpKvO4NpqDKFUQaOcwjqbIpzqfbh3zdva+3m9pdr9lo9VthVhJnmaoxC7fXNNs5"
|
||||
"uhBTMWfMwar+NqP4Kqe7a85ifT7XgRwgXJKkVWfXKZVDer2W9DptkCDrWi0tSeusMznZXa"
|
||||
"e9IBcp1CtyfEt8tPDDaEy+wplC3BITAcQuc835s6P9hFooSQgxA9M4Q868U+LpxTPDwDM3"
|
||||
"zH7DvG7qL9U1oE1WN4LKRQ/w4CEL5Y1M+/G8+kZQ2bBY0nVhgaPhMwYJ15qYaoGeBoijiS"
|
||||
"DKgWxIxcNpnHKhGxUxgntorvgnz3GKpY+1jFhaFEnnz8kZUr4w8pA9X0pgTIVnDVaXenOO"
|
||||
"wzcCekFFyCmWWw71lUN9ZXdSqE0VWHb3DPdD5auJI8oyrgzyE4XtlVVqF6ebyqXCEN+onV"
|
||||
"+dfzq7PI/j/FgyL9gvxvlF570k16Li9vjuC9xor1wSaby1HkDmQCbBxtIVlETlUEJZWELZ"
|
||||
"v9MBfVOB2BbOBnxXfkHymg9Uspof8PsUnYkEp0vwTA8PxKvh7/j3KvFx/pLfR2wyNzYhQ+"
|
||||
"VfuoYtx/PyYZD0WZQIV9t5zd+gVu6hpZliyQ4apntvSxE3X8p94w5anRn+C5mHys5+qp19"
|
||||
"SuUDpirGxcUynv7iotrTy7asp5eLagXCYfcPSLe21D5am7OP1or7qLgjh2SlLwBTKts/o3"
|
||||
"ifkvh7fOy3/s3s5X9zkOhU"
|
||||
)
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
The deployable tavolo application: the composition root wiring
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) to the
|
||||
[`tavolo-scopone`](../tavolo-scopone/README.md) and
|
||||
[`tavolo-scopone`](../tavolo-scopone/README.md),
|
||||
[`tavolo-scopa`](../tavolo-scopa/README.md) and
|
||||
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
|
||||
|
||||
## Contents
|
||||
|
||||
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
|
||||
mixins plus `PlatformMixin` and the deadline scheduler), registers the
|
||||
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
|
||||
their configured timeouts, and exposes `tavolo.app:app` for granian.
|
||||
`ScoponeEngine`, `ScopaEngine`, `ScopaQuattroEngine`, `BriscolaEngine`,
|
||||
`BriscolaQuattroEngine` and `BriscolaChiamataEngine` with their
|
||||
configured timeouts, and exposes `tavolo.app:app` for granian.
|
||||
- `config.py` — environment-driven frozen `Settings`.
|
||||
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
|
||||
meter/tracer/logger providers, the
|
||||
@@ -27,7 +29,7 @@ The deployable tavolo application: the composition root wiring
|
||||
## Development (from `packages/tavolo-app/`)
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
|
||||
uv run mypy --namespace-packages --explicit-package-bases \
|
||||
src/tavolo/app.py \
|
||||
src/tavolo/config.py \
|
||||
src/tavolo/telemetry.py \
|
||||
|
||||
@@ -11,6 +11,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"tavolo-platform",
|
||||
"tavolo-scopone",
|
||||
"tavolo-scopa",
|
||||
"tavolo-briscola",
|
||||
"kaya-core",
|
||||
"kaya-cors",
|
||||
@@ -30,10 +31,6 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy",
|
||||
"httpx-ws",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
@@ -41,6 +38,12 @@ otel = [
|
||||
"opentelemetry-instrumentation-system-metrics>=0.66b0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy",
|
||||
"httpx-ws",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = true
|
||||
|
||||
@@ -39,11 +39,17 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||
from tavolo.briscola import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||
from tavolo.platform.openapi import SCHEMAS
|
||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
|
||||
from tavolo.scopone import ScoponeEngine
|
||||
|
||||
from .config import Settings, settings
|
||||
@@ -132,6 +138,18 @@ registry.register(
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ScopaEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ScopaQuattroEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
BriscolaEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
@@ -144,6 +162,12 @@ registry.register(
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||
log.debug(
|
||||
"game timeouts: hand_ack=%ds turn=%ds",
|
||||
@@ -179,6 +203,9 @@ oidc = OIDCProviders(
|
||||
redirect_uri=provider.redirect_uri,
|
||||
post_login_redirect=provider.post_login_redirect,
|
||||
post_logout_redirect=provider.post_logout_redirect,
|
||||
# kaya-oidc defaults to openid+email+profile; the app neither
|
||||
# needs nor stores the email claim (profile covers the names).
|
||||
scopes=("openid", "profile"),
|
||||
fetch_userinfo=True,
|
||||
)
|
||||
for provider in settings.oidc_providers
|
||||
@@ -195,6 +222,7 @@ openapi_mixin = OpenAPIMixin(
|
||||
description="Multiplayer card-game platform API",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
components={"schemas": SCHEMAS},
|
||||
)
|
||||
tortoise_mixin = TortoiseMixin(
|
||||
database_url=settings.database_url,
|
||||
|
||||
@@ -72,9 +72,10 @@ _DEFAULT_OIDC_CLIENT_ID = "tavolo"
|
||||
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`` names the provider in URLs (``/auth/<provider>/login``),
|
||||
namespaces its session state (``oidc:<provider>:...``), and is part of
|
||||
the canonical user principal. It must match kaya-oidc's
|
||||
``[A-Za-z0-9_-]+`` pattern and remain stable across deployments.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"""SPA shell hosting for the compiled single-page application.
|
||||
|
||||
Static assets (wasm, js, css, card images) are served by Granian itself
|
||||
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
||||
Dockerfile) and never reach Python. This module only serves ``index.html``
|
||||
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||
Static assets (wasm, js, css, card images, PWA icons) are served by
|
||||
Granian itself under the ``/static`` and ``/assets`` prefixes
|
||||
(``GRANIAN_STATIC_PATH_*`` env vars; see the Dockerfile) and never reach
|
||||
Python. This module serves the few files that must live at the site root
|
||||
from ``STATIC_DIR``:
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||
in the API specification.
|
||||
- ``index.html`` at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes;
|
||||
- the web app manifest, whose ``start_url``/``scope`` cover the app;
|
||||
- the service worker, whose default scope is its own directory: served
|
||||
from ``/static`` it could only control ``/static``, so it is served at
|
||||
``/sw.js`` instead.
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so the fallback handler
|
||||
never appears in the API specification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
@@ -21,14 +29,30 @@ from .app import app
|
||||
from .config import settings
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
index = Path(settings.static_dir) / "index.html"
|
||||
if not index.is_file():
|
||||
async def _send_from_dist(ctx: HttpContext,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
cache_control: Optional[str] = None) -> None:
|
||||
"""Serve one file from ``STATIC_DIR``, or 404 when the build lacks it.
|
||||
|
||||
Unlike the SPA fallback, a missing file is not silently replaced by
|
||||
the app shell: a manifest or worker answer with the wrong content
|
||||
type would only confuse the browser.
|
||||
"""
|
||||
file = Path(settings.static_dir) / filename
|
||||
if not file.is_file():
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
body = await asyncio.to_thread(index.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||
body = await asyncio.to_thread(file.read_bytes)
|
||||
headers = {"content-type": (content_type,)}
|
||||
if cache_control is not None:
|
||||
headers["cache-control"] = (cache_control,)
|
||||
await ctx.send_bytes(200, body, headers)
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
await _send_from_dist(ctx, "index.html", "text/html; charset=utf-8")
|
||||
|
||||
|
||||
@app.GET("/")
|
||||
@@ -38,6 +62,25 @@ async def index(ctx: HttpContext) -> None:
|
||||
await _send_shell(ctx)
|
||||
|
||||
|
||||
@app.GET("/manifest.webmanifest")
|
||||
async def manifest(ctx: HttpContext) -> None:
|
||||
"""Serve the web app manifest (linked from index.html)."""
|
||||
await _send_from_dist(ctx, "manifest.webmanifest", "application/manifest+json")
|
||||
|
||||
|
||||
@app.GET("/sw.js")
|
||||
async def service_worker(ctx: HttpContext) -> None:
|
||||
"""Serve the service worker from the site root so its scope is the
|
||||
whole app. The script is revalidated on every load (no-cache) so a new
|
||||
build's worker takes over promptly."""
|
||||
await _send_from_dist(
|
||||
ctx,
|
||||
"sw.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
cache_control="no-cache",
|
||||
)
|
||||
|
||||
|
||||
@app.GET("/*", recursive=True)
|
||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||
"""SPA fallback: any path that matched no other route renders the app
|
||||
|
||||
@@ -95,10 +95,11 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
|
||||
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).
|
||||
irreversible in practice. The canonical provider-qualified principal
|
||||
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:
|
||||
@@ -111,9 +112,7 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
|
||||
)
|
||||
user = session_user(session)
|
||||
if user is not None:
|
||||
sub = user.get("sub")
|
||||
if sub:
|
||||
span.set_attribute("user.id", str(sub))
|
||||
span.set_attribute("user.id", user.principal)
|
||||
|
||||
|
||||
class _NoOtelLogs(logging.Filter):
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
Briscola — the Italian trick-taking game — as a
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||
Two variants share one rules core:
|
||||
Three variants share one rules core:
|
||||
|
||||
- `briscola` — the classic two-player game;
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola;
|
||||
- `briscola_chiamata` — five-player briscola with a calling auction and a
|
||||
secret partner.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -13,27 +15,42 @@ Two variants share one rules core:
|
||||
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||
envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
||||
(trump first, then the led suit; strength ace > three > king > horse >
|
||||
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
||||
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
||||
jack 2), patta re-deals and the target-score match end, deal-end
|
||||
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
||||
logging, so the whole rule set is unit-testable.
|
||||
- `engine.py` — the pure rules engine for two- and four-player briscola:
|
||||
deck, deals, trick resolution (trump first, then the led suit; strength
|
||||
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
|
||||
(trick winner first, the face-up briscola last), card points (ace 11,
|
||||
three 10, king 4, horse 3, jack 2), patta re-deals and the
|
||||
target-score match end, deal-end acknowledgements and auto-play.
|
||||
Deterministic and I/O-free apart from logging, so the whole rule set is
|
||||
unit-testable.
|
||||
- `chiamata.py` — the pure rules engine for five-player briscola
|
||||
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
|
||||
round-trip), the 8-card deal with no stock, the descending calling
|
||||
auction with point raises, the winner's call (including calling one's
|
||||
own card, which means playing alone), the called-card reveal, single-
|
||||
deal scoring with the 60–60 patta and the auto-actions for timeouts.
|
||||
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
||||
(`GameEngine`): the platform-facing adapters. They validate the
|
||||
`target_score` creation option (61–120, default 61) and extract the
|
||||
`MatchResult` (teams, winner, per-player scores and the match summary
|
||||
persisted as the match's JSON `result`).
|
||||
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||
The first two validate the `target_score` creation option (61–120,
|
||||
default 61) and extract the `MatchResult` (teams, winner, per-player
|
||||
scores and the match summary persisted as the match's JSON `result`).
|
||||
Briscola chiamata takes no creation options.
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root.
|
||||
application composition root. Briscola chiamata uses the turn timeout
|
||||
for its auction turns, the called-card choice and the plays: an absent
|
||||
bidder passes, the last player standing with no bid is made to bid the
|
||||
ace, and an absent winner's call names a random suit of the won rank. It
|
||||
never enters the between-deals acknowledgement phase (a single deal ends
|
||||
the match), so the hand-ack timeout is accepted but unused.
|
||||
|
||||
## Rules
|
||||
|
||||
### Briscola (two-player and four-player)
|
||||
|
||||
- Three cards per player; the next card is turned face up: its suit is
|
||||
the trump (briscola) for the deal and it is the last card drawn.
|
||||
- The winner of a trick is the highest trump, or the highest card of the
|
||||
@@ -47,12 +64,37 @@ application composition root.
|
||||
- Between deals of an unfinished match every player acknowledges the
|
||||
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||
|
||||
### Briscola chiamata (five-player)
|
||||
|
||||
- The whole 40-card deck is dealt out, eight cards each: there is no
|
||||
stock and no face-up card.
|
||||
- Starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a **lower** rank than the
|
||||
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
|
||||
the first bidder may bid any rank. Once the standing bid is the two,
|
||||
the auction continues on points instead (62, 64, …): the calling side
|
||||
then has to reach the raised target. The last player standing must
|
||||
bid, so a deal always gets a caller.
|
||||
- The auction winner names any card of the winning rank. If the winner
|
||||
holds it, they **play alone** against the other four; otherwise its
|
||||
holder is the **secret partner** and the two form the calling side.
|
||||
The called card's suit is the trump for the deal. The partner's
|
||||
identity is hidden until the called card is played.
|
||||
- Play follows briscola: the player after the dealer leads, the highest
|
||||
trump (or highest led-suit card) takes the trick and leads the next
|
||||
one; eight tricks exhaust every hand.
|
||||
- The calling side (caller plus partner, or the lone caller) must reach
|
||||
the declared target — 61 unless the auction raised it. With the plain
|
||||
target a 60–60 deal is a patta and is replayed with the next dealer.
|
||||
A match is a single deal, so the two sides map onto the platform's
|
||||
two-team result (Elo rating, match history).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
.venv/bin/python -m mypy -p tavolo.briscola
|
||||
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
uv run mypy -p tavolo.briscola
|
||||
```
|
||||
|
||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||
platform contract (actions, deadlines, serialization, results).
|
||||
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"""Briscola: tavolo's trick-taking game implementation.
|
||||
|
||||
Two variants share the same rules core and platform adapter:
|
||||
Three variants share the same rules core and platform adapter:
|
||||
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||
briscola (``briscola``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
|
||||
briscola chiamata (``briscola_chiamata``).
|
||||
|
||||
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
||||
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
||||
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
|
||||
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
|
||||
adapts them to the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||
game-independent platform can host them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
||||
from .plugin import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
|
||||
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
||||
__all__ = [
|
||||
"BriscolaChiamataEngine",
|
||||
"BriscolaEngine",
|
||||
"BriscolaQuattroEngine",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
"""Pure rules engine for briscola chiamata.
|
||||
|
||||
Briscola chiamata is the five-player variant of briscola. The 40-card
|
||||
deck is dealt out entirely (8 cards each, no stock): there is no face-up
|
||||
briscola card. Instead the players auction off the right to call a
|
||||
partner:
|
||||
|
||||
* starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a rank lower than the standing
|
||||
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
|
||||
bidder may bid any rank. Once the standing bid is the 2, further bids
|
||||
raise the points the calling side must reach instead (62, 64, ...).
|
||||
* the last remaining bidder wins and names the exact card of the winning
|
||||
rank (any suit). If the winner holds the named card they play alone
|
||||
against the other four; otherwise its holder is the **secret partner**
|
||||
and the two form the calling side. The called card's suit is the trump
|
||||
(*briscola*) for the whole deal.
|
||||
* play and scoring follow briscola. The calling side must reach the
|
||||
declared target (61 unless raised); a 60-60 deal with the plain target
|
||||
is a **patta** and is replayed.
|
||||
|
||||
A match is a single deal (patta replays aside), so the two sides map
|
||||
onto the platform's two-team result (Elo rating, match history). The
|
||||
partner's identity stays hidden until the called card is played, at
|
||||
which point the roles are revealed.
|
||||
|
||||
Like :mod:`tavolo.briscola.engine` this module is deterministic and
|
||||
I/O-free apart from logging: it mutates (or reads)
|
||||
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
|
||||
:class:`~tavolo.platform.errors.GameError` subclasses on rule
|
||||
violations. The platform-facing adapter is
|
||||
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
NotYourTurn,
|
||||
)
|
||||
|
||||
from .engine import TRICK_STRENGTH, card_points, shuffled_deck
|
||||
from .errors import CardNotInHand
|
||||
from .state import (
|
||||
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
SUITS,
|
||||
Card,
|
||||
Trick,
|
||||
TrickCard,
|
||||
parse_card,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Five seats, the whole 40-card deck dealt out.
|
||||
PLAYER_COUNT = 5
|
||||
HAND_SIZE = 8
|
||||
|
||||
PHASE_AUCTION = "auction"
|
||||
PHASE_CALLING = "calling"
|
||||
|
||||
# The two sides of the match: the caller (plus the partner, when there is
|
||||
# one) against the other players.
|
||||
SIDE_CALLER = 0
|
||||
SIDE_DEFENDERS = 1
|
||||
SIDE_NAMES = ("Chiamante", "Difensori")
|
||||
|
||||
# Role labels carried by the personalized view once they are public.
|
||||
ROLE_CALLER = "Chiamante"
|
||||
ROLE_PARTNER = "Socio"
|
||||
ROLE_DEFENDER = "Difensori"
|
||||
|
||||
# Points the calling side must reach: 61 unless the auction raised it.
|
||||
DEFAULT_TARGET = 61
|
||||
MIN_TARGET = 61
|
||||
MAX_TARGET = 120
|
||||
|
||||
# Rank ladder of the auction, strongest first: ace, three, king, horse,
|
||||
# jack, then the plain ranks downwards.
|
||||
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
|
||||
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
|
||||
|
||||
_rng = random.SystemRandom()
|
||||
|
||||
|
||||
# -- state -----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataPlayer:
|
||||
sub: str
|
||||
name: str
|
||||
seat: int
|
||||
hand: List[Card] = field(default_factory=list)
|
||||
captured: List[Card] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sub": self.sub,
|
||||
"name": self.name,
|
||||
"seat": self.seat,
|
||||
"hand": [c.to_json() for c in self.hand],
|
||||
"captured": [c.to_json() for c in self.captured],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataPlayer":
|
||||
return ChiamataPlayer(
|
||||
sub=str(data["sub"]),
|
||||
name=str(data["name"]),
|
||||
seat=int(data["seat"]),
|
||||
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bid:
|
||||
"""One auction bid: a rank, or (at the 2) a raised points target."""
|
||||
|
||||
seat: int
|
||||
rank: int
|
||||
points: int
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {"seat": self.seat, "rank": self.rank, "points": self.points}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Bid":
|
||||
return Bid(
|
||||
seat=int(data["seat"]),
|
||||
rank=int(data["rank"]),
|
||||
points=int(data["points"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Auction:
|
||||
"""The state of the calling auction."""
|
||||
|
||||
# Seat to act; always one of ``active``.
|
||||
turn: int = 0
|
||||
active: List[int] = field(default_factory=list)
|
||||
passed: List[int] = field(default_factory=list)
|
||||
current: Optional[Bid] = None
|
||||
history: List[Bid] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"active": list(self.active),
|
||||
"passed": list(self.passed),
|
||||
"current": self.current.to_json() if self.current else None,
|
||||
"history": [b.to_json() for b in self.history],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Auction":
|
||||
return Auction(
|
||||
turn=int(data.get("turn", 0)),
|
||||
active=[int(s) for s in data.get("active", [])],
|
||||
passed=[int(s) for s in data.get("passed", [])],
|
||||
current=(
|
||||
Bid.from_json(data["current"]) if data.get("current") else None
|
||||
),
|
||||
history=[Bid.from_json(b) for b in data.get("history", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataState:
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[ChiamataPlayer] = field(default_factory=list)
|
||||
dealer: int = 0
|
||||
# 1-based deal counter; grows with patta replays.
|
||||
hand_number: int = 1
|
||||
# 0 = the calling side (caller plus partner, when there is one),
|
||||
# 1 = the defenders. Only set once the match is finished.
|
||||
winner: Optional[int] = None
|
||||
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||
# The calling auction of the current deal.
|
||||
auction: Optional[Auction] = None
|
||||
# Seat that won the auction (public from then on).
|
||||
caller: Optional[int] = None
|
||||
# The card named by the caller; its suit is the trump.
|
||||
called_card: Optional[Card] = None
|
||||
briscola_suit: str = ""
|
||||
# Points target from the auction (61 unless raised).
|
||||
declared_target: int = DEFAULT_TARGET
|
||||
# True when the caller named a card of their own hand: no partner.
|
||||
solo: bool = False
|
||||
# True once the called card has been played; roles become public.
|
||||
called_played: bool = False
|
||||
# The partner's seat, recorded when the called card is played; stays
|
||||
# ``None`` for a solo call.
|
||||
partner_seat: Optional[int] = None
|
||||
# Cards of the trick in progress, in play order.
|
||||
trick: List[TrickCard] = field(default_factory=list)
|
||||
trick_leader: int = 0
|
||||
last_trick: Optional[Trick] = None
|
||||
# Seat currently to act (auction bidder, caller, or player on turn).
|
||||
turn: int = 0
|
||||
# Per-deal audit trail, patta entries included.
|
||||
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||
# While phase == "auction"/"playing": when the server acts for the
|
||||
# player on turn.
|
||||
turn_deadline: Optional[str] = None
|
||||
auction_deadline: Optional[str] = None
|
||||
# While phase == "calling": when the server names a card for the
|
||||
# auction winner.
|
||||
call_deadline: Optional[str] = None
|
||||
# Seconds a player has to act before the server acts for them.
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"dealer": self.dealer,
|
||||
"hand_number": self.hand_number,
|
||||
"winner": self.winner,
|
||||
"scores": list(self.scores),
|
||||
"auction": self.auction.to_json() if self.auction else None,
|
||||
"caller": self.caller,
|
||||
"called_card": (
|
||||
self.called_card.to_json() if self.called_card else None
|
||||
),
|
||||
"briscola_suit": self.briscola_suit,
|
||||
"declared_target": self.declared_target,
|
||||
"solo": self.solo,
|
||||
"called_played": self.called_played,
|
||||
"partner_seat": self.partner_seat,
|
||||
"trick": [t.to_json() for t in self.trick],
|
||||
"trick_leader": self.trick_leader,
|
||||
"last_trick": self.last_trick.to_json() if self.last_trick else None,
|
||||
"turn": self.turn,
|
||||
"hand_scores": list(self.hand_scores),
|
||||
"turn_deadline": self.turn_deadline,
|
||||
"auction_deadline": self.auction_deadline,
|
||||
"call_deadline": self.call_deadline,
|
||||
"turn_timeout": self.turn_timeout,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataState":
|
||||
return ChiamataState(
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[
|
||||
ChiamataPlayer.from_json(p) for p in data.get("players", [])
|
||||
],
|
||||
dealer=int(data.get("dealer", 0)),
|
||||
hand_number=int(data.get("hand_number", 1)),
|
||||
winner=data.get("winner"),
|
||||
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||
auction=(
|
||||
Auction.from_json(data["auction"]) if data.get("auction") else None
|
||||
),
|
||||
caller=data.get("caller"),
|
||||
called_card=(
|
||||
Card.from_json(data["called_card"])
|
||||
if data.get("called_card")
|
||||
else None
|
||||
),
|
||||
briscola_suit=str(data.get("briscola_suit", "")),
|
||||
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
|
||||
solo=bool(data.get("solo", False)),
|
||||
called_played=bool(data.get("called_played", False)),
|
||||
partner_seat=data.get("partner_seat"),
|
||||
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
|
||||
trick_leader=int(data.get("trick_leader", 0)),
|
||||
last_trick=(
|
||||
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
|
||||
),
|
||||
turn=int(data.get("turn", 0)),
|
||||
hand_scores=list(data.get("hand_scores", [])),
|
||||
turn_deadline=data.get("turn_deadline"),
|
||||
auction_deadline=data.get("auction_deadline"),
|
||||
call_deadline=data.get("call_deadline"),
|
||||
turn_timeout=int(
|
||||
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||
),
|
||||
)
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
|
||||
for player in self.players:
|
||||
if player.sub == sub:
|
||||
return player
|
||||
return None
|
||||
|
||||
def seated(self, sub: str) -> bool:
|
||||
return self.player_for(sub) is not None
|
||||
|
||||
|
||||
# -- game setup --------------------------------------------------------------
|
||||
|
||||
|
||||
def create_game(
|
||||
creator_sub: str,
|
||||
creator_name: str,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
) -> ChiamataState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
return ChiamataState(
|
||||
phase=PHASE_LOBBY,
|
||||
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
|
||||
turn_timeout=turn_timeout,
|
||||
)
|
||||
|
||||
|
||||
def join_game(state: ChiamataState, sub: str, name: str) -> None:
|
||||
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
||||
if state.phase != PHASE_LOBBY:
|
||||
raise GameNotStarted("game has already started")
|
||||
if state.seated(sub):
|
||||
raise AlreadyJoined("already joined this game")
|
||||
if len(state.players) >= PLAYER_COUNT:
|
||||
raise LobbyFull("game is full")
|
||||
seat = len(state.players)
|
||||
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
|
||||
if len(state.players) == PLAYER_COUNT:
|
||||
start_game(state)
|
||||
|
||||
|
||||
def start_game(state: ChiamataState) -> None:
|
||||
"""Deal the first hand and open the auction."""
|
||||
if len(state.players) != PLAYER_COUNT:
|
||||
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
|
||||
_deal(state)
|
||||
|
||||
|
||||
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise IllegalMove(f"no player in seat {seat}")
|
||||
|
||||
|
||||
def _set_deadline(seconds: int) -> str:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
||||
return deadline.isoformat()
|
||||
|
||||
|
||||
def _set_auction_deadline(state: ChiamataState) -> None:
|
||||
state.auction_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_call_deadline(state: ChiamataState) -> None:
|
||||
state.call_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_turn_deadline(state: ChiamataState) -> None:
|
||||
state.turn_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _deal(state: ChiamataState) -> None:
|
||||
"""Deal 8 cards each and open the auction."""
|
||||
deck = shuffled_deck()
|
||||
for player in state.players:
|
||||
player.hand = []
|
||||
player.captured = []
|
||||
state.trick = []
|
||||
state.last_trick = None
|
||||
state.turn_deadline = None
|
||||
state.call_deadline = None
|
||||
state.called_card = None
|
||||
state.briscola_suit = ""
|
||||
state.declared_target = DEFAULT_TARGET
|
||||
state.solo = False
|
||||
state.called_played = False
|
||||
state.partner_seat = None
|
||||
state.caller = None
|
||||
state.winner = None
|
||||
state.scores = [0, 0]
|
||||
# The whole deck is dealt, one card at a time, starting with the
|
||||
# player after the dealer.
|
||||
for _ in range(HAND_SIZE):
|
||||
for offset in range(PLAYER_COUNT):
|
||||
_player_at(
|
||||
state, (state.dealer + 1 + offset) % PLAYER_COUNT
|
||||
).hand.append(deck.pop())
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.auction = Auction(
|
||||
turn=first,
|
||||
active=[player.seat for player in state.players],
|
||||
)
|
||||
state.phase = PHASE_AUCTION
|
||||
_set_auction_deadline(state)
|
||||
log.debug(
|
||||
"deal %d dealt (dealer seat %d), auction opens with seat %d",
|
||||
state.hand_number,
|
||||
state.dealer,
|
||||
first,
|
||||
)
|
||||
|
||||
|
||||
# -- auction -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_auction(
|
||||
state: ChiamataState, sub: str
|
||||
) -> Tuple[Auction, ChiamataPlayer]:
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = state.player_for(sub)
|
||||
if player is None:
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - auction always exists here
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if auction.turn != player.seat:
|
||||
raise NotYourTurn("it is not your turn to bid")
|
||||
return auction, player
|
||||
|
||||
|
||||
def legal_bid_ranks(state: ChiamataState) -> List[int]:
|
||||
"""The ranks the player on turn may bid, empty at the point stage."""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return []
|
||||
current = auction.current
|
||||
if current is None:
|
||||
return list(BID_LADDER)
|
||||
if current.rank == 2:
|
||||
return []
|
||||
index = _BID_INDEX[current.rank]
|
||||
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
|
||||
|
||||
|
||||
def legal_bid_points(state: ChiamataState) -> Optional[int]:
|
||||
"""The lowest legal points raise, ``None`` when there is none.
|
||||
|
||||
Points may only be raised once the standing bid is the 2; any value
|
||||
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
|
||||
"""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return None
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
return None
|
||||
if current.points >= MAX_TARGET:
|
||||
return None
|
||||
return current.points + 2
|
||||
|
||||
|
||||
def _finish_auction(state: ChiamataState) -> None:
|
||||
"""The last active bidder wins and must name the called card."""
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
|
||||
raise IllegalMove("the auction cannot be concluded yet")
|
||||
winner = auction.active[0]
|
||||
state.caller = winner
|
||||
state.declared_target = auction.current.points
|
||||
state.turn = winner
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction_deadline = None
|
||||
_set_call_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d wins the auction (rank %d, target %d)",
|
||||
state.hand_number,
|
||||
winner,
|
||||
auction.current.rank,
|
||||
auction.current.points,
|
||||
)
|
||||
|
||||
|
||||
def _advance_auction(state: ChiamataState) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
return
|
||||
for offset in range(1, PLAYER_COUNT + 1):
|
||||
seat = (auction.turn + offset) % PLAYER_COUNT
|
||||
if seat in auction.active:
|
||||
auction.turn = seat
|
||||
break
|
||||
state.turn = auction.turn
|
||||
_set_auction_deadline(state)
|
||||
|
||||
|
||||
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction.current = bid
|
||||
auction.history.append(bid)
|
||||
log.debug(
|
||||
"deal %d: seat %d bids rank %d at %d",
|
||||
state.hand_number,
|
||||
bid.seat,
|
||||
bid.rank,
|
||||
bid.points,
|
||||
)
|
||||
if len(auction.active) == 1:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
|
||||
"""Bid ``rank``; it must be weaker than the standing bid."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if rank not in _BID_INDEX:
|
||||
raise IllegalMove(f"invalid rank: {rank!r}")
|
||||
current = auction.current
|
||||
if current is not None:
|
||||
if current.rank == 2:
|
||||
raise IllegalMove("the auction is at the 2: raise the points or pass")
|
||||
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
|
||||
raise IllegalMove("a bid must call a lower rank than the standing bid")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
|
||||
|
||||
|
||||
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
|
||||
"""Raise the points target; only legal once the standing bid is the 2."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
raise IllegalMove("points can only be raised after a bid of the 2")
|
||||
if points <= current.points:
|
||||
raise IllegalMove(
|
||||
f"the points must be higher than the standing bid ({current.points})"
|
||||
)
|
||||
if points > MAX_TARGET:
|
||||
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
|
||||
|
||||
|
||||
def pass_auction(state: ChiamataState, sub: str) -> None:
|
||||
"""Leave the auction for good."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if player.seat not in auction.active: # pragma: no cover - turn implies active
|
||||
raise IllegalMove("you already passed")
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
# Everyone else passed before any bid: the last player must bid.
|
||||
raise IllegalMove("you must bid: everyone else has passed")
|
||||
auction.active.remove(player.seat)
|
||||
auction.passed.append(player.seat)
|
||||
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
|
||||
if len(auction.active) == 1 and auction.current is not None:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
# -- calling -------------------------------------------------------------------
|
||||
|
||||
|
||||
def call(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""The auction winner names the called card (any suit of the bid rank).
|
||||
|
||||
If the caller holds the named card they play alone; the card's suit
|
||||
becomes the trump for the deal.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = None if state.caller is None else _player_at(state, state.caller)
|
||||
player = state.player_for(sub)
|
||||
if player is None or caller is None or player.seat != caller.seat:
|
||||
raise NotYourTurn("only the auction winner calls the card")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
card = parse_card(card_code)
|
||||
if card.rank != auction.current.rank:
|
||||
raise IllegalMove(
|
||||
"the called card must match the rank won in the auction"
|
||||
)
|
||||
state.called_card = card
|
||||
state.briscola_suit = card.suit
|
||||
state.solo = card in caller.hand
|
||||
state.phase = PHASE_PLAYING
|
||||
# The player after the dealer opens the play, whichever seat won the
|
||||
# auction.
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.call_deadline = None
|
||||
_set_turn_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d calls %s%s",
|
||||
state.hand_number,
|
||||
caller.seat,
|
||||
card.code,
|
||||
" (playing alone)" if state.solo else "",
|
||||
)
|
||||
|
||||
|
||||
# -- play ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def play(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""Apply one move by the player identified by ``sub``.
|
||||
|
||||
Any card in hand is a legal play; only turn order and phase are
|
||||
validated. Raises a :class:`~tavolo.platform.errors.GameError`
|
||||
subclass on any violation.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase == PHASE_AUCTION:
|
||||
raise IllegalMove("the auction is still open")
|
||||
if state.phase == PHASE_CALLING:
|
||||
raise IllegalMove("the called card has not been named yet")
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
|
||||
player = state.player_for(sub)
|
||||
if player is None or player.seat != state.turn:
|
||||
raise NotYourTurn("it is not your turn")
|
||||
|
||||
card = parse_card(card_code)
|
||||
if card not in player.hand:
|
||||
raise CardNotInHand(f"card {card.code} is not in your hand")
|
||||
|
||||
player.hand.remove(card)
|
||||
if state.called_card is not None and card == state.called_card:
|
||||
state.called_played = True
|
||||
state.partner_seat = (
|
||||
None if player.seat == state.caller else player.seat
|
||||
)
|
||||
log.info(
|
||||
"deal %d: the called card %s was played by seat %d (%s)",
|
||||
state.hand_number,
|
||||
card.code,
|
||||
player.seat,
|
||||
"the caller plays alone" if state.partner_seat is None else "partner revealed",
|
||||
)
|
||||
state.trick.append(TrickCard(seat=player.seat, card=card.code))
|
||||
log.debug(
|
||||
"deal %d: seat %d played %s (%d/%d in the trick)",
|
||||
state.hand_number,
|
||||
player.seat,
|
||||
card.code,
|
||||
len(state.trick),
|
||||
PLAYER_COUNT,
|
||||
)
|
||||
|
||||
if len(state.trick) == PLAYER_COUNT:
|
||||
_resolve_trick(state)
|
||||
else:
|
||||
state.turn = (state.turn + 1) % PLAYER_COUNT
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _resolve_trick(state: ChiamataState) -> None:
|
||||
"""Score the completed trick, draw no cards (deck already exhausted)."""
|
||||
winner = _trick_winner(state)
|
||||
taken = [parse_card(t.card) for t in state.trick]
|
||||
_player_at(state, winner).captured.extend(taken)
|
||||
state.last_trick = Trick(winner=winner, cards=list(state.trick))
|
||||
state.trick = []
|
||||
state.trick_leader = winner
|
||||
state.turn = winner
|
||||
log.debug(
|
||||
"deal %d: seat %d takes the trick with %s",
|
||||
state.hand_number,
|
||||
winner,
|
||||
[t.card for t in state.last_trick.cards],
|
||||
)
|
||||
|
||||
if all(not player.hand for player in state.players):
|
||||
_end_deal(state)
|
||||
return
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _trick_winner(state: ChiamataState) -> int:
|
||||
"""The seat winning the current (complete) trick."""
|
||||
played = [(t.seat, parse_card(t.card)) for t in state.trick]
|
||||
leader_suit = played[0][1].suit
|
||||
trumps = [
|
||||
(seat, card)
|
||||
for seat, card in played
|
||||
if card.suit == state.briscola_suit
|
||||
]
|
||||
pool = trumps or [
|
||||
(seat, card) for seat, card in played if card.suit == leader_suit
|
||||
]
|
||||
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
|
||||
|
||||
|
||||
# -- scoring -------------------------------------------------------------------
|
||||
|
||||
|
||||
def side_points(state: ChiamataState) -> Tuple[int, int]:
|
||||
"""The points captured by the calling side and the defenders.
|
||||
|
||||
The partner's pile only joins the caller's once the partner is known
|
||||
(the called card has been played); until then this is only used to
|
||||
score a finished deal, where the reveal has always happened.
|
||||
"""
|
||||
if state.caller is None:
|
||||
return (0, 0)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_points = 0
|
||||
defender_points = 0
|
||||
for player in state.players:
|
||||
points = sum(card_points(card) for card in player.captured)
|
||||
if player.seat in caller_seats:
|
||||
caller_points += points
|
||||
else:
|
||||
defender_points += points
|
||||
return (caller_points, defender_points)
|
||||
|
||||
|
||||
def _end_deal(state: ChiamataState) -> None:
|
||||
"""Score the deal: finish the match, or replay after a patta.
|
||||
|
||||
A 60-60 deal with the plain 61 target is a patta: its points do not
|
||||
count, the dealer rotates and the deal is replayed (a new auction).
|
||||
Otherwise the calling side wins by reaching the declared target.
|
||||
"""
|
||||
state.turn_deadline = None
|
||||
caller_points, defender_points = side_points(state)
|
||||
patta = (
|
||||
state.declared_target == DEFAULT_TARGET
|
||||
and caller_points == 60
|
||||
and defender_points == 60
|
||||
)
|
||||
caller_name = (
|
||||
_player_at(state, state.caller).name if state.caller is not None else None
|
||||
)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_cards = sum(
|
||||
len(player.captured)
|
||||
for player in state.players
|
||||
if player.seat in caller_seats
|
||||
)
|
||||
entry: Dict[str, Any] = {
|
||||
"deal": state.hand_number,
|
||||
"patta": patta,
|
||||
"points": {"A": caller_points, "B": defender_points},
|
||||
"cards": {"A": caller_cards, "B": 40 - caller_cards},
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": caller_name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
state.hand_scores.append(entry)
|
||||
log.debug(
|
||||
"deal %d scored caller %d - defenders %d%s (target %d)",
|
||||
state.hand_number,
|
||||
caller_points,
|
||||
defender_points,
|
||||
" patta" if patta else "",
|
||||
state.declared_target,
|
||||
)
|
||||
if patta:
|
||||
state.hand_number += 1
|
||||
state.dealer = (state.dealer + 1) % PLAYER_COUNT
|
||||
_deal(state)
|
||||
return
|
||||
|
||||
state.scores = [caller_points, defender_points]
|
||||
state.winner = (
|
||||
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
|
||||
)
|
||||
state.phase = PHASE_FINISHED
|
||||
log.info(
|
||||
"match ended, %s win %d-%d",
|
||||
SIDE_NAMES[state.winner],
|
||||
caller_points,
|
||||
defender_points,
|
||||
)
|
||||
|
||||
|
||||
# -- server-side auto actions ---------------------------------------------------
|
||||
|
||||
|
||||
def auto_bid(state: ChiamataState) -> None:
|
||||
"""Act for the bidder on turn: always pass, unless forced to bid.
|
||||
|
||||
An absent player never outbids anyone: they leave the auction. Only
|
||||
the last player standing before any bid cannot pass — they are made
|
||||
to bid the ace, whose suit :func:`auto_call` picks at random if the
|
||||
call itself times out too.
|
||||
"""
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = _player_at(state, auction.turn)
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
bid_rank(state, player.sub, 1)
|
||||
return
|
||||
pass_auction(state, player.sub)
|
||||
|
||||
|
||||
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Name a random suit of the auction rank for the winner."""
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = _player_at(state, state.caller) if state.caller is not None else None
|
||||
if caller is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("no call is waiting")
|
||||
chooser = rng or _rng
|
||||
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
|
||||
call(state, caller.sub, card.code)
|
||||
|
||||
|
||||
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Play a random card for the player currently on turn."""
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
player = _player_at(state, state.turn)
|
||||
if not player.hand:
|
||||
raise IllegalMove("the player on turn has no cards")
|
||||
chooser = rng or _rng
|
||||
card = chooser.choice(player.hand)
|
||||
play(state, player.sub, card.code)
|
||||
|
||||
|
||||
# -- views ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _roles_public(state: ChiamataState) -> bool:
|
||||
return state.called_played
|
||||
|
||||
|
||||
def state_for_player(
|
||||
state: ChiamataState, sub: Optional[str]
|
||||
) -> Dict[str, object]:
|
||||
"""Serialize ``state`` hiding other players' hands and secret roles.
|
||||
|
||||
Hands are reduced to a count, except for the requesting player's own
|
||||
hand. The partner's role only appears once the called card has been
|
||||
played; before that only the caller is labeled. The calling side's
|
||||
running total is withheld until then too: it would let onlookers
|
||||
deduce the partner's identity from who captured the points.
|
||||
"""
|
||||
viewer = state.player_for(sub)
|
||||
revealed = _roles_public(state)
|
||||
players: List[Dict[str, object]] = []
|
||||
for player in state.players:
|
||||
role = ""
|
||||
if state.caller is not None and player.seat == state.caller:
|
||||
role = ROLE_CALLER
|
||||
if revealed:
|
||||
if state.partner_seat is not None and player.seat == state.partner_seat:
|
||||
role = ROLE_PARTNER
|
||||
elif player.seat != state.caller:
|
||||
role = ROLE_DEFENDER
|
||||
view: Dict[str, object] = {
|
||||
"sub": player.sub,
|
||||
"name": player.name,
|
||||
"seat": player.seat,
|
||||
"team": role,
|
||||
"cards_left": len(player.hand),
|
||||
"captured_count": len(player.captured),
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
}
|
||||
if viewer is not None and viewer.seat == player.seat:
|
||||
view["hand"] = [c.code for c in player.hand]
|
||||
players.append(view)
|
||||
|
||||
auction = state.auction
|
||||
auction_view: Optional[Dict[str, object]] = None
|
||||
if auction is not None:
|
||||
auction_view = {
|
||||
"turn": auction.turn,
|
||||
"active": list(auction.active),
|
||||
"passed": list(auction.passed),
|
||||
"current": auction.current.to_json() if auction.current else None,
|
||||
"history": [b.to_json() for b in auction.history],
|
||||
}
|
||||
|
||||
payload: Dict[str, object] = {
|
||||
"phase": state.phase,
|
||||
"player_count": PLAYER_COUNT,
|
||||
"dealer": state.dealer,
|
||||
"hand_number": state.hand_number,
|
||||
"turn": state.turn,
|
||||
"auction": auction_view,
|
||||
"caller": state.caller,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"briscola_suit": state.briscola_suit,
|
||||
"declared_target": state.declared_target,
|
||||
"called_played": state.called_played,
|
||||
"solo": state.solo if revealed else False,
|
||||
"partner": (
|
||||
state.partner_seat if revealed and state.partner_seat is not None else None
|
||||
),
|
||||
"players": players,
|
||||
"trick": [t.to_json() for t in state.trick],
|
||||
"trick_leader": state.trick_leader,
|
||||
"last_trick": state.last_trick.to_json() if state.last_trick else None,
|
||||
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
|
||||
"auction_deadline": state.auction_deadline,
|
||||
"call_deadline": state.call_deadline,
|
||||
"turn_deadline": state.turn_deadline,
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
}
|
||||
if revealed and state.caller is not None:
|
||||
caller_points, defender_points = side_points(state)
|
||||
payload["scores"] = {"A": caller_points, "B": defender_points}
|
||||
|
||||
if viewer is not None:
|
||||
if state.phase == PHASE_AUCTION and auction is not None:
|
||||
if auction.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
payload["legal_bids"] = {
|
||||
"ranks": legal_bid_ranks(state),
|
||||
"points_min": legal_bid_points(state),
|
||||
"points_max": MAX_TARGET,
|
||||
}
|
||||
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
current = auction.current if auction is not None else None
|
||||
if current is not None:
|
||||
payload["callable_cards"] = [
|
||||
Card(rank=current.rank, suit=suit).code for suit in SUITS
|
||||
]
|
||||
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
return payload
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The platform-facing adapter for briscola.
|
||||
|
||||
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
||||
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
||||
:class:`BriscolaEngine` and four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
||||
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||
:class:`BriscolaEngine`, four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine` and five-player
|
||||
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
|
||||
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||
keeping all briscola knowledge inside this package. Nothing outside
|
||||
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
|
||||
)
|
||||
from tavolo.platform.errors import GameError, IllegalMove
|
||||
|
||||
from . import engine
|
||||
from . import chiamata, engine
|
||||
from .chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_NAMES,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
)
|
||||
from .engine import card_points
|
||||
from .state import (
|
||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
DEFAULT_TARGET_SCORE,
|
||||
@@ -207,6 +220,8 @@ class _BriscolaBase(GameEngine):
|
||||
)
|
||||
elif kind == "hand_end":
|
||||
for player in state.players:
|
||||
if state.phase != PHASE_HAND_END:
|
||||
break
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
log.info(
|
||||
"deal %d auto-advanced after the acknowledgement timeout",
|
||||
@@ -277,3 +292,299 @@ class BriscolaQuattroEngine(_BriscolaBase):
|
||||
min_players = 4
|
||||
max_players = 4
|
||||
player_count = 4
|
||||
|
||||
|
||||
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise GameError(f"no player in seat {seat}")
|
||||
|
||||
|
||||
class BriscolaChiamataEngine(GameEngine):
|
||||
"""Five-player briscola chiamata as a platform game engine.
|
||||
|
||||
One deal per match: the five players auction the call, the winner
|
||||
names a card (playing alone when it is in their own hand) and the
|
||||
calling side must reach the declared target (61 unless the auction
|
||||
raised it). A 60-60 deal with the plain target is a patta and is
|
||||
replayed. This keeps the match result a clean two-sided affair —
|
||||
caller and secret partner against the other three, or the lone
|
||||
caller against the other four — which the platform can rate,
|
||||
persist and display like any other game.
|
||||
"""
|
||||
|
||||
id = "briscola_chiamata"
|
||||
name = "Briscola chiamata"
|
||||
description = (
|
||||
"Five-player briscola with a descending auction: the winner calls "
|
||||
"a card, whose holder becomes the secret partner (or, when the "
|
||||
"caller holds it, the caller plays alone) and whose suit becomes "
|
||||
"the trump. The calling side must reach 61 points; one deal per "
|
||||
"match, replayed after a patta."
|
||||
)
|
||||
min_players = 5
|
||||
max_players = 5
|
||||
|
||||
options_schema: Mapping[str, Any] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self._turn_timeout = turn_timeout_seconds
|
||||
# A chiamata match is a single deal, so there is no between-deals
|
||||
# acknowledgement phase to time out; the parameter is accepted
|
||||
# for symmetry with the other briscola engines and wired by the
|
||||
# application root.
|
||||
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||
|
||||
# -- lobby ------------------------------------------------------------
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
creator = session.players[0]
|
||||
session.state = chiamata.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
creator_name=creator.display_name,
|
||||
turn_timeout=self._turn_timeout,
|
||||
)
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
chiamata.join_game(session.state, user_sub, display_name)
|
||||
session.players.append(
|
||||
Seat(user_sub=user_sub, display_name=display_name)
|
||||
)
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_LOBBY
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"declared_target": state.declared_target,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ChiamataState = session.state
|
||||
if action == "bid":
|
||||
rank = payload.get("rank")
|
||||
points = payload.get("points")
|
||||
if rank is not None:
|
||||
if isinstance(rank, bool) or not isinstance(rank, int):
|
||||
raise IllegalMove("'rank' must be an integer")
|
||||
chiamata.bid_rank(state, user_sub, rank)
|
||||
elif points is not None:
|
||||
if isinstance(points, bool) or not isinstance(points, int):
|
||||
raise IllegalMove("'points' must be an integer")
|
||||
chiamata.bid_points(state, user_sub, points)
|
||||
else:
|
||||
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
|
||||
elif action == "pass":
|
||||
chiamata.pass_auction(state, user_sub)
|
||||
elif action == "call":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.call(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
elif action == "play":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.play(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
return chiamata.state_for_player(session.state, user_sub)
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_FINISHED
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
caller = (
|
||||
_chiamata_player(state, state.caller) if state.caller is not None else None
|
||||
)
|
||||
partner = (
|
||||
_chiamata_player(state, state.partner_seat)
|
||||
if state.partner_seat is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": None if caller is None else caller.name,
|
||||
"partner": None if partner is None else partner.name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
|
||||
# -- (de)serialization -------------------------------------------------
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
assert isinstance(state, ChiamataState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ChiamataState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ChiamataState = session.state
|
||||
if state.phase == PHASE_AUCTION and state.auction_deadline:
|
||||
due_ms = _deadline_ms(state.auction_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="auction",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_CALLING and state.call_deadline:
|
||||
due_ms = _deadline_ms(state.call_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="call",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"call:{state.hand_number}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||
due_ms = _deadline_ms(state.turn_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="turn",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
return None
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
# Overtaken by events (a bid, call or play landed in time):
|
||||
# nothing to do.
|
||||
raise GameError("stale deadline")
|
||||
state: ChiamataState = session.state
|
||||
if kind == "auction":
|
||||
seat = state.turn
|
||||
auction = state.auction
|
||||
passed_before = len(auction.passed) if auction is not None else 0
|
||||
chiamata.auto_bid(state)
|
||||
action = (
|
||||
"auto-passed"
|
||||
if auction is not None and len(auction.passed) > passed_before
|
||||
else "forced to bid the ace"
|
||||
)
|
||||
log.info(
|
||||
"seat %s %s (auction timeout, deal %d)",
|
||||
seat,
|
||||
action,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "call":
|
||||
caller = state.caller
|
||||
chiamata.auto_call(state)
|
||||
log.info(
|
||||
"auto-called for seat %s (call timeout, deal %d)",
|
||||
caller,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "turn":
|
||||
seat = state.turn
|
||||
chiamata.auto_play(state)
|
||||
log.info(
|
||||
"auto-played for seat %s (turn timeout, deal %d)",
|
||||
seat,
|
||||
state.hand_number,
|
||||
)
|
||||
else: # pragma: no cover - next_deadline never emits other kinds
|
||||
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||
|
||||
# -- results -------------------------------------------------------------
|
||||
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
state: ChiamataState = session.state
|
||||
if state.winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
if state.caller is None: # pragma: no cover - a winner implies a caller
|
||||
raise GameError("no result: the caller is unknown")
|
||||
caller = state.caller
|
||||
partner = state.partner_seat
|
||||
caller_seats = [caller] + ([partner] if partner is not None else [])
|
||||
teams = [
|
||||
[_chiamata_player(state, seat).sub for seat in caller_seats],
|
||||
[
|
||||
player.sub
|
||||
for player in state.players
|
||||
if player.seat not in caller_seats
|
||||
],
|
||||
]
|
||||
caller_points, defender_points = chiamata.side_points(state)
|
||||
side_scores = [caller_points, defender_points]
|
||||
players = []
|
||||
for player in state.players:
|
||||
if player.seat == caller:
|
||||
role = ROLE_CALLER
|
||||
side = 0
|
||||
elif partner is not None and player.seat == partner:
|
||||
role = ROLE_PARTNER
|
||||
side = 0
|
||||
else:
|
||||
role = ROLE_DEFENDER
|
||||
side = 1
|
||||
players.append(
|
||||
PlayerResult(
|
||||
user_sub=player.sub,
|
||||
seat=player.seat,
|
||||
won=side == state.winner,
|
||||
team=role,
|
||||
score=float(side_scores[side]),
|
||||
details={
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
"captured_cards": len(player.captured),
|
||||
},
|
||||
)
|
||||
)
|
||||
return MatchResult(
|
||||
teams=teams,
|
||||
winner_team=state.winner,
|
||||
players=players,
|
||||
summary={
|
||||
"team_a_score": caller_points,
|
||||
"team_b_score": defender_points,
|
||||
"winner_team": SIDE_NAMES[state.winner],
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": _chiamata_player(state, caller).name,
|
||||
"partner": (
|
||||
_chiamata_player(state, partner).name
|
||||
if partner is not None
|
||||
else None
|
||||
),
|
||||
"solo": state.solo,
|
||||
"deals_played": state.hand_number,
|
||||
"deal_scores": state.hand_scores,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
BID_LADDER,
|
||||
Bid,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_CALLER,
|
||||
SIDE_DEFENDERS,
|
||||
)
|
||||
from tavolo.briscola.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def started_state() -> ChiamataState:
|
||||
"""A five-player game whose auction has just opened (dealer seat 0)."""
|
||||
state = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
return state
|
||||
|
||||
|
||||
def bid(state: ChiamataState, seat: int, rank: int) -> None:
|
||||
rules.bid_rank(state, f"p{seat}", rank)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
*,
|
||||
briscola_suit: str = "S",
|
||||
called_card: str | None = None,
|
||||
caller: int | None = None,
|
||||
solo: bool = False,
|
||||
called_played: bool = False,
|
||||
partner_seat: int | None = None,
|
||||
captured=None,
|
||||
turn: int = 0,
|
||||
dealer: int = 0,
|
||||
declared_target: int = rules.DEFAULT_TARGET,
|
||||
phase: str = PHASE_PLAYING,
|
||||
hand_number: int = 1,
|
||||
) -> ChiamataState:
|
||||
"""Build a controlled playing state directly (bypassing the auction)."""
|
||||
state = ChiamataState(
|
||||
phase=phase,
|
||||
dealer=dealer,
|
||||
hand_number=hand_number,
|
||||
declared_target=declared_target,
|
||||
briscola_suit=briscola_suit,
|
||||
called_card=card(called_card) if called_card else None,
|
||||
caller=caller,
|
||||
solo=solo,
|
||||
called_played=called_played,
|
||||
partner_seat=partner_seat,
|
||||
turn=turn,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
ChiamataPlayer(
|
||||
sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||
hand=[card(c) for c in hand],
|
||||
)
|
||||
)
|
||||
if captured is not None:
|
||||
for player, codes in zip(state.players, captured):
|
||||
player.captured = [card(c) for c in codes]
|
||||
return state
|
||||
|
||||
|
||||
def play_all(state: ChiamataState) -> None:
|
||||
"""Play the first card of each hand in turn order until the trick resolves."""
|
||||
for _ in range(rules.PLAYER_COUNT):
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
rules.play(state, player.sub, player.hand[0].code)
|
||||
|
||||
|
||||
class SetupTest(unittest.TestCase):
|
||||
def test_five_player_deal(self) -> None:
|
||||
state = started_state()
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
|
||||
for player in state.players:
|
||||
self.assertEqual(rules.HAND_SIZE, len(player.hand))
|
||||
# The whole deck is dealt: no stock, no face-up card.
|
||||
dealt = [c.code for p in state.players for c in p.hand]
|
||||
self.assertEqual(40, len(dealt))
|
||||
self.assertEqual(40, len(set(dealt)))
|
||||
# The auction opens with the player after the dealer.
|
||||
assert state.auction is not None
|
||||
self.assertEqual(1, state.auction.turn)
|
||||
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
|
||||
self.assertIsNone(state.auction.current)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
|
||||
|
||||
state = rules.create_game("p0", "p0")
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
rules.join_game(state, "p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
with self.assertRaises(GameNotStarted):
|
||||
rules.join_game(state, "p5", "p5")
|
||||
|
||||
lobby = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(lobby, f"p{i}", f"p{i}")
|
||||
# Joining a sixth player is impossible once the fifth started the
|
||||
# match; exercise LobbyFull on an artificially unfilled start.
|
||||
unfilled = rules.create_game("p0", "p0")
|
||||
unfilled.phase = rules.PHASE_LOBBY
|
||||
for i in range(1, 5):
|
||||
unfilled.players.append(
|
||||
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
|
||||
)
|
||||
with self.assertRaises(LobbyFull):
|
||||
rules.join_game(unfilled, "p5", "p5")
|
||||
|
||||
|
||||
class AuctionTest(unittest.TestCase):
|
||||
def test_first_bid_accepts_any_rank(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
|
||||
|
||||
def test_bids_must_descend(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 2, 1) # same rank
|
||||
bid(state, 2, 3) # three, weaker than the ace
|
||||
bid(state, 3, 10) # king, weaker than the three
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 4, 3) # three is stronger than the king
|
||||
bid(state, 4, 9) # horse
|
||||
|
||||
def test_bid_ladder_ordering(self) -> None:
|
||||
# A bid must be weaker than the standing bid: descending strength
|
||||
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
|
||||
rules.bid_rank(state, f"p{opponent}", rank)
|
||||
# 3 and A are stronger than 6: rejected.
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 3)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 1)
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = started_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
bid(state, 2, 1)
|
||||
|
||||
def test_pass_eliminates_and_last_standing_bids(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
assert state.auction is not None
|
||||
# Only seat 0 remains and nobody has bid: it must bid.
|
||||
self.assertEqual([0], state.auction.active)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.bid_rank(state, "p0", 2)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(0, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_auction_ends_when_one_bidder_remains(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
rules.pass_auction(state, "p2")
|
||||
bid(state, 3, 10) # king
|
||||
rules.pass_auction(state, "p4")
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.pass_auction(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(3, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_point_raises_only_after_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p2", 62)
|
||||
bid(state, 2, 2)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p3", 2) # no ranks below the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 61) # not higher
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 121) # above the cap
|
||||
rules.bid_points(state, "p3", 63)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
|
||||
|
||||
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertTrue(view["your_turn"])
|
||||
legal = view["legal_bids"]
|
||||
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
|
||||
self.assertIsNone(legal["points_min"])
|
||||
# A player who is not on turn gets no legal bids.
|
||||
other = rules.state_for_player(state, "p3")
|
||||
self.assertNotIn("legal_bids", other)
|
||||
self.assertNotIn("your_turn", other)
|
||||
|
||||
def test_legal_points_exposed_at_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 2)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertEqual([], view["legal_bids"]["ranks"])
|
||||
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||
self.assertEqual(120, view["legal_bids"]["points_max"])
|
||||
|
||||
def test_auto_bid_always_passes_when_legal(self) -> None:
|
||||
state = started_state()
|
||||
assert state.auction is not None
|
||||
rules.auto_bid(state)
|
||||
# Seat 1 passes: nobody has bid and the turn moves on.
|
||||
self.assertEqual([1], state.auction.passed)
|
||||
self.assertIsNone(state.auction.current)
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
# An absent bidder still passes when there is a standing bid.
|
||||
rules.bid_rank(state, "p2", 10)
|
||||
rules.auto_bid(state)
|
||||
self.assertEqual([1, 3], state.auction.passed)
|
||||
assert state.auction.current is not None
|
||||
self.assertEqual(10, state.auction.current.rank)
|
||||
|
||||
def test_auto_bid_passes_at_the_point_stage(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 2)
|
||||
rules.bid_points(state, "p2", 62)
|
||||
rules.auto_bid(state) # seat 3
|
||||
assert state.auction is not None
|
||||
self.assertIn(3, state.auction.passed)
|
||||
assert state.auction.current is not None
|
||||
self.assertEqual(62, state.auction.current.points)
|
||||
|
||||
def test_auto_bid_forced_to_call_the_ace(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
# Seat 0 is the last one standing and nobody has bid yet: the
|
||||
# forced auto-bid calls the ace.
|
||||
rules.auto_bid(state)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(0, state.caller)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(1, state.auction.current.rank)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
|
||||
class CallTest(unittest.TestCase):
|
||||
def _called_state(self, rank: int = 1) -> ChiamataState:
|
||||
state = started_state()
|
||||
bid(state, 1, rank)
|
||||
for seat in (2, 3, 4, 0):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
return state
|
||||
|
||||
def test_call_must_match_the_winning_rank(self) -> None:
|
||||
state = self._called_state(1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.call(state, "p1", "03D")
|
||||
# Any suit of the winning rank is fine.
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual("D", state.briscola_suit)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_only_the_winner_calls(self) -> None:
|
||||
state = self._called_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.call(state, "p2", "01D")
|
||||
|
||||
def test_call_in_hand_means_playing_alone(self) -> None:
|
||||
state = self._called_state(1)
|
||||
# Seat 1 calls a card it holds: solo.
|
||||
state.players[1].hand[0] = card("01S")
|
||||
rules.call(state, "p1", "01S")
|
||||
self.assertTrue(state.solo)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
|
||||
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
|
||||
state = self._called_state(1)
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertNotEqual(state.caller, None)
|
||||
|
||||
def test_auto_call_names_the_bid_rank(self) -> None:
|
||||
state = self._called_state(10)
|
||||
rules.auto_call(state, random.Random(3))
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(10, state.called_card.rank)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_calling_phase_view(self) -> None:
|
||||
state = self._called_state(1)
|
||||
view = rules.state_for_player(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, view["phase"])
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertEqual(
|
||||
["01D", "01C", "01S", "01B"], view["callable_cards"]
|
||||
)
|
||||
other = rules.state_for_player(state, "p2")
|
||||
self.assertNotIn("callable_cards", other)
|
||||
|
||||
|
||||
class PlayTest(unittest.TestCase):
|
||||
def test_five_player_trick_resolution(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_trump_wins(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
|
||||
briscola_suit="C",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_called_card_reveals_the_partner(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
turn=1,
|
||||
)
|
||||
self.assertFalse(state.called_played)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertNotIn("scores", view)
|
||||
rules.play(state, "p1", "01D")
|
||||
# The called card has not been played yet.
|
||||
self.assertFalse(state.called_played)
|
||||
# Drive the trick until the called card appears: give it to seat 3.
|
||||
state.players[3].hand = [card("01S")]
|
||||
state.turn = 3
|
||||
rules.play(state, "p3", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertEqual(3, state.partner_seat)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_PARTNER, roles[3])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
self.assertIn("scores", view)
|
||||
|
||||
def test_called_card_played_by_the_caller_is_solo(self) -> None:
|
||||
state = make_state(
|
||||
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
solo=True,
|
||||
turn=0,
|
||||
)
|
||||
rules.play(state, "p0", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
view = rules.state_for_player(state, "p4")
|
||||
self.assertTrue(view["solo"])
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
|
||||
def test_play_validations(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
turn=0,
|
||||
)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.play(state, "p1", "05D")
|
||||
with self.assertRaises(CardNotInHand):
|
||||
rules.play(state, "p0", "01D")
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_play_rejected_before_the_call(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
caller=0,
|
||||
turn=0,
|
||||
phase=PHASE_CALLING,
|
||||
)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_auto_play_advances(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="09B",
|
||||
turn=0,
|
||||
)
|
||||
rules.auto_play(state, random.Random(7))
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertEqual(1, len(state.players[0].hand))
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def _piles(self):
|
||||
# 60 points in denari + coppe, 60 in spade + bastoni.
|
||||
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
return a_cards, b_cards
|
||||
|
||||
def test_side_points_split_caller_side_and_defenders(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=3,
|
||||
called_played=True,
|
||||
captured=[
|
||||
["01D"], # caller, side A: 11
|
||||
["03C", "01S"], # defender: 21
|
||||
["10D"], # defender: 4
|
||||
["01C"], # partner, side A: 11
|
||||
["03S"], # defender: 10
|
||||
],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(22, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_solo_side_points(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
solo=True,
|
||||
called_played=True,
|
||||
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(11, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_calling_side_wins_at_the_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
b_rest = [c for c in b_cards if c != "01S"]
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, ["01S"], [], b_rest, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_CALLER, state.winner)
|
||||
self.assertEqual([71, 49], state.scores)
|
||||
|
||||
def test_defenders_win_below_the_declared_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
# Calling side only 30 points of the 61 needed.
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards[:5], [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
self.assertEqual([30, 60], state.scores)
|
||||
|
||||
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(2, state.hand_number)
|
||||
self.assertEqual(1, state.dealer)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
self.assertIsNone(state.caller)
|
||||
self.assertIsNone(state.called_card)
|
||||
# The patta entry is kept in the audit trail.
|
||||
entry = state.hand_scores[-1]
|
||||
self.assertTrue(entry["patta"])
|
||||
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||
|
||||
def test_raised_target_disables_patta(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
declared_target=62,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
|
||||
def test_full_deal_scores_120(self) -> None:
|
||||
state = started_state()
|
||||
rng = random.Random(2026)
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||
if state.phase == PHASE_AUCTION:
|
||||
rules.auto_bid(state)
|
||||
elif state.phase == PHASE_CALLING:
|
||||
rules.auto_call(state, rng)
|
||||
else:
|
||||
rules.auto_play(state, rng)
|
||||
moves += 1
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(120, sum(state.scores))
|
||||
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
|
||||
captured = sum(len(p.captured) for p in state.players)
|
||||
self.assertEqual(40, captured)
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_state_for_player_hides_other_hands(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
turn=0,
|
||||
)
|
||||
view = rules.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertEqual(1, players[1]["cards_left"])
|
||||
self.assertEqual("01D", view["called_card"])
|
||||
self.assertEqual(5, view["player_count"])
|
||||
|
||||
def test_spectator_sees_public_information_only(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=2,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
)
|
||||
view = rules.state_for_player(state, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[2])
|
||||
self.assertEqual("", roles[0])
|
||||
|
||||
def test_player_points_are_public(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
captured=[["01C", "03B"], ["10D"], [], [], []],
|
||||
)
|
||||
view = rules.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(21, players[0]["points"])
|
||||
self.assertEqual(4, players[1]["points"])
|
||||
self.assertEqual(2, players[0]["captured_count"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10)
|
||||
bid(state, 2, 7)
|
||||
restored = ChiamataState.from_json(state.to_json())
|
||||
self.assertEqual(state.phase, restored.phase)
|
||||
self.assertEqual(state.hand_number, restored.hand_number)
|
||||
self.assertEqual(state.turn, restored.turn)
|
||||
assert restored.auction is not None and state.auction is not None
|
||||
self.assertEqual(state.auction.active, restored.auction.active)
|
||||
self.assertEqual(state.auction.passed, restored.auction.passed)
|
||||
self.assertEqual(
|
||||
state.auction.history, restored.auction.history
|
||||
)
|
||||
self.assertEqual(
|
||||
[[c.code for c in p.hand] for p in state.players],
|
||||
[[c.code for c in p.hand] for p in restored.players],
|
||||
)
|
||||
|
||||
def test_legacy_state_defaults(self) -> None:
|
||||
state = started_state()
|
||||
data = state.to_json()
|
||||
del data["auction"]
|
||||
del data["called_card"]
|
||||
del data["declared_target"]
|
||||
restored = ChiamataState.from_json(data)
|
||||
self.assertIsNone(restored.auction)
|
||||
self.assertIsNone(restored.called_card)
|
||||
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
|
||||
self.assertFalse(restored.solo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Adapter tests: the platform contract over the briscola chiamata rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tavolo.briscola import BriscolaChiamataEngine
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
ChiamataState,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameError,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
)
|
||||
|
||||
|
||||
def _session(engine, **options) -> GameSession:
|
||||
session = GameSession(
|
||||
id="c1",
|
||||
game_type=engine.id,
|
||||
join_code="CODE01",
|
||||
creator_sub="p0",
|
||||
players=[Seat(user_sub="p0", display_name="P0")],
|
||||
)
|
||||
engine.create(session, options)
|
||||
return session
|
||||
|
||||
|
||||
def _started(engine, **options) -> GameSession:
|
||||
session = _session(engine, **options)
|
||||
for i in range(1, engine.max_players):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
return session
|
||||
|
||||
|
||||
def _player_on_turn(session: GameSession):
|
||||
state = session.state
|
||||
return next(p for p in state.players if p.seat == state.turn)
|
||||
|
||||
|
||||
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < limit:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = _player_on_turn(session)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
# The last player standing must bid.
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(
|
||||
p for p in state.players if p.seat == state.caller
|
||||
)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session), "match did not finish"
|
||||
|
||||
|
||||
class MetadataTest(unittest.TestCase):
|
||||
def test_metadata(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertEqual("briscola_chiamata", engine.id)
|
||||
self.assertEqual("Briscola chiamata", engine.name)
|
||||
self.assertTrue(engine.description)
|
||||
self.assertEqual(5, engine.min_players)
|
||||
self.assertEqual(5, engine.max_players)
|
||||
self.assertEqual({}, engine.options_schema)
|
||||
|
||||
|
||||
class CreateJoinTest(unittest.TestCase):
|
||||
def test_create_seats_creator_without_a_team(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertIsNone(session.players[0].team)
|
||||
self.assertIsInstance(session.state, ChiamataState)
|
||||
self.assertEqual(PHASE_LOBBY, session.state.phase)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
for i in range(1, 4):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
engine.join(session, "p4", "P4")
|
||||
self.assertFalse(engine.in_lobby(session))
|
||||
self.assertEqual(PHASE_AUCTION, session.state.phase)
|
||||
self.assertEqual(5, len(session.players))
|
||||
for seat in session.players:
|
||||
self.assertIsNone(seat.team)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
engine.join(session, "p0", "P0")
|
||||
for i in range(1, 5):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "p5", "P5")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
engine = BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
|
||||
)
|
||||
session = _session(engine)
|
||||
self.assertEqual(7, session.state.turn_timeout)
|
||||
|
||||
|
||||
class ActionTest(unittest.TestCase):
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "dance", {})
|
||||
|
||||
def test_bid_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": True})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": False})
|
||||
# points cannot be raised before a bid of the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": 64})
|
||||
|
||||
def test_auction_flow_through_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
engine.handle_action(session, "p2", "pass", {})
|
||||
engine.handle_action(session, "p3", "bid", {"rank": 2})
|
||||
engine.handle_action(session, "p4", "bid", {"points": 64})
|
||||
engine.handle_action(session, "p0", "pass", {})
|
||||
engine.handle_action(session, "p1", "pass", {})
|
||||
engine.handle_action(session, "p3", "pass", {})
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(4, state.caller)
|
||||
self.assertEqual(64, state.declared_target)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(2, state.auction.current.rank)
|
||||
|
||||
engine.handle_action(session, "p4", "call", {"card": "02S"})
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual("S", state.briscola_suit)
|
||||
|
||||
# Play the first card of the player on turn.
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
|
||||
def test_call_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": 42})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "nope"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "01D"})
|
||||
|
||||
def test_finished_match_rejects_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
with self.assertRaises(GameError):
|
||||
engine.handle_action(session, session.players[0].user_sub, "play", {})
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_view_hides_other_hands_and_roles(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertIn("hand", players[0])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
self.assertEqual(5, view["player_count"])
|
||||
# p1 opens the auction.
|
||||
auction = view["auction"]
|
||||
self.assertEqual(1, auction["turn"])
|
||||
self.assertIsNone(auction["current"])
|
||||
|
||||
def test_view_marks_the_acting_player(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p1")
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertIn("legal_bids", view)
|
||||
# The caller is labeled as soon as the auction is won.
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
view = engine.view_for(session, "p0")
|
||||
self.assertIn("callable_cards", engine.view_for(session, "p1"))
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[1])
|
||||
|
||||
def test_spectator_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
|
||||
def test_lobby_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
lobby = engine.lobby_view(session)
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(61, lobby["declared_target"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 10})
|
||||
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||
self.assertIsInstance(restored, ChiamataState)
|
||||
self.assertEqual(session.state.phase, restored.phase)
|
||||
self.assertEqual(session.state.turn, restored.turn)
|
||||
assert restored.auction is not None and session.state.auction is not None
|
||||
self.assertEqual(session.state.auction.history, restored.auction.history)
|
||||
self.assertEqual(
|
||||
[p.sub for p in session.state.players],
|
||||
[p.sub for p in restored.players],
|
||||
)
|
||||
|
||||
|
||||
class DeadlineTest(unittest.TestCase):
|
||||
def test_no_deadline_in_lobby(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||
|
||||
def test_auction_deadline_and_revalidation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("auction", deadline.kind)
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, "auction", "auction:1:1:0")
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
# The auto-bid moved the auction on: a fresh deadline exists.
|
||||
state = session.state
|
||||
self.assertIn(
|
||||
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
|
||||
)
|
||||
self.assertIsNotNone(engine.next_deadline(session))
|
||||
|
||||
def test_stale_auction_deadline_after_a_bid(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
|
||||
def test_call_deadline_auto_calls(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("call", deadline.kind)
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(2, state.called_card.rank)
|
||||
|
||||
def test_turn_deadline_auto_plays(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
engine.handle_action(session, "p1", "call", {"card": "02D"})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("turn", deadline.kind)
|
||||
state = session.state
|
||||
before = state.turn
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertNotEqual(before, state.turn)
|
||||
|
||||
|
||||
class ResultTest(unittest.TestCase):
|
||||
def test_result_of_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
state = session.state
|
||||
result = engine.result(session)
|
||||
sizes = sorted(len(team) for team in result.teams)
|
||||
self.assertIn(sizes, ([2, 3], [1, 4]))
|
||||
self.assertEqual(5, len(result.players))
|
||||
self.assertIn(result.winner_team, (0, 1))
|
||||
for player in result.players:
|
||||
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
|
||||
self.assertEqual(
|
||||
player.won,
|
||||
(player.team in (ROLE_CALLER, ROLE_PARTNER))
|
||||
== (result.winner_team == 0),
|
||||
)
|
||||
self.assertIn("points", player.details)
|
||||
summary = result.summary
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
self.assertEqual(state.hand_number, summary["deals_played"])
|
||||
|
||||
def test_result_requires_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
with self.assertRaises(GameError):
|
||||
engine.result(_started(engine))
|
||||
|
||||
def test_solo_result_teams(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
# Force a finished solo match: seat 0 called its own card.
|
||||
state.caller = 0
|
||||
state.solo = True
|
||||
state.partner_seat = None
|
||||
state.called_played = True
|
||||
state.called_card = Card.parse("01D")
|
||||
# Caller side captures 71 (60 + 11), defenders 49.
|
||||
state.players[0].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
|
||||
+ ["01S"]
|
||||
]
|
||||
state.players[1].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
|
||||
]
|
||||
state.scores = [71, 49]
|
||||
state.winner = 0
|
||||
state.phase = PHASE_FINISHED
|
||||
result = engine.result(session)
|
||||
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
|
||||
# The lone caller won: exactly one winner.
|
||||
self.assertEqual(1, sum(1 for p in result.players if p.won))
|
||||
caller = next(p for p in result.players if p.team == ROLE_CALLER)
|
||||
self.assertTrue(caller.won)
|
||||
self.assertEqual(71.0, caller.score)
|
||||
|
||||
def test_game_over_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = 1
|
||||
state.scores = [55, 65]
|
||||
state.caller = 2
|
||||
state.solo = False
|
||||
state.partner_seat = 4
|
||||
state.called_card = Card.parse("03C")
|
||||
over = engine.game_over_view(session, "p0")
|
||||
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||
self.assertEqual("Difensori", over["winner"])
|
||||
self.assertEqual("03C", over["called_card"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -33,29 +33,33 @@ itself ships no game.
|
||||
revalidate the token, so stale or duplicate deliveries are harmless.
|
||||
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
||||
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON
|
||||
`details`) and `PlayerRating` (Elo per `(user_sub, game_type)`).
|
||||
`details`) and `PlayerRating` (Elo and leaderboard aggregates per
|
||||
`(user_sub, game_type)`).
|
||||
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
|
||||
transactionally, with Elo) and `apply_elo`.
|
||||
transactionally, with Elo and the leaderboard aggregates) and
|
||||
`apply_match_stats`.
|
||||
- `elo.py` — chess-style Elo math generalized to two-team matches.
|
||||
- `backfill_elo.py` — rebuild every rating from the match history:
|
||||
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||
- `backfill_elo.py` — rebuild every rating and aggregate from the match
|
||||
history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
|
||||
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
|
||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
|
||||
page-number pagination, shared OpenAPI fragments and response schemas,
|
||||
the TortoiseORM lifecycle mixin.
|
||||
|
||||
## Adding a game
|
||||
|
||||
Implement `GameEngine` (see `engine.py` for the full contract), register
|
||||
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
||||
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
||||
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
|
||||
examples are [`tavolo-scopone`](../tavolo-scopone/README.md),
|
||||
[`tavolo-scopa`](../tavolo-scopa/README.md) and
|
||||
[`tavolo-briscola`](../tavolo-briscola/README.md).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||
.venv/bin/python -m mypy -p tavolo.platform
|
||||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||
uv run mypy -p tavolo.platform
|
||||
```
|
||||
|
||||
Tests run fully in-process against a `DummyEngine` (a two-player toy game
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
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.
|
||||
checked against the seats of the live session. Seats and persisted stats
|
||||
use a canonical provider-plus-subject principal because a bare OIDC ``sub``
|
||||
is not unique across providers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
from kaya.core import HttpContext, WebSocket
|
||||
@@ -17,25 +21,62 @@ from kaya.session import Session
|
||||
# fixed key or provider name.
|
||||
_OIDC_PREFIX = "oidc:"
|
||||
_USER_SUFFIX = ":user"
|
||||
_PRINCIPAL_PREFIX = "oidc:v1:"
|
||||
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def session_user(session: Session) -> Optional[OIDCUser]:
|
||||
"""Return the authenticated user stored in a session, if any.
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedUser:
|
||||
"""OIDC claims together with their stable, provider-qualified identity."""
|
||||
|
||||
provider: str
|
||||
claims: OIDCUser
|
||||
principal: str
|
||||
|
||||
|
||||
def canonical_principal(provider: str, sub: str) -> str:
|
||||
"""Return the global principal for one provider's OIDC subject.
|
||||
|
||||
Provider names are restricted to characters that cannot include ``:``,
|
||||
so the tuple remains unambiguous even when the subject contains colons.
|
||||
They are persisted security identifiers and must not be reused for a
|
||||
different issuer.
|
||||
"""
|
||||
if not isinstance(provider, str) or _PROVIDER_NAME.fullmatch(provider) is None:
|
||||
raise ValueError("invalid OIDC provider")
|
||||
if not isinstance(sub, str) or not sub:
|
||||
raise ValueError("invalid OIDC subject")
|
||||
return f"{_PRINCIPAL_PREFIX}{provider}:{sub}"
|
||||
|
||||
|
||||
def _authenticated_user(
|
||||
provider: str, claims: OIDCUser
|
||||
) -> Optional[AuthenticatedUser]:
|
||||
try:
|
||||
principal = canonical_principal(provider, claims.sub)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return AuthenticatedUser(provider=provider, claims=claims, principal=principal)
|
||||
|
||||
|
||||
def session_user(session: Session) -> Optional[AuthenticatedUser]:
|
||||
"""Return the authenticated identity 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).
|
||||
key. Retaining that provider is essential: an OIDC subject is only
|
||||
unique within one provider, not across all of them.
|
||||
"""
|
||||
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)
|
||||
provider = key.removeprefix(_OIDC_PREFIX).removesuffix(_USER_SUFFIX)
|
||||
return _authenticated_user(provider, OIDCUser(claims))
|
||||
return None
|
||||
|
||||
|
||||
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
||||
"""Return the authenticated user of a WebSocket connection, if any.
|
||||
def get_ws_user(ws: WebSocket) -> Optional[AuthenticatedUser]:
|
||||
"""Return the authenticated identity 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.
|
||||
@@ -47,21 +88,45 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
||||
|
||||
|
||||
def display_name(user: OIDCUser) -> str:
|
||||
"""Best-effort human-readable name for a user."""
|
||||
for key in ("name", "preferred_username", "email"):
|
||||
"""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 current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
||||
"""Return the authenticated user of an HTTP request, if any.
|
||||
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[AuthenticatedUser]:
|
||||
"""Return the authenticated identity 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)
|
||||
users = oidc.get_users(ctx)
|
||||
if not users:
|
||||
return None
|
||||
provider, claims = next(iter(users.items()))
|
||||
return _authenticated_user(provider, claims)
|
||||
|
||||
|
||||
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Recompute every Elo rating from the recorded match history.
|
||||
"""Recompute every Elo rating and leaderboard aggregate from the history.
|
||||
|
||||
Ratings are deterministic given the finished matches, so this replays all
|
||||
matches in chronological order and rewrites the ``player_rating`` table
|
||||
and each ``match_player.elo_delta`` from scratch. Run any time ratings
|
||||
need to be rebuilt::
|
||||
Ratings and aggregates are deterministic given the finished matches, so
|
||||
this replays all matches in chronological order and rewrites the
|
||||
``player_rating`` table and each ``match_player.elo_delta`` from scratch.
|
||||
Run any time the leaderboard state needs to be rebuilt::
|
||||
|
||||
python -m tavolo.platform.backfill_elo --database-url postgres://...
|
||||
"""
|
||||
@@ -17,14 +17,14 @@ from typing import Dict, List
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .stats import apply_elo
|
||||
from .stats import PlayerStats, apply_match_stats
|
||||
from .tortoise_mixin import TortoiseMixin
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def backfill_elo() -> int:
|
||||
"""Rebuild all ratings; returns the number of matches replayed."""
|
||||
"""Rebuild all ratings and aggregates; returns the matches replayed."""
|
||||
from .models import Match, MatchPlayer, PlayerRating
|
||||
|
||||
replayed = 0
|
||||
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
|
||||
),
|
||||
0,
|
||||
)
|
||||
deltas = await apply_elo(match.game_type, teams, winner_index)
|
||||
deltas = await apply_match_stats(
|
||||
match.game_type,
|
||||
teams,
|
||||
winner_index,
|
||||
[
|
||||
PlayerStats(
|
||||
user_sub=p.user_sub,
|
||||
won=p.won,
|
||||
score=p.score,
|
||||
display_name=p.display_name,
|
||||
)
|
||||
for p in players
|
||||
],
|
||||
)
|
||||
for player in players:
|
||||
player.elo_delta = deltas[player.user_sub]
|
||||
await player.save()
|
||||
|
||||
@@ -40,6 +40,7 @@ class Seat:
|
||||
persisting participations.
|
||||
"""
|
||||
|
||||
# Canonical provider-plus-subject principal, despite the legacy field name.
|
||||
user_sub: str
|
||||
display_name: str
|
||||
# Game-defined team label ("A"/"B", ...); ``None`` for games without
|
||||
@@ -73,6 +74,7 @@ class GameSession:
|
||||
id: str
|
||||
game_type: str
|
||||
join_code: str
|
||||
# Canonical principal of the creator.
|
||||
creator_sub: str
|
||||
players: List[Seat] = field(default_factory=list)
|
||||
created_at: Optional[datetime] = None
|
||||
@@ -81,13 +83,13 @@ class GameSession:
|
||||
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 seated(self, principal: str) -> bool:
|
||||
"""Whether ``principal`` occupies a seat in this game."""
|
||||
return self.seat_of(principal) is not None
|
||||
|
||||
def seat_of(self, sub: str) -> Optional[Seat]:
|
||||
def seat_of(self, principal: str) -> Optional[Seat]:
|
||||
for seat in self.players:
|
||||
if seat.user_sub == sub:
|
||||
if seat.user_sub == principal:
|
||||
return seat
|
||||
return None
|
||||
|
||||
@@ -112,6 +114,7 @@ class Deadline:
|
||||
class PlayerResult:
|
||||
"""The outcome of a finished match for one participant."""
|
||||
|
||||
# Canonical provider-plus-subject principal, despite the legacy field name.
|
||||
user_sub: str
|
||||
seat: int
|
||||
won: bool
|
||||
@@ -126,7 +129,7 @@ class PlayerResult:
|
||||
class MatchResult:
|
||||
"""The game-independent outcome of a finished match.
|
||||
|
||||
``teams`` groups the participants' subs into the sides the Elo rating
|
||||
``teams`` groups the participants' principals 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.
|
||||
@@ -145,6 +148,9 @@ class GameEngine(ABC):
|
||||
logging: all persistence, locking and transport concerns belong to
|
||||
the platform. Rule violations are reported by raising
|
||||
:class:`~tavolo.platform.errors.GameError` subclasses.
|
||||
|
||||
Every ``user_sub`` value in this contract is an opaque, canonical
|
||||
provider-plus-subject principal, never a bare cross-provider OIDC ``sub``.
|
||||
"""
|
||||
|
||||
#: Unique id carried by sessions and persisted matches.
|
||||
|
||||
@@ -6,12 +6,13 @@ 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 of a player for
|
||||
one game type, updated transactionally with every finished match (see
|
||||
:mod:`tavolo.platform.elo`).
|
||||
* :class:`MatchPlayer` — one row per participant, linking a canonical
|
||||
provider-qualified principal 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 principal for one game
|
||||
type, updated transactionally with every finished match (see
|
||||
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -47,8 +48,8 @@ class MatchPlayer(Model):
|
||||
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)
|
||||
# Canonical provider-plus-subject principal; no local users table.
|
||||
user_sub = fields.CharField(max_length=1024, db_index=True)
|
||||
display_name = fields.CharField(max_length=200)
|
||||
seat = fields.SmallIntField()
|
||||
# Game-defined team label; null for games without fixed teams.
|
||||
@@ -69,18 +70,26 @@ class MatchPlayer(Model):
|
||||
|
||||
|
||||
class PlayerRating(Model):
|
||||
"""Current Elo rating of one player for one game type."""
|
||||
"""Current Elo rating and leaderboard totals of one player per game."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# OIDC subject of the player; no local users table.
|
||||
user_sub = fields.CharField(max_length=255)
|
||||
# Canonical provider-plus-subject principal; no local users table.
|
||||
user_sub = fields.CharField(max_length=1024)
|
||||
# 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"),)
|
||||
indexes = (("game_type", "rating"),)
|
||||
# Composite index matching the leaderboard ordering within a game.
|
||||
indexes = (
|
||||
("game_type", "rating", "wins", "points", "matches_played", "user_sub"),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``."""
|
||||
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.
|
||||
|
||||
Two kinds of fragment live here:
|
||||
|
||||
* parameter lists (``PAGINATION_PARAMETERS``, ``PAGE_PARAMETERS``), used
|
||||
directly in the route decorators, and
|
||||
* reusable response schemas, registered as ``components.schemas`` by the
|
||||
application (and the test fixtures) through ``SCHEMAS`` and referenced
|
||||
from the routes with :func:`kaya.openapi.schema_ref`.
|
||||
|
||||
Engine-defined payload parts (lobby options echo, game views, match
|
||||
results) are intentionally typed as open objects: the platform is
|
||||
game-agnostic, so their exact shape belongs to each engine, not to this
|
||||
document.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from kaya.openapi import schema_ref
|
||||
|
||||
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
|
||||
{
|
||||
@@ -19,3 +35,349 @@ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
|
||||
"description": "Opaque pagination cursor from a previous response's next_cursor.",
|
||||
},
|
||||
]
|
||||
|
||||
# Page-number pagination (used by the leaderboard, which supports jumping
|
||||
# to a specific page).
|
||||
PAGE_PARAMETERS: List[Dict[str, Any]] = [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"description": "1-based page number (clamped to the last page).",
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
||||
"description": "Maximum number of results per page (clamped to [1, 100]).",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _object(
|
||||
properties: Dict[str, Any],
|
||||
required: Sequence[str] = (),
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
additional_properties: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""An object schema with the given properties (JSON Schema 2020-12)."""
|
||||
schema: Dict[str, Any] = {"type": "object", "properties": properties}
|
||||
if required:
|
||||
schema["required"] = list(required)
|
||||
if description is not None:
|
||||
schema["description"] = description
|
||||
if additional_properties:
|
||||
schema["additionalProperties"] = True
|
||||
return schema
|
||||
|
||||
|
||||
def _free_object(description: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""An open object: used for engine-specific payload parts."""
|
||||
schema: Dict[str, Any] = {"type": "object", "additionalProperties": True}
|
||||
if description is not None:
|
||||
schema["description"] = description
|
||||
return schema
|
||||
|
||||
|
||||
def _array(items: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"type": "array", "items": items}
|
||||
|
||||
|
||||
def _principal() -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "string",
|
||||
"description": "Canonical provider-qualified principal: "
|
||||
"oidc:v1:<provider>:<sub>.",
|
||||
}
|
||||
|
||||
|
||||
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": _principal(),
|
||||
"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": _principal(),
|
||||
"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": _principal(),
|
||||
"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": _principal(),
|
||||
"display_name": {"type": "string"},
|
||||
"elo": {"type": "integer"},
|
||||
"matches": {"type": "integer"},
|
||||
"wins": {"type": "integer"},
|
||||
"points": {"type": "number"},
|
||||
},
|
||||
("user_sub", "display_name", "elo", "matches", "wins", "points"),
|
||||
)
|
||||
|
||||
LEADERBOARD_PAGE_SCHEMA = _object(
|
||||
{
|
||||
"results": _array(schema_ref("LeaderboardEntry")),
|
||||
"page": {"type": "integer"},
|
||||
"pages": {"type": "integer"},
|
||||
"total": {"type": "integer"},
|
||||
"your_rank": _nullable({"type": "integer"}),
|
||||
"your_page": _nullable({"type": "integer"}),
|
||||
},
|
||||
("results", "page", "pages", "total", "your_rank", "your_page"),
|
||||
)
|
||||
|
||||
RATING_SCHEMA = _object(
|
||||
{
|
||||
"game_type": {"type": "string"},
|
||||
"rating": {"type": "integer"},
|
||||
"matches_played": {"type": "integer"},
|
||||
},
|
||||
("game_type", "rating", "matches_played"),
|
||||
)
|
||||
|
||||
RATINGS_PAGE_SCHEMA = _object(
|
||||
{"results": _array(schema_ref("Rating"))}, ("results",)
|
||||
)
|
||||
|
||||
#: Component name -> schema, passed to
|
||||
#: ``OpenAPIMixin(components={"schemas": SCHEMAS})``.
|
||||
SCHEMAS: Dict[str, Dict[str, Any]] = {
|
||||
"Error": ERROR_SCHEMA,
|
||||
"Health": HEALTH_SCHEMA,
|
||||
"Me": ME_SCHEMA,
|
||||
"AuthProvider": AUTH_PROVIDER_SCHEMA,
|
||||
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
|
||||
"GameType": GAME_TYPE_SCHEMA,
|
||||
"GameTypesPage": GAME_TYPES_PAGE_SCHEMA,
|
||||
"LobbyPlayer": LOBBY_PLAYER_SCHEMA,
|
||||
"Lobby": LOBBY_SCHEMA,
|
||||
"GamePreview": GAME_PREVIEW_SCHEMA,
|
||||
"GameView": GAME_VIEW_SCHEMA,
|
||||
"OngoingGame": ONGOING_GAME_SCHEMA,
|
||||
"OngoingGamesPage": ONGOING_GAMES_PAGE_SCHEMA,
|
||||
"MatchPlayer": MATCH_PLAYER_SCHEMA,
|
||||
"Match": MATCH_SCHEMA,
|
||||
"MatchesPage": MATCHES_PAGE_SCHEMA,
|
||||
"LeaderboardEntry": LEADERBOARD_ENTRY_SCHEMA,
|
||||
"LeaderboardPage": LEADERBOARD_PAGE_SCHEMA,
|
||||
"Rating": RATING_SCHEMA,
|
||||
"RatingsPage": RATINGS_PAGE_SCHEMA,
|
||||
}
|
||||
|
||||
@@ -30,12 +30,17 @@ MIN_LIMIT = 1
|
||||
|
||||
CURSOR_PARAM = "cursor"
|
||||
LIMIT_PARAM = "limit"
|
||||
PAGE_PARAM = "page"
|
||||
|
||||
|
||||
class CursorDecodeError(ValueError):
|
||||
"""Raised when the ``cursor`` query param cannot be decoded."""
|
||||
|
||||
|
||||
class PageParamsError(ValueError):
|
||||
"""Raised when the ``page``/``limit`` query params are invalid."""
|
||||
|
||||
|
||||
def encode_cursor(values: Dict[str, Any]) -> str:
|
||||
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
||||
# datetime-typed sort fields (e.g. finished_at for match history).
|
||||
@@ -79,6 +84,36 @@ def parse_cursor_params(query_string: str) -> Cursor:
|
||||
return Cursor(limit=limit, after=after)
|
||||
|
||||
|
||||
def parse_page_params(query_string: str) -> Tuple[int, int]:
|
||||
"""Parse the ``page``/``limit`` params: a 1-based page and a page size.
|
||||
|
||||
``limit`` is clamped to [1, 100] (default 20); ``page`` must be a
|
||||
positive integer (default 1). Clamping ``page`` to the last existing
|
||||
page is left to the endpoint, which knows how many pages there are.
|
||||
"""
|
||||
params = extract_query_params(query_string)
|
||||
limit_raw = params.get(LIMIT_PARAM)
|
||||
if limit_raw:
|
||||
try:
|
||||
limit = int(limit_raw[0])
|
||||
except ValueError as exc:
|
||||
raise PageParamsError("invalid limit") from exc
|
||||
else:
|
||||
limit = DEFAULT_LIMIT
|
||||
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
|
||||
page_raw = params.get(PAGE_PARAM)
|
||||
if page_raw:
|
||||
try:
|
||||
page = int(page_raw[0])
|
||||
except ValueError as exc:
|
||||
raise PageParamsError("invalid page") from exc
|
||||
else:
|
||||
page = 1
|
||||
if page < 1:
|
||||
raise PageParamsError("invalid page")
|
||||
return page, limit
|
||||
|
||||
|
||||
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
|
||||
# keyset; the cursor encodes exactly these fields.
|
||||
Sort = List[Tuple[str, str]]
|
||||
|
||||
@@ -16,9 +16,10 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..http import send_json
|
||||
from ..openapi import json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -36,7 +37,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
description="Names and login URLs of the configured OIDC "
|
||||
"providers, in configuration order.",
|
||||
tags=["auth"],
|
||||
responses={200: {"description": "The configured providers"}})
|
||||
responses={200: json_response("The configured providers",
|
||||
schema_ref("AuthProvidersPage"))})
|
||||
async def auth_providers(ctx: HttpContext) -> None:
|
||||
await send_json(
|
||||
ctx,
|
||||
|
||||
@@ -19,7 +19,7 @@ from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..auth import current_user, display_name, require_auth
|
||||
from ..engine import GameSession, Seat
|
||||
@@ -32,6 +32,7 @@ from ..http import (
|
||||
send_error,
|
||||
send_json,
|
||||
)
|
||||
from ..openapi import error_response, json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -108,6 +109,34 @@ def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, An
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
@@ -116,7 +145,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"describes the per-game creation options accepted "
|
||||
"by POST /api/games.",
|
||||
tags=["games"],
|
||||
responses={200: {"description": "The available game types"}})
|
||||
responses={200: json_response("The available game types",
|
||||
schema_ref("GameTypesPage"))})
|
||||
async def list_game_types(ctx: HttpContext) -> None:
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
@@ -166,9 +196,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
},
|
||||
},
|
||||
responses={
|
||||
201: {"description": "The created lobby"},
|
||||
400: {"description": "Invalid game_type, options or body"},
|
||||
401: {"description": "Authentication required"},
|
||||
201: json_response("The created lobby", schema_ref("Lobby")),
|
||||
400: error_response("Invalid game_type, options or body"),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def create_game(ctx: HttpContext) -> None:
|
||||
@@ -195,12 +225,13 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
principal = user.principal
|
||||
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))],
|
||||
creator_sub=principal,
|
||||
players=[Seat(user_sub=principal, display_name=display_name(user.claims))],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
try:
|
||||
@@ -212,7 +243,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
log.info(
|
||||
"game %s created by %s (%s, options %r)",
|
||||
session.id,
|
||||
user.sub,
|
||||
principal,
|
||||
engine.id,
|
||||
options,
|
||||
)
|
||||
@@ -236,11 +267,15 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
},
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Seated; game state (may be playing)"},
|
||||
400: {"description": "Missing code"},
|
||||
401: {"description": "Authentication required"},
|
||||
404: {"description": "Unknown join code"},
|
||||
409: {"description": "Already joined or lobby full"},
|
||||
200: json_response(
|
||||
"Seated: the lobby, or the personalized game state "
|
||||
"when the join started the match",
|
||||
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]},
|
||||
),
|
||||
400: error_response("Missing code"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Unknown join code"),
|
||||
409: error_response("Already joined or lobby full"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def join_game(ctx: HttpContext) -> None:
|
||||
@@ -256,9 +291,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None
|
||||
principal = user.principal
|
||||
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)
|
||||
log.debug("join rejected for %s: unknown code %r", principal, code)
|
||||
await send_error(ctx, 404, "unknown join code")
|
||||
return
|
||||
|
||||
@@ -269,9 +305,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
return
|
||||
engine = platform.registry.require(session.game_type)
|
||||
try:
|
||||
engine.join(session, user.sub, display_name(user))
|
||||
engine.join(session, principal, display_name(user.claims))
|
||||
except GameError as exc:
|
||||
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
|
||||
log.debug("join rejected for %s in game %s: %s", principal, session.id, exc)
|
||||
await send_error(ctx, 409, str(exc))
|
||||
return
|
||||
await platform.game_store.save(session)
|
||||
@@ -280,16 +316,16 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
# 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)
|
||||
seat = next(i for i, s in enumerate(session.players) if s.user_sub == principal)
|
||||
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,
|
||||
principal, 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))
|
||||
log.info("%s joined game %s (seat %d); match started", principal, session.id, seat)
|
||||
await send_json(ctx, 200, _view_payload(platform, session, principal))
|
||||
|
||||
@app.GET("/api/games/preview")
|
||||
@operation(summary="Preview a game by join code",
|
||||
@@ -300,10 +336,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"its engine.",
|
||||
tags=["games"],
|
||||
responses={
|
||||
200: {"description": "The lobby preview"},
|
||||
400: {"description": "Missing code"},
|
||||
401: {"description": "Authentication required"},
|
||||
404: {"description": "Unknown join code"},
|
||||
200: json_response("The lobby preview",
|
||||
schema_ref("GamePreview")),
|
||||
400: error_response("Missing code"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Unknown join code"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def preview_game(ctx: HttpContext) -> None:
|
||||
@@ -328,9 +365,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"join code, just what is public at the table.",
|
||||
tags=["games"],
|
||||
responses={
|
||||
200: {"description": "The personalized (player) or "
|
||||
"read-only (spectator) game state"},
|
||||
404: {"description": "Game not found"},
|
||||
200: json_response(
|
||||
"The personalized (player) or read-only (spectator) game state",
|
||||
schema_ref("GameView"),
|
||||
),
|
||||
404: error_response("Game not found"),
|
||||
})
|
||||
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||
session = await platform.game_store.load(game_id)
|
||||
@@ -339,5 +378,31 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
return
|
||||
user = current_user(platform.oidc, ctx)
|
||||
await send_json(
|
||||
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||
ctx, 200, _view_payload(platform, session, user.principal 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
|
||||
principal = user.principal
|
||||
sessions = await platform.game_store.list_ongoing()
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
_ongoing_game_payload(platform, session, principal)
|
||||
for session in sessions
|
||||
if session.seated(principal)
|
||||
]
|
||||
})
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..openapi import json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -14,7 +16,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/health")
|
||||
@operation(summary="Health check",
|
||||
tags=["health"],
|
||||
responses={200: {"description": "The service is up"}})
|
||||
responses={200: json_response("The service is up", schema_ref("Health"))})
|
||||
async def health(ctx: HttpContext) -> None:
|
||||
await ctx.send_bytes(
|
||||
200,
|
||||
|
||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..auth import current_user, display_name, require_auth
|
||||
from ..auth import current_user, display_name, picture, require_auth
|
||||
from ..http import send_json
|
||||
from ..openapi import error_response, json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -17,14 +18,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/me")
|
||||
@operation(summary="Current user",
|
||||
description="Returns the authenticated user's identity from the "
|
||||
"session; 401 when not logged in.",
|
||||
"session, including the profile picture URL when "
|
||||
"the provider supplies one; 401 when not logged "
|
||||
"in.",
|
||||
tags=["auth"],
|
||||
responses={
|
||||
200: {"description": "The current user"},
|
||||
401: {"description": "Not logged in"},
|
||||
200: json_response("The current user", schema_ref("Me")),
|
||||
401: error_response("Not logged in"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def me(ctx: HttpContext) -> None:
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
||||
await send_json(
|
||||
ctx,
|
||||
200,
|
||||
{
|
||||
"sub": user.principal,
|
||||
"name": display_name(user.claims),
|
||||
"picture": picture(user.claims),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,14 +13,25 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..auth import current_user, require_auth
|
||||
from ..elo import INITIAL_RATING
|
||||
from ..http import extract_query_params, send_error, send_json
|
||||
from ..models import Match, MatchPlayer, PlayerRating
|
||||
from ..openapi import PAGINATION_PARAMETERS
|
||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||
from ..openapi import (
|
||||
PAGE_PARAMETERS,
|
||||
PAGINATION_PARAMETERS,
|
||||
error_response,
|
||||
json_response,
|
||||
)
|
||||
from ..pagination import (
|
||||
CursorDecodeError,
|
||||
PageParamsError,
|
||||
paginate,
|
||||
parse_cursor_params,
|
||||
parse_page_params,
|
||||
)
|
||||
from tortoise.expressions import Q
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -33,6 +44,15 @@ GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||
}
|
||||
|
||||
LEADERBOARD_FOCUS_PARAMETER: Dict[str, Any] = {
|
||||
"name": "focus",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "string", "enum": ["me"]},
|
||||
"description": "Set to 'me' to return the page containing the caller's "
|
||||
"own ranking (page 1 for unrated or anonymous callers).",
|
||||
}
|
||||
|
||||
|
||||
def _parse_game_type(
|
||||
platform: "Platform", query_string: str
|
||||
@@ -50,6 +70,33 @@ def _parse_game_type(
|
||||
return game_type, None
|
||||
|
||||
|
||||
def _ranks_before(rating: PlayerRating) -> Q:
|
||||
"""Keyset condition selecting the rows ranked above ``rating``.
|
||||
|
||||
Mirrors the leaderboard ordering ``-rating, -wins, -points,
|
||||
matches_played, user_sub``: exactly the rows sorting before the given
|
||||
one.
|
||||
"""
|
||||
return (
|
||||
Q(rating__gt=rating.rating)
|
||||
| Q(rating=rating.rating, wins__gt=rating.wins)
|
||||
| Q(rating=rating.rating, wins=rating.wins, points__gt=rating.points)
|
||||
| Q(
|
||||
rating=rating.rating,
|
||||
wins=rating.wins,
|
||||
points=rating.points,
|
||||
matches_played__lt=rating.matches_played,
|
||||
)
|
||||
| Q(
|
||||
rating=rating.rating,
|
||||
wins=rating.wins,
|
||||
points=rating.points,
|
||||
matches_played=rating.matches_played,
|
||||
user_sub__lt=rating.user_sub,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_match(
|
||||
match: Match, viewer: str, participants: List[MatchPlayer]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -89,9 +136,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
tags=["stats"],
|
||||
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||
responses={
|
||||
200: {"description": "A page of matches"},
|
||||
400: {"description": "Invalid pagination cursor or game_type"},
|
||||
401: {"description": "Authentication required"},
|
||||
200: json_response("A page of matches",
|
||||
schema_ref("MatchesPage")),
|
||||
400: error_response("Invalid pagination cursor or game_type"),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_matches(ctx: HttpContext) -> None:
|
||||
@@ -106,7 +154,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
return
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None
|
||||
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
||||
principal = user.principal
|
||||
queryset = Match.filter(players__user_sub=principal).distinct()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(game_type=game_type)
|
||||
matches, next_cursor = await paginate(
|
||||
@@ -125,62 +174,94 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
# 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
|
||||
_serialize_match(m, principal, 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, aggregated wins, matches played and "
|
||||
"points for every player with at least one "
|
||||
"finished match. Sorted by Elo rating (the rating "
|
||||
"for the requested game_type, or the default game "
|
||||
"when the filter is absent).",
|
||||
description="Elo rating, wins, matches played and points for "
|
||||
"every rated player of a game, sorted by Elo (the "
|
||||
"requested game_type, or the default game when "
|
||||
"the filter is absent). Page-number paginated; "
|
||||
"the response carries the caller's own rank when "
|
||||
"authenticated.",
|
||||
tags=["stats"],
|
||||
parameters=[GAME_TYPE_PARAMETER],
|
||||
parameters=[
|
||||
GAME_TYPE_PARAMETER,
|
||||
*PAGE_PARAMETERS,
|
||||
LEADERBOARD_FOCUS_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: {"description": "The leaderboard"},
|
||||
400: {"description": "Unknown game_type"},
|
||||
200: json_response("A page of the leaderboard",
|
||||
schema_ref("LeaderboardPage")),
|
||||
400: error_response("Unknown game_type or invalid parameters"),
|
||||
})
|
||||
async def leaderboard(ctx: HttpContext) -> None:
|
||||
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
queryset = MatchPlayer.all()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(match__game_type=game_type)
|
||||
rows = await queryset
|
||||
# Ratings are per game type; without a filter show the default
|
||||
# game's.
|
||||
try:
|
||||
page, limit = parse_page_params(ctx.query_string)
|
||||
except PageParamsError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
focus_values = extract_query_params(ctx.query_string).get("focus")
|
||||
focus = focus_values[0] if focus_values else None
|
||||
if focus is not None and focus != "me":
|
||||
await send_error(ctx, 400, "invalid focus")
|
||||
return
|
||||
|
||||
# The board is the requested game's, or the default game's when the
|
||||
# filter is absent (ratings are per game type).
|
||||
rating_game = game_type or platform.registry.default.id
|
||||
rating_rows = await PlayerRating.filter(game_type=rating_game)
|
||||
ratings = {row.user_sub: row.rating for row in rating_rows}
|
||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
entry = aggregate.setdefault(
|
||||
row.user_sub,
|
||||
board = PlayerRating.filter(game_type=rating_game)
|
||||
total = await board.count()
|
||||
pages = (total + limit - 1) // limit
|
||||
current = min(page, pages) if pages else 1
|
||||
|
||||
# The caller's own ranking, when authenticated and rated: ranks
|
||||
# above theirs plus one. `focus=me` jumps to their page.
|
||||
user = current_user(platform.oidc, ctx)
|
||||
yours: Optional[PlayerRating] = None
|
||||
if user is not None:
|
||||
yours = await PlayerRating.get_or_none(
|
||||
user_sub=user.principal, game_type=rating_game
|
||||
)
|
||||
your_rank: Optional[int] = None
|
||||
your_page: Optional[int] = None
|
||||
if yours is not None:
|
||||
your_rank = 1 + await board.filter(_ranks_before(yours)).count()
|
||||
your_page = (your_rank - 1) // limit + 1
|
||||
if focus == "me":
|
||||
current = your_page
|
||||
|
||||
rows = await (
|
||||
board.order_by(
|
||||
"-rating", "-wins", "-points", "matches_played", "user_sub"
|
||||
)
|
||||
.offset((current - 1) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
{
|
||||
"user_sub": row.user_sub,
|
||||
"display_name": row.display_name,
|
||||
"matches": 0,
|
||||
"wins": 0,
|
||||
"points": 0,
|
||||
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
||||
},
|
||||
)
|
||||
entry["matches"] += 1
|
||||
entry["wins"] += 1 if row.won else 0
|
||||
entry["points"] += row.score
|
||||
# Keep the most recent display name seen.
|
||||
entry["display_name"] = row.display_name
|
||||
|
||||
ranking: List[Dict[str, Any]] = sorted(
|
||||
aggregate.values(),
|
||||
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
|
||||
reverse=True,
|
||||
)
|
||||
await send_json(ctx, 200, {"results": ranking})
|
||||
"display_name": row.display_name or row.user_sub,
|
||||
"matches": row.matches_played,
|
||||
"wins": row.wins,
|
||||
"points": row.points,
|
||||
"elo": row.rating,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"page": current,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"your_rank": your_rank,
|
||||
"your_page": your_page,
|
||||
})
|
||||
|
||||
@app.GET("/api/me/ratings")
|
||||
@operation(summary="My Elo ratings",
|
||||
@@ -188,14 +269,15 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"type they have played.",
|
||||
tags=["stats"],
|
||||
responses={
|
||||
200: {"description": "The caller's ratings"},
|
||||
401: {"description": "Authentication required"},
|
||||
200: json_response("The caller's ratings",
|
||||
schema_ref("RatingsPage")),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_ratings(ctx: HttpContext) -> None:
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None
|
||||
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
|
||||
rows = await PlayerRating.filter(user_sub=user.principal).order_by("game_type")
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
{
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Called once when a session reaches the finished state (guarded by the
|
||||
``stats_saved`` flag on the session). The write is transactional so a
|
||||
match never appears with only some of its players. The same transaction
|
||||
also updates the participants' Elo ratings (see
|
||||
:mod:`tavolo.platform.elo`).
|
||||
also updates the participants' Elo ratings and leaderboard aggregates
|
||||
(see :mod:`tavolo.platform.elo`).
|
||||
|
||||
This module is game-independent: everything it persists comes from the
|
||||
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Sequence
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
@@ -24,21 +25,38 @@ from .engine import GameEngine, GameSession
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def apply_elo(
|
||||
game_type: str, teams: List[List[str]], winner_team: int
|
||||
) -> Dict[str, int]:
|
||||
"""Update the Elo ratings of ``teams`` for ``game_type``.
|
||||
@dataclass(frozen=True)
|
||||
class PlayerStats:
|
||||
"""One participant's data feeding a ``player_rating`` row update."""
|
||||
|
||||
``teams`` groups the participants' subs into the two opposing sides;
|
||||
``winner_team`` is the index of the winning side. Ratings are read
|
||||
from (and written back to) the ``player_rating`` table; unrated
|
||||
players start at the initial rating. Returns the per-player delta.
|
||||
Must be called inside a transaction.
|
||||
# Canonical provider-plus-subject principal, despite the legacy field name.
|
||||
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' canonical principals 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:
|
||||
@@ -63,6 +81,13 @@ async def apply_elo(
|
||||
rating = ratings[sub]
|
||||
rating.rating += delta
|
||||
rating.matches_played += 1
|
||||
player = stats.get(sub)
|
||||
if player is not None:
|
||||
if player.won:
|
||||
rating.wins += 1
|
||||
rating.points += player.score
|
||||
if player.display_name:
|
||||
rating.display_name = player.display_name
|
||||
await rating.save()
|
||||
return deltas
|
||||
|
||||
@@ -78,6 +103,15 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||
started_at = session.created_at or datetime.now(timezone.utc)
|
||||
finished_at = session.finished_at or datetime.now(timezone.utc)
|
||||
display_names = {seat.user_sub: seat.display_name for seat in session.players}
|
||||
player_stats = [
|
||||
PlayerStats(
|
||||
user_sub=player.user_sub,
|
||||
won=player.won,
|
||||
score=player.score,
|
||||
display_name=display_names.get(player.user_sub, player.user_sub),
|
||||
)
|
||||
for player in result.players
|
||||
]
|
||||
async with in_transaction():
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
@@ -86,7 +120,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||
finished_at=finished_at,
|
||||
result=result.summary,
|
||||
)
|
||||
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
|
||||
deltas = await apply_match_stats(
|
||||
session.game_type, result.teams, result.winner_team, player_stats
|
||||
)
|
||||
for player in result.players:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
|
||||
@@ -41,7 +41,7 @@ import contextlib
|
||||
import json
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||
|
||||
@@ -72,6 +72,15 @@ 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
|
||||
@@ -137,6 +146,15 @@ class GameStore(ABC):
|
||||
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."""
|
||||
@@ -241,6 +259,27 @@ class RedisGameStore(GameStore):
|
||||
_, 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()
|
||||
@@ -352,6 +391,18 @@ class InMemoryGameStore(GameStore):
|
||||
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()
|
||||
|
||||
@@ -121,7 +121,7 @@ def register(app: KayaApp, platform: "Platform") -> 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
|
||||
sub: Optional[str] = user.principal if user is not None else None
|
||||
|
||||
session = await platform.game_store.load(game_id)
|
||||
if session is None:
|
||||
|
||||
@@ -43,8 +43,13 @@ from tavolo.platform import (
|
||||
PlayerResult,
|
||||
Seat,
|
||||
)
|
||||
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
||||
from tavolo.platform.auth import (
|
||||
AuthenticatedUser,
|
||||
canonical_principal,
|
||||
get_ws_user, # noqa: F401 (re-exported for patching)
|
||||
)
|
||||
from tavolo.platform.deadlines import DeadlineScheduler
|
||||
from tavolo.platform.openapi import SCHEMAS
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||
@@ -253,6 +258,7 @@ def make_platform(
|
||||
description="test app",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
components={"schemas": SCHEMAS},
|
||||
)
|
||||
scheduler = DeadlineScheduler(
|
||||
game_store,
|
||||
@@ -324,13 +330,32 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||
|
||||
|
||||
def make_principal(sub: str, provider: str = "default") -> str:
|
||||
return canonical_principal(provider, sub)
|
||||
|
||||
|
||||
def make_identity(
|
||||
sub: str, name: Optional[str] = None, provider: str = "default"
|
||||
) -> AuthenticatedUser:
|
||||
claims = make_user(sub, name)
|
||||
return AuthenticatedUser(
|
||||
provider=provider,
|
||||
claims=claims,
|
||||
principal=canonical_principal(provider, 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."""
|
||||
def oidc_user(
|
||||
oidc: OIDCProviders,
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> Iterator[OIDCUser]:
|
||||
"""Context manager: fake a login, using the first provider by default."""
|
||||
user = make_user(sub, name)
|
||||
provider = next(iter(oidc.providers))
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
|
||||
selected = provider or next(iter(oidc.providers))
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
@@ -339,13 +364,20 @@ def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iter
|
||||
|
||||
|
||||
@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."""
|
||||
def ws_users(
|
||||
users: Sequence[OIDCUser | AuthenticatedUser], provider: str = "default"
|
||||
) -> Iterator[None]:
|
||||
"""Patch ``auth.get_ws_user`` to hand out identities in sequence."""
|
||||
from tavolo.platform import auth
|
||||
|
||||
remaining = list(users)
|
||||
remaining = [
|
||||
user if isinstance(user, AuthenticatedUser) else AuthenticatedUser(
|
||||
provider=provider,
|
||||
claims=user,
|
||||
principal=canonical_principal(provider, user.sub),
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
last = remaining[-1] if remaining else None
|
||||
|
||||
def _next(_ws):
|
||||
|
||||
@@ -11,11 +11,19 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
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, get_ws_user, require_auth, session_user
|
||||
from tavolo.platform.auth import (
|
||||
canonical_principal,
|
||||
current_user,
|
||||
display_name,
|
||||
get_ws_user,
|
||||
picture,
|
||||
require_auth,
|
||||
session_user,
|
||||
)
|
||||
|
||||
|
||||
def _providers(*names: str) -> OIDCProviders:
|
||||
@@ -46,12 +54,16 @@ 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)
|
||||
self.assertEqual("alice", user.claims.sub)
|
||||
self.assertEqual("default", user.provider)
|
||||
self.assertEqual(canonical_principal("default", "alice"), user.principal)
|
||||
|
||||
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)
|
||||
self.assertEqual("alice", user.claims.sub)
|
||||
self.assertEqual("google", user.provider)
|
||||
self.assertEqual(canonical_principal("google", "alice"), user.principal)
|
||||
|
||||
def test_ignores_flow_state_and_tokens(self) -> None:
|
||||
session = Session(
|
||||
@@ -72,6 +84,12 @@ class SessionUserTest(unittest.TestCase):
|
||||
session = Session("sid", {"oidc:default:user": "alice"})
|
||||
self.assertIsNone(session_user(session))
|
||||
|
||||
def test_ignores_missing_or_invalid_subjects(self) -> None:
|
||||
for claims in ({}, {"sub": ""}, {"sub": 42}):
|
||||
with self.subTest(claims=claims):
|
||||
session = Session("sid", {"oidc:default:user": claims})
|
||||
self.assertIsNone(session_user(session))
|
||||
|
||||
|
||||
class GetWsUserTest(unittest.TestCase):
|
||||
def test_returns_user_from_provider_namespaced_session(self) -> None:
|
||||
@@ -81,12 +99,78 @@ class GetWsUserTest(unittest.TestCase):
|
||||
)
|
||||
user = get_ws_user(_ws(session))
|
||||
assert user is not None
|
||||
self.assertEqual("alice", user.sub)
|
||||
self.assertEqual("alice", user.claims.sub)
|
||||
|
||||
def test_missing_session_returns_none(self) -> None:
|
||||
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
||||
|
||||
|
||||
class CanonicalPrincipalTest(unittest.TestCase):
|
||||
def test_same_subject_at_different_providers_is_distinct(self) -> None:
|
||||
google = canonical_principal("google", "shared")
|
||||
github = canonical_principal("github", "shared")
|
||||
self.assertNotEqual(google, github)
|
||||
self.assertEqual("oidc:v1:google:shared", google)
|
||||
self.assertEqual("oidc:v1:github:shared", github)
|
||||
|
||||
def test_subject_may_contain_colons(self) -> None:
|
||||
self.assertEqual(
|
||||
"oidc:v1:google:tenant:alice",
|
||||
canonical_principal("google", "tenant:alice"),
|
||||
)
|
||||
|
||||
def test_invalid_components_are_rejected(self) -> None:
|
||||
for provider, sub in (("", "alice"), ("bad:provider", "alice"), ("google", "")):
|
||||
with self.subTest(provider=provider, sub=sub):
|
||||
with self.assertRaises(ValueError):
|
||||
canonical_principal(provider, sub)
|
||||
|
||||
|
||||
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({})))
|
||||
@@ -97,7 +181,9 @@ class CurrentUserTest(unittest.TestCase):
|
||||
_ctx({"oidc:google:user": {"sub": "alice"}}),
|
||||
)
|
||||
assert user is not None
|
||||
self.assertEqual("alice", user.sub)
|
||||
self.assertEqual("alice", user.claims.sub)
|
||||
self.assertEqual("google", user.provider)
|
||||
self.assertEqual(canonical_principal("google", "alice"), user.principal)
|
||||
|
||||
def test_first_configured_provider_wins(self) -> None:
|
||||
# Single-identity sessions never hold two users; this only pins the
|
||||
@@ -112,7 +198,8 @@ class CurrentUserTest(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
assert user is not None
|
||||
self.assertEqual("alice", user.sub)
|
||||
self.assertEqual("alice", user.claims.sub)
|
||||
self.assertEqual(canonical_principal("default", "alice"), user.principal)
|
||||
|
||||
|
||||
class RequireAuthTest(unittest.TestCase):
|
||||
@@ -146,7 +233,7 @@ class RequireAuthTest(unittest.TestCase):
|
||||
async def handler(request_ctx: Any) -> None:
|
||||
user = current_user(oidc, request_ctx)
|
||||
assert user is not None
|
||||
handled.append(user.sub)
|
||||
handled.append(user.principal)
|
||||
|
||||
await handler(ctx)
|
||||
self.assertEqual(["alice"], handled)
|
||||
self.assertEqual([canonical_principal("default", "alice")], handled)
|
||||
|
||||
@@ -14,7 +14,14 @@ from datetime import datetime, timezone
|
||||
from typing import Any, List
|
||||
|
||||
from tavolo.platform.models import Match
|
||||
from tavolo.platform.pagination import Cursor, decode_cursor, encode_cursor, paginate
|
||||
from tavolo.platform.pagination import (
|
||||
Cursor,
|
||||
PageParamsError,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
paginate,
|
||||
parse_page_params,
|
||||
)
|
||||
|
||||
from helpers import async_test
|
||||
|
||||
@@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase):
|
||||
self.assertEqual(match_id, id_values[0])
|
||||
|
||||
|
||||
class PageParamsTest(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
self.assertEqual((1, 20), parse_page_params(""))
|
||||
|
||||
def test_page_and_limit(self) -> None:
|
||||
self.assertEqual((3, 5), parse_page_params("page=3&limit=5"))
|
||||
|
||||
def test_limit_is_clamped(self) -> None:
|
||||
self.assertEqual((1, 100), parse_page_params("limit=500"))
|
||||
self.assertEqual((1, 1), parse_page_params("limit=0"))
|
||||
|
||||
def test_invalid_values_raise(self) -> None:
|
||||
for query in ("page=0", "page=-2", "page=abc", "limit=abc"):
|
||||
with self.assertRaises(PageParamsError, msg=query):
|
||||
parse_page_params(query)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from helpers import async_test, make_platform, oidc_user
|
||||
from helpers import async_test, make_platform, make_principal, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@@ -118,7 +118,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(5, body["target"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual("alice", body["players"][0]["sub"])
|
||||
self.assertEqual(make_principal("alice"), body["players"][0]["sub"])
|
||||
|
||||
# Join codes are case-insensitive.
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
@@ -188,6 +188,114 @@ class GamesRouteTest(unittest.TestCase):
|
||||
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(make_principal("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 CrossProviderGamesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_same_sub_other_provider_does_not_inherit_player_access(self) -> None:
|
||||
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||
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"]
|
||||
game_id = created.json()["id"]
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
|
||||
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||
listed = await client.get("/api/me/games")
|
||||
watched = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual([], listed.json()["results"])
|
||||
self.assertTrue(watched.json()["spectator"])
|
||||
self.assertFalse(watched.json()["viewer_seated"])
|
||||
self.assertNotIn("join_code", watched.json())
|
||||
|
||||
# The original provider's identical subject remains seated.
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
owned = await client.get(f"/api/games/{game_id}")
|
||||
self.assertFalse(owned.json()["spectator"])
|
||||
self.assertTrue(owned.json()["viewer_seated"])
|
||||
self.assertIn("join_code", owned.json())
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_at_two_providers_occupies_distinct_seats(self) -> None:
|
||||
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||
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, "alice", provider="google"):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, joined.status_code)
|
||||
self.assertTrue(joined.json()["viewer_seated"])
|
||||
session = await platform.game_store.load(created.json()["id"])
|
||||
assert session is not None
|
||||
self.assertEqual(
|
||||
{
|
||||
make_principal("alice"),
|
||||
make_principal("alice", "google"),
|
||||
},
|
||||
{seat.user_sub for seat in session.players},
|
||||
)
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
@@ -253,7 +361,10 @@ class MeRouteTest(unittest.TestCase):
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||
self.assertEqual(
|
||||
{"sub": make_principal("alice"), "name": "alice", "picture": None},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
from unittest import mock
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -11,7 +12,10 @@ 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
|
||||
from helpers import DummyEngine, async_test, make_platform, make_principal, oidc_user, use_db
|
||||
|
||||
ALICE = make_principal("alice")
|
||||
BOB = make_principal("bob")
|
||||
|
||||
|
||||
def _finished_session(target: int = 2):
|
||||
@@ -23,12 +27,12 @@ def _finished_session(target: int = 2):
|
||||
id="stats-game",
|
||||
game_type=engine.id,
|
||||
join_code="STATS1",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
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", {})
|
||||
engine.join(session, BOB, "bob")
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
return engine, session
|
||||
|
||||
|
||||
@@ -38,7 +42,7 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
self.assertTrue(engine.is_finished(session))
|
||||
|
||||
with ctx:
|
||||
@@ -52,19 +56,19 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
# 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(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})
|
||||
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)
|
||||
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", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
@@ -74,23 +78,59 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
}
|
||||
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)
|
||||
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)
|
||||
self.assertEqual({ALICE: 16, BOB: -16}, deltas)
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_at_two_providers_has_independent_stats(self) -> None:
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine = DummyEngine()
|
||||
google_alice = make_principal("alice", "google")
|
||||
session = GameSession(
|
||||
id="cross-provider-stats",
|
||||
game_type=engine.id,
|
||||
join_code="XPROV1",
|
||||
creator_sub=ALICE,
|
||||
players=[Seat(user_sub=ALICE, display_name="Alice", team="A")],
|
||||
)
|
||||
engine.create(session, {"target": 1})
|
||||
engine.join(session, google_alice, "Alice")
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(
|
||||
{ALICE, google_alice},
|
||||
{row.user_sub for row in await MatchPlayer.all()},
|
||||
)
|
||||
self.assertEqual(
|
||||
{ALICE, google_alice},
|
||||
{row.user_sub for row in await PlayerRating.all()},
|
||||
)
|
||||
|
||||
@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", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
@@ -98,14 +138,14 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
id="stats-game-2",
|
||||
game_type="dummy",
|
||||
join_code="STATS2",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
creator_sub=ALICE,
|
||||
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
|
||||
)
|
||||
engine2.create(session2, {"target": 2})
|
||||
engine2.join(session2, "bob", "bob")
|
||||
engine2.join(session2, BOB, "bob")
|
||||
# Bob wins the second match.
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
engine2.handle_action(session2, BOB, "play", {})
|
||||
engine2.handle_action(session2, BOB, "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
@@ -116,9 +156,9 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
}
|
||||
# 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(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
|
||||
@@ -136,6 +176,9 @@ async def _seed_two_matches(
|
||||
) -> None:
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
# The leaderboard reads the player_rating aggregates, so the seeded
|
||||
# participations are mirrored there (as save_match_result would).
|
||||
aggregates: Dict[tuple, Dict[str, Any]] = {}
|
||||
for index, (winner, finished) in enumerate(
|
||||
[
|
||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
@@ -150,16 +193,36 @@ async def _seed_two_matches(
|
||||
result={"winner": winner, "plays": 3 + index},
|
||||
)
|
||||
for seat, sub in enumerate(("alice", "bob")):
|
||||
principal = make_principal(sub)
|
||||
score = 2.0 + index if sub == winner else 1.0
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=sub,
|
||||
user_sub=principal,
|
||||
display_name=sub,
|
||||
seat=seat,
|
||||
team="A" if seat == 0 else "B",
|
||||
won=(sub == winner),
|
||||
score=2.0 + index if sub == winner else 1.0,
|
||||
score=score,
|
||||
)
|
||||
aggregate = aggregates.setdefault(
|
||||
(principal, game_types[index]),
|
||||
{"matches": 0, "wins": 0, "points": 0.0, "display_name": sub},
|
||||
)
|
||||
aggregate["matches"] += 1
|
||||
aggregate["wins"] += 1 if sub == winner else 0
|
||||
aggregate["points"] += score
|
||||
for (principal, game_type), aggregate in aggregates.items():
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=principal,
|
||||
game_type=game_type,
|
||||
rating=INITIAL_RATING,
|
||||
matches_played=aggregate["matches"],
|
||||
wins=aggregate["wins"],
|
||||
points=aggregate["points"],
|
||||
display_name=aggregate["display_name"],
|
||||
)
|
||||
|
||||
|
||||
class StatsRouteTest(unittest.TestCase):
|
||||
@@ -237,13 +300,13 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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
|
||||
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"])
|
||||
self.assertEqual(BOB, response.json()["results"][0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||
@@ -252,24 +315,19 @@ class StatsRouteTest(unittest.TestCase):
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
# Alice outranks everyone despite Bob leading on points.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="dummy",
|
||||
rating=1600,
|
||||
matches_played=2,
|
||||
)
|
||||
await PlayerRating.filter(
|
||||
user_sub=ALICE, game_type="dummy"
|
||||
).update(rating=1600)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
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"])
|
||||
# Players without a rating row report the initial rating.
|
||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||
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"])
|
||||
self.assertEqual(ALICE, results[0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||
@@ -279,18 +337,14 @@ class StatsRouteTest(unittest.TestCase):
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.filter(
|
||||
user_sub=BOB, game_type="dummy"
|
||||
).update(rating=1516)
|
||||
# Bob's rating in another game must not leak into the
|
||||
# dummy leaderboard.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
user_sub=BOB,
|
||||
game_type="second",
|
||||
rating=1800,
|
||||
matches_played=1,
|
||||
@@ -301,16 +355,108 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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(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=make_principal(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(
|
||||
[make_principal("frank"), make_principal("erin")],
|
||||
[row["user_sub"] for row in body["results"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
[make_principal("dave"), make_principal("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=make_principal(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", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -322,10 +468,37 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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, 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_same_sub_other_provider_cannot_read_history_or_ratings(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform(
|
||||
oidc_providers=("default", "google")
|
||||
)
|
||||
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", provider="google"):
|
||||
matches = await client.get("/api/me/matches")
|
||||
ratings = await client.get("/api/me/ratings")
|
||||
board = await client.get("/api/leaderboard?focus=me")
|
||||
self.assertEqual([], matches.json()["results"])
|
||||
self.assertEqual([], ratings.json()["results"])
|
||||
self.assertIsNone(board.json()["your_rank"])
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
owned_matches = await client.get("/api/me/matches")
|
||||
owned_ratings = await client.get("/api/me/ratings")
|
||||
self.assertEqual(1, len(owned_matches.json()["results"]))
|
||||
self.assertEqual(1, len(owned_ratings.json()["results"]))
|
||||
|
||||
@async_test
|
||||
async def test_my_ratings_requires_auth(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
@@ -341,14 +514,14 @@ class StatsRouteTest(unittest.TestCase):
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
user_sub=ALICE,
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
user_sub=BOB,
|
||||
game_type="dummy",
|
||||
rating=1484,
|
||||
matches_played=1,
|
||||
@@ -405,9 +578,9 @@ class GameTypeFilterTest(unittest.TestCase):
|
||||
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(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)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -48,7 +49,7 @@ class _FakePipeline:
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: str, ex: object = None) -> None:
|
||||
self._ops.append(("set", key))
|
||||
self._ops.append(("set", key, value))
|
||||
|
||||
def zadd(self, key: str, mapping: dict) -> None:
|
||||
self._ops.append(("zadd", key, mapping))
|
||||
@@ -88,6 +89,14 @@ class _FakeRedis:
|
||||
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,
|
||||
@@ -113,7 +122,7 @@ class _FakeRedis:
|
||||
def apply(self, op: tuple) -> object:
|
||||
name, *args = op
|
||||
if name == "set":
|
||||
self.keys[args[0]] = "payload"
|
||||
self.keys[args[0]] = args[1]
|
||||
return True
|
||||
if name == "zadd":
|
||||
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||
@@ -247,6 +256,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
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())
|
||||
@@ -311,6 +342,33 @@ class RedisGameStoreTest(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -10,7 +10,7 @@ from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_w
|
||||
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
|
||||
from helpers import async_test, make_identity, make_platform, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class AbruptCloseSocket(WebSocket):
|
||||
@@ -288,6 +288,27 @@ class WebSocketTest(unittest.TestCase):
|
||||
self.assertEqual("error", error["type"])
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_other_provider_is_a_spectator(self) -> None:
|
||||
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||
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_identity("alice", provider="google")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertTrue(first["game"]["spectator"])
|
||||
self.assertFalse(first["game"]["viewer_seated"])
|
||||
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"])
|
||||
|
||||
@async_test
|
||||
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
|
||||
@@ -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
|
||||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||
uv run 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,307 @@
|
||||
"""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:
|
||||
if state.phase != PHASE_HAND_END:
|
||||
break
|
||||
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()
|
||||
@@ -19,7 +19,8 @@ implementation.
|
||||
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
|
||||
It translates create/join/websocket actions/deadlines into rules-engine
|
||||
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
|
||||
creation options, and extracts the `MatchResult` (teams, winner,
|
||||
creation options (the match target defaults to the traditional 21
|
||||
points), and extracts the `MatchResult` (teams, winner,
|
||||
per-player scores and the match summary persisted as the match's JSON
|
||||
`result`).
|
||||
|
||||
@@ -30,8 +31,8 @@ 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
|
||||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run mypy -p tavolo.scopone
|
||||
```
|
||||
|
||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||
|
||||
@@ -234,6 +234,8 @@ class ScoponeEngine(GameEngine):
|
||||
)
|
||||
elif kind == "hand_end":
|
||||
for player in state.players:
|
||||
if state.phase != PHASE_HAND_END:
|
||||
break
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
log.info(
|
||||
"hand %d auto-advanced after the acknowledgement timeout",
|
||||
|
||||
@@ -37,7 +37,9 @@ PHASE_PLAYING = "playing"
|
||||
PHASE_HAND_END = "hand_end"
|
||||
PHASE_FINISHED = "finished"
|
||||
|
||||
DEFAULT_TARGET_SCORE = 11
|
||||
# The traditional match target for scopone scientifico: the partnership
|
||||
# game is played to 21 points.
|
||||
DEFAULT_TARGET_SCORE = 21
|
||||
|
||||
|
||||
def team_of(seat: int) -> int:
|
||||
|
||||
@@ -35,7 +35,8 @@ class CreateTest(unittest.TestCase):
|
||||
session = _session(engine)
|
||||
self.assertEqual("A", session.players[0].team)
|
||||
self.assertIsInstance(session.state, ScoponeState)
|
||||
self.assertEqual(11, session.state.target_score)
|
||||
# The traditional partnership target.
|
||||
self.assertEqual(21, session.state.target_score)
|
||||
self.assertTrue(session.state.napola)
|
||||
|
||||
def test_create_options(self) -> None:
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
# 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:
|
||||
# NOTE: the five tavolo distributions live in packages/ (same kaya-style
|
||||
# layout as tavolo-platform and tavolo-scopone). This file is the root of
|
||||
# the uv workspace and also holds tooling configuration that is read from
|
||||
# the working directory:
|
||||
# - uv locks and syncs the whole workspace from here;
|
||||
# - 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.uv]
|
||||
# The old pip-compile setup used --index-url for the private Gitea index
|
||||
# plus --extra-index-url for PyPI, so the best version across both indexes
|
||||
# won. Match that behavior with uv.
|
||||
index-strategy = "unsafe-best-match"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "gitea"
|
||||
url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple"
|
||||
default = true
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/*"]
|
||||
|
||||
[tool.uv.sources]
|
||||
tavolo-platform = { workspace = true }
|
||||
tavolo-scopone = { workspace = true }
|
||||
tavolo-scopa = { workspace = true }
|
||||
tavolo-briscola = { workspace = true }
|
||||
|
||||
[tool.aerich]
|
||||
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
||||
location = "./migrations"
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.14
|
||||
# by the following command (run from server/packages/tavolo-app/):
|
||||
#
|
||||
# pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml
|
||||
#
|
||||
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-briscola and tavolo-app
|
||||
# sibling packages are NOT pinned here: they are installed from ./packages in
|
||||
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
|
||||
# -e ./packages/tavolo-scopone -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-app (pyproject.toml)
|
||||
aiosqlite==0.22.1
|
||||
# via tortoise-orm
|
||||
anyio==4.15.1
|
||||
# via
|
||||
# aerich
|
||||
# httpx
|
||||
# tortoise-orm
|
||||
asyncclick==8.4.2.1
|
||||
# via aerich
|
||||
asyncpg==0.31.0
|
||||
# via tavolo-app (pyproject.toml)
|
||||
certifi==2026.7.22
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
cffi==2.1.1
|
||||
# via cryptography
|
||||
click==8.5.0
|
||||
# via granian
|
||||
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-app (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via httpcore
|
||||
httpcore==1.0.9
|
||||
# via httpx
|
||||
httpx==0.28.1
|
||||
# via
|
||||
# kaya-oidc
|
||||
# tavolo-app (pyproject.toml)
|
||||
idna==3.20
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
iso8601==2.1.0
|
||||
# via tortoise-orm
|
||||
kaya-core==0.0.7
|
||||
# via
|
||||
# kaya-cors
|
||||
# kaya-oidc
|
||||
# kaya-openapi
|
||||
# kaya-otel
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# 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.7
|
||||
# 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-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-app (pyproject.toml)
|
||||
pycparser==3.0
|
||||
# via cffi
|
||||
pyjwt[crypto]==2.15.1
|
||||
# via
|
||||
# kaya-oidc
|
||||
# tavolo-app (pyproject.toml)
|
||||
pypika-tortoise==0.6.5
|
||||
# via tortoise-orm
|
||||
pyyaml==6.0.3
|
||||
# via tavolo-app (pyproject.toml)
|
||||
redis==8.1.0
|
||||
# via
|
||||
# kaya-session-redis
|
||||
# tavolo-app (pyproject.toml)
|
||||
tortoise-orm==1.1.8
|
||||
# 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,5 +1,5 @@
|
||||
"""Test helpers package for the tavolo-app integration suite."""
|
||||
from .asynctest import async_test
|
||||
from .oidc import make_user, oidc_user, ws_users
|
||||
from .oidc import make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
__all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
|
||||
__all__ = ["async_test", "make_principal", "make_user", "oidc_user", "ws_users"]
|
||||
|
||||
@@ -17,21 +17,49 @@ from kaya.oidc import OIDCUser
|
||||
# ``tavolo.platform.auth`` themselves.
|
||||
from tavolo.app import oidc
|
||||
from tavolo.platform import auth
|
||||
from tavolo.platform.auth import AuthenticatedUser, canonical_principal
|
||||
|
||||
# The app's first configured provider (the test environment configures one
|
||||
# named "default").
|
||||
_PROVIDER = next(iter(oidc.providers))
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||
def make_user(
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
picture: Optional[str] = None,
|
||||
) -> OIDCUser:
|
||||
claims = {"sub": sub, "preferred_username": name or sub}
|
||||
if email:
|
||||
claims["email"] = email
|
||||
if picture:
|
||||
claims["picture"] = picture
|
||||
return OIDCUser(claims)
|
||||
|
||||
|
||||
def make_principal(sub: str, provider: str = _PROVIDER) -> str:
|
||||
return canonical_principal(provider, sub)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||
"""Context manager: make ``oidc.get_users`` return this user."""
|
||||
user = make_user(sub, name)
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||
def oidc_user(
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
picture: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> Iterator[OIDCUser]:
|
||||
"""Context manager: make ``oidc.get_users`` return this user.
|
||||
|
||||
``email`` is optional so tests can simulate an IdP that still returns
|
||||
the claim: the app requests only the ``openid`` and ``profile`` scopes
|
||||
and ignores email where present. ``picture`` simulates the profile
|
||||
scope's avatar claim.
|
||||
"""
|
||||
user = make_user(sub, name, email, picture)
|
||||
selected = provider or _PROVIDER
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
@@ -44,7 +72,14 @@ 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."""
|
||||
remaining = list(users)
|
||||
remaining = [
|
||||
AuthenticatedUser(
|
||||
provider=_PROVIDER,
|
||||
claims=user,
|
||||
principal=canonical_principal(_PROVIDER, user.sub),
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
last = remaining[-1] if remaining else None
|
||||
|
||||
def _next(_ws):
|
||||
|
||||
@@ -23,7 +23,7 @@ from tavolo.briscola.state import (
|
||||
PlayerState,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class BriscolaRoutesTest(unittest.TestCase):
|
||||
@@ -235,14 +235,14 @@ class BriscolaTurnTimeoutTest(unittest.TestCase):
|
||||
id="briscola-turn-timeout-1",
|
||||
game_type=engine.id,
|
||||
join_code="BT0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
session.state.turn_timeout = 1
|
||||
engine.join(session, "bob", "Bob")
|
||||
engine.join(session, make_principal("bob"), "Bob")
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
@@ -283,10 +283,10 @@ async def _seed_hand_end_state() -> str:
|
||||
id="briscola-hand-end-1",
|
||||
game_type=engine.id,
|
||||
join_code="BH0001",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = BriscolaState(
|
||||
@@ -298,11 +298,11 @@ async def _seed_hand_end_state() -> str:
|
||||
briscola_suit="S",
|
||||
players=[
|
||||
PlayerState(
|
||||
sub="alice", name="Alice", seat=0,
|
||||
sub=make_principal("alice"), name="Alice", seat=0,
|
||||
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
|
||||
),
|
||||
PlayerState(
|
||||
sub="bob", name="Bob", seat=1,
|
||||
sub=make_principal("bob"), name="Bob", seat=1,
|
||||
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
|
||||
|
||||
The per-package suite covers the rules and the engine contract; these
|
||||
tests wire the game through the full platform stack (REST, WebSocket,
|
||||
deadline scheduler).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
|
||||
from tavolo.app import app, game_store, platform
|
||||
from tavolo.briscola.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_PLAYING,
|
||||
Auction,
|
||||
Bid,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||
|
||||
|
||||
async def _create_and_join(client: AsyncClient) -> dict:
|
||||
"""Create a chiamata game and seat all five players; return the view."""
|
||||
with oidc_user(PLAYERS[0]):
|
||||
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
|
||||
assert created.status_code == 201, created.text
|
||||
code = created.json()["join_code"]
|
||||
body = created.json()
|
||||
for player in PLAYERS[1:]:
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
assert joined.status_code == 200, joined.text
|
||||
body = joined.json()
|
||||
return body
|
||||
|
||||
|
||||
class ChiamataRoutesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_and_seat_five_players(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(PLAYERS[0]):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(4, body["seats_open"])
|
||||
self.assertIsNone(body["players"][0]["team"])
|
||||
code = body["join_code"]
|
||||
|
||||
for player in PLAYERS[1:4]:
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual("lobby", joined.json()["phase"])
|
||||
self.assertEqual(
|
||||
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
|
||||
)
|
||||
|
||||
with oidc_user(PLAYERS[4]):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
state = started.json()
|
||||
self.assertEqual(PHASE_AUCTION, state["phase"])
|
||||
self.assertEqual(0, state["seats_open"])
|
||||
self.assertEqual(5, len(state["players"]))
|
||||
for seat in state["players"]:
|
||||
# Roles are hidden until the called card is played.
|
||||
self.assertEqual("", seat["team"])
|
||||
self.assertEqual(8, seat["cards_left"])
|
||||
# The auction opens with the player after the dealer (seat 0).
|
||||
self.assertEqual(1, state["auction"]["turn"])
|
||||
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
|
||||
self.assertIsNone(state["auction"]["current"])
|
||||
self.assertIsNotNone(state["auction_deadline"])
|
||||
# The last joiner's personalized view carries their own hand.
|
||||
self.assertIn("hand", state["players"][4])
|
||||
self.assertEqual(8, len(state["players"][4]["hand"]))
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
# There is no stock in this variant.
|
||||
self.assertNotIn("stock_count", state)
|
||||
|
||||
# A non-player watches without hands or the join code.
|
||||
with oidc_user("mallory"):
|
||||
watched = await client.get(f"/api/games/{state['id']}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
spectator = watched.json()
|
||||
self.assertTrue(spectator["spectator"])
|
||||
self.assertNotIn("join_code", spectator)
|
||||
for seat in spectator["players"]:
|
||||
self.assertNotIn("hand", seat)
|
||||
|
||||
@async_test
|
||||
async def test_preview_lists_chiamata(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user("bob"):
|
||||
preview = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, preview.status_code)
|
||||
body = preview.json()
|
||||
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||
self.assertEqual("Briscola chiamata", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(4, body["seats_open"])
|
||||
|
||||
|
||||
class ChiamataWebSocketTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_auction_call_and_play_over_the_socket(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await _create_and_join(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob"), make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
first = await bob_ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
self.assertIn("legal_bids", first["game"])
|
||||
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
await alice_ws.receive_json()
|
||||
# Bob (seat 1) opens the bidding with the two.
|
||||
await bob_ws.send_json({"action": "bid", "rank": 2})
|
||||
update = await bob_ws.receive_json()
|
||||
self.assertEqual("state", update["type"])
|
||||
current = update["game"]["auction"]["current"]
|
||||
self.assertEqual(
|
||||
{"seat": 1, "rank": 2, "points": 61}, current
|
||||
)
|
||||
self.assertEqual(2, update["game"]["auction"]["turn"])
|
||||
# Alice receives the broadcast of Bob's bid.
|
||||
broadcast = await alice_ws.receive_json()
|
||||
self.assertEqual("state", broadcast["type"])
|
||||
# Alice (seat 0) is not on turn: her bid is an error.
|
||||
await alice_ws.send_json({"action": "bid", "rank": 3})
|
||||
error = await alice_ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
|
||||
# The remaining players pass; Bob wins the auction and is
|
||||
# asked to name the card.
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await game_store.load(game_id)
|
||||
assert session is not None
|
||||
for sub in ("carol", "dave", "erin", "alice"):
|
||||
engine.handle_action(session, make_principal(sub), "pass", {})
|
||||
await game_store.save(session)
|
||||
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||
self.assertEqual(1, session.state.caller)
|
||||
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
self.assertEqual(
|
||||
["02D", "02C", "02S", "02B"],
|
||||
first["game"]["callable_cards"],
|
||||
)
|
||||
await ws.send_json(
|
||||
{"action": "call", "card": "02D"}
|
||||
)
|
||||
update = await ws.receive_json()
|
||||
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||
self.assertEqual("D", update["game"]["briscola_suit"])
|
||||
self.assertEqual("02D", update["game"]["called_card"])
|
||||
self.assertEqual(1, update["game"]["turn"])
|
||||
|
||||
# Bob is first to play (player after the dealer).
|
||||
bob = next(
|
||||
p for p in update["game"]["players"] if p["seat"] == 1
|
||||
)
|
||||
card = bob["hand"][0]
|
||||
await ws.send_json({"action": "play", "card": card})
|
||||
update = await ws.receive_json()
|
||||
self.assertEqual(
|
||||
[{"seat": 1, "card": card}], update["game"]["trick"]
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_spectator_cannot_act(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await _create_and_join(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertTrue(first["game"]["spectator"])
|
||||
self.assertNotIn("join_code", first["game"])
|
||||
for seat in first["game"]["players"]:
|
||||
self.assertNotIn("hand", seat)
|
||||
await ws.send_json({"action": "bid", "rank": 1})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
|
||||
class ChiamataTimeoutTest(unittest.TestCase):
|
||||
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
|
||||
from tavolo.briscola import chiamata as rules
|
||||
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type="briscola_chiamata",
|
||||
join_code=join_code,
|
||||
creator_sub=make_principal(PLAYERS[0]),
|
||||
players=[
|
||||
Seat(user_sub=make_principal(name), display_name=name.capitalize())
|
||||
for name in PLAYERS
|
||||
],
|
||||
)
|
||||
session.state = rules.create_game(
|
||||
make_principal(PLAYERS[0]), "Alice", turn_timeout=1
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
rules.join_game(session.state, make_principal(name), name.capitalize())
|
||||
state_mutator(session.state)
|
||||
await game_store.save(session)
|
||||
return session.id
|
||||
|
||||
@async_test
|
||||
async def test_auction_timeout_auto_passes(self) -> None:
|
||||
game_id = await self._seed(
|
||||
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||
)
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
self.assertIsNotNone(first["game"]["auction_deadline"])
|
||||
|
||||
update = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
update = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
update.get("type") == "state"
|
||||
and 1 in update["game"]["auction"]["passed"]
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
# The absent seat 1 passed: nobody bid and the turn
|
||||
# moved to seat 2.
|
||||
auction = update["game"]["auction"]
|
||||
self.assertIn(1, auction["passed"])
|
||||
self.assertIsNone(auction["current"])
|
||||
self.assertEqual(2, auction["turn"])
|
||||
|
||||
@async_test
|
||||
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
|
||||
def seed(state) -> None:
|
||||
state.phase = PHASE_AUCTION
|
||||
state.auction = Auction(
|
||||
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
|
||||
)
|
||||
state.turn = 0
|
||||
state.auction_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
state.call_deadline = None
|
||||
|
||||
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
|
||||
# The auction times out: seat 0 is forced to bid the
|
||||
# ace; its call then times out too and a random ace
|
||||
# suit is named.
|
||||
played = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
played = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
played.get("type") == "state"
|
||||
and played["game"]["phase"] == PHASE_PLAYING
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(played)
|
||||
assert played is not None
|
||||
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
|
||||
self.assertEqual(0, played["game"]["caller"])
|
||||
card = played["game"]["called_card"]
|
||||
self.assertIsNotNone(card)
|
||||
self.assertTrue(card.startswith("01"))
|
||||
|
||||
@async_test
|
||||
async def test_call_and_turn_timeouts(self) -> None:
|
||||
def seed(state) -> None:
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction = Auction(
|
||||
turn=1,
|
||||
active=[1],
|
||||
passed=[0, 2, 3, 4],
|
||||
current=Bid(seat=1, rank=2, points=61),
|
||||
)
|
||||
state.caller = 1
|
||||
state.turn = 1
|
||||
state.auction_deadline = None
|
||||
state.call_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
|
||||
game_id = await self._seed(
|
||||
"chiamata-call-timeout-1", "CH0002", seed
|
||||
)
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||
|
||||
called = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
called = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
called.get("type") == "state"
|
||||
and called["game"]["phase"] == PHASE_PLAYING
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(called)
|
||||
assert called is not None
|
||||
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
|
||||
card = called["game"]["called_card"]
|
||||
self.assertIsNotNone(card)
|
||||
self.assertTrue(card.startswith("02"))
|
||||
|
||||
played = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
played = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
played.get("type") == "state"
|
||||
and len(played["game"]["trick"]) == 1
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(played)
|
||||
assert played is not None
|
||||
# Seat 1 was on turn and was auto-played for.
|
||||
self.assertEqual(1, played["game"]["trick"][0]["seat"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Deadline-queue timeout tests (scopone game, full platform stack).
|
||||
"""Deadline-queue timeout tests (full platform stack).
|
||||
|
||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||
not by connected sockets: these tests seed sessions, queue their
|
||||
@@ -10,12 +10,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import permutations
|
||||
from typing import Optional
|
||||
|
||||
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.deadlines import encode
|
||||
from tavolo.platform.deadlines import DeadlineScheduler, encode
|
||||
from tavolo.platform.models import Match
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
||||
from tests.helpers import async_test
|
||||
|
||||
@@ -27,8 +30,9 @@ def _started_session(
|
||||
code: str,
|
||||
turn_timeout: int = 3600,
|
||||
hand_ack_timeout: int = 3600,
|
||||
game_type: str = "scopone_scientifico",
|
||||
) -> GameSession:
|
||||
engine = platform.registry.require("scopone_scientifico")
|
||||
engine = platform.registry.require(game_type)
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
@@ -39,7 +43,7 @@ def _started_session(
|
||||
engine.create(session, {})
|
||||
session.state.hand_ack_timeout = hand_ack_timeout
|
||||
session.state.turn_timeout = turn_timeout
|
||||
for name in PLAYERS[1:]:
|
||||
for name in PLAYERS[1:engine.max_players]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
return session
|
||||
|
||||
@@ -171,6 +175,85 @@ async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]:
|
||||
return session if session is not None and session.state.phase == phase else None
|
||||
|
||||
|
||||
class PartialHandEndAckTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_every_partial_ack_ordering_advances_persisted_game(self) -> None:
|
||||
# Use an isolated store so the exhaustive matrix cannot affect other
|
||||
# integration tests through the app's ongoing-game list or queue.
|
||||
registry = GameRegistry(platform.registry.all())
|
||||
store = InMemoryGameStore(registry)
|
||||
local_scheduler = DeadlineScheduler(store, registry, heartbeat_ms=60_000)
|
||||
loop = asyncio.get_running_loop()
|
||||
case_count = 0
|
||||
try:
|
||||
for game_type in (
|
||||
"scopa",
|
||||
"scopa_a_quattro",
|
||||
"scopone_scientifico",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
):
|
||||
engine = registry.require(game_type)
|
||||
for ack_count in range(1, engine.max_players):
|
||||
for ack_order in permutations(
|
||||
range(engine.max_players), ack_count
|
||||
):
|
||||
case_count += 1
|
||||
with self.subTest(game_type=game_type, ack_order=ack_order):
|
||||
session = _started_session(
|
||||
f"dl-partial-{case_count}",
|
||||
f"DLP{case_count:03d}",
|
||||
game_type=game_type,
|
||||
)
|
||||
state = session.state
|
||||
state.phase = "hand_end"
|
||||
state.turn_deadline = None
|
||||
state.hand_end_deadline = (
|
||||
datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
).isoformat()
|
||||
for seat in ack_order:
|
||||
engine.handle_action(
|
||||
session, state.players[seat].sub, "ack", {}
|
||||
)
|
||||
self.assertEqual(list(ack_order), state.acked)
|
||||
|
||||
await store.save(session)
|
||||
current = engine.next_deadline(session)
|
||||
assert current is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": current.kind,
|
||||
"token": current.token,
|
||||
})
|
||||
# Avoid racing the background consumer with this
|
||||
# direct, deterministic process_due call.
|
||||
await store.add_deadline(
|
||||
member,
|
||||
due_at=datetime.now(timezone.utc).timestamp() + 3600,
|
||||
)
|
||||
|
||||
await local_scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
self.assertEqual("playing", result.state.phase)
|
||||
self.assertEqual(2, result.state.hand_number)
|
||||
self.assertEqual([], result.state.acked)
|
||||
self.assertIsNone(result.state.hand_end_deadline)
|
||||
following = engine.next_deadline(result)
|
||||
assert following is not None
|
||||
self.assertEqual("turn", following.kind)
|
||||
self.assertNotIn(
|
||||
member,
|
||||
await store.due_deadlines(
|
||||
float("inf"), limit=1000
|
||||
),
|
||||
)
|
||||
finally:
|
||||
local_scheduler.stop_consumer(loop)
|
||||
self.assertEqual(124, case_count)
|
||||
|
||||
|
||||
class ProcessDueTest(unittest.TestCase):
|
||||
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""OpenAPI document tests: components, response schemas and Swagger UI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from kaya.openapi import schema_ref
|
||||
|
||||
from tavolo.app import app
|
||||
from tavolo.platform.openapi import SCHEMAS
|
||||
from tests.helpers import async_test
|
||||
|
||||
#: Methods documented for the application's own routes.
|
||||
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
|
||||
|
||||
|
||||
class OpenApiDocumentTest(unittest.TestCase):
|
||||
async def _get(self, path: str) -> Any:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get(path)
|
||||
self.assertEqual(200, response.status_code)
|
||||
return response
|
||||
|
||||
async def _spec(self) -> Dict[str, Any]:
|
||||
response = await self._get("/api/openapi.json")
|
||||
body: Dict[str, Any] = response.json()
|
||||
return body
|
||||
|
||||
async def _schema(
|
||||
self, spec: Dict[str, Any], path: str, method: str, status: str
|
||||
) -> Dict[str, Any]:
|
||||
schema: Dict[str, Any] = spec["paths"][path][method]["responses"][status][
|
||||
"content"
|
||||
]["application/json"]["schema"]
|
||||
return schema
|
||||
|
||||
@async_test
|
||||
async def test_components_registered(self) -> None:
|
||||
spec = await self._spec()
|
||||
self.assertEqual(SCHEMAS, spec["components"]["schemas"])
|
||||
|
||||
@async_test
|
||||
async def test_every_api_response_documents_a_schema(self) -> None:
|
||||
spec = await self._spec()
|
||||
checked = 0
|
||||
for path, item in spec["paths"].items():
|
||||
if not path.startswith("/api/"):
|
||||
continue
|
||||
for method, operation in item.items():
|
||||
if method not in HTTP_METHODS:
|
||||
continue
|
||||
for status, response in operation["responses"].items():
|
||||
with self.subTest(path=path, method=method, status=status):
|
||||
if status == "204":
|
||||
# No content, so no schema either.
|
||||
self.assertNotIn("content", response)
|
||||
continue
|
||||
self.assertIn("application/json", response["content"])
|
||||
self.assertTrue(
|
||||
response["content"]["application/json"]["schema"]
|
||||
)
|
||||
checked += 1
|
||||
self.assertGreater(checked, 0)
|
||||
|
||||
@async_test
|
||||
async def test_response_schemas_reference_components(self) -> None:
|
||||
spec = await self._spec()
|
||||
cases: Tuple[Tuple[str, str, str, Dict[str, Any]], ...] = (
|
||||
("/api/health", "get", "200", schema_ref("Health")),
|
||||
("/api/me", "get", "200", schema_ref("Me")),
|
||||
("/api/me", "get", "401", schema_ref("Error")),
|
||||
("/api/auth/providers", "get", "200", schema_ref("AuthProvidersPage")),
|
||||
("/api/game-types", "get", "200", schema_ref("GameTypesPage")),
|
||||
("/api/games", "post", "201", schema_ref("Lobby")),
|
||||
("/api/games/preview", "get", "200", schema_ref("GamePreview")),
|
||||
("/api/games/{game_id}", "get", "200", schema_ref("GameView")),
|
||||
("/api/games/{game_id}", "get", "404", schema_ref("Error")),
|
||||
("/api/me/games", "get", "200", schema_ref("OngoingGamesPage")),
|
||||
("/api/me/matches", "get", "200", schema_ref("MatchesPage")),
|
||||
("/api/leaderboard", "get", "200", schema_ref("LeaderboardPage")),
|
||||
("/api/me/ratings", "get", "200", schema_ref("RatingsPage")),
|
||||
)
|
||||
for path, method, status, expected in cases:
|
||||
with self.subTest(path=path, method=method, status=status):
|
||||
self.assertEqual(
|
||||
expected, await self._schema(spec, path, method, status)
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_join_response_is_lobby_or_game_view(self) -> None:
|
||||
spec = await self._spec()
|
||||
schema = await self._schema(spec, "/api/games/join", "post", "200")
|
||||
self.assertEqual(
|
||||
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]}, schema
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_component_nesting_and_nullability(self) -> None:
|
||||
spec = await self._spec()
|
||||
schemas = spec["components"]["schemas"]
|
||||
|
||||
matches = schemas["MatchesPage"]["properties"]
|
||||
self.assertEqual(schema_ref("Match"), matches["results"]["items"])
|
||||
self.assertEqual(["string", "null"], matches["next_cursor"]["type"])
|
||||
|
||||
match = schemas["Match"]["properties"]
|
||||
self.assertEqual(schema_ref("MatchPlayer"), match["players"]["items"])
|
||||
self.assertEqual(["integer", "null"], match["your_elo_delta"]["type"])
|
||||
|
||||
lobby = schemas["Lobby"]
|
||||
self.assertEqual(schema_ref("LobbyPlayer"), lobby["properties"]["players"]["items"])
|
||||
self.assertIs(True, lobby["additionalProperties"])
|
||||
self.assertEqual(
|
||||
["string", "null"],
|
||||
schemas["LobbyPlayer"]["properties"]["team"]["type"],
|
||||
)
|
||||
|
||||
game_view = schemas["GameView"]
|
||||
self.assertEqual("boolean", game_view["properties"]["spectator"]["type"])
|
||||
self.assertIs(True, game_view["additionalProperties"])
|
||||
# The join code is optional (players only), so it is not required.
|
||||
self.assertNotIn("join_code", game_view["required"])
|
||||
|
||||
board = schemas["LeaderboardPage"]
|
||||
self.assertEqual(["integer", "null"], board["properties"]["your_rank"]["type"])
|
||||
self.assertEqual(
|
||||
["integer", "null"], board["properties"]["your_page"]["type"]
|
||||
)
|
||||
self.assertEqual(schema_ref("LeaderboardEntry"),
|
||||
board["properties"]["results"]["items"])
|
||||
|
||||
@async_test
|
||||
async def test_docs_serve_swagger_ui(self) -> None:
|
||||
response = await self._get("/api/docs")
|
||||
self.assertIn("text/html", response.headers["content-type"])
|
||||
self.assertIn("swagger-ui", response.text)
|
||||
@@ -10,7 +10,7 @@ import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app
|
||||
from tavolo.app import app, oidc
|
||||
from tests.helpers import async_test
|
||||
|
||||
|
||||
@@ -33,3 +33,10 @@ class AuthRoutesTest(unittest.TestCase):
|
||||
response = await client.get("/auth/login", follow_redirects=False)
|
||||
self.assertEqual(302, response.status_code)
|
||||
self.assertEqual("/auth/default/login", response.headers["location"])
|
||||
|
||||
def test_authorization_scopes_exclude_email(self) -> None:
|
||||
# Only the scopes the app uses are requested: openid (sub) and
|
||||
# profile (preferred_username/name). kaya-oidc's default would also
|
||||
# request email, which the app neither needs nor stores.
|
||||
mixin = oidc.provider("default")
|
||||
self.assertEqual(("openid", "profile"), tuple(mixin._config.scopes))
|
||||
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app
|
||||
from tests.helpers import async_test, oidc_user
|
||||
from tests.helpers import async_test, make_principal, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@@ -37,7 +37,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
with oidc_user("alice"):
|
||||
snapshot = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, snapshot.status_code)
|
||||
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
||||
self.assertEqual(make_principal("alice"), snapshot.json()["players"][0]["sub"])
|
||||
|
||||
# A logged-in non-player is a read-only spectator: no join
|
||||
# code, and the view is marked as a spectator view.
|
||||
@@ -98,6 +98,32 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(0, participant["captured_count"])
|
||||
self.assertEqual(0, participant["scope"])
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_other_provider_cannot_read_private_hand(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
game_id = created.json()["id"]
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
|
||||
with oidc_user("alice", provider="google"):
|
||||
watched = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
spectator = watched.json()
|
||||
self.assertTrue(spectator["spectator"])
|
||||
self.assertNotIn("join_code", spectator)
|
||||
for participant in spectator["players"]:
|
||||
self.assertNotIn("hand", participant)
|
||||
|
||||
with oidc_user("alice"):
|
||||
owned = await client.get(f"/api/games/{game_id}")
|
||||
self.assertFalse(owned.json()["spectator"])
|
||||
self.assertIn("hand", owned.json()["players"][0])
|
||||
|
||||
@async_test
|
||||
async def test_create_napola_option(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -180,7 +206,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertFalse(body["napola"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual(3, body["seats_open"])
|
||||
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
|
||||
self.assertEqual([make_principal("alice")], [p["sub"] for p in body["players"]])
|
||||
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
@@ -236,6 +262,52 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(400, text.status_code)
|
||||
self.assertEqual(400, huge.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_games_lists_my_ongoing_games(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "briscola"}
|
||||
)
|
||||
game_id = created.json()["id"]
|
||||
|
||||
with oidc_user("alice"):
|
||||
listed = await client.get("/api/me/games")
|
||||
self.assertEqual(200, listed.status_code)
|
||||
# The app suite shares one in-memory store, so other tests'
|
||||
# games may be listed too: pick ours by id.
|
||||
by_id = {g["id"]: g for g in listed.json()["results"]}
|
||||
self.assertIn(game_id, by_id)
|
||||
entry = by_id[game_id]
|
||||
self.assertEqual("briscola", entry["game_type"])
|
||||
self.assertEqual("Briscola", entry["game_name"])
|
||||
self.assertTrue(entry["game_description"])
|
||||
self.assertEqual("lobby", entry["phase"])
|
||||
self.assertEqual(1, entry["seats_open"])
|
||||
self.assertEqual(0, entry["your_seat"])
|
||||
self.assertNotIn("join_code", entry)
|
||||
|
||||
# A logged-in non-player does not see the game.
|
||||
with oidc_user("mallory"):
|
||||
others = await client.get("/api/me/games")
|
||||
self.assertNotIn(
|
||||
game_id, [g["id"] for g in others.json()["results"]]
|
||||
)
|
||||
|
||||
# Once the match starts it stays listed, for both players.
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user("bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user("bob"):
|
||||
started = await client.get("/api/me/games")
|
||||
bob_entry = next(
|
||||
g for g in started.json()["results"] if g["id"] == game_id
|
||||
)
|
||||
self.assertEqual("playing", bob_entry["phase"])
|
||||
self.assertEqual(1, bob_entry["your_seat"])
|
||||
self.assertEqual(0, bob_entry["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_get_unknown_game(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -254,7 +326,14 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(
|
||||
["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||
[
|
||||
"scopone_scientifico",
|
||||
"scopa",
|
||||
"scopa_a_quattro",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
[g["id"] for g in results],
|
||||
)
|
||||
scopone = results[0]
|
||||
@@ -263,22 +342,61 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(4, scopone["min_players"])
|
||||
self.assertEqual(4, scopone["max_players"])
|
||||
self.assertIn("target_score", scopone["options_schema"]["properties"])
|
||||
self.assertEqual(
|
||||
21,
|
||||
scopone["options_schema"]["properties"]["target_score"]["default"],
|
||||
)
|
||||
self.assertIn("napola", scopone["options_schema"]["properties"])
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", scopone["options_schema"]["properties"]
|
||||
)
|
||||
briscola = results[1]
|
||||
scopa = results[1]
|
||||
self.assertEqual("Scopa", scopa["name"])
|
||||
self.assertTrue(scopa["description"])
|
||||
self.assertEqual(2, scopa["min_players"])
|
||||
self.assertEqual(2, scopa["max_players"])
|
||||
self.assertIn("target_score", scopa["options_schema"]["properties"])
|
||||
self.assertEqual(
|
||||
11, scopa["options_schema"]["properties"]["target_score"]["default"]
|
||||
)
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", scopa["options_schema"]["properties"]
|
||||
)
|
||||
scopa_quattro = results[2]
|
||||
self.assertEqual("Scopa a quattro", scopa_quattro["name"])
|
||||
self.assertTrue(scopa_quattro["description"])
|
||||
self.assertEqual(4, scopa_quattro["min_players"])
|
||||
self.assertEqual(4, scopa_quattro["max_players"])
|
||||
self.assertIn(
|
||||
"target_score", scopa_quattro["options_schema"]["properties"]
|
||||
)
|
||||
self.assertEqual(
|
||||
21,
|
||||
scopa_quattro["options_schema"]["properties"]["target_score"][
|
||||
"default"
|
||||
],
|
||||
)
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", scopa_quattro["options_schema"]["properties"]
|
||||
)
|
||||
briscola = results[3]
|
||||
self.assertEqual("Briscola", briscola["name"])
|
||||
self.assertTrue(briscola["description"])
|
||||
self.assertEqual(2, briscola["min_players"])
|
||||
self.assertEqual(2, briscola["max_players"])
|
||||
self.assertIn("target_score", briscola["options_schema"]["properties"])
|
||||
quattro = results[2]
|
||||
quattro = results[4]
|
||||
self.assertEqual("Briscola a quattro", quattro["name"])
|
||||
self.assertTrue(quattro["description"])
|
||||
self.assertEqual(4, quattro["min_players"])
|
||||
self.assertEqual(4, quattro["max_players"])
|
||||
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
||||
chiamata = results[5]
|
||||
self.assertEqual("Briscola chiamata", chiamata["name"])
|
||||
self.assertTrue(chiamata["description"])
|
||||
self.assertEqual(5, chiamata["min_players"])
|
||||
self.assertEqual(5, chiamata["max_players"])
|
||||
self.assertEqual({}, chiamata["options_schema"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app
|
||||
from tavolo.config import settings
|
||||
from tests.helpers import async_test, oidc_user
|
||||
from tests.helpers import async_test, make_principal, oidc_user
|
||||
|
||||
|
||||
class MeRouteTest(unittest.TestCase):
|
||||
@@ -19,10 +19,36 @@ class MeRouteTest(unittest.TestCase):
|
||||
async def test_me_authenticated(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
with oidc_user("alice", email="alice@example.com"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||
# The email claim the IdP returns is neither stored nor exposed;
|
||||
# no picture claim in this request.
|
||||
self.assertEqual(
|
||||
{"sub": make_principal("alice"), "name": "alice", "picture": None},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_me_includes_the_profile_picture(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice", picture="https://cdn.example.com/alice.png"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
"https://cdn.example.com/alice.png",
|
||||
response.json()["picture"],
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_me_rejects_non_http_picture_claims(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice", picture="javascript:alert(1)"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertIsNone(response.json()["picture"])
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
@@ -55,6 +81,46 @@ class StaticRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, fallback.status_code)
|
||||
self.assertIn(b"spa", fallback.content)
|
||||
|
||||
@async_test
|
||||
async def test_serves_pwa_manifest_and_worker(self) -> None:
|
||||
"""The manifest and the service worker are served from the site
|
||||
root, with the content types browsers require, instead of falling
|
||||
through to the app shell."""
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
(Path(dist) / "manifest.webmanifest").write_text('{"name": "Tavolo"}')
|
||||
(Path(dist) / "sw.js").write_text("self.addEventListener('fetch', () => {});")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("tavolo.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
self.assertEqual(200, manifest.status_code)
|
||||
self.assertEqual("application/manifest+json", manifest.headers["content-type"])
|
||||
self.assertEqual('{"name": "Tavolo"}', manifest.text)
|
||||
|
||||
worker = await client.get("/sw.js")
|
||||
self.assertEqual(200, worker.status_code)
|
||||
self.assertEqual("text/javascript; charset=utf-8", worker.headers["content-type"])
|
||||
# The worker script itself must never be cached stale.
|
||||
self.assertEqual("no-cache", worker.headers["cache-control"])
|
||||
self.assertIn(b"addEventListener", worker.content)
|
||||
|
||||
@async_test
|
||||
async def test_missing_pwa_files_return_404(self) -> None:
|
||||
"""A build without the PWA files must 404 those exact paths rather
|
||||
than answer them with the shell (the wrong content type)."""
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("tavolo.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
self.assertEqual(404, (await client.get("/manifest.webmanifest")).status_code)
|
||||
self.assertEqual(404, (await client.get("/sw.js")).status_code)
|
||||
|
||||
@async_test
|
||||
async def test_missing_dist_returns_404(self) -> None:
|
||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Scopa integration tests: lobby, websocket play, timeouts and summaries.
|
||||
|
||||
The per-package suites cover the rules and the engine contract; these
|
||||
tests wire both scopa variants through the full platform stack (REST,
|
||||
WebSocket, deadline scheduler).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
|
||||
from tavolo.app import app, game_store, platform
|
||||
from tavolo.scopa.state import (
|
||||
PHASE_HAND_END,
|
||||
PHASE_PLAYING,
|
||||
Card,
|
||||
PlayerState,
|
||||
ScopaState,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class ScopaRoutesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_and_start_two_player_game(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games",
|
||||
json={
|
||||
"game_type": "scopa",
|
||||
"options": {"target_score": 21, "asso_pigliatutto": True},
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("scopa", body["game_type"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual(21, body["target_score"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual("A", body["players"][0]["team"])
|
||||
code = body["join_code"]
|
||||
|
||||
with oidc_user("bob"):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, started.status_code)
|
||||
state = started.json()
|
||||
self.assertEqual("playing", state["phase"])
|
||||
self.assertEqual(2, len(state["players"]))
|
||||
self.assertEqual(["A", "B"], [p["team"] for p in state["players"]])
|
||||
for participant in state["players"]:
|
||||
self.assertEqual(3, participant["cards_left"])
|
||||
self.assertEqual(4, len(state["table"]))
|
||||
self.assertEqual(30, state["deck_count"])
|
||||
self.assertEqual(1, state["turn"])
|
||||
self.assertTrue(state["asso_pigliatutto"])
|
||||
# Bob's view carries his own hand only.
|
||||
self.assertIn("hand", state["players"][1])
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
|
||||
# A non-player watches without a hand or the join code.
|
||||
watched = await client.get(f"/api/games/{state['id']}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
spectator = watched.json()
|
||||
self.assertTrue(spectator["spectator"])
|
||||
self.assertNotIn("join_code", spectator)
|
||||
for participant in spectator["players"]:
|
||||
self.assertNotIn("hand", participant)
|
||||
|
||||
@async_test
|
||||
async def test_create_and_start_four_player_game(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "scopa_a_quattro"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("scopa_a_quattro", body["game_type"])
|
||||
self.assertEqual(3, body["seats_open"])
|
||||
code = body["join_code"]
|
||||
|
||||
for player in ("bob", "carol"):
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual("lobby", joined.json()["phase"])
|
||||
|
||||
with oidc_user("dave"):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
state = started.json()
|
||||
self.assertEqual("playing", state["phase"])
|
||||
self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]])
|
||||
# Partnership scopa defaults to the traditional 21 points.
|
||||
self.assertEqual(21, state["target_score"])
|
||||
for participant in state["players"]:
|
||||
self.assertEqual(3, participant["cards_left"])
|
||||
self.assertEqual(4, len(state["table"]))
|
||||
self.assertEqual(24, state["deck_count"])
|
||||
|
||||
@async_test
|
||||
async def test_preview_shows_scopa_options(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games",
|
||||
json={
|
||||
"game_type": "scopa",
|
||||
"options": {"target_score": 21, "asso_pigliatutto": True},
|
||||
},
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user("bob"):
|
||||
preview = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, preview.status_code)
|
||||
body = preview.json()
|
||||
self.assertEqual("scopa", body["game_type"])
|
||||
self.assertEqual("Scopa", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual(21, body["target_score"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_bad_options(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
for bad in (0, 101, "eleven", True):
|
||||
with oidc_user("alice"):
|
||||
response = await client.post(
|
||||
"/api/games",
|
||||
json={"game_type": "scopa", "options": {"target_score": bad}},
|
||||
)
|
||||
self.assertEqual(400, response.status_code, bad)
|
||||
with oidc_user("alice"):
|
||||
response = await client.post(
|
||||
"/api/games",
|
||||
json={"game_type": "scopa", "options": {"asso_pigliatutto": "yes"}},
|
||||
)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
|
||||
class ScopaWebSocketTest(unittest.TestCase):
|
||||
async def _started_two_player(self, client: AsyncClient) -> dict:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={"game_type": "scopa"})
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user("bob"):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
return started.json()
|
||||
|
||||
@async_test
|
||||
async def test_play_trails_or_captures(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_two_player(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
first = await bob_ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
game = first["game"]
|
||||
self.assertEqual(1, game["turn"])
|
||||
self.assertTrue(game.get("your_turn"))
|
||||
self.assertEqual(4, len(game["table"]))
|
||||
card = game["players"][1]["hand"][0]
|
||||
options = (game.get("legal_moves") or {}).get(card)
|
||||
payload = {"action": "play", "card": card}
|
||||
if options:
|
||||
payload["capture"] = options[0]
|
||||
await bob_ws.send_json(payload)
|
||||
|
||||
update = await bob_ws.receive_json()
|
||||
self.assertEqual("state", update["type"])
|
||||
game = update["game"]
|
||||
self.assertEqual(0, game["turn"])
|
||||
self.assertIsNotNone(game["last_move"])
|
||||
self.assertEqual(1, game["last_move"]["seat"])
|
||||
self.assertEqual(card, game["last_move"]["card"])
|
||||
if options:
|
||||
# The pile grows by the captured table cards
|
||||
# plus the card just played.
|
||||
self.assertEqual(
|
||||
len(options[0]) + 1,
|
||||
game["players"][1]["captured_count"],
|
||||
)
|
||||
else:
|
||||
self.assertEqual(5, len(game["table"]))
|
||||
|
||||
@async_test
|
||||
async def test_illegal_move_returns_error(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_two_player(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
await alice_ws.receive_json()
|
||||
# Bob (seat 1) is on turn, not Alice.
|
||||
await alice_ws.send_json({"action": "play", "card": "01D"})
|
||||
error = await alice_ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
|
||||
@async_test
|
||||
async def test_spectator_cannot_act(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_two_player(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertTrue(first["game"]["spectator"])
|
||||
self.assertNotIn("join_code", first["game"])
|
||||
for player in first["game"]["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
await ws.send_json({"action": "play", "card": "01D"})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
@async_test
|
||||
async def test_asso_pigliatutto_sweeps_the_table(self) -> None:
|
||||
# A controlled scopa d'assi position: Alice's ace sweeps the whole
|
||||
# table, scores no scopa and counts as the last capture.
|
||||
state = ScopaState(
|
||||
player_count=2,
|
||||
target_score=100,
|
||||
asso_pigliatutto=True,
|
||||
phase=PHASE_PLAYING,
|
||||
dealer=0,
|
||||
turn=0,
|
||||
players=[
|
||||
PlayerState(
|
||||
sub=make_principal("alice"), name="Alice", seat=0,
|
||||
hand=[Card.parse("01D"), Card.parse("09C")],
|
||||
),
|
||||
PlayerState(
|
||||
sub=make_principal("bob"), name="Bob", seat=1,
|
||||
hand=[Card.parse("04D")],
|
||||
),
|
||||
],
|
||||
table=[Card.parse("05C"), Card.parse("02D")],
|
||||
)
|
||||
engine = platform.registry.require("scopa")
|
||||
session = GameSession(
|
||||
id="scopa-asso-pigliatutto-1",
|
||||
game_type="scopa",
|
||||
join_code="SA0001",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = state
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(0, first["game"]["turn"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
await ws.send_json(
|
||||
{"action": "play", "card": "01D", "capture": ["05C", "02D"]}
|
||||
)
|
||||
update = await ws.receive_json()
|
||||
game = update["game"]
|
||||
self.assertEqual([], game["table"])
|
||||
self.assertEqual(3, game["players"][0]["captured_count"])
|
||||
self.assertEqual(0, game["players"][0]["scope"])
|
||||
self.assertEqual(0, game["scores"]["A"])
|
||||
self.assertTrue(game["last_move"]["pigliatutto"])
|
||||
self.assertFalse(game["last_move"]["scopa"])
|
||||
|
||||
@async_test
|
||||
async def test_hand_end_ack_flow(self) -> None:
|
||||
# A one-play hand with target 100 so the match continues to the
|
||||
# scoring summary.
|
||||
state = ScopaState(
|
||||
player_count=2,
|
||||
target_score=100,
|
||||
phase=PHASE_PLAYING,
|
||||
dealer=0,
|
||||
turn=0,
|
||||
players=[
|
||||
PlayerState(
|
||||
sub=make_principal("alice"), name="Alice", seat=0,
|
||||
hand=[Card.parse("02D")],
|
||||
),
|
||||
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
|
||||
],
|
||||
table=[Card.parse("02C")],
|
||||
)
|
||||
session = GameSession(
|
||||
id="scopa-hand-end-1",
|
||||
game_type="scopa",
|
||||
join_code="SH0001",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = state
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
with ws_users([make_user("alice"), make_user("bob")]):
|
||||
sockets = [
|
||||
await stack.enter_async_context(
|
||||
aconnect_ws(f"/ws/games/{session.id}", ws_client)
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
for ws in sockets:
|
||||
await ws.receive_json()
|
||||
|
||||
# Alice captures the last table card: the hand ends.
|
||||
await sockets[0].send_json(
|
||||
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||
)
|
||||
summaries = [await ws.receive_json() for ws in sockets]
|
||||
for summary in summaries:
|
||||
self.assertEqual("state", summary["type"])
|
||||
self.assertEqual(PHASE_HAND_END, summary["game"]["phase"])
|
||||
self.assertEqual([], summary["game"]["acknowledged"])
|
||||
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
|
||||
hand = summary["game"]["last_hand"]
|
||||
self.assertEqual({"A": 2, "B": 0}, hand["cards"])
|
||||
self.assertEqual("A", hand["award"]["carte"])
|
||||
|
||||
# Both acknowledge; the second ack deals the next hand.
|
||||
await sockets[0].send_json({"action": "ack"})
|
||||
updates = [await ws.receive_json() for ws in sockets]
|
||||
for update in updates:
|
||||
self.assertEqual(PHASE_HAND_END, update["game"]["phase"])
|
||||
self.assertEqual([0], update["game"]["acknowledged"])
|
||||
await sockets[1].send_json({"action": "ack"})
|
||||
updates = [await ws.receive_json() for ws in sockets]
|
||||
for update in updates:
|
||||
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||
self.assertEqual(2, update["game"]["hand_number"])
|
||||
for player in update["game"]["players"]:
|
||||
self.assertEqual(3, player["cards_left"])
|
||||
|
||||
|
||||
class ScopaTurnTimeoutTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||
engine = platform.registry.require("scopa")
|
||||
session = GameSession(
|
||||
id="scopa-turn-timeout-1",
|
||||
game_type=engine.id,
|
||||
join_code="ST0001",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
session.state.turn_timeout = 1
|
||||
engine.join(session, make_principal("bob"), "Bob")
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
# Bob (seat 1) is first to act and never connects.
|
||||
self.assertEqual(1, first["game"]["turn"])
|
||||
self.assertIsNotNone(first["game"]["turn_deadline"])
|
||||
|
||||
update = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
update = await asyncio.wait_for(ws.receive_json(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
update.get("type") == "state"
|
||||
and update["game"]["last_move"] is not None
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
# The absent player was auto-played: the move is on the
|
||||
# board and the turn advanced.
|
||||
self.assertEqual(1, update["game"]["last_move"]["seat"])
|
||||
self.assertEqual(0, update["game"]["turn"])
|
||||
players = {p["seat"]: p for p in update["game"]["players"]}
|
||||
self.assertEqual(2, players[1]["cards_left"])
|
||||
self.assertEqual(3, players[0]["cards_left"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,12 +12,16 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, platform, tortoise_mixin
|
||||
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.errors import IllegalMove
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopa import engine as scopa_rules
|
||||
from tavolo.scopone import engine as rules
|
||||
from tests.helpers import async_test, oidc_user
|
||||
from tests.helpers import async_test, make_principal, oidc_user
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
@@ -28,12 +32,12 @@ async def _finished_session(target_score: int = 1) -> GameSession:
|
||||
id="stats-scope-1",
|
||||
game_type=engine.id,
|
||||
join_code="SS0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(session, {"target_score": target_score})
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
@@ -129,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase):
|
||||
self.assertIn("your_elo_delta", results[0])
|
||||
self.assertEqual(4, len(results[0]["players"]))
|
||||
|
||||
board = await client.get("/api/leaderboard")
|
||||
with oidc_user("alice"):
|
||||
board = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, board.status_code)
|
||||
by_sub = {r["user_sub"]: r for r in board.json()["results"]}
|
||||
body = board.json()
|
||||
by_sub = {r["user_sub"]: r for r in body["results"]}
|
||||
self.assertEqual(4, len(by_sub))
|
||||
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
||||
self.assertEqual(1, body["page"])
|
||||
self.assertEqual(1, body["pages"])
|
||||
self.assertEqual(4, body["total"])
|
||||
# The caller's own rank rides along on the response.
|
||||
self.assertIsNotNone(body["your_rank"])
|
||||
self.assertEqual(1, body["your_page"])
|
||||
|
||||
paged = await client.get("/api/leaderboard?limit=1&page=2")
|
||||
self.assertEqual(200, paged.status_code)
|
||||
self.assertEqual(4, paged.json()["pages"])
|
||||
self.assertEqual(2, paged.json()["page"])
|
||||
self.assertEqual(1, len(paged.json()["results"]))
|
||||
|
||||
|
||||
async def _finished_briscola_session() -> GameSession:
|
||||
@@ -142,11 +160,11 @@ async def _finished_briscola_session() -> GameSession:
|
||||
id="stats-briscola-1",
|
||||
game_type=engine.id,
|
||||
join_code="BS0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(session, {})
|
||||
engine.join(session, "bob", "Bob")
|
||||
engine.join(session, make_principal("bob"), "Bob")
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
@@ -222,5 +240,225 @@ class BriscolaStatsTest(unittest.TestCase):
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
async def _finished_chiamata_session() -> GameSession:
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = GameSession(
|
||||
id="stats-chiamata-1",
|
||||
game_type=engine.id,
|
||||
join_code="BC0001",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub=make_principal(name), display_name=name.capitalize())
|
||||
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
for name in ("bob", "carol", "dave", "erin"):
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(p for p in state.players if p.seat == state.caller)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
class ChiamataStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await _finished_chiamata_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(5, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("briscola_chiamata", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
|
||||
players = await MatchPlayer.all()
|
||||
roles = {p.team for p in players}
|
||||
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||
# Roles match the sides: exactly one caller, the rest defenders
|
||||
# plus at most one partner.
|
||||
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||
winner_team = summary["winner_team"]
|
||||
for row in players:
|
||||
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(5, len(ratings))
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
|
||||
class ScopaStatsTest(unittest.TestCase):
|
||||
async def _finished_session(
|
||||
self, player_count: int = 2, asso_pigliatutto: bool = False
|
||||
) -> GameSession:
|
||||
engine = platform.registry.require(
|
||||
"scopa" if player_count == 2 else "scopa_a_quattro"
|
||||
)
|
||||
session = GameSession(
|
||||
id=f"stats-scopa-{player_count}",
|
||||
game_type=engine.id,
|
||||
join_code="SC0001",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(
|
||||
session,
|
||||
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
|
||||
)
|
||||
for name in PLAYERS[1:player_count]:
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == "hand_end":
|
||||
for player in state.players:
|
||||
engine.handle_action(session, player.sub, "ack", {})
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = scopa_rules.legal_captures(
|
||||
state.table, played,
|
||||
asso_pigliatutto=state.asso_pigliatutto,
|
||||
)
|
||||
payload = {"card": played.code}
|
||||
if options:
|
||||
payload["capture"] = [c.code for c in options[0]]
|
||||
engine.handle_action(session, player.sub, "play", payload)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
@async_test
|
||||
async def test_finished_two_player_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopa")
|
||||
session = await self._finished_session(2, asso_pigliatutto=True)
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(2, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("scopa", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"target_score",
|
||||
"asso_pigliatutto",
|
||||
"hands_played",
|
||||
"hand_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertTrue(summary["asso_pigliatutto"])
|
||||
|
||||
winner_team = summary["winner_team"]
|
||||
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
||||
for row in players.values():
|
||||
self.assertEqual(row.won, row.team == winner_team)
|
||||
# A two-player match moves Elo by 16 points each.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_finished_four_player_match_persisted_with_teams(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopa_a_quattro")
|
||||
session = await self._finished_session(4)
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(4, await MatchPlayer.all().count())
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("scopa_a_quattro", match.game_type)
|
||||
players = await MatchPlayer.all()
|
||||
self.assertEqual({"A", "B"}, {p.team for p in players})
|
||||
# Partnership Elo: the winning side gains 16 each.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_history_exposes_scopa_result(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopa")
|
||||
session = await self._finished_session(2)
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
history = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, history.status_code)
|
||||
results = history.json()["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual("scopa", results[0]["game_type"])
|
||||
self.assertIn("team_a_score", results[0]["result"])
|
||||
self.assertIn("your_elo_delta", results[0])
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -25,7 +25,7 @@ from tavolo.telemetry import (
|
||||
)
|
||||
from tavolo.config import Settings
|
||||
|
||||
from tests.helpers import async_test
|
||||
from tests.helpers import async_test, make_principal
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
|
||||
@@ -158,7 +158,7 @@ class SessionTagHookTest(unittest.TestCase):
|
||||
hashlib.sha256(b"sid").hexdigest()[:16],
|
||||
span.attributes["session.id"],
|
||||
)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
|
||||
|
||||
def test_tags_user_of_any_provider(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
@@ -166,7 +166,7 @@ class SessionTagHookTest(unittest.TestCase):
|
||||
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
self.assertEqual(make_principal("alice", "google"), span.attributes["user.id"])
|
||||
|
||||
def test_ignores_legacy_unprefixed_user_key(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
@@ -190,7 +190,7 @@ class SessionTagHookTest(unittest.TestCase):
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertNotIn("session.id", span.attributes)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
|
||||
|
||||
def test_missing_session_is_a_noop(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
@@ -266,7 +266,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
|
||||
attributes = span.attributes
|
||||
assert attributes is not None
|
||||
self.assertEqual(digest, attributes["session.id"], name)
|
||||
self.assertEqual("alice", attributes["user.id"], name)
|
||||
self.assertEqual(make_principal("alice"), attributes["user.id"], name)
|
||||
|
||||
|
||||
def _metric_points(reader) -> dict:
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx_ws.transport import ASGIWebSocketTransport
|
||||
from tavolo.app import app, game_store, platform
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
@@ -27,8 +27,8 @@ def _started_session(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
join_code=code,
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
|
||||
)
|
||||
engine.create(
|
||||
session,
|
||||
@@ -42,7 +42,7 @@ def _started_session(
|
||||
session.state.hand_ack_timeout = hand_ack_timeout
|
||||
session.state.turn_timeout = turn_timeout
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
engine.join(session, make_principal(name), name.capitalize())
|
||||
return session
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class WebSocketTest(unittest.TestCase):
|
||||
alice_first = await alice_ws.receive_json()
|
||||
self.assertEqual("state", alice_first["type"])
|
||||
self.assertEqual(
|
||||
"alice", alice_first["game"]["players"][0]["sub"]
|
||||
make_principal("alice"), alice_first["game"]["players"][0]["sub"]
|
||||
)
|
||||
self.assertNotIn("hand", alice_first["game"]["players"][1])
|
||||
|
||||
@@ -189,12 +189,12 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
id="hand-end-1",
|
||||
game_type=engine.id,
|
||||
join_code="HEND01",
|
||||
creator_sub="alice",
|
||||
creator_sub=make_principal("alice"),
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
Seat(user_sub="carol", display_name="Carol", team="A"),
|
||||
Seat(user_sub="dave", display_name="Dave", team="B"),
|
||||
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
|
||||
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
|
||||
Seat(user_sub=make_principal("carol"), display_name="Carol", team="A"),
|
||||
Seat(user_sub=make_principal("dave"), display_name="Dave", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = ScoponeState(
|
||||
@@ -203,10 +203,10 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
turn=0,
|
||||
table=[Card.parse("02C")],
|
||||
players=[
|
||||
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
PlayerState(sub="carol", name="Carol", seat=2),
|
||||
PlayerState(sub="dave", name="Dave", seat=3),
|
||||
PlayerState(sub=make_principal("alice"), name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
|
||||
PlayerState(sub=make_principal("carol"), name="Carol", seat=2),
|
||||
PlayerState(sub=make_principal("dave"), name="Dave", seat=3),
|
||||
],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage", "ServiceWorkerContainer", "ServiceWorkerRegistration", "Notification", "NotificationPermission", "NotificationOptions", "Document"] }
|
||||
js-sys = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
|
||||
speckles, tiled as a CSS background over the green table gradient.
|
||||
stitchTiles + a filter region equal to the tile keep the seams
|
||||
invisible. -->
|
||||
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
|
||||
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
|
||||
</filter>
|
||||
<rect width="240" height="240" filter="url(#felt)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<!-- Maskable variant: the felt background bleeds to the edges and the
|
||||
spade shrinks inside the 80% safe zone, so platform masks (circles,
|
||||
squircles, rounded squares) never clip the glyph. Also rasterized
|
||||
into the 180x180 apple-touch-icon, where iOS applies its own mask. -->
|
||||
<defs>
|
||||
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0" stop-color="#1d5c38"/>
|
||||
<stop offset="1" stop-color="#14341f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#felt)"/>
|
||||
<path fill="#e8c547" transform="translate(128 134) scale(16)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<!-- Tavolo app icon: the gold spade from the navbar on dark felt.
|
||||
The spade glyph is Bootstrap Icons' "suit-spade-fill" (MIT), the
|
||||
same license as the vendored Bootstrap CSS. Rasterized into the
|
||||
PNG sizes by make-icons.sh (rsvg-convert); not used directly at
|
||||
runtime except as the SVG favicon. -->
|
||||
<defs>
|
||||
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0" stop-color="#1d5c38"/>
|
||||
<stop offset="1" stop-color="#14341f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#felt)"/>
|
||||
<path fill="#e8c547" transform="translate(96 104) scale(20)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1022 B |
@@ -4,12 +4,27 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tavolo</title>
|
||||
<!-- PWA: installable app shell. The manifest and the (install-only)
|
||||
service worker are served from the site root, where the worker's
|
||||
default scope covers the whole app; the Python host routes them in
|
||||
production, trunk serve serves them in development. -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#14341f">
|
||||
<link rel="icon" href="/assets/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/assets/icons/icon-192.png" sizes="192x192" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Tavolo">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link data-trunk rel="rust" href="Cargo.toml">
|
||||
<link data-trunk rel="copy-file" href="manifest.webmanifest">
|
||||
<link data-trunk rel="copy-file" href="sw.js">
|
||||
<!-- Bootstrap 5.3.3 (MIT), vendored so builds work offline. -->
|
||||
<link data-trunk rel="css" href="vendor/bootstrap.min.css">
|
||||
<link data-trunk rel="css" href="theme.css">
|
||||
<link data-trunk rel="css" href="style.css">
|
||||
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||
<!-- Card images (CC0 woodcut napoletane deck) and the PWA icons
|
||||
(generated by make-icons.sh) copied verbatim into dist. -->
|
||||
<link data-trunk rel="copy-dir" href="assets">
|
||||
</head>
|
||||
<body></body>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Regenerate the PWA icon PNGs from the vector sources in assets/icons/.
|
||||
# The PNGs are committed (like the card art) because the Docker builder has
|
||||
# no SVG rasterizer; run this only after editing icon.svg or
|
||||
# icon-maskable.svg. Requires rsvg-convert (librsvg).
|
||||
set -eu
|
||||
|
||||
ICONS="$(dirname "$0")/assets/icons"
|
||||
|
||||
rsvg-convert -w 192 -h 192 "$ICONS/icon.svg" -o "$ICONS/icon-192.png"
|
||||
rsvg-convert -w 512 -h 512 "$ICONS/icon.svg" -o "$ICONS/icon-512.png"
|
||||
rsvg-convert -w 512 -h 512 "$ICONS/icon-maskable.svg" -o "$ICONS/icon-maskable-512.png"
|
||||
# iOS masks the home-screen icon itself; a full-bleed square is expected.
|
||||
rsvg-convert -w 180 -h 180 "$ICONS/icon-maskable.svg" -o "$ICONS/apple-touch-icon.png"
|
||||
|
||||
echo "icons ready in $ICONS"
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Tavolo",
|
||||
"short_name": "Tavolo",
|
||||
"description": "Multiplayer card games: scopone, scopa and briscola.",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#14341f",
|
||||
"theme_color": "#14341f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +1,32 @@
|
||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||
//! session cookie automatically.
|
||||
use crate::auth;
|
||||
use crate::model::*;
|
||||
use gloo_net::http::Request;
|
||||
use gloo_net::http::{Request, Response};
|
||||
|
||||
fn server_error(status: u16) -> String {
|
||||
format!("server returned {status}")
|
||||
}
|
||||
|
||||
/// Map a failed response to an error. A `401` additionally starts the
|
||||
/// login flow: the session is gone (or the caller was never logged in),
|
||||
/// and the user is returned to this page after logging in.
|
||||
fn check(resp: &Response) -> Result<(), String> {
|
||||
if resp.status() == 401 {
|
||||
auth::redirect_to_login();
|
||||
return Err("login required".to_string());
|
||||
}
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the current user; `None` when unauthenticated (401).
|
||||
///
|
||||
/// Deliberately bypasses [`check`]: an unauthenticated answer is how the
|
||||
/// app renders its logged-out state, so it must not trigger the login
|
||||
/// redirect.
|
||||
pub async fn me() -> Result<Option<User>, String> {
|
||||
let resp = Request::get("/api/me")
|
||||
.send()
|
||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.providers)
|
||||
}
|
||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.results)
|
||||
}
|
||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 401 {
|
||||
auth::redirect_to_login();
|
||||
return Err("login required".to_string());
|
||||
}
|
||||
if !resp.ok() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let message = body
|
||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
||||
if resp.status() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -128,15 +143,25 @@ pub async fn probe_game(game_id: &str) -> GameEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the live games the caller is seated in, for the lobby's
|
||||
/// "Continue" list.
|
||||
pub async fn my_games() -> Result<Vec<MyGame>, String> {
|
||||
let resp = Request::get("/api/me/games")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
check(&resp)?;
|
||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.results)
|
||||
}
|
||||
|
||||
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||
let url = match cursor {
|
||||
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||
None => "/api/me/matches?limit=10".to_string(),
|
||||
};
|
||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -146,19 +171,28 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
||||
let resp = Request::get("/api/leaderboard")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
/// Fetch one page of the leaderboard for a game. With `focus_me` the
|
||||
/// server returns the page holding the caller's own ranking (page 1 for
|
||||
/// anonymous or unrated callers).
|
||||
pub async fn leaderboard(
|
||||
game_type: &str,
|
||||
page: Option<u32>,
|
||||
focus_me: bool,
|
||||
) -> Result<LeaderboardPage, String> {
|
||||
let mut url = format!(
|
||||
"/api/leaderboard?game_type={game_type}&limit={LEADERBOARD_PAGE_SIZE}"
|
||||
);
|
||||
if focus_me {
|
||||
url.push_str("&focus=me");
|
||||
}
|
||||
if let Some(page) = page {
|
||||
url.push_str(&format!("&page={page}"));
|
||||
}
|
||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Client-side login redirect handling.
|
||||
//!
|
||||
//! The OIDC callback always returns the browser to the configured
|
||||
//! post-login redirect (usually `/`), and the backend's login flow has no
|
||||
//! per-request `next` support. So when the API answers `401`, the page the
|
||||
//! user is on is saved in `sessionStorage` and restored once a session
|
||||
//! exists again.
|
||||
use sycamore_router::navigate_replace;
|
||||
|
||||
/// `sessionStorage` key holding the page to return to after login.
|
||||
const RETURN_TO_KEY: &str = "tavolo.login.return_to";
|
||||
|
||||
fn session_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.session_storage().ok().flatten()
|
||||
}
|
||||
|
||||
/// The current page (path plus query), the URL to come back to after
|
||||
/// login.
|
||||
pub fn current_path() -> String {
|
||||
let Some(location) = web_sys::window().map(|w| w.location()) else {
|
||||
return String::new();
|
||||
};
|
||||
format!(
|
||||
"{}{}",
|
||||
location.pathname().unwrap_or_default(),
|
||||
location.search().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a path is a page worth returning to: not the login flow
|
||||
/// itself, and not an absolute/protocol-relative URL.
|
||||
fn is_restorable(path: &str) -> bool {
|
||||
path.starts_with('/')
|
||||
&& !path.starts_with("//")
|
||||
&& !path.starts_with("/auth/")
|
||||
&& !path.starts_with("/api/")
|
||||
&& path != "/login"
|
||||
}
|
||||
|
||||
/// Handle an expired session: remember the current page and go to the
|
||||
/// login page.
|
||||
///
|
||||
/// The navigation is deferred to a timer so the caller can finish handling
|
||||
/// the failed response (e.g. set an error) before its page is unmounted.
|
||||
pub fn redirect_to_login() {
|
||||
let path = current_path();
|
||||
if is_restorable(&path) {
|
||||
if let Some(storage) = session_storage() {
|
||||
let _ = storage.set_item(RETURN_TO_KEY, &path);
|
||||
}
|
||||
}
|
||||
gloo_timers::callback::Timeout::new(0, move || {
|
||||
// Several in-flight requests may fail at once; only the first
|
||||
// takes us to the login page.
|
||||
if current_path() != "/login" {
|
||||
navigate_replace("/login");
|
||||
}
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
/// The page saved by [`redirect_to_login`], if any.
|
||||
pub fn saved_return_to() -> Option<String> {
|
||||
let storage = session_storage()?;
|
||||
let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?;
|
||||
is_restorable(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// Drop the saved page, e.g. after it has been restored.
|
||||
pub fn clear_return_to() {
|
||||
if let Some(storage) = session_storage() {
|
||||
let _ = storage.remove_item(RETURN_TO_KEY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! User avatar: the provider's `picture` claim when available, a
|
||||
//! deterministic identicon generated from the canonical user principal
|
||||
//! otherwise.
|
||||
//!
|
||||
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
|
||||
//! FNV-1a hash. The hash is implemented here (instead of
|
||||
//! `DefaultHasher`) so the picture is stable across Rust versions and
|
||||
//! the helper functions stay unit-testable on the host, with no DOM.
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
||||
/// FNV-1a 64-bit: a stable hash for identicon seeds.
|
||||
fn fnv1a(input: &str) -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in input.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// The identicon hue (0..360) for a seed.
|
||||
fn hue_for(seed: &str) -> u32 {
|
||||
(fnv1a(seed) % 360) as u32
|
||||
}
|
||||
|
||||
/// The identicon cell grid as 5 columns x 5 rows. Only 15 bits (3
|
||||
/// columns) are derived from the hash; the left half is mirrored onto
|
||||
/// the right so the icon is symmetric. The center cell is forced on so
|
||||
/// an unlucky hash can never produce a blank icon.
|
||||
fn cells_for(seed: &str) -> [[bool; 5]; 5] {
|
||||
let hash = fnv1a(seed);
|
||||
let mut half = [false; 15];
|
||||
for (index, cell) in half.iter_mut().enumerate() {
|
||||
*cell = (hash >> index) & 1 == 1;
|
||||
}
|
||||
if !half.iter().any(|set| *set) {
|
||||
half[8] = true; // center of the half grid: row 2, column 2
|
||||
}
|
||||
let mut cells = [[false; 5]; 5];
|
||||
for row in 0..5 {
|
||||
for column in 0..3 {
|
||||
let set = half[row * 3 + column];
|
||||
cells[row][column] = set;
|
||||
if column < 2 {
|
||||
cells[row][4 - column] = set;
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// Only absolute http(s) picture URLs are rendered. The backend filters
|
||||
/// the claim the same way; this is the client-side half of the rule.
|
||||
fn safe_picture_url(url: &str) -> Option<String> {
|
||||
let trimmed = url.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
Some(trimmed.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's avatar: their profile picture, or an identicon generated
|
||||
/// from the canonical principal. A picture that fails to load falls back to
|
||||
/// the identicon as well.
|
||||
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
|
||||
let Some(url) = picture.and_then(safe_picture_url) else {
|
||||
return identicon(sub);
|
||||
};
|
||||
let seed = sub.to_string();
|
||||
let failed = create_signal(false);
|
||||
view! {
|
||||
(move || {
|
||||
if failed.get() {
|
||||
identicon(&seed)
|
||||
} else {
|
||||
let src = url.clone();
|
||||
view! {
|
||||
img(class="avatar", src=src, alt="",
|
||||
on:error=move |_| failed.set(true))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The generated fallback avatar.
|
||||
fn identicon(seed: &str) -> View {
|
||||
let hue = hue_for(seed);
|
||||
let background = format!("hsl({hue}, 45%, 22%)");
|
||||
let foreground = format!("hsl({hue}, 65%, 62%)");
|
||||
let cells = cells_for(seed);
|
||||
let mut squares: Vec<View> = Vec::new();
|
||||
for (row, line) in cells.iter().enumerate() {
|
||||
for (column, set) in line.iter().enumerate() {
|
||||
if *set {
|
||||
let x = column.to_string();
|
||||
let y = row.to_string();
|
||||
let fill = foreground.clone();
|
||||
squares.push(view! {
|
||||
rect(x=x, y=y, width="1", height="1", fill=fill)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
view! {
|
||||
svg(class="avatar", viewBox="0 0 5 5", "aria-hidden"="true") {
|
||||
rect(x="0", y="0", width="5", height="5", fill=background)
|
||||
(squares)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_is_deterministic_and_seed_dependent() {
|
||||
assert_eq!(fnv1a("alice"), fnv1a("alice"));
|
||||
assert_ne!(fnv1a("alice"), fnv1a("bob"));
|
||||
// Pinned vectors: guard against accidental changes to the hash
|
||||
// (it must stay stable for the identicons users see).
|
||||
assert_eq!(149_266_183, fnv1a("alice") % 1_000_000_000);
|
||||
assert_eq!(23, hue_for("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_stays_in_range() {
|
||||
for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] {
|
||||
assert!(hue_for(seed) < 360);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_is_symmetric_and_never_blank() {
|
||||
for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] {
|
||||
let cells = cells_for(seed);
|
||||
assert!(
|
||||
cells.iter().flatten().any(|cell| *cell),
|
||||
"blank for {seed:?}"
|
||||
);
|
||||
for row in 0..5 {
|
||||
for column in 0..5 {
|
||||
assert_eq!(
|
||||
cells[row][column],
|
||||
cells[row][4 - column],
|
||||
"asymmetric for {seed:?} at ({row}, {column})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_draw_different_patterns() {
|
||||
assert_ne!(cells_for("alice"), cells_for("bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_absolute_http_picture_urls_are_rendered() {
|
||||
assert_eq!(
|
||||
Some("https://cdn.example.com/a.png".to_string()),
|
||||
safe_picture_url("https://cdn.example.com/a.png")
|
||||
);
|
||||
assert_eq!(
|
||||
Some("https://cdn.example.com/a.png".to_string()),
|
||||
safe_picture_url(" https://cdn.example.com/a.png ")
|
||||
);
|
||||
assert_eq!(
|
||||
Some("HTTPS://cdn.example.com/a.png".to_string()),
|
||||
safe_picture_url("HTTPS://cdn.example.com/a.png")
|
||||
);
|
||||
assert_eq!(None, safe_picture_url("httpz://x"));
|
||||
assert_eq!(None, safe_picture_url("/relative.png"));
|
||||
assert_eq!(None, safe_picture_url("javascript:alert(1)"));
|
||||
assert_eq!(
|
||||
None,
|
||||
safe_picture_url("data:image/svg+xml;base64,PHN2ZyB4bW")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,25 @@
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::model::{DealSummary, GameView};
|
||||
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
/// Names of the two sides: the caller and partner for briscola chiamata,
|
||||
/// the players' names for games without partnerships (classic two-player
|
||||
/// briscola), "Team A"/"Team B" otherwise.
|
||||
fn side_names(game: &GameView) -> (String, String) {
|
||||
if is_chiamata(&game.game_type) {
|
||||
game.chiamata_sides()
|
||||
} else if is_individual_game(&game.game_type) {
|
||||
(
|
||||
game.player_name_for_team("A"),
|
||||
game.player_name_for_team("B"),
|
||||
)
|
||||
} else {
|
||||
("Team A".to_string(), "Team B".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
||||
fn summary_row(
|
||||
icon: View,
|
||||
@@ -31,9 +47,10 @@ fn summary_row(
|
||||
}
|
||||
|
||||
/// The scoring rows of one completed deal (also used by the final screen).
|
||||
pub fn deal_rows(summary: &DealSummary) -> View {
|
||||
pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
let a = summary.points.a;
|
||||
let b = summary.points.b;
|
||||
let (name_a, name_b) = side_names(game);
|
||||
let cls = if summary.patta {
|
||||
"score-row tie"
|
||||
} else if a > b {
|
||||
@@ -51,10 +68,29 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
||||
"60–60: patta — the deal is replayed and its points do not count."
|
||||
}
|
||||
});
|
||||
let called_row = if is_chiamata(&game.game_type) {
|
||||
game.called_card.clone().map(|code| {
|
||||
let label = card_label(&code);
|
||||
let text = if game.solo {
|
||||
format!("{name_a} called {label} in hand and plays alone.")
|
||||
} else {
|
||||
format!("{label}: its holder is the secret partner; its suit is trump.")
|
||||
};
|
||||
summary_row(
|
||||
card_img(code, "score-mini"),
|
||||
"Chiamata",
|
||||
text,
|
||||
"score-row chiamata-row",
|
||||
label,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let points_row = summary_row(
|
||||
card_img("01D".to_string(), "score-mini"),
|
||||
"Deal points",
|
||||
format!("Team A {a} · Team B {b}"),
|
||||
format!("{name_a} {a} · {name_b} {b}"),
|
||||
cls,
|
||||
chip,
|
||||
);
|
||||
@@ -62,13 +98,14 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
||||
summary_row(
|
||||
card_back("score-mini"),
|
||||
"Cards captured",
|
||||
format!("Team A {} · Team B {}", c.a, c.b),
|
||||
format!("{name_a} {} · {name_b} {}", c.a, c.b),
|
||||
"score-row",
|
||||
format!("{} · {}", c.a, c.b),
|
||||
)
|
||||
});
|
||||
view! {
|
||||
div(class="score-rows") {
|
||||
(called_row)
|
||||
(points_row)
|
||||
(patta_note)
|
||||
(cards)
|
||||
@@ -79,33 +116,38 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
||||
/// Running totals with progress toward the target, including the points
|
||||
/// gained in the deal just played (zero for a patta).
|
||||
fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
// Briscola chiamata plays a single deal: there is no running total.
|
||||
let Some(running) = summary.totals else {
|
||||
return view! {};
|
||||
};
|
||||
let target = game.target_score.max(1);
|
||||
let pct_a = (100 * summary.totals.a / target).min(100);
|
||||
let pct_b = (100 * summary.totals.b / target).min(100);
|
||||
let pct_a = (100 * running.a / target).min(100);
|
||||
let pct_b = (100 * running.b / target).min(100);
|
||||
let (gained_a, gained_b) = if summary.patta {
|
||||
(0, 0)
|
||||
} else {
|
||||
(summary.points.a, summary.points.b)
|
||||
};
|
||||
let (name_a, name_b) = side_names(game);
|
||||
view! {
|
||||
div(class="totals") {
|
||||
div(class="total-row team-a") {
|
||||
span(class="total-label") { "Team A" }
|
||||
span(class="total-label") { (name_a) }
|
||||
div(class="progress") {
|
||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(summary.totals.a) " / " (target)
|
||||
(running.a) " / " (target)
|
||||
span(class="gained") { "+" (gained_a) }
|
||||
}
|
||||
}
|
||||
div(class="total-row team-b") {
|
||||
span(class="total-label") { "Team B" }
|
||||
span(class="total-label") { (name_b) }
|
||||
div(class="progress") {
|
||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(summary.totals.b) " / " (target)
|
||||
(running.b) " / " (target)
|
||||
span(class="gained") { "+" (gained_b) }
|
||||
}
|
||||
}
|
||||
@@ -151,7 +193,7 @@ pub fn deal_summary_modal(
|
||||
});
|
||||
|
||||
let title = format!("Deal {} — results", summary.deal);
|
||||
let rows = deal_rows(&summary);
|
||||
let rows = deal_rows(&game, &summary);
|
||||
let totals_view = totals(&game, &summary);
|
||||
|
||||
let action = if game.spectator {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Login card, shared by the lobby's logged-out state and the `/login`
|
||||
//! page.
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::model::AuthProvider;
|
||||
|
||||
/// Human-readable label for a provider name ("google" -> "Google").
|
||||
fn provider_label(name: &str) -> String {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Card offering one login button per configured provider.
|
||||
///
|
||||
/// `providers` is filled by the caller from `GET /api/auth/providers`;
|
||||
/// while it is empty (or the fetch failed) the card falls back to the
|
||||
/// generic `/auth/login` route, which redirects to the first configured
|
||||
/// provider.
|
||||
pub fn login_card(providers: Signal<Vec<AuthProvider>>) -> View {
|
||||
view! {
|
||||
div(class="status-card") {
|
||||
div(class="card") {
|
||||
div(class="card-body p-4") {
|
||||
h1(class="h3 mb-3") {
|
||||
span(class="text-warning") { "♠" }
|
||||
" tavolo"
|
||||
}
|
||||
p(class="text-secondary") { "Log in with your account to play." }
|
||||
// rel="external" opts out of the SPA router's click
|
||||
// interception: these must be full page navigations
|
||||
// to the backend's OIDC login endpoints.
|
||||
(move || {
|
||||
let providers = providers.get_clone();
|
||||
if providers.is_empty() {
|
||||
// Providers could not be fetched: the generic
|
||||
// route redirects to the first configured
|
||||
// provider.
|
||||
view! {
|
||||
a(
|
||||
class="btn btn-warning btn-lg w-100",
|
||||
href="/auth/login",
|
||||
rel="external",
|
||||
) {
|
||||
"Log in"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
div(class="d-grid gap-2") {
|
||||
(providers.iter().cloned().map(|p| view! {
|
||||
a(
|
||||
class="btn btn-warning btn-lg w-100",
|
||||
href=p.login_url,
|
||||
rel="external",
|
||||
) {
|
||||
"Log in with " (provider_label(&p.name))
|
||||
}
|
||||
}).collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod avatar;
|
||||
pub mod briscola;
|
||||
pub mod card;
|
||||
pub mod copy;
|
||||
pub mod login;
|
||||
pub mod nav;
|
||||
pub mod notify;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! HUD bell for desktop notifications about the other players' moves.
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::notify::{self, Permission};
|
||||
|
||||
/// Notification state shared by the game page and its table views: the
|
||||
/// opt-in flag and the browser permission, both page-scoped so a
|
||||
/// permission prompt that resolves after a table re-render still lands
|
||||
/// somewhere safe. `alive` mirrors the game page's unmount flag.
|
||||
#[derive(Clone)]
|
||||
pub struct NotifyCtx {
|
||||
pub enabled: Signal<bool>,
|
||||
pub permission: Signal<Permission>,
|
||||
pub alive: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
/// The caption for the current (enabled, permission) state, and whether
|
||||
/// the button is a dead end (the user must change browser settings).
|
||||
fn face(enabled: bool, permission: Permission) -> (&'static str, bool) {
|
||||
match (enabled, permission) {
|
||||
(true, _) => ("🔔 Notifications on", false),
|
||||
(false, Permission::Granted | Permission::Default) => ("🔔 Notify me", false),
|
||||
(false, Permission::Denied | Permission::Unsupported) => {
|
||||
("🔔 Notifications blocked", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The move-notification bell, rendered in the HUD of seated players.
|
||||
///
|
||||
/// The preference is global (all games) and persisted per device. The
|
||||
/// permission prompt fires on the first click: browsers require a user
|
||||
/// gesture, and asking on page load would be ignored.
|
||||
pub fn notify_toggle(ctx: NotifyCtx) -> View {
|
||||
let NotifyCtx { enabled, permission, alive } = ctx;
|
||||
let on_click = move |_| {
|
||||
if enabled.get() {
|
||||
enabled.set(false);
|
||||
notify::set_enabled(false);
|
||||
return;
|
||||
}
|
||||
match permission.get() {
|
||||
Permission::Default => {
|
||||
let alive = alive.clone();
|
||||
spawn_local(async move {
|
||||
let granted = notify::request_permission().await;
|
||||
if !alive.get() {
|
||||
return;
|
||||
}
|
||||
permission.set(granted);
|
||||
if granted == Permission::Granted {
|
||||
enabled.set(true);
|
||||
notify::set_enabled(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
Permission::Granted => {
|
||||
enabled.set(true);
|
||||
notify::set_enabled(true);
|
||||
}
|
||||
Permission::Denied | Permission::Unsupported => {}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
button(
|
||||
class="btn btn-outline-light",
|
||||
disabled=move || face(enabled.get(), permission.get()).1,
|
||||
title="Desktop notification when another player plays a card, \
|
||||
while this tab is in the background",
|
||||
on:click=on_click,
|
||||
) {
|
||||
(move || face(enabled.get(), permission.get()).0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,56 @@
|
||||
//! language, how each scoring category played out and how the running
|
||||
//! totals moved toward the target. Every player must acknowledge it before
|
||||
//! the next hand is dealt (or the server-side timeout deals anyway).
|
||||
//!
|
||||
//! The sides are named from the game: the players' names for individual
|
||||
//! games (classic two-player scopa), "Team A"/"Team B" for partnership
|
||||
//! games (scopone scientifico, scopa a quattro).
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::model::{GameView, HandSummary, Scores};
|
||||
use crate::model::{is_individual_game, GameView, HandSummary, Scores};
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
/// Names of the two sides: the players' names for games without
|
||||
/// partnerships, "Team A"/"Team B" otherwise.
|
||||
fn side_names(game: &GameView) -> (String, String) {
|
||||
if is_individual_game(&game.game_type) {
|
||||
(
|
||||
game.player_name_for_team("A"),
|
||||
game.player_name_for_team("B"),
|
||||
)
|
||||
} else {
|
||||
("Team A".to_string(), "Team B".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The display label for the side that won a category ("A"/"B").
|
||||
fn side_label(game: &GameView, team: &str) -> String {
|
||||
let (a, b) = side_names(game);
|
||||
match team {
|
||||
"A" => a,
|
||||
"B" => b,
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
||||
/// `team` is the winning side's letter, or `None` for a tie.
|
||||
fn award_row(
|
||||
icon: View,
|
||||
title: &'static str,
|
||||
text: String,
|
||||
winner: Option<String>,
|
||||
team: Option<&str>,
|
||||
name: &str,
|
||||
points: &'static str,
|
||||
) -> View {
|
||||
let cls = match winner.as_deref() {
|
||||
let cls = match team {
|
||||
Some("A") => "score-row team-a",
|
||||
Some("B") => "score-row team-b",
|
||||
_ => "score-row tie",
|
||||
};
|
||||
let chip = match &winner {
|
||||
Some(t) => format!("Team {t} {points}"),
|
||||
let chip = match team {
|
||||
Some(_) => format!("{name} {points}"),
|
||||
None => "tie".to_string(),
|
||||
};
|
||||
view! {
|
||||
@@ -48,69 +77,109 @@ fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
|
||||
}
|
||||
|
||||
/// The five scoring rows of a completed hand.
|
||||
pub fn summary_rows(summary: HandSummary) -> View {
|
||||
pub fn summary_rows(game: &GameView, summary: HandSummary) -> View {
|
||||
// Carte
|
||||
let carte = summary.award.carte.clone();
|
||||
let carte_name = carte
|
||||
.as_deref()
|
||||
.map(|t| side_label(game, t))
|
||||
.unwrap_or_default();
|
||||
let carte_text = match &carte {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
|
||||
format!(
|
||||
"{} captured more cards ({w} vs {l})",
|
||||
side_label(game, t)
|
||||
)
|
||||
}
|
||||
None => format!(
|
||||
"Both sides captured {} cards — no point",
|
||||
summary.cards.a
|
||||
),
|
||||
};
|
||||
// Denara
|
||||
let denara = summary.award.denara.clone();
|
||||
let denara_name = denara
|
||||
.as_deref()
|
||||
.map(|t| side_label(game, t))
|
||||
.unwrap_or_default();
|
||||
let denara_text = match &denara {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
|
||||
format!(
|
||||
"{} collected more denari cards ({w} vs {l})",
|
||||
side_label(game, t)
|
||||
)
|
||||
}
|
||||
None => format!(
|
||||
"Both sides collected {} denari cards — no point",
|
||||
summary.denara.a
|
||||
),
|
||||
};
|
||||
// Settebello
|
||||
let settebello = summary.award.settebello.clone();
|
||||
let settebello_name = settebello
|
||||
.as_deref()
|
||||
.map(|t| side_label(game, t))
|
||||
.unwrap_or_default();
|
||||
let settebello_text = match &settebello {
|
||||
Some(t) => format!(
|
||||
"{} captured the 7 of denari — the Settebello",
|
||||
side_label(game, t)
|
||||
),
|
||||
None => "Nobody captured the Settebello".to_string(),
|
||||
};
|
||||
// Primiera
|
||||
let primiera = summary.award.primiera.clone();
|
||||
let primiera_name = primiera
|
||||
.as_deref()
|
||||
.map(|t| side_label(game, t))
|
||||
.unwrap_or_default();
|
||||
let primiera_text = match &primiera {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.primiera.a, summary.primiera.b, Some(t));
|
||||
format!(
|
||||
"{} holds the strongest primiera ({w} vs {l})",
|
||||
side_label(game, t)
|
||||
)
|
||||
}
|
||||
None => format!(
|
||||
"Both primiere are worth {} — no point",
|
||||
summary.primiera.a
|
||||
),
|
||||
};
|
||||
|
||||
let rows: Vec<View> = vec![
|
||||
// Carte
|
||||
award_row(
|
||||
card_back("score-mini"),
|
||||
"Carte",
|
||||
match &summary.award.carte {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
|
||||
format!("Team {t} captured more cards ({w} vs {l})")
|
||||
}
|
||||
None => format!(
|
||||
"Both teams captured {} cards — no point",
|
||||
summary.cards.a
|
||||
),
|
||||
},
|
||||
summary.award.carte.clone(),
|
||||
carte_text,
|
||||
carte.as_deref(),
|
||||
&carte_name,
|
||||
"+1",
|
||||
),
|
||||
// Denara
|
||||
award_row(
|
||||
card_img("02D".to_string(), "score-mini"),
|
||||
"Denari",
|
||||
match &summary.award.denara {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
|
||||
format!("Team {t} collected more denari cards ({w} vs {l})")
|
||||
}
|
||||
None => format!(
|
||||
"Both teams collected {} denari cards — no point",
|
||||
summary.denara.a
|
||||
),
|
||||
},
|
||||
summary.award.denara.clone(),
|
||||
"Denara",
|
||||
denara_text,
|
||||
denara.as_deref(),
|
||||
&denara_name,
|
||||
"+1",
|
||||
),
|
||||
// Settebello
|
||||
award_row(
|
||||
card_img("07D".to_string(), "score-mini"),
|
||||
"Settebello",
|
||||
match &summary.award.settebello {
|
||||
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
|
||||
None => "Nobody captured the Settebello".to_string(),
|
||||
},
|
||||
summary.award.settebello.clone(),
|
||||
settebello_text,
|
||||
settebello.as_deref(),
|
||||
&settebello_name,
|
||||
"+1",
|
||||
),
|
||||
// Primiera
|
||||
award_row(
|
||||
card_img("10D".to_string(), "score-mini"),
|
||||
"Primiera",
|
||||
match &summary.award.primiera {
|
||||
Some(t) => {
|
||||
let (w, l) =
|
||||
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
|
||||
format!("Team {t} holds the strongest primiera ({w} vs {l})")
|
||||
}
|
||||
None => format!(
|
||||
"Both primiere are worth {} — no point",
|
||||
summary.primiera.a
|
||||
),
|
||||
},
|
||||
summary.award.primiera.clone(),
|
||||
primiera_text,
|
||||
primiera.as_deref(),
|
||||
&primiera_name,
|
||||
"+1",
|
||||
),
|
||||
// Napola (only when the rule is enabled for this match)
|
||||
@@ -124,15 +193,17 @@ pub fn summary_rows(summary: HandSummary) -> View {
|
||||
};
|
||||
let text = match (&summary.award.napola, n) {
|
||||
(Some(t), 10) => format!(
|
||||
"Team {t} swept the whole denari suit — napola! Instant match win"
|
||||
"{} swept the whole denari suit — napola! Instant match win",
|
||||
side_label(game, t)
|
||||
),
|
||||
(Some(t), n) => format!(
|
||||
"Team {t} captured {n} consecutive denari from the ace"
|
||||
"{} captured {n} consecutive denari from the ace",
|
||||
side_label(game, t)
|
||||
),
|
||||
(None, _) => "No napola this hand".to_string(),
|
||||
};
|
||||
let chip = match &summary.award.napola {
|
||||
Some(t) => format!("Team {t} +{n}"),
|
||||
Some(t) => format!("{} +{n}", side_label(game, t)),
|
||||
None => "tie".to_string(),
|
||||
};
|
||||
let cls = match summary.award.napola.as_deref() {
|
||||
@@ -154,12 +225,13 @@ pub fn summary_rows(summary: HandSummary) -> View {
|
||||
},
|
||||
// Scope
|
||||
{
|
||||
let (name_a, name_b) = side_names(game);
|
||||
let a = summary.scope.a;
|
||||
let b = summary.scope.b;
|
||||
let text = if a == 0 && b == 0 {
|
||||
"No scope this hand".to_string()
|
||||
} else {
|
||||
format!("Team A made {a} scope · Team B made {b} scope")
|
||||
format!("{name_a} made {a} scope · {name_b} made {b} scope")
|
||||
};
|
||||
let chip = format!("+{a} · +{b}");
|
||||
view! {
|
||||
@@ -193,10 +265,11 @@ pub fn totals(game: &GameView) -> View {
|
||||
.as_ref()
|
||||
.map(|s| (s.team_a_points, s.team_b_points))
|
||||
.unwrap_or((0, 0));
|
||||
let (name_a, name_b) = side_names(game);
|
||||
view! {
|
||||
div(class="totals") {
|
||||
div(class="total-row team-a") {
|
||||
span(class="total-label") { "Team A" }
|
||||
span(class="total-label") { (name_a) }
|
||||
div(class="progress") {
|
||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||
}
|
||||
@@ -206,7 +279,7 @@ pub fn totals(game: &GameView) -> View {
|
||||
}
|
||||
}
|
||||
div(class="total-row team-b") {
|
||||
span(class="total-label") { "Team B" }
|
||||
span(class="total-label") { (name_b) }
|
||||
div(class="progress") {
|
||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||
}
|
||||
@@ -256,7 +329,7 @@ pub fn hand_summary_modal(
|
||||
}
|
||||
});
|
||||
|
||||
let rows = summary_rows(summary.clone());
|
||||
let rows = summary_rows(&game, summary.clone());
|
||||
let totals_view = totals(&game);
|
||||
let title = format!("Hand {} — results", summary.hand);
|
||||
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
//! Application entry point.
|
||||
mod api;
|
||||
mod auth;
|
||||
mod components;
|
||||
mod model;
|
||||
mod notify;
|
||||
mod pages;
|
||||
mod pwa;
|
||||
mod ws;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::{HistoryIntegration, Route, Router};
|
||||
use sycamore_router::{navigate, HistoryIntegration, Route, Router};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use pages::game::GamePage;
|
||||
use pages::history::HistoryPage;
|
||||
use pages::leaderboard::LeaderboardPage;
|
||||
use pages::lobby::LobbyPage;
|
||||
use pages::login::LoginPage;
|
||||
|
||||
#[derive(Route, Clone)]
|
||||
enum AppRoutes {
|
||||
#[to("/")]
|
||||
Lobby,
|
||||
#[to("/login")]
|
||||
Login,
|
||||
#[to("/game/<id>")]
|
||||
Game { id: String },
|
||||
#[to("/history")]
|
||||
@@ -27,6 +34,31 @@ enum AppRoutes {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// After the OIDC callback lands the SPA back on the configured
|
||||
/// post-login redirect, forward the user to the page they were on before
|
||||
/// being sent to the login page (see [`auth`]) — but only once a session
|
||||
/// exists, so a cancelled login never bounces into a stale page.
|
||||
fn restore_page_after_login() {
|
||||
let Some(path) = auth::saved_return_to() else {
|
||||
return;
|
||||
};
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
Ok(Some(_)) => {
|
||||
auth::clear_return_to();
|
||||
if auth::current_path() != path {
|
||||
navigate(&path);
|
||||
}
|
||||
}
|
||||
// Not logged in: keep the target for the next successful
|
||||
// login (a reload of the login page must not lose it).
|
||||
Ok(None) => {}
|
||||
// The probe failed (offline?): keep the target, too.
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
sycamore::render(|| {
|
||||
@@ -38,6 +70,7 @@ fn main() {
|
||||
div(class="app") {
|
||||
(match route.get_clone() {
|
||||
AppRoutes::Lobby => view! { LobbyPage() },
|
||||
AppRoutes::Login => view! { LoginPage() },
|
||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||
AppRoutes::History => view! { HistoryPage() },
|
||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||
@@ -60,4 +93,6 @@ fn main() {
|
||||
)
|
||||
}
|
||||
});
|
||||
pwa::register();
|
||||
restore_page_after_login();
|
||||
}
|
||||
|
||||
@@ -9,17 +9,24 @@ fn default_true() -> bool {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct User {
|
||||
/// Canonical provider-qualified principal (`oidc:v1:<provider>:<sub>`).
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub picture: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PlayerView {
|
||||
/// Canonical provider-qualified principal.
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
/// Game-defined role/team label; empty before briscola chiamata
|
||||
/// reveals the roles.
|
||||
#[serde(default)]
|
||||
pub team: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cards_left: usize,
|
||||
#[serde(default)]
|
||||
@@ -84,9 +91,21 @@ pub struct DealSummary {
|
||||
#[serde(default)]
|
||||
pub patta: bool,
|
||||
pub points: Scores,
|
||||
pub totals: Scores,
|
||||
/// Running match totals; absent for briscola chiamata, whose match is
|
||||
/// a single deal.
|
||||
#[serde(default)]
|
||||
pub totals: Option<Scores>,
|
||||
#[serde(default)]
|
||||
pub cards: Option<TeamCounts>,
|
||||
/// Briscola chiamata: the points target of the played deal.
|
||||
#[serde(default)]
|
||||
pub declared_target: Option<i32>,
|
||||
/// Briscola chiamata: the card named by the caller.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: the caller played without a partner.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
@@ -178,6 +197,9 @@ pub struct GameView {
|
||||
pub winner: Option<String>,
|
||||
#[serde(default)]
|
||||
pub table: Vec<String>,
|
||||
/// Scopa: face-down cards left in the deck for the current hand.
|
||||
#[serde(default)]
|
||||
pub deck_count: usize,
|
||||
#[serde(default)]
|
||||
pub players: Vec<PlayerView>,
|
||||
#[serde(default)]
|
||||
@@ -227,6 +249,123 @@ pub struct GameView {
|
||||
/// Briscola: scoring breakdown of the most recent deal.
|
||||
#[serde(default)]
|
||||
pub last_deal: Option<DealSummary>,
|
||||
/// Briscola chiamata: the calling auction of the current deal.
|
||||
#[serde(default)]
|
||||
pub auction: Option<AuctionView>,
|
||||
/// Briscola chiamata: seat that won the auction.
|
||||
#[serde(default)]
|
||||
pub caller: Option<usize>,
|
||||
/// Briscola chiamata: the named card; its suit is the trump.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: points the calling side must reach.
|
||||
#[serde(default)]
|
||||
pub declared_target: i32,
|
||||
/// Briscola chiamata: the called card has been played; roles are
|
||||
/// public from then on.
|
||||
#[serde(default)]
|
||||
pub called_played: bool,
|
||||
/// Briscola chiamata: the partner's seat, once revealed.
|
||||
#[serde(default)]
|
||||
pub partner: Option<usize>,
|
||||
/// Briscola chiamata: the caller named one of their own cards.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
/// Briscola chiamata: legal bids for the player on turn.
|
||||
#[serde(default)]
|
||||
pub legal_bids: Option<LegalBids>,
|
||||
/// Briscola chiamata: the cards the auction winner may name.
|
||||
#[serde(default)]
|
||||
pub callable_cards: Option<Vec<String>>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
|
||||
#[serde(default)]
|
||||
pub auction_deadline: Option<String>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
|
||||
#[serde(default)]
|
||||
pub call_deadline: Option<String>,
|
||||
}
|
||||
|
||||
/// One bid of the briscola chiamata auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionBid {
|
||||
pub seat: usize,
|
||||
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
|
||||
pub rank: i32,
|
||||
/// Points target the bid commits its side to.
|
||||
#[serde(default)]
|
||||
pub points: i32,
|
||||
}
|
||||
|
||||
/// The briscola chiamata calling auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionView {
|
||||
/// Seat to act.
|
||||
pub turn: usize,
|
||||
#[serde(default)]
|
||||
pub active: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub passed: Vec<usize>,
|
||||
/// The standing bid, if any.
|
||||
#[serde(default)]
|
||||
pub current: Option<AuctionBid>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<AuctionBid>,
|
||||
}
|
||||
|
||||
/// The bids available to the player on turn.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LegalBids {
|
||||
/// Ranks that may be bid, strongest first (empty at the point stage).
|
||||
#[serde(default)]
|
||||
pub ranks: Vec<i32>,
|
||||
/// Lowest legal points raise, when the standing bid is the two.
|
||||
#[serde(default)]
|
||||
pub points_min: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub points_max: Option<i32>,
|
||||
}
|
||||
|
||||
impl GameView {
|
||||
/// Display name of the player on team `team` ("A"/"B"), for games
|
||||
/// without partnerships; falls back to the team label itself.
|
||||
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.team.as_deref() == Some(team))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| team.to_string())
|
||||
}
|
||||
|
||||
/// Display name of the player in `seat`.
|
||||
pub fn player_name_for_seat(&self, seat: usize) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.seat == seat)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||
}
|
||||
|
||||
/// Side labels for briscola chiamata: the caller (with the partner's
|
||||
/// name once revealed, or "(solo)") against the defenders.
|
||||
pub fn chiamata_sides(&self) -> (String, String) {
|
||||
let side_a = match self.caller {
|
||||
Some(caller) => {
|
||||
let caller_name = self.player_name_for_seat(caller);
|
||||
match self.partner {
|
||||
Some(partner) => {
|
||||
format!("{caller_name} & {}", self.player_name_for_seat(partner))
|
||||
}
|
||||
None if self.called_played => format!("{caller_name} (solo)"),
|
||||
None => caller_name,
|
||||
}
|
||||
}
|
||||
None => "Chiamante".to_string(),
|
||||
};
|
||||
(side_a, "Difensori".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -249,6 +388,7 @@ pub enum ServerMessage {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MatchPlayer {
|
||||
/// Canonical provider-qualified principal (legacy API field name).
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
pub seat: usize,
|
||||
@@ -331,9 +471,14 @@ fn default_elo() -> i32 {
|
||||
1500
|
||||
}
|
||||
|
||||
fn default_page() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardEntry {
|
||||
/// Canonical provider-qualified principal (legacy API field name).
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
/// Chess-style Elo rating for the requested game type.
|
||||
@@ -377,10 +522,30 @@ pub struct RatingsPage {
|
||||
pub results: Vec<PlayerRating>,
|
||||
}
|
||||
|
||||
/// Page size requested by the leaderboard page. Also used to turn a rank
|
||||
/// into the row number on a page (and back).
|
||||
pub const LEADERBOARD_PAGE_SIZE: u32 = 20;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<LeaderboardEntry>,
|
||||
/// 1-based page actually returned (clamped to the last page).
|
||||
#[serde(default = "default_page")]
|
||||
pub page: u32,
|
||||
/// Total number of pages; 0 when the board has no rated players.
|
||||
#[serde(default)]
|
||||
pub pages: u32,
|
||||
/// Total number of rated players.
|
||||
#[serde(default)]
|
||||
pub total: u32,
|
||||
/// The viewer's own rank, when authenticated and rated for this game.
|
||||
#[serde(default)]
|
||||
pub your_rank: Option<u32>,
|
||||
/// The page holding the viewer's row.
|
||||
#[serde(default)]
|
||||
pub your_page: Option<u32>,
|
||||
}
|
||||
|
||||
/// A card game the platform can host (GET /api/game-types).
|
||||
@@ -408,6 +573,62 @@ pub struct GameTypesPage {
|
||||
pub results: Vec<GameTypeInfo>,
|
||||
}
|
||||
|
||||
/// The games to show in the lobby's and leaderboard's game selectors when
|
||||
/// the `/api/game-types` fetch fails: match creation and per-game views
|
||||
/// must still work.
|
||||
pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
vec![
|
||||
GameTypeInfo {
|
||||
id: "scopone_scientifico".to_string(),
|
||||
name: "Scopone scientifico".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 4,
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "scopa".to_string(),
|
||||
name: "Scopa".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 2,
|
||||
max_players: 2,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "scopa_a_quattro".to_string(),
|
||||
name: "Scopa a quattro".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 4,
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "briscola".to_string(),
|
||||
name: "Briscola".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 2,
|
||||
max_players: 2,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "briscola_a_quattro".to_string(),
|
||||
name: "Briscola a quattro".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 4,
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "briscola_chiamata".to_string(),
|
||||
name: "Briscola chiamata".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 5,
|
||||
max_players: 5,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// One configured OIDC login provider (GET /api/auth/providers).
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct AuthProvider {
|
||||
@@ -454,6 +675,66 @@ pub struct PreviewPlayer {
|
||||
pub team: Option<String>,
|
||||
}
|
||||
|
||||
/// One ongoing game the caller is seated in (GET /api/me/games): the
|
||||
/// lobby payload plus the game's display name, for the "Continue" list.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MyGame {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub game_type: String,
|
||||
#[serde(default)]
|
||||
pub game_name: String,
|
||||
#[serde(default)]
|
||||
pub game_description: String,
|
||||
#[serde(default)]
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub players: Vec<PreviewPlayer>,
|
||||
#[serde(default)]
|
||||
pub seats_open: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub your_seat: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
impl MyGame {
|
||||
/// Human-readable phase for the lobby list.
|
||||
pub fn phase_label(&self) -> &'static str {
|
||||
match self.phase.as_str() {
|
||||
"lobby" => "Waiting for players",
|
||||
"hand_end" => "Between hands",
|
||||
_ => "In progress",
|
||||
}
|
||||
}
|
||||
|
||||
/// Seated/total note, e.g. `"2/4 seated"`.
|
||||
pub fn seats_text(&self) -> String {
|
||||
match self.seats_open {
|
||||
Some(open) => {
|
||||
format!("{}/{} seated", self.players.len(), self.players.len() + open)
|
||||
}
|
||||
None => format!("{} seated", self.players.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The seated players' names, for the list subtitle.
|
||||
pub fn players_text(&self) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MyGamesPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<MyGame>,
|
||||
}
|
||||
|
||||
/// One option to show in the join preview, rendered from the game's
|
||||
/// schema and the engine-echoed value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -631,6 +912,43 @@ pub fn is_briscola(game_type: &str) -> bool {
|
||||
game_type == "briscola" || game_type == "briscola_a_quattro"
|
||||
}
|
||||
|
||||
/// Whether a game type is one of the scopa variants (classic two-player
|
||||
/// scopa and four-player partnership scopa a quattro).
|
||||
pub fn is_scopa(game_type: &str) -> bool {
|
||||
game_type == "scopa" || game_type == "scopa_a_quattro"
|
||||
}
|
||||
|
||||
/// Whether a game type is five-player briscola chiamata, which has its
|
||||
/// own table view (auction, secret partner, five seats).
|
||||
pub fn is_chiamata(game_type: &str) -> bool {
|
||||
game_type == "briscola_chiamata"
|
||||
}
|
||||
|
||||
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
|
||||
/// "Tre", `10` -> "Re", and so on.
|
||||
pub fn rank_name(rank: i32) -> String {
|
||||
match rank {
|
||||
1 => "Asso".to_string(),
|
||||
2 => "Due".to_string(),
|
||||
3 => "Tre".to_string(),
|
||||
4 => "Quattro".to_string(),
|
||||
5 => "Cinque".to_string(),
|
||||
6 => "Sei".to_string(),
|
||||
7 => "Sette".to_string(),
|
||||
8 => "Fante".to_string(),
|
||||
9 => "Cavallo".to_string(),
|
||||
10 => "Re".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a game is played by two individuals rather than partnerships
|
||||
/// (classic two-player briscola, classic two-player scopa): team labels
|
||||
/// and team messages are replaced by the players' names.
|
||||
pub fn is_individual_game(game_type: &str) -> bool {
|
||||
game_type == "briscola" || game_type == "scopa"
|
||||
}
|
||||
|
||||
/// Human-readable suit name for a one-letter code (`D` -> "denari").
|
||||
pub fn suit_name(suit: &str) -> String {
|
||||
match suit {
|
||||
@@ -825,6 +1143,31 @@ mod tests {
|
||||
assert_eq!(1500, e.elo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_page_parses_pagination_fields() {
|
||||
let p: LeaderboardPage = serde_json::from_value(json!({
|
||||
"results": [],
|
||||
"page": 3,
|
||||
"pages": 12,
|
||||
"total": 234,
|
||||
"your_rank": 47,
|
||||
"your_page": 3,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(3, p.page);
|
||||
assert_eq!(12, p.pages);
|
||||
assert_eq!(234, p.total);
|
||||
assert_eq!(Some(47), p.your_rank);
|
||||
assert_eq!(Some(3), p.your_page);
|
||||
// The pagination fields are optional for older payloads.
|
||||
let legacy: LeaderboardPage =
|
||||
serde_json::from_value(json!({"results": []})).unwrap();
|
||||
assert_eq!(1, legacy.page);
|
||||
assert_eq!(0, legacy.pages);
|
||||
assert_eq!(None, legacy.your_rank);
|
||||
assert_eq!(None, legacy.your_page);
|
||||
}
|
||||
|
||||
fn scopone_game_type() -> GameTypeInfo {
|
||||
serde_json::from_value(json!({
|
||||
"id": "scopone_scientifico",
|
||||
@@ -837,7 +1180,7 @@ mod tests {
|
||||
"properties": {
|
||||
"target_score": {
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"default": 11,
|
||||
"default": 21,
|
||||
},
|
||||
"napola": {"type": "boolean", "default": true},
|
||||
"asso_pigliatutto": {"type": "boolean", "default": false},
|
||||
@@ -858,7 +1201,7 @@ mod tests {
|
||||
target.kind,
|
||||
OptionKind::Integer { min: Some(1), max: Some(100) }
|
||||
));
|
||||
assert_eq!(json!(11), target.default);
|
||||
assert_eq!(json!(21), target.default);
|
||||
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
|
||||
assert_eq!(json!(true), by_key["napola"].default);
|
||||
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
|
||||
@@ -892,7 +1235,7 @@ mod tests {
|
||||
#[test]
|
||||
fn default_options_collect_schema_defaults() {
|
||||
let opts = scopone_game_type().default_options();
|
||||
assert_eq!(Some(&json!(11)), opts.get("target_score"));
|
||||
assert_eq!(Some(&json!(21)), opts.get("target_score"));
|
||||
assert_eq!(Some(&json!(true)), opts.get("napola"));
|
||||
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
|
||||
}
|
||||
@@ -922,7 +1265,7 @@ mod tests {
|
||||
assert_eq!(16, g.target_score);
|
||||
assert!(!g.napola);
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!("A", g.players[0].team);
|
||||
assert_eq!(Some("A".to_string()), g.players[0].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1134,7 +1477,10 @@ mod tests {
|
||||
fn is_briscola_matches_both_variants() {
|
||||
assert!(is_briscola("briscola"));
|
||||
assert!(is_briscola("briscola_a_quattro"));
|
||||
assert!(!is_briscola("briscola_chiamata"));
|
||||
assert!(!is_briscola("scopone_scientifico"));
|
||||
assert!(is_chiamata("briscola_chiamata"));
|
||||
assert!(!is_chiamata("briscola"));
|
||||
assert_eq!("denari", suit_name("D"));
|
||||
assert_eq!("coppe", suit_name("C"));
|
||||
assert_eq!("spade", suit_name("S"));
|
||||
@@ -1142,6 +1488,106 @@ mod tests {
|
||||
assert_eq!("?", suit_name("?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_scopa_matches_both_variants() {
|
||||
assert!(is_scopa("scopa"));
|
||||
assert!(is_scopa("scopa_a_quattro"));
|
||||
assert!(!is_scopa("scopone_scientifico"));
|
||||
assert!(!is_scopa("briscola"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_names_cover_the_ladder() {
|
||||
assert_eq!("Asso", rank_name(1));
|
||||
assert_eq!("Due", rank_name(2));
|
||||
assert_eq!("Tre", rank_name(3));
|
||||
assert_eq!("Fante", rank_name(8));
|
||||
assert_eq!("Cavallo", rank_name(9));
|
||||
assert_eq!("Re", rank_name(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_individual_game_matches_two_player_games() {
|
||||
assert!(is_individual_game("briscola"));
|
||||
assert!(is_individual_game("scopa"));
|
||||
assert!(!is_individual_game("briscola_a_quattro"));
|
||||
assert!(!is_individual_game("scopa_a_quattro"));
|
||||
assert!(!is_individual_game("scopone_scientifico"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_name_for_team_resolves_names() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "b1",
|
||||
"game_type": "briscola",
|
||||
"phase": "playing",
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("Alice", g.player_name_for_team("A"));
|
||||
assert_eq!("Bob", g.player_name_for_team("B"));
|
||||
// Unknown labels fall back to the label itself.
|
||||
assert_eq!("C", g.player_name_for_team("C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn my_game_phase_and_seats_text() {
|
||||
let g: MyGame = serde_json::from_value(json!({
|
||||
"id": "g1",
|
||||
"game_type": "briscola_a_quattro",
|
||||
"game_name": "Briscola a quattro",
|
||||
"phase": "lobby",
|
||||
"players": [
|
||||
{"name": "Alice", "seat": 0, "team": "A"},
|
||||
{"name": "Bob", "seat": 1, "team": "B"},
|
||||
],
|
||||
"seats_open": 2,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("Waiting for players", g.phase_label());
|
||||
assert_eq!("2/4 seated", g.seats_text());
|
||||
assert_eq!("Alice, Bob", g.players_text());
|
||||
|
||||
let playing: MyGame = serde_json::from_value(json!({
|
||||
"id": "g2",
|
||||
"game_type": "briscola",
|
||||
"phase": "playing",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("In progress", playing.phase_label());
|
||||
assert_eq!("0 seated", playing.seats_text());
|
||||
|
||||
let between: MyGame = serde_json::from_value(json!({
|
||||
"id": "g3",
|
||||
"phase": "hand_end",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("Between hands", between.phase_label());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_game_types_cover_the_registered_games() {
|
||||
let types = fallback_game_types();
|
||||
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
vec![
|
||||
"scopone_scientifico",
|
||||
"scopa",
|
||||
"scopa_a_quattro",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
ids
|
||||
);
|
||||
// The lobby's first game (the creation default) must exist.
|
||||
assert_eq!("scopone_scientifico", types[0].id);
|
||||
assert_eq!(5, types[5].max_players);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_briscola_payload() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
@@ -1217,7 +1663,7 @@ mod tests {
|
||||
assert_eq!(1, deal.deal);
|
||||
assert!(!deal.patta);
|
||||
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
||||
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
|
||||
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
|
||||
let cards = deal.cards.unwrap();
|
||||
assert_eq!(19, cards.a);
|
||||
assert_eq!(21, cards.b);
|
||||
@@ -1237,4 +1683,223 @@ mod tests {
|
||||
assert_eq!(0, d.deal);
|
||||
assert!(d.cards.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_deal_summary_has_no_totals() {
|
||||
let d: DealSummary = serde_json::from_value(json!({
|
||||
"deal": 2,
|
||||
"patta": true,
|
||||
"points": {"A": 60, "B": 60},
|
||||
"declared_target": 61,
|
||||
"called_card": "01D",
|
||||
"caller": "Alice",
|
||||
"solo": false,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(d.patta);
|
||||
assert_eq!(2, d.deal);
|
||||
assert!(d.totals.is_none());
|
||||
assert!(d.cards.is_none());
|
||||
assert_eq!(Some(61), d.declared_target);
|
||||
assert_eq!(Some("01D".to_string()), d.called_card);
|
||||
assert!(!d.solo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_auction() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c1",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "auction",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 1,
|
||||
"declared_target": 61,
|
||||
"auction": {
|
||||
"turn": 2,
|
||||
"active": [0, 1, 2, 3, 4],
|
||||
"passed": [],
|
||||
"current": {"seat": 1, "rank": 10, "points": 61},
|
||||
"history": [{"seat": 1, "rank": 10, "points": 61}],
|
||||
},
|
||||
"your_turn": true,
|
||||
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0},
|
||||
],
|
||||
"auction_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"call_deadline": null,
|
||||
"turn_deadline": null,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_chiamata(&g.game_type));
|
||||
assert!(!is_briscola(&g.game_type));
|
||||
let auction = g.auction.unwrap();
|
||||
assert_eq!(2, auction.turn);
|
||||
assert_eq!(5, auction.active.len());
|
||||
let current = auction.current.unwrap();
|
||||
assert_eq!(1, current.seat);
|
||||
assert_eq!(10, current.rank);
|
||||
assert_eq!(61, current.points);
|
||||
assert_eq!(1, auction.history.len());
|
||||
let bids = g.legal_bids.unwrap();
|
||||
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
|
||||
assert!(bids.points_min.is_none());
|
||||
assert_eq!(Some(120), bids.points_max);
|
||||
assert_eq!(Some("".to_string()), g.players[0].team);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_playing_and_reveal() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c2",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "playing",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 3,
|
||||
"caller": 1,
|
||||
"called_card": "03C",
|
||||
"briscola_suit": "C",
|
||||
"declared_target": 64,
|
||||
"called_played": true,
|
||||
"partner": 3,
|
||||
"solo": false,
|
||||
"scores": {"A": 45, "B": 30},
|
||||
"players": [
|
||||
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
|
||||
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
|
||||
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
|
||||
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(Some(1), g.caller);
|
||||
assert_eq!(Some("03C".to_string()), g.called_card);
|
||||
assert_eq!("C", g.briscola_suit);
|
||||
assert_eq!(64, g.declared_target);
|
||||
assert!(g.called_played);
|
||||
assert_eq!(Some(3), g.partner);
|
||||
assert!(!g.solo);
|
||||
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
|
||||
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_sides_label_the_solo_caller() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c3",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "finished",
|
||||
"scores": {"A": 70, "B": 50},
|
||||
"winner": "Chiamante",
|
||||
"caller": 2,
|
||||
"called_played": true,
|
||||
"solo": true,
|
||||
"players": [
|
||||
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
|
||||
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
let (a, b) = g.chiamata_sides();
|
||||
assert_eq!("Carol (solo)", a);
|
||||
assert_eq!("Difensori", b);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_scopa_payload() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "s1",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "scopa",
|
||||
"phase": "playing",
|
||||
"target_score": 11,
|
||||
"asso_pigliatutto": true,
|
||||
"player_count": 2,
|
||||
"hand_number": 1,
|
||||
"dealer": 0,
|
||||
"turn": 1,
|
||||
"deck_count": 24,
|
||||
"scores": {"A": 0, "B": 0},
|
||||
"table": ["05C", "02D", "03S", "10B"],
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
||||
"cards_left": 3, "captured_count": 0, "scope": 0},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
|
||||
"cards_left": 3, "captured_count": 0, "scope": 0,
|
||||
"hand": ["01D", "06C", "09S"]},
|
||||
],
|
||||
"last_hand": null,
|
||||
"last_move": {"seat": 0, "name": "Alice", "card": "04D",
|
||||
"captured": ["01C", "03C"], "scopa": true,
|
||||
"pigliatutto": false},
|
||||
"acknowledged": [],
|
||||
"hand_end_deadline": null,
|
||||
"turn_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"your_turn": true,
|
||||
"legal_moves": {"01D": [["01C"]]},
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_scopa(&g.game_type));
|
||||
assert!(is_individual_game(&g.game_type));
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!(2, g.player_count);
|
||||
assert_eq!(24, g.deck_count);
|
||||
assert_eq!(4, g.table.len());
|
||||
assert_eq!(Some(vec!["01D".to_string(), "06C".to_string(), "09S".to_string()]),
|
||||
g.players[1].hand.clone());
|
||||
let mv = g.last_move.unwrap();
|
||||
assert!(mv.scopa);
|
||||
assert_eq!(2, mv.captured.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_scopa_a_quattro_hand_summary() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "s2",
|
||||
"game_type": "scopa_a_quattro",
|
||||
"phase": "hand_end",
|
||||
"player_count": 4,
|
||||
"scores": {"A": 3, "B": 0},
|
||||
"players": [
|
||||
{"sub": "a", "name": "Alice", "seat": 0, "team": "A"},
|
||||
{"sub": "b", "name": "Bob", "seat": 1, "team": "B"},
|
||||
{"sub": "c", "name": "Carol", "seat": 2, "team": "A"},
|
||||
{"sub": "d", "name": "Dave", "seat": 3, "team": "B"},
|
||||
],
|
||||
"last_hand": {
|
||||
"cards": {"A": 21, "B": 19},
|
||||
"denara": {"A": 6, "B": 4},
|
||||
"settebello": {"A": true, "B": false},
|
||||
"primiera": {"A": 70, "B": 60},
|
||||
"scope": {"A": 1, "B": 0},
|
||||
"award": {
|
||||
"carte": "A", "denara": "A", "settebello": "A",
|
||||
"primiera": "A",
|
||||
},
|
||||
"hand": 1,
|
||||
"team_a_points": 3,
|
||||
"team_b_points": 0,
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_scopa(&g.game_type));
|
||||
assert!(!is_individual_game(&g.game_type));
|
||||
let hand = g.last_hand.unwrap();
|
||||
assert_eq!(21, hand.cards.a);
|
||||
assert_eq!(Some("A".to_string()), hand.award.carte);
|
||||
assert!(hand.napola.is_none());
|
||||
assert_eq!(3, hand.team_a_points);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Desktop notifications for the other players' card plays.
|
||||
//!
|
||||
//! Move notifications are opt-in (the bell in the game HUD), fire only
|
||||
//! while the game page is in the background, and only for games the viewer
|
||||
//! is seated in. They are delivered through the service worker —
|
||||
//! page-context notifications are rejected on some platforms (Android in
|
||||
//! particular) — with a page-context fallback when no worker is active.
|
||||
//!
|
||||
//! The interesting logic is split into pure functions ([`last_play`],
|
||||
//! [`opponent_play`], [`message`]) so it is unit-testable on the host;
|
||||
//! everything else talks to the browser.
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
|
||||
use crate::model::{card_label, is_briscola, is_chiamata, GameView};
|
||||
|
||||
/// localStorage key holding whether the user wants move notifications.
|
||||
const ENABLED_KEY: &str = "tavolo.notify.moves";
|
||||
|
||||
/// The most recent card played, in a form comparable across snapshots.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LastPlay {
|
||||
pub seat: usize,
|
||||
pub card: String,
|
||||
}
|
||||
|
||||
/// The browser's notification permission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Permission {
|
||||
Default,
|
||||
Granted,
|
||||
Denied,
|
||||
/// The browser has no Notification API at all.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Whether the user has opted into move notifications. The preference is
|
||||
/// global (all games) and per device; browsers without `localStorage`
|
||||
/// (rare) just never remember it.
|
||||
pub fn enabled() -> bool {
|
||||
storage()
|
||||
.and_then(|s| s.get_item(ENABLED_KEY).ok().flatten())
|
||||
.as_deref()
|
||||
== Some("1")
|
||||
}
|
||||
|
||||
/// Persist the opt-in state.
|
||||
pub fn set_enabled(on: bool) {
|
||||
if let Some(storage) = storage() {
|
||||
let _ = if on {
|
||||
storage.set_item(ENABLED_KEY, "1")
|
||||
} else {
|
||||
storage.remove_item(ENABLED_KEY)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
fn has_notification_api() -> bool {
|
||||
js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("Notification"))
|
||||
.map(|value| !value.is_undefined())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The browser's current notification permission.
|
||||
pub fn permission() -> Permission {
|
||||
if !has_notification_api() {
|
||||
return Permission::Unsupported;
|
||||
}
|
||||
match web_sys::Notification::permission() {
|
||||
web_sys::NotificationPermission::Granted => Permission::Granted,
|
||||
web_sys::NotificationPermission::Denied => Permission::Denied,
|
||||
_ => Permission::Default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the browser for the notification permission.
|
||||
///
|
||||
/// Must be called from a user gesture (a click): Chrome rejects
|
||||
/// permission prompts without one. Returns the resulting permission.
|
||||
pub async fn request_permission() -> Permission {
|
||||
if !has_notification_api() {
|
||||
return Permission::Unsupported;
|
||||
}
|
||||
match web_sys::Notification::request_permission() {
|
||||
Ok(promise) => {
|
||||
let _ = JsFuture::from(promise).await;
|
||||
// Re-read the permission: the promise resolves to the same
|
||||
// enum, but re-reading also covers browsers that settled it
|
||||
// through their own UI.
|
||||
permission()
|
||||
}
|
||||
Err(_) => permission(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the page is not in the foreground: the tab is hidden, or its
|
||||
/// window lost focus. Notifications are only useful — and only shown —
|
||||
/// when the viewer cannot see the table.
|
||||
pub fn page_in_background() -> bool {
|
||||
let Some(window) = web_sys::window() else {
|
||||
// No window object: headless contexts cannot show notifications
|
||||
// anyway; treat the page as visible and stay quiet.
|
||||
return false;
|
||||
};
|
||||
let Some(document) = window.document() else {
|
||||
return false;
|
||||
};
|
||||
document.hidden() || !document.has_focus().unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The viewer's seat, or `None` for spectators. The viewer is the only
|
||||
/// player whose hand is present in the snapshot.
|
||||
pub fn viewer_seat(game: &GameView) -> Option<usize> {
|
||||
if game.spectator {
|
||||
return None;
|
||||
}
|
||||
game.players
|
||||
.iter()
|
||||
.find(|p| p.hand.is_some())
|
||||
.map(|p| p.seat)
|
||||
}
|
||||
|
||||
/// The most recent card played in the snapshot, across all game families:
|
||||
///
|
||||
/// - scopone and scopa report every play in `last_move`;
|
||||
/// - briscola and briscola chiamata don't: a play either extends `trick`
|
||||
/// or, when it completes the trick, moves `trick` into `last_trick` —
|
||||
/// in both cases the latest play is the last card of the trick.
|
||||
pub fn last_play(game: &GameView) -> Option<LastPlay> {
|
||||
if let Some(mv) = &game.last_move {
|
||||
return Some(LastPlay { seat: mv.seat, card: mv.card.clone() });
|
||||
}
|
||||
if let Some(card) = game.trick.last() {
|
||||
return Some(LastPlay { seat: card.seat, card: card.card.clone() });
|
||||
}
|
||||
game.last_trick
|
||||
.as_ref()
|
||||
.and_then(|trick| trick.cards.last())
|
||||
.map(|card| LastPlay { seat: card.seat, card: card.card.clone() })
|
||||
}
|
||||
|
||||
/// The play to announce between two snapshots.
|
||||
///
|
||||
/// `primed` is false for the first snapshot after a (re)connection: the
|
||||
/// snapshot is a resync, not a play, and only establishes the baseline.
|
||||
/// Only plays by *other* seats are reported; the viewer's own plays (and
|
||||
/// the server's auto-plays for them) are already visible.
|
||||
pub fn opponent_play(primed: bool, prev: Option<&LastPlay>, next: &GameView) -> Option<LastPlay> {
|
||||
let cur = last_play(next)?;
|
||||
if !primed || Some(&cur) == prev {
|
||||
return None;
|
||||
}
|
||||
// Spectators (and snapshots without a visible hand) have no seat to
|
||||
// compare against: stay quiet rather than announce every play.
|
||||
let seat = viewer_seat(next)?;
|
||||
if cur.seat == seat {
|
||||
return None;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// Track the notification baseline across game snapshots. The baseline
|
||||
/// must be reset for every (re)connection; see [`opponent_play`].
|
||||
#[derive(Clone)]
|
||||
pub struct Baseline {
|
||||
prev: Rc<RefCell<Option<LastPlay>>>,
|
||||
primed: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl Baseline {
|
||||
pub fn new() -> Self {
|
||||
Baseline {
|
||||
prev: Rc::new(RefCell::new(None)),
|
||||
primed: Rc::new(Cell::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the previous snapshot: the next one is a resync baseline.
|
||||
pub fn reset(&self) {
|
||||
*self.prev.borrow_mut() = None;
|
||||
self.primed.set(false);
|
||||
}
|
||||
|
||||
/// Record `game` and return the opponent's play to notify about, if
|
||||
/// any. Must be called for every snapshot, whether or not the
|
||||
/// preference is on, so that enabling it mid-game never replays an
|
||||
/// old move.
|
||||
pub fn observe(&self, game: &GameView) -> Option<LastPlay> {
|
||||
let play = opponent_play(self.primed.get(), self.prev.borrow().as_ref(), game);
|
||||
*self.prev.borrow_mut() = last_play(game);
|
||||
self.primed.set(true);
|
||||
play
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification title and body for `play` in `game`.
|
||||
pub fn message(game: &GameView, play: &LastPlay) -> (String, String) {
|
||||
let title = format!(
|
||||
"{} played {}",
|
||||
game.player_name_for_seat(play.seat),
|
||||
card_label(&play.card)
|
||||
);
|
||||
let mut parts = vec![if is_briscola(&game.game_type) || is_chiamata(&game.game_type) {
|
||||
format!("Deal {}", game.hand_number)
|
||||
} else {
|
||||
format!("Hand {}", game.hand_number)
|
||||
}];
|
||||
if game.your_turn == Some(true) {
|
||||
parts.push("Your turn".to_string());
|
||||
} else {
|
||||
parts.push(format!("{} to play", game.player_name_for_seat(game.turn)));
|
||||
}
|
||||
if let Some(mv) = &game.last_move {
|
||||
if mv.scopa {
|
||||
parts.push("Scopa!".to_string());
|
||||
}
|
||||
if mv.pigliatutto {
|
||||
parts.push("Asso pigliatutto!".to_string());
|
||||
}
|
||||
}
|
||||
(title, parts.join(" · "))
|
||||
}
|
||||
|
||||
/// Show a desktop notification for `play`.
|
||||
///
|
||||
/// Best-effort: failures (no permission, no worker, platform rejection)
|
||||
/// are silently ignored — the table itself is the source of truth.
|
||||
pub fn notify_move(game: &GameView, play: &LastPlay) {
|
||||
let (title, body) = message(game, play);
|
||||
let url = format!("/game/{}", game.id);
|
||||
let tag = format!("tavolo-{}", game.id);
|
||||
spawn_local(async move {
|
||||
show(&title, &body, &tag, &url).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the notification options shared by both delivery paths.
|
||||
fn options(body: &str, tag: Option<&str>, url: &str) -> web_sys::NotificationOptions {
|
||||
let options = web_sys::NotificationOptions::new();
|
||||
options.set_body(body);
|
||||
options.set_icon("/assets/icons/icon-192.png");
|
||||
if let Some(tag) = tag {
|
||||
// renotify needs a tag; the tag replaces the previous move of the
|
||||
// same game instead of stacking one notification per play.
|
||||
options.set_tag(tag);
|
||||
options.set_renotify(true);
|
||||
}
|
||||
let data = js_sys::Object::new();
|
||||
let _ = js_sys::Reflect::set(
|
||||
&data,
|
||||
&wasm_bindgen::JsValue::from_str("url"),
|
||||
&wasm_bindgen::JsValue::from_str(url),
|
||||
);
|
||||
options.set_data(&data);
|
||||
options
|
||||
}
|
||||
|
||||
async fn show(title: &str, body: &str, tag: &str, url: &str) {
|
||||
// Prefer the service worker registration: it is the only path that
|
||||
// works for installed PWAs on Android. `getRegistration` resolves
|
||||
// promptly (to `undefined` when nothing is registered), unlike
|
||||
// `ready`, which never settles without a worker.
|
||||
if let Some(window) = web_sys::window() {
|
||||
let container = window.navigator().service_worker();
|
||||
if let Ok(value) = JsFuture::from(container.get_registration()).await {
|
||||
if let Some(registration) = value.dyn_ref::<web_sys::ServiceWorkerRegistration>() {
|
||||
if let Ok(promise) =
|
||||
registration.show_notification_with_options(title, &options(body, Some(tag), url))
|
||||
{
|
||||
if JsFuture::from(promise).await.is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = web_sys::Notification::new_with_options(title, &options(body, None, url));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::GameView;
|
||||
use serde_json::json;
|
||||
|
||||
fn scopone_view(last_move: serde_json::Value, my_turn: bool) -> GameView {
|
||||
serde_json::from_value(json!({
|
||||
"id": "g1",
|
||||
"game_type": "scopone_scientifico",
|
||||
"phase": "playing",
|
||||
"hand_number": 2,
|
||||
"turn": 2,
|
||||
"your_turn": my_turn,
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "hand": ["01D", "02C"]},
|
||||
{"sub": "carol", "name": "Carol", "seat": 2},
|
||||
{"sub": "dave", "name": "Dave", "seat": 3},
|
||||
],
|
||||
"last_move": last_move,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn briscola_view(trick: Vec<serde_json::Value>, last_trick: serde_json::Value) -> GameView {
|
||||
serde_json::from_value(json!({
|
||||
"id": "g2",
|
||||
"game_type": "briscola",
|
||||
"phase": "playing",
|
||||
"hand_number": 1,
|
||||
"turn": 1,
|
||||
"your_turn": false,
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "hand": ["01D"]},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1},
|
||||
],
|
||||
"trick": trick,
|
||||
"last_trick": last_trick,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn play(seat: usize, card: &str) -> LastPlay {
|
||||
LastPlay { seat, card: card.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_play_reads_last_move() {
|
||||
let g = scopone_view(
|
||||
json!({"seat": 3, "name": "Dave", "card": "07D", "captured": []}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(Some(play(3, "07D")), last_play(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_play_reads_the_trick_in_progress() {
|
||||
let g = briscola_view(
|
||||
vec![json!({"seat": 0, "card": "04C"}), json!({"seat": 1, "card": "02D"})],
|
||||
serde_json::Value::Null,
|
||||
);
|
||||
assert_eq!(Some(play(1, "02D")), last_play(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_play_reads_the_completed_trick() {
|
||||
// The trick-completing play empties `trick`; the newest card is
|
||||
// the last one of `last_trick.cards`.
|
||||
let g = briscola_view(
|
||||
vec![],
|
||||
json!({"winner": 1, "cards": [
|
||||
{"seat": 0, "card": "04C"},
|
||||
{"seat": 1, "card": "02D"},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(Some(play(1, "02D")), last_play(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_play_is_none_before_any_card() {
|
||||
let g = briscola_view(vec![], serde_json::Value::Null);
|
||||
assert_eq!(None, last_play(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opponent_play_skips_the_first_snapshot() {
|
||||
let g = scopone_view(
|
||||
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(None, opponent_play(false, None, &g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opponent_play_skips_the_viewer_seat() {
|
||||
// Bob (seat 1) carries the hand: his own play is not announced.
|
||||
let g = scopone_view(
|
||||
json!({"seat": 1, "name": "Bob", "card": "07D"}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(None, opponent_play(true, Some(&play(2, "03B")), &g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opponent_play_reports_a_new_move_and_not_a_repeat() {
|
||||
let g = scopone_view(
|
||||
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
||||
false,
|
||||
);
|
||||
let prev = play(2, "03B");
|
||||
assert_eq!(Some(play(3, "07D")), opponent_play(true, Some(&prev), &g));
|
||||
assert_eq!(None, opponent_play(true, Some(&play(3, "07D")), &g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opponent_play_stays_quiet_for_spectators() {
|
||||
let mut g = scopone_view(
|
||||
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
||||
false,
|
||||
);
|
||||
g.spectator = true;
|
||||
for player in &mut g.players {
|
||||
player.hand = None;
|
||||
}
|
||||
assert_eq!(None, opponent_play(true, Some(&play(2, "03B")), &g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opponent_play_reports_after_a_hand_boundary() {
|
||||
// The new hand cleared `last_move`; the first play is still news.
|
||||
let g = scopone_view(
|
||||
json!({"seat": 0, "name": "Alice", "card": "01D"}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(Some(play(0, "01D")), opponent_play(true, None, &g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_resets_per_connection() {
|
||||
let baseline = Baseline::new();
|
||||
let g = scopone_view(
|
||||
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
||||
false,
|
||||
);
|
||||
// First snapshot of the connection: baseline only.
|
||||
assert_eq!(None, baseline.observe(&g));
|
||||
// A new play by another seat is reported.
|
||||
let next = scopone_view(
|
||||
json!({"seat": 0, "name": "Alice", "card": "06C"}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(Some(play(0, "06C")), baseline.observe(&next));
|
||||
// A reconnect resyncs to the same snapshot: no notification.
|
||||
baseline.reset();
|
||||
assert_eq!(None, baseline.observe(&next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_names_the_player_and_card() {
|
||||
let g = scopone_view(
|
||||
json!({"seat": 3, "name": "Dave", "card": "07D", "scopa": true}),
|
||||
true,
|
||||
);
|
||||
let (title, body) = message(&g, &play(3, "07D"));
|
||||
assert_eq!("Dave played 7 di denari", title);
|
||||
assert_eq!("Hand 2 · Your turn · Scopa!", body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_names_the_next_player_and_the_deal() {
|
||||
let mut g = briscola_view(vec![json!({"seat": 1, "card": "02D"})], serde_json::Value::Null);
|
||||
g.turn = 0;
|
||||
let (title, body) = message(&g, &play(1, "02D"));
|
||||
assert_eq!("Bob played 2 di denari", title);
|
||||
assert_eq!("Deal 1 · Alice to play", body);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::copy::CopyButton;
|
||||
use crate::model::{hand_sort_key, suit_name, GameView, PlayerView, Scores};
|
||||
use crate::components::notify::{notify_toggle, NotifyCtx};
|
||||
use crate::model::{
|
||||
hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores,
|
||||
};
|
||||
use crate::pages::game::watch_link;
|
||||
|
||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||
@@ -25,7 +28,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team = player.team.clone();
|
||||
let team_badge = (!is_individual_game(&game.game_type))
|
||||
.then(|| player.team.clone())
|
||||
.flatten()
|
||||
.filter(|team| !team.is_empty())
|
||||
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
|
||||
let points = player.points;
|
||||
let captured = player.captured_count;
|
||||
let backs = (0..player.cards_left)
|
||||
@@ -35,7 +42,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
div(class=cls) {
|
||||
div(class="seat-name") {
|
||||
(name)
|
||||
span(class="team-badge") { "Team " (team) }
|
||||
(team_badge)
|
||||
}
|
||||
div(class="seat-cards") { (backs) }
|
||||
div(class="seat-stats") {
|
||||
@@ -115,6 +122,7 @@ pub fn briscola_table(
|
||||
game: GameView,
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
now: Signal<f64>,
|
||||
notify: NotifyCtx,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
@@ -160,6 +168,19 @@ pub fn briscola_table(
|
||||
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||
let target = game.target_score;
|
||||
let deal = game.hand_number;
|
||||
// Two-player briscola has no partnerships: show the players' names.
|
||||
let score_line = if is_individual_game(&game.game_type) {
|
||||
format!(
|
||||
"{} {} — {} {} (target {})",
|
||||
game.player_name_for_team("A"),
|
||||
scores.a,
|
||||
scores.b,
|
||||
game.player_name_for_team("B"),
|
||||
target
|
||||
)
|
||||
} else {
|
||||
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
|
||||
};
|
||||
|
||||
let (grid_cls, top, left, right) = if player_count == 2 {
|
||||
(
|
||||
@@ -217,6 +238,7 @@ pub fn briscola_table(
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||
let watch_link = watch_link(&game.id);
|
||||
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
|
||||
let suit = suit_name(&game.briscola_suit);
|
||||
@@ -234,13 +256,11 @@ pub fn briscola_table(
|
||||
span { "Deal " (deal) }
|
||||
(trump_badge)
|
||||
(spectator_badge)
|
||||
span(class="hud-scores") {
|
||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||
(target) ")"
|
||||
}
|
||||
span(class="hud-scores") { (score_line) }
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
(bell)
|
||||
}
|
||||
div(class=grid_cls) {
|
||||
(top)
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
//! Briscola chiamata table: five seats, the calling auction and the
|
||||
//! secret-partner reveal.
|
||||
//!
|
||||
//! The auction starts with the player after the dealer: each player bids
|
||||
//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 >
|
||||
//! 4 > 2) or passes, and once the standing bid is the two the bidding
|
||||
//! continues on points. The last bidder names a card of the won rank:
|
||||
//! its holder is the secret partner, or the caller plays alone when it
|
||||
//! is one of their own cards, and its suit is the trump. The partner's
|
||||
//! identity is only revealed when the called card is played.
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::copy::CopyButton;
|
||||
use crate::components::notify::{notify_toggle, NotifyCtx};
|
||||
use crate::model::{
|
||||
card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView,
|
||||
};
|
||||
use crate::pages::game::watch_link;
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||
}
|
||||
|
||||
fn player_name(game: &GameView, seat: usize) -> String {
|
||||
player_for_seat(game, seat)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||
}
|
||||
|
||||
/// Human label of one auction bid.
|
||||
fn bid_label(bid: &AuctionBid) -> String {
|
||||
if bid.rank == 2 {
|
||||
format!("2 at {} pts", bid.points)
|
||||
} else {
|
||||
rank_name(bid.rank)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel for one player seat (name, revealed role, hidden cards, points).
|
||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
let Some(player) = player_for_seat(&game, seat) else {
|
||||
return view! {};
|
||||
};
|
||||
let active = game.phase == "playing" && game.turn == seat;
|
||||
let cls = format!(
|
||||
"seat seat-{position}{}",
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
// Role labels only appear once the server makes them public.
|
||||
let role_badge = player
|
||||
.team
|
||||
.clone()
|
||||
.filter(|role| !role.is_empty())
|
||||
.map(|role| view! { span(class="team-badge role-badge") { (role) } });
|
||||
let points = player.points;
|
||||
let captured = player.captured_count;
|
||||
let backs = (0..player.cards_left)
|
||||
.map(|_| card_back("mini"))
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class=cls) {
|
||||
div(class="seat-name") {
|
||||
(name)
|
||||
(role_badge)
|
||||
}
|
||||
div(class="seat-cards") { (backs) }
|
||||
div(class="seat-stats") {
|
||||
(points) " points · " (captured) " cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cards of the trick in progress, in play order, with player names.
|
||||
fn trick_view(game: &GameView) -> View {
|
||||
if game.trick.is_empty() {
|
||||
return view! { p(class="table-empty") { "Empty table" } };
|
||||
}
|
||||
let cards = game
|
||||
.trick
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let name = player_name(game, tc.seat);
|
||||
let card = card_img(tc.card.clone(), "table-card");
|
||||
view! {
|
||||
div(class="trick-slot") {
|
||||
span(class="trick-name") { (name) }
|
||||
(card)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! { (cards) }
|
||||
}
|
||||
|
||||
/// Announcement strip for the most recently completed trick.
|
||||
fn last_trick_banner(game: &GameView) -> View {
|
||||
let Some(trick) = game.last_trick.clone() else {
|
||||
return view! {};
|
||||
};
|
||||
let name = player_name(game, trick.winner);
|
||||
let cards = trick
|
||||
.cards
|
||||
.iter()
|
||||
.map(|tc| card_img(tc.card.clone(), "move-card"))
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="move-banner") {
|
||||
strong { (name) }
|
||||
span { " takes the trick " }
|
||||
(cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Patta — the deal is replayed" after a 60-60 deal.
|
||||
fn patta_banner(game: &GameView) -> View {
|
||||
let patta = game
|
||||
.last_deal
|
||||
.as_ref()
|
||||
.is_some_and(|deal| deal.patta);
|
||||
if !patta || game.phase != "auction" {
|
||||
return view! {};
|
||||
}
|
||||
view! {
|
||||
div(class="move-banner patta-banner") {
|
||||
strong { "Patta!" }
|
||||
span { " 60–60 — the deal is replayed." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Announcement of the partner (or the solo caller) once the called card
|
||||
/// has hit the table.
|
||||
fn reveal_banner(game: &GameView) -> View {
|
||||
if !game.called_played || game.phase == "auction" || game.phase == "calling" {
|
||||
return view! {};
|
||||
}
|
||||
let caller = game
|
||||
.caller
|
||||
.map(|seat| player_name(game, seat))
|
||||
.unwrap_or_default();
|
||||
let text = if game.solo {
|
||||
format!("{caller} is playing alone")
|
||||
} else {
|
||||
let partner = game
|
||||
.partner
|
||||
.map(|seat| player_name(game, seat))
|
||||
.unwrap_or_default();
|
||||
format!("{partner} is the partner of {caller}")
|
||||
};
|
||||
view! {
|
||||
div(class="move-banner reveal-banner") {
|
||||
span(class="reveal-text") { (text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The calling auction panel: standing bid, trail and, on turn, the bids.
|
||||
fn auction_panel(
|
||||
game: &GameView,
|
||||
on_bid_rank: impl Fn(i32) + Copy + 'static,
|
||||
on_bid_points: impl Fn(i32) + Copy + 'static,
|
||||
on_pass: impl Fn() + Copy + 'static,
|
||||
) -> View {
|
||||
let Some(auction) = game.auction.clone() else {
|
||||
return view! {};
|
||||
};
|
||||
let standing = match &auction.current {
|
||||
Some(bid) => format!(
|
||||
"Standing bid: {} — {}",
|
||||
player_name(game, bid.seat),
|
||||
bid_label(bid)
|
||||
),
|
||||
None => "No bids yet".to_string(),
|
||||
};
|
||||
let waiting_for = player_name(game, auction.turn);
|
||||
let history = auction
|
||||
.history
|
||||
.iter()
|
||||
.map(|bid| {
|
||||
let text = format!("{}: {}", player_name(game, bid.seat), bid_label(bid));
|
||||
view! { span(class="auction-chip") { (text) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actions = if game.your_turn == Some(true) {
|
||||
let legal = game.legal_bids.clone();
|
||||
let (rank_buttons, point_buttons) = match legal {
|
||||
Some(bids) => {
|
||||
let ranks = bids
|
||||
.ranks
|
||||
.iter()
|
||||
.map(|rank| {
|
||||
let rank = *rank;
|
||||
let name = rank_name(rank);
|
||||
view! {
|
||||
button(
|
||||
class="bid-button",
|
||||
on:click=move |_| on_bid_rank(rank),
|
||||
) { (name) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let points = match (bids.points_min, bids.points_max) {
|
||||
(Some(min), Some(max)) => (0..=2)
|
||||
.map(|step| min + 2 * step)
|
||||
.filter(|points| *points <= max)
|
||||
.map(|points| {
|
||||
view! {
|
||||
button(
|
||||
class="bid-button points",
|
||||
on:click=move |_| on_bid_points(points),
|
||||
) { (points) " pts" }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(ranks, points)
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
view! {
|
||||
div(class="bid-options") {
|
||||
(rank_buttons)
|
||||
(point_buttons)
|
||||
button(class="pass-button", on:click=move |_| on_pass()) { "Pass" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
p(class="text-secondary small mb-0") {
|
||||
"Waiting for " (waiting_for) " to bid…"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="auction-panel") {
|
||||
div(class="auction-standing") { (standing) }
|
||||
div(class="auction-history") { (history) }
|
||||
(actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main briscola chiamata table.
|
||||
pub fn chiamata_table(
|
||||
game: GameView,
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
on_bid_rank: impl Fn(i32) + Copy + 'static,
|
||||
on_bid_points: impl Fn(i32) + Copy + 'static,
|
||||
on_pass: impl Fn() + Copy + 'static,
|
||||
now: Signal<f64>,
|
||||
notify: NotifyCtx,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
let viewer_seat = game
|
||||
.players
|
||||
.iter()
|
||||
.find(|p| p.hand.is_some())
|
||||
.map(|p| p.seat)
|
||||
.unwrap_or(0);
|
||||
let my_turn = game.your_turn == Some(true);
|
||||
let can_play = game.phase == "playing" && my_turn;
|
||||
|
||||
let turn_note = if game.phase == "finished" {
|
||||
"Match finished".to_string()
|
||||
} else if game.phase == "auction" {
|
||||
if my_turn {
|
||||
"Your bid".to_string()
|
||||
} else {
|
||||
let name = game
|
||||
.auction
|
||||
.as_ref()
|
||||
.map(|a| player_name(&game, a.turn))
|
||||
.unwrap_or_default();
|
||||
format!("{name} to bid")
|
||||
}
|
||||
} else if game.phase == "calling" {
|
||||
if my_turn {
|
||||
"Name the called card".to_string()
|
||||
} else {
|
||||
let name = game
|
||||
.caller
|
||||
.map(|seat| player_name(&game, seat))
|
||||
.unwrap_or_default();
|
||||
format!("{name} is calling")
|
||||
}
|
||||
} else if my_turn {
|
||||
"Your turn".to_string()
|
||||
} else {
|
||||
format!("{}'s turn", player_name(&game, game.turn))
|
||||
};
|
||||
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||||
|
||||
let deadline = if game.phase == "auction" {
|
||||
game.auction_deadline.clone()
|
||||
} else if game.phase == "calling" {
|
||||
game.call_deadline.clone()
|
||||
} else {
|
||||
game.turn_deadline.clone()
|
||||
};
|
||||
let countdown = deadline.map(|deadline| {
|
||||
// A dynamic closure so only the ticking number re-renders.
|
||||
let deadline_ms = js_sys::Date::parse(&deadline);
|
||||
view! {
|
||||
span(class="turn-timer") {
|
||||
"Auto in "
|
||||
(move || {
|
||||
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||||
})
|
||||
"s"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let score_line = match game.scores {
|
||||
Some(scores) => {
|
||||
let (side_a, side_b) = game.chiamata_sides();
|
||||
format!(
|
||||
"{side_a} {} — {} {side_b} (target {})",
|
||||
scores.a, scores.b, game.declared_target
|
||||
)
|
||||
}
|
||||
// Before the called card is played the side totals would leak the
|
||||
// partner's identity; only the target is shown.
|
||||
None => format!("Target {}", game.declared_target),
|
||||
};
|
||||
|
||||
// Four opponents around the table: next player on the left, then
|
||||
// clockwise up to the player before us on the right.
|
||||
let left = seat_panel(game.clone(), (viewer_seat + 1) % 5, "left");
|
||||
let top_left = seat_panel(game.clone(), (viewer_seat + 2) % 5, "tl");
|
||||
let top_right = seat_panel(game.clone(), (viewer_seat + 3) % 5, "tr");
|
||||
let right = seat_panel(game.clone(), (viewer_seat + 4) % 5, "right");
|
||||
|
||||
let patta = patta_banner(&game);
|
||||
let reveal = reveal_banner(&game);
|
||||
let center_content = match game.phase.as_str() {
|
||||
"auction" => auction_panel(&game, on_bid_rank, on_bid_points, on_pass),
|
||||
"calling" => {
|
||||
let caller = game
|
||||
.caller
|
||||
.map(|seat| player_name(&game, seat))
|
||||
.unwrap_or_default();
|
||||
view! {
|
||||
div(class="auction-panel") {
|
||||
div(class="auction-standing") { (caller) " won the auction" }
|
||||
p(class="text-secondary small mb-0") {
|
||||
"Choosing the called card…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let banner = last_trick_banner(&game);
|
||||
let trick = trick_view(&game);
|
||||
view! {
|
||||
(banner)
|
||||
div(class="table-cards") { (trick) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let viewer = player_for_seat(&game, viewer_seat);
|
||||
let my_points = viewer.as_ref().map(|p| p.points).unwrap_or(0);
|
||||
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
|
||||
hand.sort_by_key(|c| hand_sort_key(c));
|
||||
let hand_cards = hand
|
||||
.into_iter()
|
||||
.map(|code| {
|
||||
let card_view = card_img(code.clone(), "hand-card");
|
||||
let click_code = code.clone();
|
||||
view! {
|
||||
button(class="hand-slot", on:click=move |_| {
|
||||
if can_play {
|
||||
on_hand_card(click_code.clone());
|
||||
}
|
||||
}) { (card_view) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let hint = if can_play {
|
||||
Some(view! {
|
||||
p(class="text-secondary small text-center") {
|
||||
"Click a card to play it."
|
||||
}
|
||||
})
|
||||
} else if game.phase == "auction" {
|
||||
Some(view! {
|
||||
p(class="text-secondary small text-center") {
|
||||
"Bid or pass, then play the cards."
|
||||
}
|
||||
})
|
||||
} else if game.phase == "calling" && my_turn {
|
||||
Some(view! {
|
||||
p(class="text-secondary small text-center") {
|
||||
"Name a card: its holder will be your secret partner."
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Spectators have no hand: render the pivot seat as a regular panel so
|
||||
// its points stay visible where the hand would be.
|
||||
let bottom = if game.spectator {
|
||||
seat_panel(game.clone(), viewer_seat, "bottom")
|
||||
} else {
|
||||
view! {
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") { (my_points) " points" }
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||
let watch_link = watch_link(&game.id);
|
||||
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
|
||||
let suit = suit_name(&game.briscola_suit);
|
||||
view! { span(class="briscola-badge") { "Briscola: " (suit) } }
|
||||
});
|
||||
let called_badge = game.called_card.clone().map(|code| {
|
||||
let label = card_label(&code);
|
||||
view! { span(class="called-badge") { "Chiamata: " (label) } }
|
||||
});
|
||||
let deal = game.hand_number;
|
||||
|
||||
view! {
|
||||
div(class="table-wrap") {
|
||||
div(class="hud") {
|
||||
a(href="/") { "← Lobby" }
|
||||
span { "Deal " (deal) }
|
||||
(trump_badge)
|
||||
(called_badge)
|
||||
(spectator_badge)
|
||||
span(class="hud-scores") { (score_line) }
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
(bell)
|
||||
}
|
||||
div(class="table-grid cinque") {
|
||||
(top_left)
|
||||
(top_right)
|
||||
(left)
|
||||
div(class="center") {
|
||||
(patta)
|
||||
(reveal)
|
||||
(center_content)
|
||||
}
|
||||
(right)
|
||||
(bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay for the auction winner: name the called card.
|
||||
///
|
||||
/// The winning bid fixes the rank, so the choice is which of the four
|
||||
/// suits to call: its holder becomes the secret partner, unless the
|
||||
/// caller holds it too — then that option means playing alone.
|
||||
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||||
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
||||
return view! {};
|
||||
};
|
||||
let rank = cards
|
||||
.first()
|
||||
.and_then(|code| code.get(..2))
|
||||
.and_then(|rank| rank.parse::<i32>().ok())
|
||||
.map(rank_name)
|
||||
.unwrap_or_default();
|
||||
let title = if rank.is_empty() {
|
||||
"Name the called card".to_string()
|
||||
} else {
|
||||
format!("You won with the {rank} — name the card")
|
||||
};
|
||||
let own_hand = game
|
||||
.players
|
||||
.iter()
|
||||
.find(|p| p.hand.is_some())
|
||||
.and_then(|p| p.hand.clone())
|
||||
.unwrap_or_default();
|
||||
let options = cards
|
||||
.into_iter()
|
||||
.map(|code| {
|
||||
let held = own_hand.contains(&code);
|
||||
let label = card_label(&code);
|
||||
let img = card_img(code.clone(), "call-card");
|
||||
let note = if held {
|
||||
view! { span(class="call-note solo") { "Plays alone" } }
|
||||
} else {
|
||||
view! { span(class="call-note partner") { "Partner" } }
|
||||
};
|
||||
let click_code = code.clone();
|
||||
view! {
|
||||
button(class="call-option", on:click=move |_| {
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.call_card(&click_code);
|
||||
}
|
||||
}) {
|
||||
(img)
|
||||
span(class="call-label") { (label) }
|
||||
(note)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker call-picker") {
|
||||
h3 { (title) }
|
||||
p(class="text-secondary small") {
|
||||
"Its holder becomes your secret partner; name one of your "
|
||||
"own cards to play alone."
|
||||
}
|
||||
div(class="call-options") { (options) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,17 @@ use crate::api;
|
||||
use crate::components::briscola::{deal_rows, deal_summary_modal};
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::copy::CopyButton;
|
||||
use crate::components::notify::{notify_toggle, NotifyCtx};
|
||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{
|
||||
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores,
|
||||
ServerMessage,
|
||||
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, is_scopa, GameView,
|
||||
MoveView, PlayerView, Scores, ServerMessage,
|
||||
};
|
||||
use crate::notify::{self, Permission};
|
||||
use crate::pages::briscola::briscola_table;
|
||||
use crate::pages::chiamata::{call_picker, chiamata_table};
|
||||
use crate::pages::scopa::scopa_table;
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||
@@ -43,7 +47,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team = player.team.clone();
|
||||
let team = player.team.clone().unwrap_or_default();
|
||||
let captured = player.captured_count;
|
||||
let scope = player.scope;
|
||||
let backs = (0..player.cards_left)
|
||||
@@ -64,7 +68,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
}
|
||||
|
||||
/// The last-move announcement strip.
|
||||
fn move_banner(mv: MoveView) -> View {
|
||||
pub(crate) fn move_banner(mv: MoveView) -> View {
|
||||
let name = mv.name.clone();
|
||||
let action = if mv.captured.is_empty() {
|
||||
"played"
|
||||
@@ -94,7 +98,7 @@ fn move_banner(mv: MoveView) -> View {
|
||||
}
|
||||
|
||||
/// Signals shared by the websocket connection and its reconnect attempts.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
struct ConnCtx {
|
||||
socket: Signal<Option<GameSocket>>,
|
||||
game: Signal<Option<GameView>>,
|
||||
@@ -109,6 +113,12 @@ struct ConnCtx {
|
||||
attempts: Signal<u32>,
|
||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||
selected: Signal<Option<String>>,
|
||||
/// Baseline for the move notifications; reset on every (re)connect.
|
||||
baseline: notify::Baseline,
|
||||
/// Whether the player opted into move notifications.
|
||||
notify_enabled: Signal<bool>,
|
||||
/// The browser's notification permission, as last read.
|
||||
notify_permission: Signal<Permission>,
|
||||
}
|
||||
|
||||
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
|
||||
@@ -123,7 +133,11 @@ fn backoff_ms(attempt: u32) -> u32 {
|
||||
/// The server pushes a full state snapshot on connect, so a reconnect is
|
||||
/// also a resync: no client-side state merging is needed.
|
||||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
// A fresh connection resyncs from scratch: the first snapshot it
|
||||
// delivers is the notification baseline, not a play to announce.
|
||||
ctx.baseline.reset();
|
||||
let on_message = {
|
||||
let ctx = ctx.clone();
|
||||
let alive = alive.clone();
|
||||
move |msg: ServerMessage| {
|
||||
if !alive.get() {
|
||||
@@ -132,6 +146,19 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
}
|
||||
match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
// Observe every snapshot, even with the preference
|
||||
// off: enabling it mid-game must not replay old moves.
|
||||
let play = ctx.baseline.observe(&g);
|
||||
if let Some(play) = play {
|
||||
// Move notifications are for when the player
|
||||
// cannot watch the table themselves.
|
||||
if ctx.notify_enabled.get()
|
||||
&& ctx.notify_permission.get() == Permission::Granted
|
||||
&& notify::page_in_background()
|
||||
{
|
||||
notify::notify_move(&g, &play);
|
||||
}
|
||||
}
|
||||
ctx.capture_choice.set(None);
|
||||
ctx.selected.set(None);
|
||||
// A received state proves the (re)connection works.
|
||||
@@ -149,6 +176,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
};
|
||||
let on_close = {
|
||||
let id = id.clone();
|
||||
let ctx = ctx.clone();
|
||||
let alive = alive.clone();
|
||||
move |_code: Option<u16>| {
|
||||
if !alive.get() {
|
||||
@@ -164,6 +192,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
// transient. Only on the first failure: later attempts
|
||||
// are governed by the retry backoff.
|
||||
let id = id.clone();
|
||||
let ctx = ctx.clone();
|
||||
let alive = alive.clone();
|
||||
spawn_local(async move {
|
||||
match api::probe_game(&id).await {
|
||||
@@ -176,7 +205,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
schedule_retry(id.clone(), ctx, alive.clone());
|
||||
schedule_retry(id.clone(), ctx.clone(), alive.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -279,6 +308,17 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
});
|
||||
|
||||
// Move notifications: opt-in and persisted per device. The signals
|
||||
// are page-scoped (not per table render) so a permission prompt that
|
||||
// resolves after the table rebuilt still lands somewhere safe.
|
||||
let notify_enabled = create_signal(notify::enabled());
|
||||
let notify_permission = create_signal(notify::permission());
|
||||
let notify_ctx = NotifyCtx {
|
||||
enabled: notify_enabled,
|
||||
permission: notify_permission,
|
||||
alive: alive.clone(),
|
||||
};
|
||||
|
||||
let id = Rc::new(id);
|
||||
let ctx = ConnCtx {
|
||||
socket,
|
||||
@@ -291,13 +331,16 @@ pub fn GamePage(id: String) -> View {
|
||||
attempts,
|
||||
capture_choice,
|
||||
selected,
|
||||
baseline: notify::Baseline::new(),
|
||||
notify_enabled,
|
||||
notify_permission,
|
||||
};
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
start_connect(id.clone(), ctx.clone(), alive.clone());
|
||||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||
ctx.attempts.set(0);
|
||||
ctx.gave_up.set(false);
|
||||
ctx.closed.set(false);
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
start_connect(id.clone(), ctx.clone(), alive.clone());
|
||||
});
|
||||
|
||||
// Clicking a card in the player's own hand.
|
||||
@@ -324,6 +367,32 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
};
|
||||
|
||||
// Briscola chiamata: auction and call actions.
|
||||
let on_bid_rank = move |rank: i32| {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.bid_rank(rank);
|
||||
}
|
||||
};
|
||||
let on_bid_points = move |points: i32| {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.bid_points(points);
|
||||
}
|
||||
};
|
||||
let on_pass = move || {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.pass();
|
||||
}
|
||||
};
|
||||
|
||||
let reconnect_banner = reconnect.clone();
|
||||
view! {
|
||||
div(class="game-page") {
|
||||
@@ -392,22 +461,54 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
||||
// Briscola chiamata has its own five-seat table with the
|
||||
// calling auction and the secret-partner reveal.
|
||||
Some(g) if is_chiamata(&g.game_type) => {
|
||||
chiamata_table(
|
||||
g,
|
||||
on_hand_card,
|
||||
on_bid_rank,
|
||||
on_bid_points,
|
||||
on_pass,
|
||||
now,
|
||||
notify_ctx.clone(),
|
||||
)
|
||||
}
|
||||
// Briscola has its own table view for both variants.
|
||||
Some(g) if is_briscola(&g.game_type) => {
|
||||
briscola_table(g, on_hand_card, now)
|
||||
briscola_table(g, on_hand_card, now, notify_ctx.clone())
|
||||
}
|
||||
// Scopa has its own table view for both variants.
|
||||
Some(g) if is_scopa(&g.game_type) => {
|
||||
scopa_table(g, on_hand_card, now, notify_ctx.clone())
|
||||
}
|
||||
// The live table is scopone-specific; other games get a
|
||||
// fallback until they ship their own play view.
|
||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
||||
table_view(g, on_hand_card, selected, now)
|
||||
table_view(g, on_hand_card, selected, now, notify_ctx.clone())
|
||||
}
|
||||
Some(g) => unsupported_game_view(g),
|
||||
})
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
// Briscola chiamata: the auction winner names the called card.
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||
Some(g)
|
||||
if is_chiamata(&g.game_type)
|
||||
&& g.phase == "calling"
|
||||
&& g.your_turn == Some(true) =>
|
||||
{
|
||||
call_picker(g, socket)
|
||||
}
|
||||
_ => view! {},
|
||||
})
|
||||
(move || match game.get_clone() {
|
||||
Some(g)
|
||||
if g.phase == "hand_end"
|
||||
&& (g.game_type == "scopone_scientifico"
|
||||
|| is_scopa(&g.game_type)) =>
|
||||
{
|
||||
hand_summary_modal(g, socket, now)
|
||||
}
|
||||
Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => {
|
||||
@@ -433,19 +534,24 @@ fn LobbyView(game: GameView) -> View {
|
||||
let join_code = game.join_code.clone();
|
||||
let watch_link = watch_link(&game.id);
|
||||
|
||||
let show_teams = !is_individual_game(&game.game_type);
|
||||
let players = game
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.name.clone();
|
||||
let seat = p.seat;
|
||||
let team = p.team.clone();
|
||||
let team_badge = show_teams
|
||||
.then(|| p.team.clone())
|
||||
.flatten()
|
||||
.filter(|team| !team.is_empty())
|
||||
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
|
||||
view! {
|
||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||
strong { (name) }
|
||||
span {
|
||||
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
||||
span(class="badge text-bg-info") { "Team " (team) }
|
||||
(team_badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,6 +655,7 @@ fn table_view(
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
selected: Signal<Option<String>>,
|
||||
now: Signal<f64>,
|
||||
notify: NotifyCtx,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
@@ -656,6 +763,7 @@ fn table_view(
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||
let watch_link = watch_link(&game.id);
|
||||
|
||||
view! {
|
||||
@@ -672,6 +780,7 @@ fn table_view(
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
(bell)
|
||||
}
|
||||
div(class="table-grid") {
|
||||
(top)
|
||||
@@ -730,8 +839,10 @@ fn capture_picker(
|
||||
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
|
||||
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
|
||||
let scopa = game.as_ref().is_some_and(|g| is_scopa(&g.game_type));
|
||||
if let Some(g) = &game {
|
||||
if g.game_type != "scopone_scientifico" && !briscola {
|
||||
if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa {
|
||||
return view! {};
|
||||
}
|
||||
}
|
||||
@@ -744,19 +855,32 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
None => view! {},
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
||||
// Games without partnerships (two-player briscola and scopa)
|
||||
// name the winner instead.
|
||||
// Briscola chiamata names the caller's side (or the defenders).
|
||||
let winner_label = match game.as_ref() {
|
||||
Some(g) if is_individual_game(&g.game_type) => {
|
||||
g.player_name_for_team(&winner)
|
||||
}
|
||||
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
|
||||
_ => format!("Team {winner}"),
|
||||
};
|
||||
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
||||
// Spectators have no match history to look at.
|
||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||
// Explain the final hand's (scopone) or deal's (briscola)
|
||||
// scoring before the result.
|
||||
let final_summary: View = if briscola {
|
||||
match game.as_ref().and_then(|g| g.last_deal.clone()) {
|
||||
Some(deal) => deal_rows(&deal),
|
||||
// Explain the final hand's (scopone, scopa) or deal's
|
||||
// (briscola, briscola chiamata) scoring before the result.
|
||||
let final_summary: View = if briscola || chiamata {
|
||||
match game.as_ref() {
|
||||
Some(g) => match g.last_deal.clone() {
|
||||
Some(deal) => deal_rows(g, &deal),
|
||||
None => view! {},
|
||||
},
|
||||
None => view! {},
|
||||
}
|
||||
} else {
|
||||
match game.as_ref().and_then(|g| g.last_hand.clone()) {
|
||||
Some(hand) => summary_rows(hand),
|
||||
match game.as_ref().and_then(|g| g.last_hand.clone().map(|h| (g.clone(), h))) {
|
||||
Some((g, hand)) => summary_rows(&g, hand),
|
||||
None => view! {},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,34 +12,58 @@ use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||
|
||||
/// Distinct team labels in seat order; players without a team render in
|
||||
/// a shared "Players" column.
|
||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
||||
let mut teams = BTreeSet::new();
|
||||
/// The match's teams, each as a `(label, names)` pair, in a readable
|
||||
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
|
||||
/// then other team labels alphabetically (A, B, ...), with team-less
|
||||
/// players last.
|
||||
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
|
||||
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
|
||||
for p in &m.players {
|
||||
teams.insert(p.team.clone());
|
||||
labels.insert(p.team.clone());
|
||||
}
|
||||
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
|
||||
// Seated teams first (in first-seat order), then the team-less column.
|
||||
ordered.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
|
||||
ordered.sort_by_key(team_sort_key);
|
||||
ordered
|
||||
.into_iter()
|
||||
.map(|label| (team_header(&label), team_names(m, &label)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sort key for team labels: chiamata roles in play order, other labels
|
||||
/// alphabetically, team-less players last.
|
||||
fn team_sort_key(label: &Option<String>) -> (u8, String) {
|
||||
match label.as_deref() {
|
||||
Some("Chiamante") => (0, String::new()),
|
||||
Some("Socio") => (1, String::new()),
|
||||
Some("Difensori") => (2, String::new()),
|
||||
Some(other) => (0, other.to_string()),
|
||||
None => (9, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||
m.players
|
||||
let names = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| &p.team == team)
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
.collect::<Vec<_>>();
|
||||
match names.len() {
|
||||
0 => String::new(),
|
||||
1 => names[0].clone(),
|
||||
_ => {
|
||||
let (last, rest) = names.split_last().expect("non-empty");
|
||||
format!("{} & {}", rest.join(", "), last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn team_header(team: &Option<String>) -> String {
|
||||
match team {
|
||||
Some(t) => format!("Team {t}"),
|
||||
// Single-letter labels are the A/B team names; longer labels
|
||||
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
|
||||
Some(t) if t.len() <= 2 => format!("Team {t}"),
|
||||
Some(t) => t.clone(),
|
||||
None => "Players".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -55,7 +79,11 @@ fn score_text(m: &MatchSummary) -> String {
|
||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||
fn winner_text(m: &MatchSummary) -> String {
|
||||
if let Some(team) = m.result_str("winner_team") {
|
||||
return format!("Team {team}");
|
||||
return if team.len() <= 2 {
|
||||
format!("Team {team}")
|
||||
} else {
|
||||
team
|
||||
};
|
||||
}
|
||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
@@ -98,18 +126,29 @@ pub fn HistoryPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::my_matches(next.as_deref()).await {
|
||||
Ok(p) => {
|
||||
// The page can unmount (e.g. the login redirect) while
|
||||
// the request is in flight.
|
||||
if !page.is_alive() {
|
||||
return;
|
||||
}
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
Err(e) => {
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(types) = api::game_types().await {
|
||||
game_types.set(types);
|
||||
if game_types.is_alive() {
|
||||
game_types.set(types);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,11 +156,18 @@ pub fn HistoryPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::my_matches(None).await {
|
||||
Ok(p) => {
|
||||
if !page.is_alive() {
|
||||
return;
|
||||
}
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.set(p.results.clone());
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
Err(e) => {
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,10 +196,18 @@ pub fn HistoryPage() -> View {
|
||||
.map(|m| {
|
||||
let finished = local_finished(&m.finished_at);
|
||||
let game = game_name(&names, &m.game_type);
|
||||
let mut cells: Vec<View> = team_columns(&m)
|
||||
.iter()
|
||||
.map(|t| team_names(&m, t))
|
||||
.map(|c| view! { td { (c) } })
|
||||
let teams: Vec<View> = team_groups(&m)
|
||||
.into_iter()
|
||||
.map(|(label, names)| {
|
||||
view! {
|
||||
div(class="match-team") {
|
||||
span(class="text-secondary") {
|
||||
(label) ": "
|
||||
}
|
||||
(names)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let score = score_text(&m);
|
||||
let winner = winner_text(&m);
|
||||
@@ -173,37 +227,18 @@ pub fn HistoryPage() -> View {
|
||||
} else {
|
||||
"text-secondary"
|
||||
};
|
||||
let mut row_cells = vec![
|
||||
let row_cells = vec![
|
||||
view! { td { (finished) } },
|
||||
view! { td { (game) } },
|
||||
view! { td { (teams) } },
|
||||
view! { td { (score) } },
|
||||
view! { td { (winner) } },
|
||||
view! { td(class=cls) { (outcome) } },
|
||||
view! { td(class=cls) { (elo_delta) } },
|
||||
];
|
||||
row_cells.append(&mut cells);
|
||||
row_cells.push(view! { td { (score) } });
|
||||
row_cells.push(view! { td { (winner) } });
|
||||
row_cells.push(view! { td(class=cls) { (outcome) } });
|
||||
row_cells.push(view! { td(class=cls) { (elo_delta) } });
|
||||
view! { tr { (row_cells) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Team columns are per-row (matches may mix games), so
|
||||
// the header shows the union across the loaded rows.
|
||||
let mut header_teams = BTreeSet::new();
|
||||
for m in rows.get_clone().iter() {
|
||||
for t in team_columns(m) {
|
||||
header_teams.insert(t);
|
||||
}
|
||||
}
|
||||
let mut header_teams: Vec<Option<String>> =
|
||||
header_teams.into_iter().collect();
|
||||
header_teams.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let header_cells: Vec<View> = header_teams
|
||||
.iter()
|
||||
.map(team_header)
|
||||
.map(|h| view! { th { (h) } })
|
||||
.collect();
|
||||
view! {
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-striped table-hover align-middle") {
|
||||
@@ -211,7 +246,7 @@ pub fn HistoryPage() -> View {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Game" }
|
||||
(header_cells)
|
||||
th { "Teams" }
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
th { "You" }
|
||||
@@ -236,3 +271,96 @@ pub fn HistoryPage() -> View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn match_summary(players: serde_json::Value) -> MatchSummary {
|
||||
serde_json::from_value(json!({
|
||||
"id": "m1",
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"finished_at": "2026-01-01T01:00:00+00:00",
|
||||
"players": players,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_groups_order_chiamata_roles() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": "Difensori", "won": false},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "Chiamante", "won": true},
|
||||
{"user_sub": "c", "display_name": "Carol", "seat": 2,
|
||||
"team": "Difensori", "won": false},
|
||||
{"user_sub": "d", "display_name": "Dave", "seat": 3,
|
||||
"team": "Socio", "won": true},
|
||||
{"user_sub": "e", "display_name": "Erin", "seat": 4,
|
||||
"team": "Difensori", "won": false},
|
||||
]));
|
||||
assert_eq!(
|
||||
vec![
|
||||
("Chiamante".to_string(), "Bob".to_string()),
|
||||
("Socio".to_string(), "Dave".to_string()),
|
||||
(
|
||||
"Difensori".to_string(),
|
||||
"Alice, Carol & Erin".to_string()
|
||||
),
|
||||
],
|
||||
team_groups(&m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_groups_keep_a_and_b_in_order() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": "A", "won": true},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "B", "won": false},
|
||||
]));
|
||||
assert_eq!(
|
||||
vec![
|
||||
("Team A".to_string(), "Alice".to_string()),
|
||||
("Team B".to_string(), "Bob".to_string()),
|
||||
],
|
||||
team_groups(&m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_groups_put_teamless_players_last() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": null, "won": false},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "A", "won": true},
|
||||
]));
|
||||
assert_eq!(
|
||||
vec![
|
||||
("Team A".to_string(), "Bob".to_string()),
|
||||
("Players".to_string(), "Alice".to_string()),
|
||||
],
|
||||
team_groups(&m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_names_join_three_names_readably() {
|
||||
let m = match_summary(json!([
|
||||
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||
"team": "A", "won": true},
|
||||
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||
"team": "A", "won": true},
|
||||
{"user_sub": "c", "display_name": "Carol", "seat": 2,
|
||||
"team": "A", "won": true},
|
||||
]));
|
||||
assert_eq!(
|
||||
"Alice, Bob & Carol",
|
||||
team_names(&m, &Some("A".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,148 @@
|
||||
//! Global leaderboard page.
|
||||
//! Per-game leaderboard page.
|
||||
//!
|
||||
//! Elo ratings and the leaderboard aggregates live in one
|
||||
//! `player_rating` row per player and game, so the board is
|
||||
//! page-number paginated: the first load lands on the page holding the
|
||||
//! signed-in player's own ranking (the server resolves it with
|
||||
//! `focus=me`) and a footer pager changes pages.
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{fmt_points, LeaderboardPage};
|
||||
use crate::model::{
|
||||
fallback_game_types, fmt_points, LeaderboardPage, LEADERBOARD_PAGE_SIZE,
|
||||
};
|
||||
|
||||
/// Page numbers to show in the pager: the first and last page, the
|
||||
/// current page and its neighbours, with `None` marking an ellipsis.
|
||||
fn page_window(page: u32, pages: u32) -> Vec<Option<u32>> {
|
||||
if pages <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut window = vec![Some(1)];
|
||||
let start = page.saturating_sub(2).max(2);
|
||||
let end = (page + 2).min(pages.saturating_sub(1));
|
||||
if start > 2 {
|
||||
window.push(None);
|
||||
}
|
||||
for number in start..=end {
|
||||
window.push(Some(number));
|
||||
}
|
||||
if end + 1 < pages {
|
||||
window.push(None);
|
||||
}
|
||||
window.push(Some(pages));
|
||||
window
|
||||
}
|
||||
|
||||
/// The footer pager: first/previous, page numbers, next/last. Rendered
|
||||
/// only when the board has more than one page.
|
||||
fn pager(
|
||||
page: &LeaderboardPage,
|
||||
busy: bool,
|
||||
load: impl Fn(Option<u32>, bool) + Copy + 'static,
|
||||
) -> View {
|
||||
let current = page.page;
|
||||
let pages = page.pages;
|
||||
if pages <= 1 {
|
||||
return view! {};
|
||||
}
|
||||
let button = move |label: &'static str, target: u32, enabled: bool| {
|
||||
view! {
|
||||
li(class=if enabled { "page-item" } else { "page-item disabled" }) {
|
||||
button(
|
||||
class="page-link",
|
||||
disabled=!enabled || busy,
|
||||
on:click=move |_| if enabled && !busy {
|
||||
load(Some(target), false);
|
||||
},
|
||||
) { (label) }
|
||||
}
|
||||
}
|
||||
};
|
||||
let items = page_window(current, pages)
|
||||
.into_iter()
|
||||
.map(|entry| match entry {
|
||||
None => view! {
|
||||
li(class="page-item disabled") {
|
||||
span(class="page-link") { "…" }
|
||||
}
|
||||
},
|
||||
Some(number) => {
|
||||
let active = number == current;
|
||||
view! {
|
||||
li(class=if active { "page-item active" } else { "page-item" }) {
|
||||
button(
|
||||
class="page-link",
|
||||
disabled=active || busy,
|
||||
on:click=move |_| if !active && !busy {
|
||||
load(Some(number), false);
|
||||
},
|
||||
) { (number) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
nav(aria-label="Leaderboard pages") {
|
||||
ul(class="pagination justify-content-center mt-4") {
|
||||
(button("«", 1, current > 1))
|
||||
(button("‹", current.saturating_sub(1).max(1), current > 1))
|
||||
(items)
|
||||
(button("›", (current + 1).min(pages), current < pages))
|
||||
(button("»", pages, current < pages))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LeaderboardPage() -> View {
|
||||
let page = create_signal(Option::<LeaderboardPage>::None);
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let game_types = create_signal(fallback_game_types());
|
||||
let selected_game = create_signal("scopone_scientifico".to_string());
|
||||
// A leaderboard request is in flight; the pager is disabled meanwhile.
|
||||
let loading = create_signal(false);
|
||||
|
||||
let load = move |target: Option<u32>, focus_me: bool| {
|
||||
let game_type = selected_game.get_clone();
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
let response = api::leaderboard(&game_type, target, focus_me).await;
|
||||
// A response for a game the user has already switched away
|
||||
// from is dropped (and must not clear the new game's spinner).
|
||||
if selected_game.get_clone() != game_type {
|
||||
return;
|
||||
}
|
||||
loading.set(false);
|
||||
match response {
|
||||
Ok(p) => page.set(Some(p)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Reload whenever the selection changes; the first load focuses the
|
||||
// viewer's own page so they see their name right away.
|
||||
create_effect(move || {
|
||||
let _game = selected_game.get_clone();
|
||||
page.set(None);
|
||||
load(None, true);
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
match api::leaderboard().await {
|
||||
Ok(p) => page.set(Some(p)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
match api::game_types().await {
|
||||
Ok(types) if !types.is_empty() => {
|
||||
let current = selected_game.get_clone();
|
||||
if !types.iter().any(|g| g.id == current) {
|
||||
selected_game.set(types[0].id.clone());
|
||||
}
|
||||
game_types.set(types);
|
||||
}
|
||||
_ => {} // keep the fallback list
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,55 +150,111 @@ pub fn LeaderboardPage() -> View {
|
||||
div {
|
||||
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
||||
div(class="container page-shell py-4") {
|
||||
h1(class="h3 mb-4") { "Leaderboard" }
|
||||
div(
|
||||
class="d-flex flex-wrap gap-3 align-items-center \
|
||||
justify-content-between mb-4"
|
||||
) {
|
||||
h1(class="h3 mb-0") { "Leaderboard" }
|
||||
div(class="d-flex align-items-center gap-2") {
|
||||
label(class="form-label mb-0", r#for="leaderboard-game") {
|
||||
"Game"
|
||||
}
|
||||
select(
|
||||
class="form-select w-auto",
|
||||
id="leaderboard-game",
|
||||
bind:value=selected_game,
|
||||
) {
|
||||
Keyed(
|
||||
list=game_types,
|
||||
view=|g| view! {
|
||||
option(value=g.id.clone()) { (g.name) }
|
||||
},
|
||||
key=|g| g.id.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
(toast(error))
|
||||
(move || match page.get_clone() {
|
||||
None => view! {
|
||||
div(class="d-flex justify-content-center p-5") {
|
||||
div(class="spinner-border text-warning", role="status") {
|
||||
span(class="visually-hidden") { "Loading…" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(p) if p.results.is_empty() => view! {
|
||||
p(class="text-secondary text-center p-5") {
|
||||
"No ratings yet — finish a match to appear here."
|
||||
}
|
||||
},
|
||||
Some(p) => {
|
||||
let rows = p
|
||||
.results
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
view! {
|
||||
tr {
|
||||
td { (i + 1) }
|
||||
td { (e.display_name.clone()) }
|
||||
td { (e.elo) }
|
||||
td { (e.wins) }
|
||||
td { (e.matches) }
|
||||
td { (fmt_points(e.points)) }
|
||||
}
|
||||
(move || {
|
||||
let busy = loading.get();
|
||||
match page.get_clone() {
|
||||
None => view! {
|
||||
div(class="d-flex justify-content-center p-5") {
|
||||
div(class="spinner-border text-warning", role="status") {
|
||||
span(class="visually-hidden") { "Loading…" }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-striped table-hover align-middle") {
|
||||
thead {
|
||||
tr {
|
||||
th { "#" }
|
||||
th { "Player" }
|
||||
th { "Elo" }
|
||||
th { "Wins" }
|
||||
th { "Matches" }
|
||||
th { "Points" }
|
||||
}
|
||||
},
|
||||
Some(p) if p.results.is_empty() => view! {
|
||||
p(class="text-secondary text-center p-5") {
|
||||
"No ratings yet — finish a match to appear here."
|
||||
}
|
||||
},
|
||||
Some(p) => {
|
||||
let rows = p
|
||||
.results
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
let rank = (p.page.saturating_sub(1))
|
||||
* LEADERBOARD_PAGE_SIZE
|
||||
+ i as u32
|
||||
+ 1;
|
||||
let is_me = p.your_rank == Some(rank);
|
||||
let cls = if is_me {
|
||||
"table-active fw-semibold"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
view! {
|
||||
tr(class=cls) {
|
||||
td { (rank) }
|
||||
td {
|
||||
(e.display_name.clone())
|
||||
(is_me.then(|| view! {
|
||||
span(
|
||||
class="badge text-bg-warning ms-2"
|
||||
) {
|
||||
"you"
|
||||
}
|
||||
}))
|
||||
}
|
||||
td { (e.elo) }
|
||||
td { (e.wins) }
|
||||
td { (e.matches) }
|
||||
td { (fmt_points(e.points)) }
|
||||
}
|
||||
}
|
||||
tbody { (rows) }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let pager_view = pager(&p, busy, load);
|
||||
let rated = if p.total == 1 {
|
||||
"1 player rated".to_string()
|
||||
} else {
|
||||
format!("{} players rated", p.total)
|
||||
};
|
||||
view! {
|
||||
p(class="text-secondary small mb-2") { (rated) }
|
||||
div(class="table-responsive") {
|
||||
table(
|
||||
class="table table-striped table-hover \
|
||||
align-middle"
|
||||
) {
|
||||
thead {
|
||||
tr {
|
||||
th { "#" }
|
||||
th { "Player" }
|
||||
th { "Elo" }
|
||||
th { "Wins" }
|
||||
th { "Matches" }
|
||||
th { "Points" }
|
||||
}
|
||||
}
|
||||
tbody { (rows) }
|
||||
}
|
||||
}
|
||||
(pager_view)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,3 +263,44 @@ pub fn LeaderboardPage() -> View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_window_small_boards() {
|
||||
// Boards with a single page (or none) have no pager at all.
|
||||
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 0));
|
||||
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 1));
|
||||
assert_eq!(vec![Some(1), Some(2)], page_window(1, 2));
|
||||
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(1, 3));
|
||||
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_window_ellipsizes_long_boards() {
|
||||
assert_eq!(
|
||||
vec![Some(1), Some(2), Some(3), None, Some(10)],
|
||||
page_window(1, 10)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![
|
||||
Some(1),
|
||||
None,
|
||||
Some(3),
|
||||
Some(4),
|
||||
Some(5),
|
||||
Some(6),
|
||||
Some(7),
|
||||
None,
|
||||
Some(10),
|
||||
],
|
||||
page_window(5, 10)
|
||||
);
|
||||
assert_eq!(
|
||||
vec![Some(1), None, Some(8), Some(9), Some(10)],
|
||||
page_window(10, 10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||