Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5c64218f
|
||
|
|
03a571a6a5
|
||
|
|
517ac6fb46
|
||
|
|
46f10f145a
|
||
|
|
a9eb97e6e1
|
||
|
|
e98d43e59a
|
||
|
|
1ab748a34f
|
||
|
|
ae7ab5ce60
|
||
|
|
68200bbe62
|
||
|
|
9eea293471
|
||
|
|
4ba5ae1434
|
||
|
|
0f9fb9b6bb
|
||
|
|
322ca27bf4
|
||
|
|
0674e06565
|
||
|
|
353f012906
|
||
|
|
6427afd12b
|
||
|
|
d2dae476f0
|
||
|
|
18877b8109
|
||
|
|
b821274f0b
|
||
|
|
6222e38da3
|
||
|
|
97d4b3f217
|
@@ -7,11 +7,11 @@
|
|||||||
.env.example
|
.env.example
|
||||||
**/.venv/
|
**/.venv/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.pyc
|
**/*.pyc
|
||||||
*.pyo
|
**/*.pyo
|
||||||
*.egg-info/
|
**/*.egg-info/
|
||||||
.mypy_cache/
|
**/.mypy_cache/
|
||||||
.pytest_cache/
|
**/.pytest_cache/
|
||||||
web/target/
|
web/target/
|
||||||
web/dist/
|
web/dist/
|
||||||
server/tests/
|
server/tests/
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
# tavolo
|
# 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
|
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||||
game — and **briscola**, in its classic two-player and four-player
|
game — **scopa**, in its classic two-player and four-player partnership
|
||||||
partnership (*briscola a quattro*) variants:
|
(*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)
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
A monorepo of four distributions sharing the `tavolo` namespace: the
|
A monorepo of five distributions sharing the `tavolo` namespace: the
|
||||||
game-independent `tavolo-platform`, the `tavolo-scopone` and
|
game-independent `tavolo-platform`, the `tavolo-scopone`,
|
||||||
`tavolo-briscola` games, and the `tavolo-app` composition root wiring
|
`tavolo-scopa` and `tavolo-briscola` games, and the `tavolo-app`
|
||||||
them together. See [server/README.md](server/README.md).
|
composition root wiring them together. See
|
||||||
|
[server/README.md](server/README.md).
|
||||||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||||
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||||||
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
||||||
`web/vendor/` so builds work offline) plus the custom felt theme in
|
`web/vendor/` so builds work offline) plus the custom felt theme in
|
||||||
`web/theme.css`. Card images are the
|
`web/theme.css`. Also an installable PWA — see [PWA](#pwa). Card images
|
||||||
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
are the CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||||
|
|
||||||
## Run the whole stack
|
## Run the whole stack
|
||||||
@@ -39,13 +44,16 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
|||||||
## Between hands
|
## Between hands
|
||||||
|
|
||||||
When a hand ends but the match is not decided, the game pauses on a
|
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
|
**scoring summary screen**: scopone and scopa players see how each
|
||||||
(carte, denara, settebello, primiera, scope — plus napola and asso
|
category was won (carte, denara, settebello, primiera, scope — plus
|
||||||
pigliatutto when enabled), briscola players see the deal's points and any
|
napola and asso pigliatutto when enabled), briscola players see the
|
||||||
*patta*; both see the running totals and must click "Understood" before
|
deal's points and any *patta*; both see the running totals and must click
|
||||||
the next hand is dealt. If someone is away the next hand is dealt
|
"Understood" before the next hand is dealt. If someone is away the next
|
||||||
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
|
hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` (default
|
||||||
match-ending hand is explained on the final screen.
|
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
|
## 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
|
player does not move — disconnected or fallen asleep — the server plays a
|
||||||
random legal card for them (choosing among the legal captures when the
|
random legal card for them (choosing among the legal captures when the
|
||||||
rules require a capture), so one absent player cannot stall the table.
|
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
|
## Watching
|
||||||
|
|
||||||
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
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
|
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
|
"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
|
the URL to share. A spectator who opens the URL before the match starts
|
||||||
sees a "game not started yet" waiting room.
|
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
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`), using [uv](https://docs.astral.sh/uv/):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd server
|
cd server
|
||||||
python3 -m venv .venv
|
uv sync --package tavolo-app --extra otel
|
||||||
.venv/bin/pip install -r requirements.txt
|
uv run python -m unittest discover -s tests -t .
|
||||||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
|
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||||
.venv/bin/python -m unittest discover -s tests -t .
|
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
.venv/bin/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:
|
Frontend (from `web/`), with the backend running on :8080:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -96,8 +144,10 @@ OIDC_0_CLIENT_ID=tavolo \
|
|||||||
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
|
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
|
||||||
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||||
OIDC_0_POST_LOGOUT_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`
|
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,
|
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
|
||||||
# disabled unless OTEL_ENABLED is set.
|
# disabled unless OTEL_ENABLED is set.
|
||||||
# The image bundles the otel extra (see packages/tavolo-app/pyproject.toml
|
# 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_ENABLED: ${OTEL_ENABLED:-}
|
||||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
|
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
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
|
# builder alpine python -> python venv with the backend + deps
|
||||||
# runtime alpine python + the venv + the compiled frontend
|
# 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
|
# caches persist in the builder's cache across builds instead of being
|
||||||
# re-downloaded, and never land in the image layers.
|
# 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);
|
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
||||||
# web/fetch-cards.sh can regenerate them.
|
# 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 ./
|
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
|
COPY web/assets ./assets
|
||||||
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
|
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
|
||||||
# image builds and runs without network access.
|
# image builds and runs without network access.
|
||||||
@@ -46,29 +49,43 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
# --- Python builder ----------------------------------------------------------
|
# --- Python builder ----------------------------------------------------------
|
||||||
FROM alpine:3.24 AS 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 \
|
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
|
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 ./
|
# The five sibling distributions are workspace members (PEP 420 namespace
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
# packages sharing the `tavolo` namespace). --no-editable installs them as
|
||||||
python3 -m venv /opt/venv \
|
# wheels, so the runtime image does not need their source tree.
|
||||||
&& /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.
|
|
||||||
COPY server/packages/ ./packages/
|
COPY server/packages/ ./packages/
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-briscola ./packages/tavolo-app
|
uv sync --locked --no-editable --package tavolo-app --extra otel --no-dev
|
||||||
# server/pyproject.toml only carries the [tool.aerich] section (the
|
|
||||||
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
|
|
||||||
# next to ./migrations).
|
|
||||||
COPY server/pyproject.toml ./
|
|
||||||
# aerich migration files are a release artifact: the db-migrate compose
|
# aerich migration files are a release artifact: the db-migrate compose
|
||||||
# service runs `aerich upgrade` from this image before the app starts.
|
# service runs `aerich upgrade` from this image before the app starts.
|
||||||
COPY server/migrations/ ./migrations/
|
COPY server/migrations/ ./migrations/
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# tavolo (backend)
|
# tavolo (backend)
|
||||||
|
|
||||||
The backend for a multiplayer card-game platform, built on the
|
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
|
sharing the `tavolo` namespace (see
|
||||||
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
|
[`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
|
and
|
||||||
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
|
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
|
||||||
|
|
||||||
@@ -15,10 +16,15 @@ and
|
|||||||
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
|
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
|
||||||
scientifico, the four-player, fixed-partnership variant of the classic
|
scientifico, the four-player, fixed-partnership variant of the classic
|
||||||
Italian card game: pure rules plus the platform adapter.
|
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
|
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
||||||
Italian trick-taking game, in its classic two-player (`briscola`) and
|
Italian trick-taking game, in its classic two-player (`briscola`),
|
||||||
four-player partnership (`briscola_a_quattro`) variants: pure rules
|
four-player partnership (`briscola_a_quattro`) and five-player
|
||||||
plus the platform adapter.
|
called-partner (`briscola_chiamata`) variants: pure rules plus the
|
||||||
|
platform adapter.
|
||||||
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||||
composition root wiring the platform to the games, with the
|
composition root wiring the platform to the games, with the
|
||||||
environment-driven settings and the SPA shell.
|
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
|
session cookie. That cookie authenticates both the REST API and the
|
||||||
WebSocket endpoint.
|
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
|
## 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_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_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
| `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_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_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
|
||||||
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
||||||
@@ -235,14 +243,20 @@ aggregate with `max`, not `sum`.
|
|||||||
scoped per game), start/finish timestamps, and the game-specific outcome
|
scoped per game), start/finish timestamps, and the game-specific outcome
|
||||||
as reported by the engine (`result` JSON — for scopone: both teams'
|
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||||
final scores, winner, target score, hands played and the per-hand
|
final scores, winner, target score, hands played and the per-hand
|
||||||
audit; for briscola: the final scores, winner, target score, deals
|
audit; for scopa: both sides' final scores, winner, target score, the
|
||||||
played and the per-deal audit).
|
asso pigliatutto flag, hands played and the per-hand audit; for
|
||||||
|
briscola: the final scores, winner, target score, deals played and the
|
||||||
|
per-deal audit; for briscola chiamata: the calling side's points
|
||||||
|
against the defenders', the declared target, the called card and
|
||||||
|
whether the caller played alone).
|
||||||
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||||
seat, game-defined team label (nullable), whether they won, the points
|
seat, game-defined team label (nullable), whether they won, the points
|
||||||
they scored, the Elo change the match produced (`elo_delta`) and
|
they scored, the Elo change the match produced (`elo_delta`) and
|
||||||
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||||
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
- `player_rating` — current Elo rating and leaderboard totals per
|
||||||
the number of rated matches played.
|
`(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
|
When a match ends, the engine's result is written transactionally to
|
||||||
Postgres (once, guarded by a flag on the session); the finished session
|
Postgres (once, guarded by a flag on the session); the finished session
|
||||||
@@ -256,13 +270,13 @@ Players carry a chess-style Elo rating per game type
|
|||||||
mean of its members, and the standard formula
|
mean of its members, and the standard formula
|
||||||
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
|
`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
|
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
|
a team, zero-sum between the two teams. Ratings and the leaderboard
|
||||||
transaction as the match result. To recompute every rating from the
|
aggregates update in the same transaction as the match result. To
|
||||||
recorded match history (e.g. to backfill matches recorded before ratings
|
rebuild every rating and aggregate from the recorded match history (e.g.
|
||||||
existed):
|
to backfill matches recorded before ratings existed):
|
||||||
|
|
||||||
```sh
|
```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
|
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -275,13 +289,14 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
| `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 |
|
| `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/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/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/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/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
|
## WebSocket protocol
|
||||||
|
|
||||||
@@ -308,7 +323,7 @@ platform handles `state` (resend the snapshot) itself; every other action
|
|||||||
is dispatched to the session's game engine with the rest of the message
|
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
|
as its payload. Spectators may only send `state`/`sync`: any other
|
||||||
action is answered with an `error` whose `code` is `forbidden`. For
|
action is answered with an `error` whose `code` is `forbidden`. For
|
||||||
scopone scientifico:
|
scopone scientifico and scopa (both variants):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
@@ -340,6 +355,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
|
|||||||
every player has acknowledged, or automatically after
|
every player has acknowledged, or automatically after
|
||||||
`HAND_ACK_TIMEOUT_SECONDS`.
|
`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
|
After every accepted move the new state is broadcast to every connected
|
||||||
client, spectators included.
|
client, spectators included.
|
||||||
|
|
||||||
@@ -352,18 +385,21 @@ is required), so a disconnected or idle player cannot stall the match. The
|
|||||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||||
queue (see above), not from timers tied to client connections, so the
|
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
|
### Hand-end summary
|
||||||
|
|
||||||
When a hand finishes but the match continues, the game enters the
|
When a hand finishes but the match continues, the game enters the
|
||||||
`hand_end` phase instead of dealing immediately: the state carries the
|
`hand_end` phase instead of dealing immediately: the state carries the
|
||||||
scoring breakdown of the hand just played (`last_hand` with an `award`
|
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
|
map naming the side that won each scopone/scopa category; `last_deal`
|
||||||
deal's points and running totals for briscola), the `acknowledged` seats
|
with the deal's points and running totals for briscola), the
|
||||||
and a `hand_end_deadline`. The frontend renders this as a screen every
|
`acknowledged` seats and a `hand_end_deadline`. The frontend renders this
|
||||||
player must dismiss. A play attempted in this phase is rejected with an
|
as a screen every player must dismiss. A play attempted in this phase is
|
||||||
`illegal_move` error.
|
rejected with an `illegal_move` error.
|
||||||
|
|
||||||
## Rules implemented
|
## Rules implemented
|
||||||
|
|
||||||
@@ -388,9 +424,42 @@ player must dismiss. A play attempted in this phase is rejected with an
|
|||||||
empty. The capture never scores a scopa, but it counts as the last
|
empty. The capture never scores a scopa, but it counts as the last
|
||||||
capture, and the mandatory equal-rank capture still applies when another
|
capture, and the mandatory equal-rank capture still applies when another
|
||||||
ace is on the table.
|
ace is on the table.
|
||||||
- The match ends when a team reaches the target score (default 11,
|
- The match ends when a team reaches the target score (default 21,
|
||||||
configurable per game via the `target_score` creation option) with a
|
the traditional partnership target, configurable per game via the
|
||||||
clear lead; a tie at or above the target is broken by another hand.
|
`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
|
### Briscola
|
||||||
|
|
||||||
@@ -415,24 +484,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
|
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||||
opposite).
|
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
|
## 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
|
```sh
|
||||||
python3 -m venv .venv
|
uv sync --package tavolo-app --extra otel
|
||||||
.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]'
|
|
||||||
# app integration suite:
|
# 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):
|
# per-package suites (each package is self-contained):
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
uv run 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-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):
|
# type checks (see packages/tavolo-app/README.md for the app modules):
|
||||||
.venv/bin/python -m mypy -p tavolo.platform
|
uv run mypy -p tavolo.platform
|
||||||
.venv/bin/python -m mypy -p tavolo.scopone
|
uv run mypy -p tavolo.scopone
|
||||||
.venv/bin/python -m mypy -p tavolo.briscola
|
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
|
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` /
|
for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
|
||||||
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
|
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
|
||||||
@@ -447,8 +552,8 @@ To add a migration after changing
|
|||||||
`packages/tavolo-platform/src/tavolo/platform/models.py`:
|
`packages/tavolo-platform/src/tavolo/platform/models.py`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run 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 upgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
||||||
@@ -468,7 +573,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
|
|||||||
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
||||||
├── elo.py # chess-style Elo math (1500 start, K=32)
|
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||||
├── stats.py # MatchResult -> Postgres persistence + Elo update
|
├── 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)
|
├── store.py # Redis / in-memory live-session store (+ deadline queue)
|
||||||
├── deadlines.py # connection-independent timeout scheduler
|
├── deadlines.py # connection-independent timeout scheduler
|
||||||
├── ws.py # WebSocket live-play endpoint (generic envelope)
|
├── ws.py # WebSocket live-play endpoint (generic envelope)
|
||||||
@@ -488,12 +593,19 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
|
|||||||
├── engine.py # pure scopone scientifico rules
|
├── engine.py # pure scopone scientifico rules
|
||||||
├── errors.py # scopone-specific rule violations
|
├── errors.py # scopone-specific rule violations
|
||||||
└── plugin.py # ScoponeEngine: the GameEngine adapter
|
└── 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)
|
packages/tavolo-briscola/ # briscola game (own pyproject + tests)
|
||||||
src/tavolo/briscola/
|
src/tavolo/briscola/
|
||||||
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
||||||
├── engine.py # pure briscola rules (2- and 4-player)
|
├── 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
|
├── 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)
|
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||||
src/tavolo/
|
src/tavolo/
|
||||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||||
@@ -502,7 +614,8 @@ packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
|||||||
├── aerich_config.py # aerich CLI configuration
|
├── aerich_config.py # aerich CLI configuration
|
||||||
├── logging_config.py # logging setup
|
├── logging_config.py # logging setup
|
||||||
└── static.py # SPA shell hosting
|
└── 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
|
migrations/ # aerich migrations for the platform models
|
||||||
tests/ # app integration suite (platform + games wired together)
|
tests/ # app integration suite (platform + games wired together)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"claims": {
|
"claims": {
|
||||||
"sub": "alice",
|
"sub": "alice",
|
||||||
"preferred_username": "alice",
|
"preferred_username": "alice",
|
||||||
"name": "Alice"
|
"name": "Alice",
|
||||||
|
"picture": "https://i.pravatar.cc/128?u=alice"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
"claims": {
|
"claims": {
|
||||||
"sub": "bob",
|
"sub": "bob",
|
||||||
"preferred_username": "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"
|
||||||
|
)
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
The deployable tavolo application: the composition root wiring
|
The deployable tavolo application: the composition root wiring
|
||||||
[`tavolo-platform`](../tavolo-platform/README.md) to the
|
[`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.
|
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
|
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
|
||||||
mixins plus `PlatformMixin` and the deadline scheduler), registers the
|
mixins plus `PlatformMixin` and the deadline scheduler), registers the
|
||||||
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
|
`ScoponeEngine`, `ScopaEngine`, `ScopaQuattroEngine`, `BriscolaEngine`,
|
||||||
their configured timeouts, and exposes `tavolo.app:app` for granian.
|
`BriscolaQuattroEngine` and `BriscolaChiamataEngine` with their
|
||||||
|
configured timeouts, and exposes `tavolo.app:app` for granian.
|
||||||
- `config.py` — environment-driven frozen `Settings`.
|
- `config.py` — environment-driven frozen `Settings`.
|
||||||
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
|
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
|
||||||
meter/tracer/logger providers, the
|
meter/tracer/logger providers, the
|
||||||
@@ -27,7 +29,7 @@ The deployable tavolo application: the composition root wiring
|
|||||||
## Development (from `packages/tavolo-app/`)
|
## Development (from `packages/tavolo-app/`)
|
||||||
|
|
||||||
```sh
|
```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/app.py \
|
||||||
src/tavolo/config.py \
|
src/tavolo/config.py \
|
||||||
src/tavolo/telemetry.py \
|
src/tavolo/telemetry.py \
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ requires-python = ">=3.10"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"tavolo-platform",
|
"tavolo-platform",
|
||||||
"tavolo-scopone",
|
"tavolo-scopone",
|
||||||
|
"tavolo-scopa",
|
||||||
"tavolo-briscola",
|
"tavolo-briscola",
|
||||||
"kaya-core",
|
"kaya-core",
|
||||||
"kaya-cors",
|
"kaya-cors",
|
||||||
@@ -30,10 +31,6 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
|
||||||
"mypy",
|
|
||||||
"httpx-ws",
|
|
||||||
]
|
|
||||||
otel = [
|
otel = [
|
||||||
"kaya-otel>=0.0.7",
|
"kaya-otel>=0.0.7",
|
||||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||||
@@ -41,6 +38,12 @@ otel = [
|
|||||||
"opentelemetry-instrumentation-system-metrics>=0.66b0",
|
"opentelemetry-instrumentation-system-metrics>=0.66b0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy",
|
||||||
|
"httpx-ws",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
namespaces = true
|
namespaces = true
|
||||||
|
|||||||
@@ -39,11 +39,17 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
|||||||
from kaya.openapi import OpenAPIMixin
|
from kaya.openapi import OpenAPIMixin
|
||||||
from kaya.session import SessionMixin
|
from kaya.session import SessionMixin
|
||||||
from redis.asyncio import Redis
|
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 import GameRegistry, Platform, PlatformMixin
|
||||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||||
|
from tavolo.platform.openapi import SCHEMAS
|
||||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||||
|
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
|
||||||
from tavolo.scopone import ScoponeEngine
|
from tavolo.scopone import ScoponeEngine
|
||||||
|
|
||||||
from .config import Settings, settings
|
from .config import Settings, settings
|
||||||
@@ -132,6 +138,18 @@ registry.register(
|
|||||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
registry.register(
|
||||||
|
ScopaEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
ScopaQuattroEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
registry.register(
|
registry.register(
|
||||||
BriscolaEngine(
|
BriscolaEngine(
|
||||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
@@ -144,6 +162,12 @@ registry.register(
|
|||||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
registry.register(
|
||||||
|
BriscolaChiamataEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||||
log.debug(
|
log.debug(
|
||||||
"game timeouts: hand_ack=%ds turn=%ds",
|
"game timeouts: hand_ack=%ds turn=%ds",
|
||||||
@@ -179,6 +203,9 @@ oidc = OIDCProviders(
|
|||||||
redirect_uri=provider.redirect_uri,
|
redirect_uri=provider.redirect_uri,
|
||||||
post_login_redirect=provider.post_login_redirect,
|
post_login_redirect=provider.post_login_redirect,
|
||||||
post_logout_redirect=provider.post_logout_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,
|
fetch_userinfo=True,
|
||||||
)
|
)
|
||||||
for provider in settings.oidc_providers
|
for provider in settings.oidc_providers
|
||||||
@@ -195,6 +222,7 @@ openapi_mixin = OpenAPIMixin(
|
|||||||
description="Multiplayer card-game platform API",
|
description="Multiplayer card-game platform API",
|
||||||
spec_path="/api/openapi.json",
|
spec_path="/api/openapi.json",
|
||||||
docs_path="/api/docs",
|
docs_path="/api/docs",
|
||||||
|
components={"schemas": SCHEMAS},
|
||||||
)
|
)
|
||||||
tortoise_mixin = TortoiseMixin(
|
tortoise_mixin = TortoiseMixin(
|
||||||
database_url=settings.database_url,
|
database_url=settings.database_url,
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
"""SPA shell hosting for the compiled single-page application.
|
"""SPA shell hosting for the compiled single-page application.
|
||||||
|
|
||||||
Static assets (wasm, js, css, card images) are served by Granian itself
|
Static assets (wasm, js, css, card images, PWA icons) are served by
|
||||||
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
Granian itself under the ``/static`` and ``/assets`` prefixes
|
||||||
Dockerfile) and never reach Python. This module only serves ``index.html``
|
(``GRANIAN_STATIC_PATH_*`` env vars; see the Dockerfile) and never reach
|
||||||
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
Python. This module serves the few files that must live at the site root
|
||||||
every path no API or auth route claimed, so client-side routes
|
from ``STATIC_DIR``:
|
||||||
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
|
||||||
|
|
||||||
kaya-openapi deliberately skips glob routes, so this handler never appears
|
- ``index.html`` at the site root and — via the glob catch-all — for
|
||||||
in the API specification.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from kaya.core import HttpContext
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
@@ -21,14 +29,30 @@ from .app import app
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
async def _send_shell(ctx: HttpContext) -> None:
|
async def _send_from_dist(ctx: HttpContext,
|
||||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
filename: str,
|
||||||
index = Path(settings.static_dir) / "index.html"
|
content_type: str,
|
||||||
if not index.is_file():
|
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)
|
await ctx.send_empty(404)
|
||||||
return
|
return
|
||||||
body = await asyncio.to_thread(index.read_bytes)
|
body = await asyncio.to_thread(file.read_bytes)
|
||||||
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
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("/")
|
@app.GET("/")
|
||||||
@@ -38,6 +62,25 @@ async def index(ctx: HttpContext) -> None:
|
|||||||
await _send_shell(ctx)
|
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)
|
@app.GET("/*", recursive=True)
|
||||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||||
"""SPA fallback: any path that matched no other route renders the app
|
"""SPA fallback: any path that matched no other route renders the app
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Briscola — the Italian trick-taking game — as a
|
Briscola — the Italian trick-taking game — as a
|
||||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
[`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` — 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
|
## Contents
|
||||||
|
|
||||||
@@ -13,27 +15,42 @@ Two variants share one rules core:
|
|||||||
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||||
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||||
envelope, no transport, no I/O.
|
envelope, no transport, no I/O.
|
||||||
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
- `engine.py` — the pure rules engine for two- and four-player briscola:
|
||||||
(trump first, then the led suit; strength ace > three > king > horse >
|
deck, deals, trick resolution (trump first, then the led suit; strength
|
||||||
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
|
||||||
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
(trick winner first, the face-up briscola last), card points (ace 11,
|
||||||
jack 2), patta re-deals and the target-score match end, deal-end
|
three 10, king 4, horse 3, jack 2), patta re-deals and the
|
||||||
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
target-score match end, deal-end acknowledgements and auto-play.
|
||||||
logging, so the whole rule set is unit-testable.
|
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
|
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||||
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||||
(`GameEngine`): the platform-facing adapters. They validate the
|
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||||
`target_score` creation option (61–120, default 61) and extract the
|
The first two validate the `target_score` creation option (61–120,
|
||||||
`MatchResult` (teams, winner, per-player scores and the match summary
|
default 61) and extract the `MatchResult` (teams, winner, per-player
|
||||||
persisted as the match's JSON `result`).
|
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`,
|
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
`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
|
## Rules
|
||||||
|
|
||||||
|
### Briscola (two-player and four-player)
|
||||||
|
|
||||||
- Three cards per player; the next card is turned face up: its suit is
|
- 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 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
|
- 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
|
- Between deals of an unfinished match every player acknowledges the
|
||||||
summary (or the hand-end timeout deals on), exactly like scopone.
|
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/`)
|
## Development (from `server/`)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
.venv/bin/python -m mypy -p tavolo.briscola
|
uv run mypy -p tavolo.briscola
|
||||||
```
|
```
|
||||||
|
|
||||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||||
platform contract (actions, deadlines, serialization, results).
|
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
"""Briscola: tavolo's trick-taking game implementation.
|
"""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
|
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||||
briscola (``briscola``);
|
briscola (``briscola``);
|
||||||
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
- :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`,
|
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||||
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
|
||||||
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
|
||||||
|
adapts them to the platform's
|
||||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||||
game-independent platform can host them.
|
game-independent platform can host them.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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.
|
"""The platform-facing adapter for briscola.
|
||||||
|
|
||||||
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||||
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||||
:class:`BriscolaEngine` and four-player fixed-partnership
|
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||||
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
: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,
|
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||||
keeping all briscola knowledge inside this package. Nothing outside
|
keeping all briscola knowledge inside this package. Nothing outside
|
||||||
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
``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 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 (
|
from .state import (
|
||||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
DEFAULT_TARGET_SCORE,
|
DEFAULT_TARGET_SCORE,
|
||||||
@@ -277,3 +290,299 @@ class BriscolaQuattroEngine(_BriscolaBase):
|
|||||||
min_players = 4
|
min_players = 4
|
||||||
max_players = 4
|
max_players = 4
|
||||||
player_count = 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.
|
revalidate the token, so stale or duplicate deliveries are harmless.
|
||||||
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
||||||
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, 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,
|
- `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.
|
- `elo.py` — chess-style Elo math generalized to two-team matches.
|
||||||
- `backfill_elo.py` — rebuild every rating from the match history:
|
- `backfill_elo.py` — rebuild every rating and aggregate from the match
|
||||||
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||||
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
||||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
|
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
|
||||||
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
|
page-number pagination, shared OpenAPI fragments and response schemas,
|
||||||
|
the TortoiseORM lifecycle mixin.
|
||||||
|
|
||||||
## Adding a game
|
## Adding a game
|
||||||
|
|
||||||
Implement `GameEngine` (see `engine.py` for the full contract), register
|
Implement `GameEngine` (see `engine.py` for the full contract), register
|
||||||
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
||||||
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
`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).
|
[`tavolo-briscola`](../tavolo-briscola/README.md).
|
||||||
|
|
||||||
## Development (from `server/`)
|
## Development (from `server/`)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||||
.venv/bin/python -m mypy -p tavolo.platform
|
uv run mypy -p tavolo.platform
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests run fully in-process against a `DummyEngine` (a two-player toy game
|
Tests run fully in-process against a `DummyEngine` (a two-player toy game
|
||||||
|
|||||||
@@ -47,14 +47,32 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
|||||||
|
|
||||||
|
|
||||||
def display_name(user: OIDCUser) -> str:
|
def display_name(user: OIDCUser) -> str:
|
||||||
"""Best-effort human-readable name for a user."""
|
"""Best-effort human-readable name for a user.
|
||||||
for key in ("name", "preferred_username", "email"):
|
|
||||||
|
``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)
|
value = user.get(key)
|
||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
return value
|
return value
|
||||||
return user.sub
|
return user.sub
|
||||||
|
|
||||||
|
|
||||||
|
def picture(user: OIDCUser) -> Optional[str]:
|
||||||
|
"""The user's avatar URL, when the provider supplies a usable one.
|
||||||
|
|
||||||
|
Only absolute ``http(s)`` URLs are exported: the claim ends up in an
|
||||||
|
``<img src>`` in the SPA, where relative or script-ish schemes have no
|
||||||
|
business.
|
||||||
|
"""
|
||||||
|
value = user.picture
|
||||||
|
if isinstance(value, str) and value.startswith(("http://", "https://")):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
||||||
"""Return the authenticated user of an HTTP request, if any.
|
"""Return the authenticated user of an HTTP request, if any.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Recompute every Elo rating from the recorded match history.
|
"""Recompute every Elo rating and leaderboard aggregate from the history.
|
||||||
|
|
||||||
Ratings are deterministic given the finished matches, so this replays all
|
Ratings and aggregates are deterministic given the finished matches, so
|
||||||
matches in chronological order and rewrites the ``player_rating`` table
|
this replays all matches in chronological order and rewrites the
|
||||||
and each ``match_player.elo_delta`` from scratch. Run any time ratings
|
``player_rating`` table and each ``match_player.elo_delta`` from scratch.
|
||||||
need to be rebuilt::
|
Run any time the leaderboard state needs to be rebuilt::
|
||||||
|
|
||||||
python -m tavolo.platform.backfill_elo --database-url postgres://...
|
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 tortoise.transactions import in_transaction
|
||||||
|
|
||||||
from .stats import apply_elo
|
from .stats import PlayerStats, apply_match_stats
|
||||||
from .tortoise_mixin import TortoiseMixin
|
from .tortoise_mixin import TortoiseMixin
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def backfill_elo() -> int:
|
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
|
from .models import Match, MatchPlayer, PlayerRating
|
||||||
|
|
||||||
replayed = 0
|
replayed = 0
|
||||||
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
|
|||||||
),
|
),
|
||||||
0,
|
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:
|
for player in players:
|
||||||
player.elo_delta = deltas[player.user_sub]
|
player.elo_delta = deltas[player.user_sub]
|
||||||
await player.save()
|
await player.save()
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ completed matches are written here. The schema is game-independent:
|
|||||||
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
||||||
``sub`` to a seat, an optional team label, whether they won, the
|
``sub`` to a seat, an optional team label, whether they won, the
|
||||||
points they scored and their Elo delta.
|
points they scored and their Elo delta.
|
||||||
* :class:`PlayerRating` — current chess-style Elo rating of a player for
|
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
|
||||||
one game type, updated transactionally with every finished match (see
|
totals (wins, points, latest display name) of a player for one game
|
||||||
:mod:`tavolo.platform.elo`).
|
type, updated transactionally with every finished match (see
|
||||||
|
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class MatchPlayer(Model):
|
|||||||
|
|
||||||
|
|
||||||
class PlayerRating(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)
|
id = fields.UUIDField(pk=True)
|
||||||
# OIDC subject of the player; no local users table.
|
# OIDC subject of the player; no local users table.
|
||||||
@@ -78,9 +79,17 @@ class PlayerRating(Model):
|
|||||||
game_type = fields.CharField(max_length=32)
|
game_type = fields.CharField(max_length=32)
|
||||||
rating = fields.IntField(default=INITIAL_RATING)
|
rating = fields.IntField(default=INITIAL_RATING)
|
||||||
matches_played = fields.IntField(default=0)
|
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)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "player_rating"
|
table = "player_rating"
|
||||||
unique_together = (("user_sub", "game_type"),)
|
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 __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]] = [
|
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
@@ -19,3 +35,341 @@ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
|
|||||||
"description": "Opaque pagination cursor from a previous response's next_cursor.",
|
"description": "Opaque pagination cursor from a previous response's next_cursor.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Page-number pagination (used by the leaderboard, which supports jumping
|
||||||
|
# to a specific page).
|
||||||
|
PAGE_PARAMETERS: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"required": False,
|
||||||
|
"schema": {"type": "integer", "minimum": 1, "default": 1},
|
||||||
|
"description": "1-based page number (clamped to the last page).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"required": False,
|
||||||
|
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
||||||
|
"description": "Maximum number of results per page (clamped to [1, 100]).",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _object(
|
||||||
|
properties: Dict[str, Any],
|
||||||
|
required: Sequence[str] = (),
|
||||||
|
*,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
additional_properties: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""An object schema with the given properties (JSON Schema 2020-12)."""
|
||||||
|
schema: Dict[str, Any] = {"type": "object", "properties": properties}
|
||||||
|
if required:
|
||||||
|
schema["required"] = list(required)
|
||||||
|
if description is not None:
|
||||||
|
schema["description"] = description
|
||||||
|
if additional_properties:
|
||||||
|
schema["additionalProperties"] = True
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _free_object(description: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""An open object: used for engine-specific payload parts."""
|
||||||
|
schema: Dict[str, Any] = {"type": "object", "additionalProperties": True}
|
||||||
|
if description is not None:
|
||||||
|
schema["description"] = description
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _array(items: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {"type": "array", "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
|
||||||
|
nullable = dict(schema)
|
||||||
|
kind = nullable.get("type")
|
||||||
|
if isinstance(kind, str):
|
||||||
|
nullable["type"] = [kind, "null"]
|
||||||
|
return nullable
|
||||||
|
|
||||||
|
|
||||||
|
def json_response(description: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""A JSON response object carrying ``schema``, for ``@operation``."""
|
||||||
|
return {
|
||||||
|
"description": description,
|
||||||
|
"content": {"application/json": {"schema": schema}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(description: str) -> Dict[str, Any]:
|
||||||
|
"""The standard JSON error envelope (``{"error": <message>}``)."""
|
||||||
|
return json_response(description, schema_ref("Error"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Component schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ERROR_SCHEMA = _object({"error": {"type": "string"}}, ("error",))
|
||||||
|
|
||||||
|
HEALTH_SCHEMA = _object(
|
||||||
|
{"status": {"type": "string", "enum": ["ok"]}}, ("status",)
|
||||||
|
)
|
||||||
|
|
||||||
|
ME_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"sub": {"type": "string"},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"picture": _nullable({"type": "string"}),
|
||||||
|
},
|
||||||
|
("sub", "name", "picture"),
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_PROVIDER_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"login_url": {"type": "string"},
|
||||||
|
},
|
||||||
|
("name", "login_url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_PROVIDERS_PAGE_SCHEMA = _object(
|
||||||
|
{"providers": _array(schema_ref("AuthProvider"))}, ("providers",)
|
||||||
|
)
|
||||||
|
|
||||||
|
GAME_TYPE_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"min_players": {"type": "integer"},
|
||||||
|
"max_players": {"type": "integer"},
|
||||||
|
"options_schema": _free_object(
|
||||||
|
"JSON-schema fragment describing the game-specific creation "
|
||||||
|
"options accepted by POST /api/games."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
("id", "name", "description", "min_players", "max_players", "options_schema"),
|
||||||
|
)
|
||||||
|
|
||||||
|
GAME_TYPES_PAGE_SCHEMA = _object(
|
||||||
|
{"results": _array(schema_ref("GameType"))}, ("results",)
|
||||||
|
)
|
||||||
|
|
||||||
|
LOBBY_PLAYER_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"sub": {"type": "string"},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"seat": {"type": "integer"},
|
||||||
|
"team": _nullable({
|
||||||
|
"type": "string",
|
||||||
|
"description": "Game-defined team label; null for games without fixed teams.",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
("sub", "name", "seat", "team"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOBBY_PROPERTIES: Dict[str, Any] = {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"join_code": {"type": "string"},
|
||||||
|
"game_type": {"type": "string"},
|
||||||
|
"players": _array(schema_ref("LobbyPlayer")),
|
||||||
|
"seats_open": {"type": "integer"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_LOBBY_REQUIRED = ("id", "join_code", "game_type", "players", "seats_open")
|
||||||
|
|
||||||
|
LOBBY_SCHEMA = _object(
|
||||||
|
dict(_LOBBY_PROPERTIES),
|
||||||
|
_LOBBY_REQUIRED,
|
||||||
|
description="The lobby payload: the platform envelope plus the engine's "
|
||||||
|
"lobby_view fields (the creation-options echo and the phase).",
|
||||||
|
additional_properties=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
GAME_PREVIEW_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
**_LOBBY_PROPERTIES,
|
||||||
|
"game_name": {"type": "string"},
|
||||||
|
"game_description": {"type": "string"},
|
||||||
|
},
|
||||||
|
(*_LOBBY_REQUIRED, "game_name", "game_description"),
|
||||||
|
description="The join-code preview: the lobby payload plus the game's "
|
||||||
|
"display name and description.",
|
||||||
|
additional_properties=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
GAME_VIEW_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"game_type": {"type": "string"},
|
||||||
|
"spectator": {"type": "boolean"},
|
||||||
|
"seats_open": {"type": "integer"},
|
||||||
|
"join_code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The join code is a credential: present only in a "
|
||||||
|
"seated player's view, never to spectators.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
("id", "game_type", "spectator", "seats_open"),
|
||||||
|
description="A game snapshot: the platform envelope plus the engine's "
|
||||||
|
"view_for fields. Seated players get their personalized view "
|
||||||
|
"(own hand, join code); everyone else the read-only spectator "
|
||||||
|
"view (no hands, no join code).",
|
||||||
|
additional_properties=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ONGOING_GAME_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"game_type": {"type": "string"},
|
||||||
|
"players": _array(schema_ref("LobbyPlayer")),
|
||||||
|
"seats_open": {"type": "integer"},
|
||||||
|
"game_name": {"type": "string"},
|
||||||
|
"game_description": {"type": "string"},
|
||||||
|
"your_seat": _nullable({"type": "integer"}),
|
||||||
|
"created_at": _nullable({"type": "string", "format": "date-time"}),
|
||||||
|
},
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
"game_type",
|
||||||
|
"players",
|
||||||
|
"seats_open",
|
||||||
|
"game_name",
|
||||||
|
"game_description",
|
||||||
|
"your_seat",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
description="One entry of the caller's ongoing-games list: the lobby "
|
||||||
|
"payload without the join code, plus the game name and the "
|
||||||
|
"caller's seat.",
|
||||||
|
additional_properties=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ONGOING_GAMES_PAGE_SCHEMA = _object(
|
||||||
|
{"results": _array(schema_ref("OngoingGame"))}, ("results",)
|
||||||
|
)
|
||||||
|
|
||||||
|
MATCH_PLAYER_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"user_sub": {"type": "string"},
|
||||||
|
"display_name": {"type": "string"},
|
||||||
|
"seat": {"type": "integer"},
|
||||||
|
"team": _nullable({"type": "string"}),
|
||||||
|
"won": {"type": "boolean"},
|
||||||
|
"score": {"type": "number"},
|
||||||
|
"elo_delta": _nullable({"type": "integer"}),
|
||||||
|
"details": _nullable(_free_object("Game-specific extras reported by the engine.")),
|
||||||
|
},
|
||||||
|
(
|
||||||
|
"user_sub",
|
||||||
|
"display_name",
|
||||||
|
"seat",
|
||||||
|
"team",
|
||||||
|
"won",
|
||||||
|
"score",
|
||||||
|
"elo_delta",
|
||||||
|
"details",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
MATCH_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"game_type": {"type": "string"},
|
||||||
|
"result": _nullable(_free_object(
|
||||||
|
"The game-specific outcome reported by the engine."
|
||||||
|
)),
|
||||||
|
"started_at": {"type": "string", "format": "date-time"},
|
||||||
|
"finished_at": {"type": "string", "format": "date-time"},
|
||||||
|
"you_won": {"type": "boolean"},
|
||||||
|
"your_elo_delta": _nullable({"type": "integer"}),
|
||||||
|
"players": _array(schema_ref("MatchPlayer")),
|
||||||
|
},
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
"game_type",
|
||||||
|
"result",
|
||||||
|
"started_at",
|
||||||
|
"finished_at",
|
||||||
|
"you_won",
|
||||||
|
"your_elo_delta",
|
||||||
|
"players",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
MATCHES_PAGE_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"results": _array(schema_ref("Match")),
|
||||||
|
"next_cursor": _nullable({"type": "string"}),
|
||||||
|
},
|
||||||
|
("results", "next_cursor"),
|
||||||
|
)
|
||||||
|
|
||||||
|
LEADERBOARD_ENTRY_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"user_sub": {"type": "string"},
|
||||||
|
"display_name": {"type": "string"},
|
||||||
|
"elo": {"type": "integer"},
|
||||||
|
"matches": {"type": "integer"},
|
||||||
|
"wins": {"type": "integer"},
|
||||||
|
"points": {"type": "number"},
|
||||||
|
},
|
||||||
|
("user_sub", "display_name", "elo", "matches", "wins", "points"),
|
||||||
|
)
|
||||||
|
|
||||||
|
LEADERBOARD_PAGE_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"results": _array(schema_ref("LeaderboardEntry")),
|
||||||
|
"page": {"type": "integer"},
|
||||||
|
"pages": {"type": "integer"},
|
||||||
|
"total": {"type": "integer"},
|
||||||
|
"your_rank": _nullable({"type": "integer"}),
|
||||||
|
"your_page": _nullable({"type": "integer"}),
|
||||||
|
},
|
||||||
|
("results", "page", "pages", "total", "your_rank", "your_page"),
|
||||||
|
)
|
||||||
|
|
||||||
|
RATING_SCHEMA = _object(
|
||||||
|
{
|
||||||
|
"game_type": {"type": "string"},
|
||||||
|
"rating": {"type": "integer"},
|
||||||
|
"matches_played": {"type": "integer"},
|
||||||
|
},
|
||||||
|
("game_type", "rating", "matches_played"),
|
||||||
|
)
|
||||||
|
|
||||||
|
RATINGS_PAGE_SCHEMA = _object(
|
||||||
|
{"results": _array(schema_ref("Rating"))}, ("results",)
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Component name -> schema, passed to
|
||||||
|
#: ``OpenAPIMixin(components={"schemas": SCHEMAS})``.
|
||||||
|
SCHEMAS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"Error": ERROR_SCHEMA,
|
||||||
|
"Health": HEALTH_SCHEMA,
|
||||||
|
"Me": ME_SCHEMA,
|
||||||
|
"AuthProvider": AUTH_PROVIDER_SCHEMA,
|
||||||
|
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
|
||||||
|
"GameType": GAME_TYPE_SCHEMA,
|
||||||
|
"GameTypesPage": GAME_TYPES_PAGE_SCHEMA,
|
||||||
|
"LobbyPlayer": LOBBY_PLAYER_SCHEMA,
|
||||||
|
"Lobby": LOBBY_SCHEMA,
|
||||||
|
"GamePreview": GAME_PREVIEW_SCHEMA,
|
||||||
|
"GameView": GAME_VIEW_SCHEMA,
|
||||||
|
"OngoingGame": ONGOING_GAME_SCHEMA,
|
||||||
|
"OngoingGamesPage": ONGOING_GAMES_PAGE_SCHEMA,
|
||||||
|
"MatchPlayer": MATCH_PLAYER_SCHEMA,
|
||||||
|
"Match": MATCH_SCHEMA,
|
||||||
|
"MatchesPage": MATCHES_PAGE_SCHEMA,
|
||||||
|
"LeaderboardEntry": LEADERBOARD_ENTRY_SCHEMA,
|
||||||
|
"LeaderboardPage": LEADERBOARD_PAGE_SCHEMA,
|
||||||
|
"Rating": RATING_SCHEMA,
|
||||||
|
"RatingsPage": RATINGS_PAGE_SCHEMA,
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,12 +30,17 @@ MIN_LIMIT = 1
|
|||||||
|
|
||||||
CURSOR_PARAM = "cursor"
|
CURSOR_PARAM = "cursor"
|
||||||
LIMIT_PARAM = "limit"
|
LIMIT_PARAM = "limit"
|
||||||
|
PAGE_PARAM = "page"
|
||||||
|
|
||||||
|
|
||||||
class CursorDecodeError(ValueError):
|
class CursorDecodeError(ValueError):
|
||||||
"""Raised when the ``cursor`` query param cannot be decoded."""
|
"""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:
|
def encode_cursor(values: Dict[str, Any]) -> str:
|
||||||
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
||||||
# datetime-typed sort fields (e.g. finished_at for match history).
|
# 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)
|
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
|
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
|
||||||
# keyset; the cursor encodes exactly these fields.
|
# keyset; the cursor encodes exactly these fields.
|
||||||
Sort = List[Tuple[str, str]]
|
Sort = List[Tuple[str, str]]
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
from kaya.core import HttpContext, KayaApp
|
||||||
from kaya.openapi import operation
|
from kaya.openapi import operation, schema_ref
|
||||||
|
|
||||||
from ..http import send_json
|
from ..http import send_json
|
||||||
|
from ..openapi import json_response
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..mixin import Platform
|
from ..mixin import Platform
|
||||||
@@ -36,7 +37,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
description="Names and login URLs of the configured OIDC "
|
description="Names and login URLs of the configured OIDC "
|
||||||
"providers, in configuration order.",
|
"providers, in configuration order.",
|
||||||
tags=["auth"],
|
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:
|
async def auth_providers(ctx: HttpContext) -> None:
|
||||||
await send_json(
|
await send_json(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from logging import getLogger
|
|||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
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, require_auth
|
||||||
from ..engine import GameSession, Seat
|
from ..engine import GameSession, Seat
|
||||||
@@ -32,6 +32,7 @@ from ..http import (
|
|||||||
send_error,
|
send_error,
|
||||||
send_json,
|
send_json,
|
||||||
)
|
)
|
||||||
|
from ..openapi import error_response, json_response
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..mixin import Platform
|
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:
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
@app.GET("/api/game-types")
|
@app.GET("/api/game-types")
|
||||||
@operation(summary="List available games",
|
@operation(summary="List available games",
|
||||||
@@ -116,7 +145,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
"describes the per-game creation options accepted "
|
"describes the per-game creation options accepted "
|
||||||
"by POST /api/games.",
|
"by POST /api/games.",
|
||||||
tags=["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:
|
async def list_game_types(ctx: HttpContext) -> None:
|
||||||
await send_json(ctx, 200, {
|
await send_json(ctx, 200, {
|
||||||
"results": [
|
"results": [
|
||||||
@@ -166,9 +196,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses={
|
responses={
|
||||||
201: {"description": "The created lobby"},
|
201: json_response("The created lobby", schema_ref("Lobby")),
|
||||||
400: {"description": "Invalid game_type, options or body"},
|
400: error_response("Invalid game_type, options or body"),
|
||||||
401: {"description": "Authentication required"},
|
401: error_response("Authentication required"),
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
@require_auth(platform.oidc)
|
||||||
async def create_game(ctx: HttpContext) -> None:
|
async def create_game(ctx: HttpContext) -> None:
|
||||||
@@ -236,11 +266,15 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "Seated; game state (may be playing)"},
|
200: json_response(
|
||||||
400: {"description": "Missing code"},
|
"Seated: the lobby, or the personalized game state "
|
||||||
401: {"description": "Authentication required"},
|
"when the join started the match",
|
||||||
404: {"description": "Unknown join code"},
|
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]},
|
||||||
409: {"description": "Already joined or lobby full"},
|
),
|
||||||
|
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)
|
@require_auth(platform.oidc)
|
||||||
async def join_game(ctx: HttpContext) -> None:
|
async def join_game(ctx: HttpContext) -> None:
|
||||||
@@ -300,10 +334,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
"its engine.",
|
"its engine.",
|
||||||
tags=["games"],
|
tags=["games"],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The lobby preview"},
|
200: json_response("The lobby preview",
|
||||||
400: {"description": "Missing code"},
|
schema_ref("GamePreview")),
|
||||||
401: {"description": "Authentication required"},
|
400: error_response("Missing code"),
|
||||||
404: {"description": "Unknown join code"},
|
401: error_response("Authentication required"),
|
||||||
|
404: error_response("Unknown join code"),
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
@require_auth(platform.oidc)
|
||||||
async def preview_game(ctx: HttpContext) -> None:
|
async def preview_game(ctx: HttpContext) -> None:
|
||||||
@@ -328,9 +363,11 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
"join code, just what is public at the table.",
|
"join code, just what is public at the table.",
|
||||||
tags=["games"],
|
tags=["games"],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The personalized (player) or "
|
200: json_response(
|
||||||
"read-only (spectator) game state"},
|
"The personalized (player) or read-only (spectator) game state",
|
||||||
404: {"description": "Game not found"},
|
schema_ref("GameView"),
|
||||||
|
),
|
||||||
|
404: error_response("Game not found"),
|
||||||
})
|
})
|
||||||
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
@@ -341,3 +378,28 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
await send_json(
|
await send_json(
|
||||||
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.GET("/api/me/games")
|
||||||
|
@operation(summary="List my ongoing games",
|
||||||
|
description="Live games the caller is seated in — waiting "
|
||||||
|
"lobbies and matches in progress or between "
|
||||||
|
"hands — newest first, for the lobby's Continue "
|
||||||
|
"list. Finished matches are not included.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={
|
||||||
|
200: json_response("The caller's ongoing games",
|
||||||
|
schema_ref("OngoingGamesPage")),
|
||||||
|
401: error_response("Authentication required"),
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def my_games(ctx: HttpContext) -> None:
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
sessions = await platform.game_store.list_ongoing()
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
_ongoing_game_payload(platform, session, user.sub)
|
||||||
|
for session in sessions
|
||||||
|
if session.seated(user.sub)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
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:
|
if TYPE_CHECKING:
|
||||||
from ..mixin import Platform
|
from ..mixin import Platform
|
||||||
@@ -14,7 +16,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
@app.GET("/api/health")
|
@app.GET("/api/health")
|
||||||
@operation(summary="Health check",
|
@operation(summary="Health check",
|
||||||
tags=["health"],
|
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:
|
async def health(ctx: HttpContext) -> None:
|
||||||
await ctx.send_bytes(
|
await ctx.send_bytes(
|
||||||
200,
|
200,
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
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 ..http import send_json
|
||||||
|
from ..openapi import error_response, json_response
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..mixin import Platform
|
from ..mixin import Platform
|
||||||
@@ -17,14 +18,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
@app.GET("/api/me")
|
@app.GET("/api/me")
|
||||||
@operation(summary="Current user",
|
@operation(summary="Current user",
|
||||||
description="Returns the authenticated user's identity from the "
|
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"],
|
tags=["auth"],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The current user"},
|
200: json_response("The current user", schema_ref("Me")),
|
||||||
401: {"description": "Not logged in"},
|
401: error_response("Not logged in"),
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
@require_auth(platform.oidc)
|
||||||
async def me(ctx: HttpContext) -> None:
|
async def me(ctx: HttpContext) -> None:
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None # enforced by @require_auth
|
assert user is not None # enforced by @require_auth
|
||||||
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
await send_json(
|
||||||
|
ctx,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"sub": user.sub,
|
||||||
|
"name": display_name(user),
|
||||||
|
"picture": picture(user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,14 +13,25 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
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 ..auth import current_user, require_auth
|
||||||
from ..elo import INITIAL_RATING
|
|
||||||
from ..http import extract_query_params, send_error, send_json
|
from ..http import extract_query_params, send_error, send_json
|
||||||
from ..models import Match, MatchPlayer, PlayerRating
|
from ..models import Match, MatchPlayer, PlayerRating
|
||||||
from ..openapi import PAGINATION_PARAMETERS
|
from ..openapi import (
|
||||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
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:
|
if TYPE_CHECKING:
|
||||||
from ..mixin import Platform
|
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).",
|
"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(
|
def _parse_game_type(
|
||||||
platform: "Platform", query_string: str
|
platform: "Platform", query_string: str
|
||||||
@@ -50,6 +70,33 @@ def _parse_game_type(
|
|||||||
return game_type, None
|
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(
|
def _serialize_match(
|
||||||
match: Match, viewer: str, participants: List[MatchPlayer]
|
match: Match, viewer: str, participants: List[MatchPlayer]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -89,9 +136,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
tags=["stats"],
|
tags=["stats"],
|
||||||
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "A page of matches"},
|
200: json_response("A page of matches",
|
||||||
400: {"description": "Invalid pagination cursor or game_type"},
|
schema_ref("MatchesPage")),
|
||||||
401: {"description": "Authentication required"},
|
400: error_response("Invalid pagination cursor or game_type"),
|
||||||
|
401: error_response("Authentication required"),
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
@require_auth(platform.oidc)
|
||||||
async def my_matches(ctx: HttpContext) -> None:
|
async def my_matches(ctx: HttpContext) -> None:
|
||||||
@@ -131,56 +179,88 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
@app.GET("/api/leaderboard")
|
@app.GET("/api/leaderboard")
|
||||||
@operation(summary="Global leaderboard",
|
@operation(summary="Global leaderboard",
|
||||||
description="Elo rating, aggregated wins, matches played and "
|
description="Elo rating, wins, matches played and points for "
|
||||||
"points for every player with at least one "
|
"every rated player of a game, sorted by Elo (the "
|
||||||
"finished match. Sorted by Elo rating (the rating "
|
"requested game_type, or the default game when "
|
||||||
"for the requested game_type, or the default game "
|
"the filter is absent). Page-number paginated; "
|
||||||
"when the filter is absent).",
|
"the response carries the caller's own rank when "
|
||||||
|
"authenticated.",
|
||||||
tags=["stats"],
|
tags=["stats"],
|
||||||
parameters=[GAME_TYPE_PARAMETER],
|
parameters=[
|
||||||
|
GAME_TYPE_PARAMETER,
|
||||||
|
*PAGE_PARAMETERS,
|
||||||
|
LEADERBOARD_FOCUS_PARAMETER,
|
||||||
|
],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The leaderboard"},
|
200: json_response("A page of the leaderboard",
|
||||||
400: {"description": "Unknown game_type"},
|
schema_ref("LeaderboardPage")),
|
||||||
|
400: error_response("Unknown game_type or invalid parameters"),
|
||||||
})
|
})
|
||||||
async def leaderboard(ctx: HttpContext) -> None:
|
async def leaderboard(ctx: HttpContext) -> None:
|
||||||
game_type, error = _parse_game_type(platform, ctx.query_string)
|
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
await send_error(ctx, 400, error)
|
await send_error(ctx, 400, error)
|
||||||
return
|
return
|
||||||
queryset = MatchPlayer.all()
|
try:
|
||||||
if game_type is not None:
|
page, limit = parse_page_params(ctx.query_string)
|
||||||
queryset = queryset.filter(match__game_type=game_type)
|
except PageParamsError as exc:
|
||||||
rows = await queryset
|
await send_error(ctx, 400, str(exc))
|
||||||
# Ratings are per game type; without a filter show the default
|
return
|
||||||
# game's.
|
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_game = game_type or platform.registry.default.id
|
||||||
rating_rows = await PlayerRating.filter(game_type=rating_game)
|
board = PlayerRating.filter(game_type=rating_game)
|
||||||
ratings = {row.user_sub: row.rating for row in rating_rows}
|
total = await board.count()
|
||||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
pages = (total + limit - 1) // limit
|
||||||
for row in rows:
|
current = min(page, pages) if pages else 1
|
||||||
entry = aggregate.setdefault(
|
|
||||||
row.user_sub,
|
# The caller's own ranking, when authenticated and rated: ranks
|
||||||
|
# above theirs plus one. `focus=me` jumps to their page.
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
yours: Optional[PlayerRating] = None
|
||||||
|
if user is not None:
|
||||||
|
yours = await PlayerRating.get_or_none(
|
||||||
|
user_sub=user.sub, game_type=rating_game
|
||||||
|
)
|
||||||
|
your_rank: Optional[int] = None
|
||||||
|
your_page: Optional[int] = None
|
||||||
|
if yours is not None:
|
||||||
|
your_rank = 1 + await board.filter(_ranks_before(yours)).count()
|
||||||
|
your_page = (your_rank - 1) // limit + 1
|
||||||
|
if focus == "me":
|
||||||
|
current = your_page
|
||||||
|
|
||||||
|
rows = await (
|
||||||
|
board.order_by(
|
||||||
|
"-rating", "-wins", "-points", "matches_played", "user_sub"
|
||||||
|
)
|
||||||
|
.offset((current - 1) * limit)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
{
|
{
|
||||||
"user_sub": row.user_sub,
|
"user_sub": row.user_sub,
|
||||||
"display_name": row.display_name,
|
"display_name": row.display_name or row.user_sub,
|
||||||
"matches": 0,
|
"matches": row.matches_played,
|
||||||
"wins": 0,
|
"wins": row.wins,
|
||||||
"points": 0,
|
"points": row.points,
|
||||||
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
"elo": row.rating,
|
||||||
},
|
}
|
||||||
)
|
for row in rows
|
||||||
entry["matches"] += 1
|
],
|
||||||
entry["wins"] += 1 if row.won else 0
|
"page": current,
|
||||||
entry["points"] += row.score
|
"pages": pages,
|
||||||
# Keep the most recent display name seen.
|
"total": total,
|
||||||
entry["display_name"] = row.display_name
|
"your_rank": your_rank,
|
||||||
|
"your_page": your_page,
|
||||||
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})
|
|
||||||
|
|
||||||
@app.GET("/api/me/ratings")
|
@app.GET("/api/me/ratings")
|
||||||
@operation(summary="My Elo ratings",
|
@operation(summary="My Elo ratings",
|
||||||
@@ -188,8 +268,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
"type they have played.",
|
"type they have played.",
|
||||||
tags=["stats"],
|
tags=["stats"],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The caller's ratings"},
|
200: json_response("The caller's ratings",
|
||||||
401: {"description": "Authentication required"},
|
schema_ref("RatingsPage")),
|
||||||
|
401: error_response("Authentication required"),
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
@require_auth(platform.oidc)
|
||||||
async def my_ratings(ctx: HttpContext) -> None:
|
async def my_ratings(ctx: HttpContext) -> None:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
Called once when a session reaches the finished state (guarded by the
|
Called once when a session reaches the finished state (guarded by the
|
||||||
``stats_saved`` flag on the session). The write is transactional so a
|
``stats_saved`` flag on the session). The write is transactional so a
|
||||||
match never appears with only some of its players. The same transaction
|
match never appears with only some of its players. The same transaction
|
||||||
also updates the participants' Elo ratings (see
|
also updates the participants' Elo ratings and leaderboard aggregates
|
||||||
:mod:`tavolo.platform.elo`).
|
(see :mod:`tavolo.platform.elo`).
|
||||||
|
|
||||||
This module is game-independent: everything it persists comes from the
|
This module is game-independent: everything it persists comes from the
|
||||||
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||||
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Sequence
|
||||||
|
|
||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
@@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession
|
|||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def apply_elo(
|
@dataclass(frozen=True)
|
||||||
game_type: str, teams: List[List[str]], winner_team: int
|
class PlayerStats:
|
||||||
|
"""One participant's data feeding a ``player_rating`` row update."""
|
||||||
|
|
||||||
|
user_sub: str
|
||||||
|
won: bool
|
||||||
|
score: float
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_match_stats(
|
||||||
|
game_type: str,
|
||||||
|
teams: List[List[str]],
|
||||||
|
winner_team: int,
|
||||||
|
players: Sequence[PlayerStats],
|
||||||
) -> Dict[str, int]:
|
) -> Dict[str, int]:
|
||||||
"""Update the Elo ratings of ``teams`` for ``game_type``.
|
"""Update the ratings and leaderboard aggregates of ``teams``.
|
||||||
|
|
||||||
``teams`` groups the participants' subs into the two opposing sides;
|
``teams`` groups the participants' subs into the two opposing sides;
|
||||||
``winner_team`` is the index of the winning side. Ratings are read
|
``winner_team`` is the index of the winning side. Ratings are read
|
||||||
from (and written back to) the ``player_rating`` table; unrated
|
from (and written back to) the ``player_rating`` table; unrated
|
||||||
players start at the initial rating. Returns the per-player delta.
|
players start at the initial rating. Alongside the Elo delta the
|
||||||
Must be called inside a transaction.
|
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:
|
if len(teams) != 2:
|
||||||
raise ValueError("Elo rating requires exactly two teams")
|
raise ValueError("Elo rating requires exactly two teams")
|
||||||
from .models import PlayerRating
|
from .models import PlayerRating
|
||||||
|
|
||||||
|
stats = {player.user_sub: player for player in players}
|
||||||
ratings: Dict[str, "PlayerRating"] = {}
|
ratings: Dict[str, "PlayerRating"] = {}
|
||||||
for subs in teams:
|
for subs in teams:
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
@@ -63,6 +80,13 @@ async def apply_elo(
|
|||||||
rating = ratings[sub]
|
rating = ratings[sub]
|
||||||
rating.rating += delta
|
rating.rating += delta
|
||||||
rating.matches_played += 1
|
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()
|
await rating.save()
|
||||||
return deltas
|
return deltas
|
||||||
|
|
||||||
@@ -78,6 +102,15 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
|||||||
started_at = session.created_at or datetime.now(timezone.utc)
|
started_at = session.created_at or datetime.now(timezone.utc)
|
||||||
finished_at = session.finished_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}
|
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():
|
async with in_transaction():
|
||||||
match = await Match.create(
|
match = await Match.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
|||||||
finished_at=finished_at,
|
finished_at=finished_at,
|
||||||
result=result.summary,
|
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:
|
for player in result.players:
|
||||||
await MatchPlayer.create(
|
await MatchPlayer.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
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
|
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]:
|
def _dt_from_json(value: Any) -> Optional[datetime]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -137,6 +146,15 @@ class GameStore(ABC):
|
|||||||
still retained for the final-board view do not.
|
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
|
@abstractmethod
|
||||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||||
"""Async context manager serializing mutations of one game."""
|
"""Async context manager serializing mutations of one game."""
|
||||||
@@ -241,6 +259,27 @@ class RedisGameStore(GameStore):
|
|||||||
_, count = await pipe.execute()
|
_, count = await pipe.execute()
|
||||||
return int(count)
|
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
|
@contextlib.asynccontextmanager
|
||||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
pubsub = self._redis.pubsub()
|
pubsub = self._redis.pubsub()
|
||||||
@@ -352,6 +391,18 @@ class InMemoryGameStore(GameStore):
|
|||||||
async def count_ongoing(self) -> int:
|
async def count_ongoing(self) -> int:
|
||||||
return len(self._ongoing)
|
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
|
@contextlib.asynccontextmanager
|
||||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
queue: asyncio.Queue = asyncio.Queue()
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from tavolo.platform import (
|
|||||||
)
|
)
|
||||||
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
||||||
from tavolo.platform.deadlines import DeadlineScheduler
|
from tavolo.platform.deadlines import DeadlineScheduler
|
||||||
|
from tavolo.platform.openapi import SCHEMAS
|
||||||
from tavolo.platform.registry import GameRegistry
|
from tavolo.platform.registry import GameRegistry
|
||||||
from tavolo.platform.store import InMemoryGameStore
|
from tavolo.platform.store import InMemoryGameStore
|
||||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||||
@@ -253,6 +254,7 @@ def make_platform(
|
|||||||
description="test app",
|
description="test app",
|
||||||
spec_path="/api/openapi.json",
|
spec_path="/api/openapi.json",
|
||||||
docs_path="/api/docs",
|
docs_path="/api/docs",
|
||||||
|
components={"schemas": SCHEMAS},
|
||||||
)
|
)
|
||||||
scheduler = DeadlineScheduler(
|
scheduler = DeadlineScheduler(
|
||||||
game_store,
|
game_store,
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
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 kaya.session import InMemorySessionStore, Session, SessionMixin
|
||||||
|
|
||||||
from helpers import async_test
|
from helpers import async_test
|
||||||
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
|
from tavolo.platform.auth import (
|
||||||
|
current_user,
|
||||||
|
display_name,
|
||||||
|
get_ws_user,
|
||||||
|
picture,
|
||||||
|
require_auth,
|
||||||
|
session_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _providers(*names: str) -> OIDCProviders:
|
def _providers(*names: str) -> OIDCProviders:
|
||||||
@@ -87,6 +94,51 @@ class GetWsUserTest(unittest.TestCase):
|
|||||||
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayNameTest(unittest.TestCase):
|
||||||
|
"""The claim priority used for the name shown at the table."""
|
||||||
|
|
||||||
|
def test_prefers_preferred_username_over_name(self) -> None:
|
||||||
|
user = OIDCUser(
|
||||||
|
{"sub": "alice", "preferred_username": "alice", "name": "Alice"}
|
||||||
|
)
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_name(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "name": "Alice"})
|
||||||
|
self.assertEqual("Alice", display_name(user))
|
||||||
|
|
||||||
|
def test_ignores_email(self) -> None:
|
||||||
|
# The app requests only the openid and profile scopes; an email
|
||||||
|
# claim the provider still returns must not leak into the name.
|
||||||
|
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_sub(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice"})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_ignores_empty_and_non_string_claims(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "preferred_username": "", "name": 42})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
|
||||||
|
class PictureTest(unittest.TestCase):
|
||||||
|
"""Only absolute http(s) picture claims are exported to the SPA."""
|
||||||
|
|
||||||
|
def test_uses_an_absolute_http_url(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "picture": "https://cdn.example.com/a.png"})
|
||||||
|
self.assertEqual("https://cdn.example.com/a.png", picture(user))
|
||||||
|
|
||||||
|
def test_rejects_relative_and_script_schemes(self) -> None:
|
||||||
|
for value in ("/relative.png", "javascript:alert(1)", "data:image/png;base64,x"):
|
||||||
|
with self.subTest(picture=value):
|
||||||
|
user = OIDCUser({"sub": "alice", "picture": value})
|
||||||
|
self.assertIsNone(picture(user))
|
||||||
|
|
||||||
|
def test_missing_claim_is_none(self) -> None:
|
||||||
|
self.assertIsNone(picture(OIDCUser({"sub": "alice"})))
|
||||||
|
|
||||||
|
|
||||||
class CurrentUserTest(unittest.TestCase):
|
class CurrentUserTest(unittest.TestCase):
|
||||||
def test_anonymous_session_has_no_user(self) -> None:
|
def test_anonymous_session_has_no_user(self) -> None:
|
||||||
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from tavolo.platform.models import Match
|
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
|
from helpers import async_test
|
||||||
|
|
||||||
@@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase):
|
|||||||
self.assertEqual(match_id, id_values[0])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -188,6 +188,63 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
response = await client.get("/api/games/does-not-exist")
|
response = await client.get("/api/games/does-not-exist")
|
||||||
self.assertEqual(404, response.status_code)
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
anonymous = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(401, anonymous.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
mine = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": 5}}
|
||||||
|
)
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
theirs = await client.post("/api/games", json={})
|
||||||
|
self.assertEqual(201, mine.status_code)
|
||||||
|
self.assertEqual(201, theirs.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
listed = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(200, listed.status_code)
|
||||||
|
results = listed.json()["results"]
|
||||||
|
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
|
||||||
|
entry = results[0]
|
||||||
|
self.assertEqual("dummy", entry["game_type"])
|
||||||
|
self.assertEqual("Dummy game", entry["game_name"])
|
||||||
|
self.assertTrue(entry["game_description"])
|
||||||
|
self.assertEqual("lobby", entry["phase"])
|
||||||
|
self.assertEqual(5, entry["target"])
|
||||||
|
self.assertEqual(1, entry["seats_open"])
|
||||||
|
self.assertEqual(0, entry["your_seat"])
|
||||||
|
self.assertNotIn("join_code", entry)
|
||||||
|
self.assertEqual("alice", entry["players"][0]["sub"])
|
||||||
|
|
||||||
|
# Bob's own game is the only one he sees; Alice's is not his.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
bobs = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(
|
||||||
|
[theirs.json()["id"]],
|
||||||
|
[g["id"] for g in bobs.json()["results"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once Bob joins Alice's game it appears for both, now in the
|
||||||
|
# playing phase.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post(
|
||||||
|
"/api/games/join", json={"code": mine.json()["join_code"]}
|
||||||
|
)
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
bobs = await client.get("/api/me/games")
|
||||||
|
by_id = {g["id"]: g for g in bobs.json()["results"]}
|
||||||
|
self.assertEqual(
|
||||||
|
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
|
||||||
|
)
|
||||||
|
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
|
||||||
|
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
|
||||||
|
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||||
|
|
||||||
|
|
||||||
class GameTypesRouteTest(unittest.TestCase):
|
class GameTypesRouteTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
@@ -253,7 +310,10 @@ class MeRouteTest(unittest.TestCase):
|
|||||||
with oidc_user(platform.oidc, "alice"):
|
with oidc_user(platform.oidc, "alice"):
|
||||||
response = await client.get("/api/me")
|
response = await client.get("/api/me")
|
||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
self.assertEqual(
|
||||||
|
{"sub": "alice", "name": "alice", "picture": None},
|
||||||
|
response.json(),
|
||||||
|
)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_me_unauthenticated(self) -> None:
|
async def test_me_unauthenticated(self) -> None:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -78,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, ratings["alice"].matches_played)
|
self.assertEqual(1, ratings["alice"].matches_played)
|
||||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||||
self.assertEqual(1, ratings["bob"].matches_played)
|
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.
|
# The per-match delta is recorded on each participation row.
|
||||||
deltas = {
|
deltas = {
|
||||||
@@ -136,6 +143,9 @@ async def _seed_two_matches(
|
|||||||
) -> None:
|
) -> None:
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
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(
|
for index, (winner, finished) in enumerate(
|
||||||
[
|
[
|
||||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||||
@@ -150,6 +160,7 @@ async def _seed_two_matches(
|
|||||||
result={"winner": winner, "plays": 3 + index},
|
result={"winner": winner, "plays": 3 + index},
|
||||||
)
|
)
|
||||||
for seat, sub in enumerate(("alice", "bob")):
|
for seat, sub in enumerate(("alice", "bob")):
|
||||||
|
score = 2.0 + index if sub == winner else 1.0
|
||||||
await MatchPlayer.create(
|
await MatchPlayer.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
match=match,
|
match=match,
|
||||||
@@ -158,7 +169,25 @@ async def _seed_two_matches(
|
|||||||
seat=seat,
|
seat=seat,
|
||||||
team="A" if seat == 0 else "B",
|
team="A" if seat == 0 else "B",
|
||||||
won=(sub == winner),
|
won=(sub == winner),
|
||||||
score=2.0 + index if sub == winner else 1.0,
|
score=score,
|
||||||
|
)
|
||||||
|
aggregate = aggregates.setdefault(
|
||||||
|
(sub, game_types[index]),
|
||||||
|
{"matches": 0, "wins": 0, "points": 0.0},
|
||||||
|
)
|
||||||
|
aggregate["matches"] += 1
|
||||||
|
aggregate["wins"] += 1 if sub == winner else 0
|
||||||
|
aggregate["points"] += score
|
||||||
|
for (sub, game_type), aggregate in aggregates.items():
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub=sub,
|
||||||
|
game_type=game_type,
|
||||||
|
rating=INITIAL_RATING,
|
||||||
|
matches_played=aggregate["matches"],
|
||||||
|
wins=aggregate["wins"],
|
||||||
|
points=aggregate["points"],
|
||||||
|
display_name=sub,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,13 +281,9 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
with ctx:
|
||||||
# Alice outranks everyone despite Bob leading on points.
|
# Alice outranks everyone despite Bob leading on points.
|
||||||
await PlayerRating.create(
|
await PlayerRating.filter(
|
||||||
id=uuid.uuid4(),
|
user_sub="alice", game_type="dummy"
|
||||||
user_sub="alice",
|
).update(rating=1600)
|
||||||
game_type="dummy",
|
|
||||||
rating=1600,
|
|
||||||
matches_played=2,
|
|
||||||
)
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
response = await client.get("/api/leaderboard")
|
response = await client.get("/api/leaderboard")
|
||||||
@@ -266,7 +291,6 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
by_sub = {row["user_sub"]: row for row in results}
|
by_sub = {row["user_sub"]: row for row in results}
|
||||||
self.assertEqual(1600, by_sub["alice"]["elo"])
|
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(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||||
# Elo outranks wins/points.
|
# Elo outranks wins/points.
|
||||||
self.assertEqual("alice", results[0]["user_sub"])
|
self.assertEqual("alice", results[0]["user_sub"])
|
||||||
@@ -279,13 +303,9 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
await _seed_two_matches(tortoise_mixin)
|
await _seed_two_matches(tortoise_mixin)
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
with ctx:
|
||||||
await PlayerRating.create(
|
await PlayerRating.filter(
|
||||||
id=uuid.uuid4(),
|
user_sub="bob", game_type="dummy"
|
||||||
user_sub="bob",
|
).update(rating=1516)
|
||||||
game_type="dummy",
|
|
||||||
rating=1516,
|
|
||||||
matches_played=1,
|
|
||||||
)
|
|
||||||
# Bob's rating in another game must not leak into the
|
# Bob's rating in another game must not leak into the
|
||||||
# dummy leaderboard.
|
# dummy leaderboard.
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
@@ -305,6 +325,97 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||||
self.assertEqual("second", platform.registry.all()[1].id)
|
self.assertEqual("second", platform.registry.all()[1].id)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_pagination(self) -> None:
|
||||||
|
app, _, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub=sub,
|
||||||
|
game_type="dummy",
|
||||||
|
rating=1600 + index,
|
||||||
|
matches_played=1,
|
||||||
|
wins=1,
|
||||||
|
points=2.0,
|
||||||
|
display_name=sub,
|
||||||
|
)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
first = await client.get("/api/leaderboard?limit=2")
|
||||||
|
second = await client.get("/api/leaderboard?limit=2&page=2")
|
||||||
|
third = await client.get("/api/leaderboard?limit=2&page=3")
|
||||||
|
beyond = await client.get("/api/leaderboard?limit=2&page=99")
|
||||||
|
body = first.json()
|
||||||
|
self.assertEqual(1, body["page"])
|
||||||
|
self.assertEqual(3, body["pages"])
|
||||||
|
self.assertEqual(6, body["total"])
|
||||||
|
self.assertEqual(2, len(body["results"]))
|
||||||
|
self.assertEqual(
|
||||||
|
["frank", "erin"], [row["user_sub"] for row in body["results"]]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["dave", "carol"],
|
||||||
|
[row["user_sub"] for row in second.json()["results"]],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["bob", "alice"],
|
||||||
|
[row["user_sub"] for row in third.json()["results"]],
|
||||||
|
)
|
||||||
|
# Out-of-range pages clamp to the last page.
|
||||||
|
self.assertEqual(3, beyond.json()["page"])
|
||||||
|
self.assertEqual(
|
||||||
|
["bob", "alice"],
|
||||||
|
[row["user_sub"] for row in beyond.json()["results"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_focus_me(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
for index, sub in enumerate(("carol", "dave")):
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub=sub,
|
||||||
|
game_type="dummy",
|
||||||
|
rating=1600 + index,
|
||||||
|
matches_played=1,
|
||||||
|
wins=1,
|
||||||
|
points=2.0,
|
||||||
|
display_name=sub,
|
||||||
|
)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
mine = await client.get("/api/leaderboard?limit=1&focus=me")
|
||||||
|
anonymous = await client.get("/api/leaderboard?limit=1&focus=me")
|
||||||
|
# dave, carol, bob, alice: the caller lands on their own page.
|
||||||
|
body = mine.json()
|
||||||
|
self.assertEqual(4, body["page"])
|
||||||
|
self.assertEqual(4, body["your_rank"])
|
||||||
|
self.assertEqual(4, body["your_page"])
|
||||||
|
self.assertEqual("alice", body["results"][0]["user_sub"])
|
||||||
|
anonymous_body = anonymous.json()
|
||||||
|
self.assertEqual(1, anonymous_body["page"])
|
||||||
|
self.assertIsNone(anonymous_body["your_rank"])
|
||||||
|
self.assertIsNone(anonymous_body["your_page"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_rejects_bad_page_params(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
for query in ("page=0", "page=abc", "focus=elsewhere"):
|
||||||
|
response = await client.get(f"/api/leaderboard?{query}")
|
||||||
|
self.assertEqual(400, response.status_code, query)
|
||||||
|
# The limit is clamped, not rejected.
|
||||||
|
clamped = await client.get("/api/leaderboard?limit=500")
|
||||||
|
self.assertEqual(200, clamped.status_code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_my_matches_include_elo_delta(self) -> None:
|
async def test_my_matches_include_elo_delta(self) -> None:
|
||||||
app, platform, tortoise_mixin = make_platform()
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional, cast
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
@@ -48,7 +49,7 @@ class _FakePipeline:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def set(self, key: str, value: str, ex: object = None) -> None:
|
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:
|
def zadd(self, key: str, mapping: dict) -> None:
|
||||||
self._ops.append(("zadd", key, mapping))
|
self._ops.append(("zadd", key, mapping))
|
||||||
@@ -88,6 +89,14 @@ class _FakeRedis:
|
|||||||
async def zrem(self, key: str, member: str) -> int:
|
async def zrem(self, key: str, member: str) -> int:
|
||||||
return cast(int, self.apply(("zrem", key, member)))
|
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(
|
async def zrange(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -113,7 +122,7 @@ class _FakeRedis:
|
|||||||
def apply(self, op: tuple) -> object:
|
def apply(self, op: tuple) -> object:
|
||||||
name, *args = op
|
name, *args = op
|
||||||
if name == "set":
|
if name == "set":
|
||||||
self.keys[args[0]] = "payload"
|
self.keys[args[0]] = args[1]
|
||||||
return True
|
return True
|
||||||
if name == "zadd":
|
if name == "zadd":
|
||||||
self.zsets.setdefault(args[0], {}).update(args[1])
|
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||||
@@ -247,6 +256,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
|||||||
await store.save(other)
|
await store.save(other)
|
||||||
self.assertEqual(0, await store.count_ongoing())
|
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_test
|
||||||
async def test_deadline_queue(self) -> None:
|
async def test_deadline_queue(self) -> None:
|
||||||
store = InMemoryGameStore(_registry())
|
store = InMemoryGameStore(_registry())
|
||||||
@@ -311,6 +342,33 @@ class RedisGameStoreTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, await store.count_ongoing())
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
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_test
|
||||||
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
||||||
redis = _FakeRedis()
|
redis = _FakeRedis()
|
||||||
|
|||||||
@@ -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,305 @@
|
|||||||
|
"""The platform-facing adapter for scopa.
|
||||||
|
|
||||||
|
:class:`ScopaEngine` (classic two-player) and
|
||||||
|
:class:`ScopaQuattroEngine` (four-player fixed partnerships) implement
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
|
||||||
|
:mod:`tavolo.scopa.engine`: they translate platform calls (create, join,
|
||||||
|
websocket actions, deadlines) into rules-engine calls and back, keeping
|
||||||
|
all scopa knowledge inside this package. Nothing outside ``tavolo.scopa``
|
||||||
|
needs to know about phases, turns or hand scoring.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Mapping, Optional
|
||||||
|
|
||||||
|
from tavolo.platform.engine import (
|
||||||
|
Deadline,
|
||||||
|
GameEngine,
|
||||||
|
GameSession,
|
||||||
|
MatchResult,
|
||||||
|
PlayerResult,
|
||||||
|
Seat,
|
||||||
|
)
|
||||||
|
from tavolo.platform.errors import GameError, IllegalMove
|
||||||
|
|
||||||
|
from . import engine
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
DEFAULT_TARGET_SCORE_QUATTRO,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
TEAM_NAMES,
|
||||||
|
ScopaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||||
|
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||||
|
or unparseable."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _options_schema(default_target: int) -> Dict[str, Any]:
|
||||||
|
"""The creation options schema with this variant's target default."""
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"default": default_target,
|
||||||
|
"description": "Match points the winning side must reach.",
|
||||||
|
},
|
||||||
|
"asso_pigliatutto": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Scopa d'assi: an ace sweeps the whole "
|
||||||
|
"table (no scopa)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ScopaBase(GameEngine):
|
||||||
|
"""Shared platform adapter for the two-player and four-player games."""
|
||||||
|
|
||||||
|
player_count: int
|
||||||
|
#: Traditional match target of the variant; also the schema default.
|
||||||
|
default_target_score: int
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self._turn_timeout = turn_timeout_seconds
|
||||||
|
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||||
|
|
||||||
|
# -- lobby ------------------------------------------------------------
|
||||||
|
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
target_score = options.get("target_score", self.default_target_score)
|
||||||
|
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||||
|
raise IllegalMove("target_score must be an integer")
|
||||||
|
asso_pigliatutto = options.get("asso_pigliatutto", False)
|
||||||
|
if not isinstance(asso_pigliatutto, bool):
|
||||||
|
raise IllegalMove("asso_pigliatutto must be a boolean")
|
||||||
|
creator = session.players[0]
|
||||||
|
session.state = engine.create_game(
|
||||||
|
creator_sub=creator.user_sub,
|
||||||
|
creator_name=creator.display_name,
|
||||||
|
player_count=self.player_count,
|
||||||
|
target_score=target_score,
|
||||||
|
hand_ack_timeout=self._hand_ack_timeout,
|
||||||
|
turn_timeout=self._turn_timeout,
|
||||||
|
asso_pigliatutto=asso_pigliatutto,
|
||||||
|
)
|
||||||
|
session.players[0] = Seat(
|
||||||
|
user_sub=creator.user_sub,
|
||||||
|
display_name=creator.display_name,
|
||||||
|
team=TEAM_NAMES[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
seat = len(session.players)
|
||||||
|
engine.join_game(session.state, user_sub, display_name)
|
||||||
|
# join_game raises before appending on any violation, so the seat
|
||||||
|
# list stays in sync with the engine's players.
|
||||||
|
session.players.append(
|
||||||
|
Seat(
|
||||||
|
user_sub=user_sub,
|
||||||
|
display_name=display_name,
|
||||||
|
team=TEAM_NAMES[seat % 2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_LOBBY
|
||||||
|
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
state: ScopaState = session.state
|
||||||
|
return {
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"asso_pigliatutto": state.asso_pigliatutto,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- play -------------------------------------------------------------
|
||||||
|
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
state: ScopaState = session.state
|
||||||
|
if action == "play":
|
||||||
|
card = payload.get("card")
|
||||||
|
capture = payload.get("capture")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
raise IllegalMove("'card' must be a card code string")
|
||||||
|
if capture is not None and (
|
||||||
|
not isinstance(capture, list)
|
||||||
|
or any(not isinstance(item, str) for item in capture)
|
||||||
|
):
|
||||||
|
raise IllegalMove("'capture' must be a list of card codes")
|
||||||
|
try:
|
||||||
|
engine.play(state, user_sub, card, capture)
|
||||||
|
except ValueError:
|
||||||
|
raise IllegalMove("invalid card code")
|
||||||
|
elif action == "ack":
|
||||||
|
engine.acknowledge_hand(state, user_sub)
|
||||||
|
else:
|
||||||
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
return engine.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_FINISHED
|
||||||
|
|
||||||
|
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
state: ScopaState = session.state
|
||||||
|
return {
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- (de)serialization -------------------------------------------------
|
||||||
|
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
assert isinstance(state, ScopaState)
|
||||||
|
return state.to_json()
|
||||||
|
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
return ScopaState.from_json(dict(data))
|
||||||
|
|
||||||
|
# -- deadlines ----------------------------------------------------------
|
||||||
|
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
state: ScopaState = session.state
|
||||||
|
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||||
|
due_ms = _deadline_ms(state.turn_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="turn",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||||
|
)
|
||||||
|
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||||
|
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="hand_end",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"hand_end:{state.hand_number}:{due_ms}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
current = self.next_deadline(session)
|
||||||
|
if current is None or current.kind != kind or current.token != token:
|
||||||
|
# Overtaken by events (a play landed in time, the hand was
|
||||||
|
# acknowledged, the deadline moved): nothing to do.
|
||||||
|
raise GameError("stale deadline")
|
||||||
|
state: ScopaState = session.state
|
||||||
|
if kind == "turn":
|
||||||
|
engine.auto_play(state)
|
||||||
|
actor = state.last_move.seat if state.last_move is not None else None
|
||||||
|
log.info(
|
||||||
|
"auto-played for seat %s (turn timeout, hand %d)",
|
||||||
|
actor,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
elif kind == "hand_end":
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
log.info(
|
||||||
|
"hand %d auto-advanced after the acknowledgement timeout",
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - next_deadline never emits other kinds
|
||||||
|
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||||
|
|
||||||
|
# -- results -------------------------------------------------------------
|
||||||
|
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
state: ScopaState = session.state
|
||||||
|
if state.winner is None:
|
||||||
|
raise GameError("no result: the match is not finished")
|
||||||
|
teams = [
|
||||||
|
[p.sub for p in state.players if p.team == 0],
|
||||||
|
[p.sub for p in state.players if p.team == 1],
|
||||||
|
]
|
||||||
|
return MatchResult(
|
||||||
|
teams=teams,
|
||||||
|
winner_team=state.winner,
|
||||||
|
players=[
|
||||||
|
PlayerResult(
|
||||||
|
user_sub=player.sub,
|
||||||
|
seat=player.seat,
|
||||||
|
won=player.team == state.winner,
|
||||||
|
team=TEAM_NAMES[player.team],
|
||||||
|
score=float(state.scores[player.team]),
|
||||||
|
details={
|
||||||
|
"scope": player.scope,
|
||||||
|
"captured_cards": len(player.captured),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for player in state.players
|
||||||
|
],
|
||||||
|
summary={
|
||||||
|
"team_a_score": state.scores[0],
|
||||||
|
"team_b_score": state.scores[1],
|
||||||
|
"winner_team": TEAM_NAMES[state.winner],
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"asso_pigliatutto": state.asso_pigliatutto,
|
||||||
|
"hands_played": state.hand_number,
|
||||||
|
"hand_scores": state.hand_scores,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaEngine(_ScopaBase):
|
||||||
|
"""Classic two-player scopa as a platform game engine."""
|
||||||
|
|
||||||
|
id = "scopa"
|
||||||
|
name = "Scopa"
|
||||||
|
description = (
|
||||||
|
"The classic two-player Italian fishing game: three cards each, "
|
||||||
|
"four on the table, capture by rank or sum and sweep for scope. "
|
||||||
|
"First to the target score wins."
|
||||||
|
)
|
||||||
|
min_players = 2
|
||||||
|
max_players = 2
|
||||||
|
player_count = 2
|
||||||
|
default_target_score = DEFAULT_TARGET_SCORE
|
||||||
|
options_schema = _options_schema(DEFAULT_TARGET_SCORE)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaQuattroEngine(_ScopaBase):
|
||||||
|
"""Four-player partnership scopa as a platform game engine."""
|
||||||
|
|
||||||
|
id = "scopa_a_quattro"
|
||||||
|
name = "Scopa a quattro"
|
||||||
|
description = (
|
||||||
|
"Scopa for four players in fixed partnerships: three cards each, "
|
||||||
|
"four on the table, capture by rank or sum and sweep for scope. "
|
||||||
|
"First side to the target score wins."
|
||||||
|
)
|
||||||
|
min_players = 4
|
||||||
|
max_players = 4
|
||||||
|
player_count = 4
|
||||||
|
default_target_score = DEFAULT_TARGET_SCORE_QUATTRO
|
||||||
|
options_schema = _options_schema(DEFAULT_TARGET_SCORE_QUATTRO)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""In-memory representation of a scopa game.
|
||||||
|
|
||||||
|
The whole mutable game lives in :class:`ScopaState`, which is serialized
|
||||||
|
to and from plain JSON for storage inside the platform's session envelope
|
||||||
|
(see :mod:`tavolo.platform.store`). Keeping the representation JSON-native
|
||||||
|
means the store needs no custom codecs and the state is inspectable with
|
||||||
|
``redis-cli``.
|
||||||
|
|
||||||
|
The same state serves the classic two-player game and the four-player
|
||||||
|
partnership variant (``player_count``): with two players the two seats
|
||||||
|
are individual sides, with four they are fixed partnerships (0/2 vs 1/3),
|
||||||
|
and either way the side is the seat's parity. Within a hand the deck is
|
||||||
|
dealt in rounds of three cards per player and persists across rounds in
|
||||||
|
:attr:`ScopaState.deck`.
|
||||||
|
|
||||||
|
The state contains only game data: the platform owns the session envelope
|
||||||
|
(id, join code, seats, timestamps, stats persistence) — see
|
||||||
|
:class:`tavolo.platform.engine.GameSession`.
|
||||||
|
|
||||||
|
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
||||||
|
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
||||||
|
A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
SUITS = ("D", "C", "S", "B")
|
||||||
|
RANKS = tuple(range(1, 11))
|
||||||
|
|
||||||
|
# Teams are derived from the seat parity: seats 0 and 2 form team A
|
||||||
|
# (index 0), seats 1 and 3 form team B (index 1). In the two-player game
|
||||||
|
# that makes the two players their own sides (seats 0 and 1); in the
|
||||||
|
# four-player game partners sit opposite each other.
|
||||||
|
TEAM_A = 0
|
||||||
|
TEAM_B = 1
|
||||||
|
TEAM_NAMES = ("A", "B")
|
||||||
|
|
||||||
|
PHASE_LOBBY = "lobby"
|
||||||
|
PHASE_PLAYING = "playing"
|
||||||
|
# Between hands of an unfinished match: scoring summary shown to every
|
||||||
|
# player; the next hand is dealt once everyone acknowledges (or the
|
||||||
|
# hand-end timeout elapses).
|
||||||
|
PHASE_HAND_END = "hand_end"
|
||||||
|
PHASE_FINISHED = "finished"
|
||||||
|
|
||||||
|
# Traditional match targets: two-player scopa is played to 11 points,
|
||||||
|
# while the four-player partnership game (like scopone) goes to 21.
|
||||||
|
DEFAULT_TARGET_SCORE = 11
|
||||||
|
DEFAULT_TARGET_SCORE_QUATTRO = 21
|
||||||
|
DEFAULT_TARGET_SCORES = {2: DEFAULT_TARGET_SCORE, 4: DEFAULT_TARGET_SCORE_QUATTRO}
|
||||||
|
DEFAULT_PLAYER_COUNT = 2
|
||||||
|
PLAYER_COUNTS = (2, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def team_of(seat: int) -> int:
|
||||||
|
return seat % 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Card:
|
||||||
|
rank: int
|
||||||
|
suit: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.suit not in SUITS:
|
||||||
|
raise ValueError(f"invalid suit: {self.suit!r}")
|
||||||
|
if self.rank not in RANKS:
|
||||||
|
raise ValueError(f"invalid rank: {self.rank!r}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self) -> str:
|
||||||
|
return f"{self.rank:02d}{self.suit}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(code: str) -> "Card":
|
||||||
|
code = str(code).upper()
|
||||||
|
if len(code) != 3 or not code[:2].isdigit():
|
||||||
|
raise ValueError(f"invalid card code: {code!r}")
|
||||||
|
return Card(rank=int(code[:2]), suit=code[2])
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return self.code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(value: Any) -> "Card":
|
||||||
|
return Card.parse(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_card(code: Any) -> Card:
|
||||||
|
"""Parse a card code, raising :class:`ValueError` on malformed input."""
|
||||||
|
return Card.parse(str(code))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Move:
|
||||||
|
"""Record of a single play, broadcast so every client can show who
|
||||||
|
played which card and what it captured."""
|
||||||
|
|
||||||
|
seat: int
|
||||||
|
name: str
|
||||||
|
card: str
|
||||||
|
captured: List[str] = field(default_factory=list)
|
||||||
|
scopa: bool = False
|
||||||
|
# The play was an asso pigliatutto capture (the whole table, or the
|
||||||
|
# ace itself when the table was empty).
|
||||||
|
pigliatutto: bool = False
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"seat": self.seat,
|
||||||
|
"name": self.name,
|
||||||
|
"card": self.card,
|
||||||
|
"captured": list(self.captured),
|
||||||
|
"scopa": self.scopa,
|
||||||
|
"pigliatutto": self.pigliatutto,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "Move":
|
||||||
|
return Move(
|
||||||
|
seat=int(data["seat"]),
|
||||||
|
name=str(data["name"]),
|
||||||
|
card=str(data["card"]),
|
||||||
|
captured=[str(c) for c in data.get("captured", [])],
|
||||||
|
scopa=bool(data.get("scopa", False)),
|
||||||
|
pigliatutto=bool(data.get("pigliatutto", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayerState:
|
||||||
|
sub: str
|
||||||
|
name: str
|
||||||
|
seat: int
|
||||||
|
hand: List[Card] = field(default_factory=list)
|
||||||
|
captured: List[Card] = field(default_factory=list)
|
||||||
|
scope: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def team(self) -> int:
|
||||||
|
return team_of(self.seat)
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sub": self.sub,
|
||||||
|
"name": self.name,
|
||||||
|
"seat": self.seat,
|
||||||
|
"hand": [c.to_json() for c in self.hand],
|
||||||
|
"captured": [c.to_json() for c in self.captured],
|
||||||
|
"scope": self.scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "PlayerState":
|
||||||
|
return PlayerState(
|
||||||
|
sub=str(data["sub"]),
|
||||||
|
name=str(data["name"]),
|
||||||
|
seat=int(data["seat"]),
|
||||||
|
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||||
|
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||||
|
scope=int(data.get("scope", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScopaState:
|
||||||
|
# 2 (individual) or 4 (fixed partnerships). Fixed for the session.
|
||||||
|
player_count: int = DEFAULT_PLAYER_COUNT
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE
|
||||||
|
# Whether the asso pigliatutto rule is scored (scopa d'assi: an ace
|
||||||
|
# sweeps the whole table when no equal rank is on it, or captures
|
||||||
|
# itself when the table is empty; the capture never scores a scopa).
|
||||||
|
# Default off, also for states serialized before the option existed.
|
||||||
|
asso_pigliatutto: bool = False
|
||||||
|
phase: str = PHASE_LOBBY
|
||||||
|
players: List[PlayerState] = field(default_factory=list)
|
||||||
|
table: List[Card] = field(default_factory=list)
|
||||||
|
# Cards of the current hand not dealt yet: scopa deals three cards per
|
||||||
|
# player at a time, and re-deals when every hand is empty until the
|
||||||
|
# deck is exhausted.
|
||||||
|
deck: List[Card] = field(default_factory=list)
|
||||||
|
dealer: int = 0
|
||||||
|
turn: int = 0
|
||||||
|
hand_number: int = 1
|
||||||
|
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||||
|
winner: Optional[int] = None
|
||||||
|
last_taker: Optional[int] = None
|
||||||
|
# Per-hand points awarded, for a compact audit trail in the API.
|
||||||
|
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
# The most recent play in the current hand, for move announcements.
|
||||||
|
last_move: Optional[Move] = None
|
||||||
|
# While phase == "hand_end": seats that acknowledged the summary, and
|
||||||
|
# when the auto-continue timeout fires.
|
||||||
|
acked: List[int] = field(default_factory=list)
|
||||||
|
hand_end_deadline: Optional[str] = None
|
||||||
|
# Seconds the hand-end summary waits before dealing anyway.
|
||||||
|
hand_ack_timeout: int = 30
|
||||||
|
# While phase == "playing": when the server plays a random legal card
|
||||||
|
# for the player on turn.
|
||||||
|
turn_deadline: Optional[str] = None
|
||||||
|
turn_timeout: int = 30
|
||||||
|
|
||||||
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"player_count": self.player_count,
|
||||||
|
"target_score": self.target_score,
|
||||||
|
"asso_pigliatutto": self.asso_pigliatutto,
|
||||||
|
"phase": self.phase,
|
||||||
|
"players": [p.to_json() for p in self.players],
|
||||||
|
"table": [c.to_json() for c in self.table],
|
||||||
|
"deck": [c.to_json() for c in self.deck],
|
||||||
|
"dealer": self.dealer,
|
||||||
|
"turn": self.turn,
|
||||||
|
"hand_number": self.hand_number,
|
||||||
|
"scores": list(self.scores),
|
||||||
|
"winner": self.winner,
|
||||||
|
"last_taker": self.last_taker,
|
||||||
|
"hand_scores": list(self.hand_scores),
|
||||||
|
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||||
|
"acked": list(self.acked),
|
||||||
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
|
"hand_ack_timeout": self.hand_ack_timeout,
|
||||||
|
"turn_deadline": self.turn_deadline,
|
||||||
|
"turn_timeout": self.turn_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "ScopaState":
|
||||||
|
return ScopaState(
|
||||||
|
player_count=int(data.get("player_count", DEFAULT_PLAYER_COUNT)),
|
||||||
|
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||||
|
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
|
||||||
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||||
|
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||||
|
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||||
|
deck=[Card.from_json(c) for c in data.get("deck", [])],
|
||||||
|
dealer=int(data.get("dealer", 0)),
|
||||||
|
turn=int(data.get("turn", 0)),
|
||||||
|
hand_number=int(data.get("hand_number", 1)),
|
||||||
|
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||||
|
winner=data.get("winner"),
|
||||||
|
last_taker=data.get("last_taker"),
|
||||||
|
hand_scores=list(data.get("hand_scores", [])),
|
||||||
|
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||||
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
|
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
|
||||||
|
turn_deadline=data.get("turn_deadline"),
|
||||||
|
turn_timeout=int(data.get("turn_timeout", 30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||||
|
for player in self.players:
|
||||||
|
if player.sub == sub:
|
||||||
|
return player
|
||||||
|
return None
|
||||||
|
|
||||||
|
def seated(self, sub: str) -> bool:
|
||||||
|
return self.player_for(sub) is not None
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
"""Rule engine tests: deals, captures, scope, scoring and full matches."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tavolo.scopa import engine
|
||||||
|
from tavolo.scopa.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
CardNotInHand,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from tavolo.scopa.state import (
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
Card,
|
||||||
|
PlayerState,
|
||||||
|
ScopaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def card(code: str) -> Card:
|
||||||
|
return Card.parse(code)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
hands,
|
||||||
|
table,
|
||||||
|
turn: int = 0,
|
||||||
|
*,
|
||||||
|
dealer: int = 0,
|
||||||
|
captured=None,
|
||||||
|
scope=None,
|
||||||
|
target: int = 11,
|
||||||
|
last_taker=None,
|
||||||
|
deck=None,
|
||||||
|
asso_pigliatutto: bool = False,
|
||||||
|
) -> ScopaState:
|
||||||
|
"""Build a controlled game state directly (bypassing the deal)."""
|
||||||
|
state = ScopaState(
|
||||||
|
player_count=len(hands),
|
||||||
|
target_score=target,
|
||||||
|
asso_pigliatutto=asso_pigliatutto,
|
||||||
|
phase=PHASE_PLAYING,
|
||||||
|
dealer=dealer,
|
||||||
|
turn=turn,
|
||||||
|
last_taker=last_taker,
|
||||||
|
)
|
||||||
|
for seat, hand in enumerate(hands):
|
||||||
|
state.players.append(
|
||||||
|
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||||
|
hand=[card(c) for c in hand])
|
||||||
|
)
|
||||||
|
if captured is not None:
|
||||||
|
for player, codes in zip(state.players, captured):
|
||||||
|
player.captured = [card(c) for c in codes]
|
||||||
|
if scope is not None:
|
||||||
|
for player, value in zip(state.players, scope):
|
||||||
|
player.scope = value
|
||||||
|
state.table = [card(c) for c in table]
|
||||||
|
state.deck = [card(c) for c in (deck or [])]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
class DeckTest(unittest.TestCase):
|
||||||
|
def test_full_deck_has_40_unique_cards(self) -> None:
|
||||||
|
deck = engine.full_deck()
|
||||||
|
self.assertEqual(40, len(deck))
|
||||||
|
self.assertEqual(40, len({c.code for c in deck}))
|
||||||
|
self.assertEqual(4, len({c.suit for c in deck}))
|
||||||
|
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
|
||||||
|
|
||||||
|
def test_shuffled_deck_is_permutation(self) -> None:
|
||||||
|
deck = engine.shuffled_deck()
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(c.code for c in engine.full_deck()),
|
||||||
|
sorted(c.code for c in deck),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureTest(unittest.TestCase):
|
||||||
|
def test_equal_card_is_mandatory(self) -> None:
|
||||||
|
table = [card("05C"), card("02D"), card("03S")]
|
||||||
|
options = engine.legal_captures(table, card("05D"))
|
||||||
|
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
|
||||||
|
|
||||||
|
def test_sum_combination(self) -> None:
|
||||||
|
table = [card("01C"), card("03C"), card("02S")]
|
||||||
|
options = engine.legal_captures(table, card("04D"))
|
||||||
|
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
|
||||||
|
|
||||||
|
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
|
||||||
|
table = [card("05C"), card("05S")]
|
||||||
|
options = engine.legal_captures(table, card("05D"))
|
||||||
|
self.assertEqual(
|
||||||
|
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_capture(self) -> None:
|
||||||
|
table = [card("09C"), card("08S")]
|
||||||
|
self.assertEqual([], engine.legal_captures(table, card("02D")))
|
||||||
|
|
||||||
|
def test_play_without_capture_places_card_on_table(self) -> None:
|
||||||
|
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertIn("02D", [c.code for c in state.table])
|
||||||
|
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
|
||||||
|
def test_play_capture_and_scopa(self) -> None:
|
||||||
|
state = make_state([["02D", "09C"], ["01C"]], table=["02C"])
|
||||||
|
engine.play(state, "p0", "02D", ["02C"])
|
||||||
|
self.assertEqual(1, state.players[0].scope)
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
self.assertEqual(
|
||||||
|
["02C", "02D"], [c.code for c in state.players[0].captured]
|
||||||
|
)
|
||||||
|
# The move is recorded for the "who played what" announcement.
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual(0, state.last_move.seat)
|
||||||
|
self.assertEqual("p0", state.last_move.name)
|
||||||
|
self.assertEqual("02D", state.last_move.card)
|
||||||
|
self.assertEqual(["02C"], state.last_move.captured)
|
||||||
|
self.assertTrue(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_play_without_capture_records_move(self) -> None:
|
||||||
|
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual("02D", state.last_move.card)
|
||||||
|
self.assertEqual([], state.last_move.captured)
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_illegal_combination_when_equal_card_present(self) -> None:
|
||||||
|
state = make_state([["05D"], ["01C"]],
|
||||||
|
table=["05C", "02D", "03S"])
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "05D", ["02D", "03S"])
|
||||||
|
|
||||||
|
def test_illegal_capture_rejected(self) -> None:
|
||||||
|
state = make_state([["04D"], ["01C"]], table=["02C", "03S"])
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "04D", ["02C"])
|
||||||
|
|
||||||
|
def test_no_capture_requested_when_capture_possible(self) -> None:
|
||||||
|
state = make_state([["05D"], ["01C"]], table=["05C"])
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "05D")
|
||||||
|
|
||||||
|
def test_not_your_turn(self) -> None:
|
||||||
|
state = make_state([["02D"], ["03D"]], table=[], turn=1)
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
def test_card_not_in_hand(self) -> None:
|
||||||
|
state = make_state([["02D"], ["03D"]], table=[])
|
||||||
|
with self.assertRaises(CardNotInHand):
|
||||||
|
engine.play(state, "p0", "07D")
|
||||||
|
|
||||||
|
def test_finished_game_rejects_moves(self) -> None:
|
||||||
|
state = make_state([["02D"], ["03D"]], table=[])
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
|
||||||
|
class DealTest(unittest.TestCase):
|
||||||
|
def test_create_game_defaults(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
self.assertEqual(2, state.player_count)
|
||||||
|
# Two-player scopa is traditionally played to 11 points.
|
||||||
|
self.assertEqual(11, state.target_score)
|
||||||
|
self.assertFalse(state.asso_pigliatutto)
|
||||||
|
self.assertEqual("p0", state.players[0].sub)
|
||||||
|
self.assertEqual("lobby", state.phase)
|
||||||
|
|
||||||
|
def test_create_game_four_player_default_target(self) -> None:
|
||||||
|
# The four-player partnership game (like scopone) goes to 21.
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
self.assertEqual(21, state.target_score)
|
||||||
|
# An explicit target still wins over the default.
|
||||||
|
explicit = engine.create_game("p0", "p0", player_count=4, target_score=16)
|
||||||
|
self.assertEqual(16, explicit.target_score)
|
||||||
|
|
||||||
|
def test_create_game_player_count_validation(self) -> None:
|
||||||
|
for bad in (1, 3, 5, 0):
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", player_count=bad)
|
||||||
|
|
||||||
|
def test_join_starts_two_player_game(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(4, len(state.table))
|
||||||
|
self.assertEqual(4, len({c.code for c in state.table}))
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(3, len(player.hand))
|
||||||
|
# 40 - 4 on the table - 6 in hand.
|
||||||
|
self.assertEqual(30, len(state.deck))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
|
||||||
|
def test_join_starts_four_player_game(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for seat in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{seat}", f"p{seat}")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(4, len(state.table))
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(3, len(player.hand))
|
||||||
|
# 40 - 4 on the table - 12 in hand.
|
||||||
|
self.assertEqual(24, len(state.deck))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
for seat in range(4):
|
||||||
|
self.assertEqual(seat % 2, engine._player_at(state, seat).team)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join_game(state, "p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
# The lobby filled up and the match started.
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join_game(state, "p2", "p2")
|
||||||
|
|
||||||
|
def test_start_requires_full_table(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.start_game(state)
|
||||||
|
|
||||||
|
|
||||||
|
class RoundTest(unittest.TestCase):
|
||||||
|
def test_redeal_when_hands_empty_and_deck_has_cards(self) -> None:
|
||||||
|
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
|
||||||
|
for suit in ("D", "C")]
|
||||||
|
state = make_state([["02D"], ["03D"]], table=["09C"],
|
||||||
|
deck=deck, turn=0, dealer=0)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
# The last card of the round: the next three cards are dealt.
|
||||||
|
engine.play(state, "p1", "03D")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(3, len(player.hand))
|
||||||
|
self.assertEqual([], state.deck)
|
||||||
|
# The table is never topped up.
|
||||||
|
self.assertEqual(
|
||||||
|
{"09C", "02D", "03D"}, {c.code for c in state.table}
|
||||||
|
)
|
||||||
|
# The player after the dealer leads every round again.
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
|
||||||
|
def test_redeal_gives_the_lead_back_to_the_non_dealer(self) -> None:
|
||||||
|
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
|
||||||
|
for suit in ("D", "C")]
|
||||||
|
state = make_state([["02D"], ["03D"]], table=[], deck=deck,
|
||||||
|
turn=0, dealer=1)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "03D")
|
||||||
|
# Dealer seat 1: the lead is seat 0 again.
|
||||||
|
self.assertEqual(0, state.turn)
|
||||||
|
|
||||||
|
def test_hand_ends_when_deck_is_exhausted(self) -> None:
|
||||||
|
state = make_state([["02D"], ["03D"]], table=[], deck=[], target=100)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "03D")
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaTest(unittest.TestCase):
|
||||||
|
def test_scopa_on_the_last_play_of_a_round_with_deck_left(self) -> None:
|
||||||
|
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
|
||||||
|
for suit in ("D", "C")]
|
||||||
|
state = make_state([["02D"], []], table=["02C"], deck=deck, turn=0)
|
||||||
|
engine.play(state, "p0", "02D", ["02C"])
|
||||||
|
# Cards remain in the deck: the sweep is a scopa, and the round is
|
||||||
|
# re-dealt immediately.
|
||||||
|
self.assertEqual(1, state.players[0].scope)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.scopa)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(3, len(player.hand))
|
||||||
|
|
||||||
|
def test_no_scopa_on_the_last_play_of_the_hand(self) -> None:
|
||||||
|
state = make_state([["02D"], []], table=["02C"], deck=[],
|
||||||
|
turn=0, target=100)
|
||||||
|
engine.play(state, "p0", "02D", ["02C"])
|
||||||
|
self.assertEqual(0, state.players[0].scope)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
|
||||||
|
|
||||||
|
|
||||||
|
class AssoPigliatuttoTest(unittest.TestCase):
|
||||||
|
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
|
||||||
|
# p1 still holds cards, so emptying the table would normally score
|
||||||
|
# a scopa: the sweep must suppress it.
|
||||||
|
state = make_state(
|
||||||
|
[["01D", "09C"], ["04D"]],
|
||||||
|
table=["05C", "02D", "03S"],
|
||||||
|
asso_pigliatutto=True,
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
self.assertEqual(
|
||||||
|
{"05C", "02D", "03S", "01D"},
|
||||||
|
{c.code for c in state.players[0].captured},
|
||||||
|
)
|
||||||
|
self.assertEqual(0, state.players[0].scope)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.pigliatutto)
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_legal_captures_offer_only_the_whole_table(self) -> None:
|
||||||
|
table = [card("05C"), card("02D"), card("03S")]
|
||||||
|
options = engine.legal_captures(
|
||||||
|
table, card("01D"), asso_pigliatutto=True
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
|
||||||
|
)
|
||||||
|
# A bare table leaves only the self-capture option; without the
|
||||||
|
# rule the ace stays on the table.
|
||||||
|
self.assertEqual(
|
||||||
|
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
|
||||||
|
)
|
||||||
|
# Non-ace cards keep the normal rules.
|
||||||
|
self.assertEqual(
|
||||||
|
[],
|
||||||
|
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["01D"], ["04D"]],
|
||||||
|
table=["01C", "02D"],
|
||||||
|
asso_pigliatutto=True,
|
||||||
|
)
|
||||||
|
options = engine.legal_captures(
|
||||||
|
state.table, card("01D"), asso_pigliatutto=True
|
||||||
|
)
|
||||||
|
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "01D", ["01C", "02D"])
|
||||||
|
# A rejected play consumes the card, so retry on a fresh state.
|
||||||
|
state = make_state(
|
||||||
|
[["01D"], ["04D"]],
|
||||||
|
table=["01C", "02D"],
|
||||||
|
asso_pigliatutto=True,
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "01D", ["01C"])
|
||||||
|
self.assertEqual(["02D"], [c.code for c in state.table])
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.pigliatutto)
|
||||||
|
|
||||||
|
def test_disabled_by_default(self) -> None:
|
||||||
|
state = make_state([["01D", "09C"], ["04D"]], table=["05C", "02D"])
|
||||||
|
engine.play(state, "p0", "01D")
|
||||||
|
# Without the rule an ace captures nothing here and stays on the
|
||||||
|
# table.
|
||||||
|
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.pigliatutto)
|
||||||
|
# ... and on a bare table it stays there too.
|
||||||
|
state = make_state([["01D", "09C"], ["04D"]], table=[])
|
||||||
|
engine.play(state, "p0", "01D")
|
||||||
|
self.assertEqual(["01D"], [c.code for c in state.table])
|
||||||
|
self.assertEqual([], state.players[0].captured)
|
||||||
|
|
||||||
|
def test_ace_on_empty_table_captures_itself(self) -> None:
|
||||||
|
# The self-capture makes p0 the last taker and scores no scopa.
|
||||||
|
state = make_state(
|
||||||
|
[["01D", "09C"], ["04D"]],
|
||||||
|
table=[],
|
||||||
|
last_taker=1,
|
||||||
|
asso_pigliatutto=True,
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "01D")
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
|
||||||
|
self.assertEqual(0, state.players[0].scope)
|
||||||
|
self.assertEqual(0, state.last_taker)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.pigliatutto)
|
||||||
|
self.assertEqual([], state.last_move.captured)
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_non_ace_on_empty_table_stays(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "09C"], ["04D"]], table=[], asso_pigliatutto=True
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertEqual(["02D"], [c.code for c in state.table])
|
||||||
|
self.assertEqual([], state.players[0].captured)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.pigliatutto)
|
||||||
|
|
||||||
|
|
||||||
|
class HandEndTest(unittest.TestCase):
|
||||||
|
def test_table_swept_to_last_taker(self) -> None:
|
||||||
|
state = make_state([["02D"], []], table=["05C", "04D"],
|
||||||
|
last_taker=1, deck=[], target=100)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
captured = {c.code for c in state.players[1].captured}
|
||||||
|
self.assertEqual({"05C", "04D", "02D"}, captured)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
|
||||||
|
def test_match_ends_at_target_with_clear_lead(self) -> None:
|
||||||
|
state = make_state([["02D"], []], table=[], deck=[], turn=0,
|
||||||
|
target=1, captured=[["07D"], []])
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(0, state.winner)
|
||||||
|
|
||||||
|
def test_tie_at_target_plays_another_hand(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], []],
|
||||||
|
table=[],
|
||||||
|
deck=[],
|
||||||
|
target=1,
|
||||||
|
captured=[["01D", "02C"], ["02D", "01C"]],
|
||||||
|
)
|
||||||
|
state.scores = [1, 1]
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
# A 0-0 hand at the target is not a win: another hand is played.
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
self.assertIsNone(state.winner)
|
||||||
|
|
||||||
|
def test_acknowledge_deals_next_hand(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
engine.acknowledge_hand(state, "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual(1, state.dealer)
|
||||||
|
self.assertEqual(4, len(state.table))
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(3, len(player.hand))
|
||||||
|
|
||||||
|
def test_acknowledge_rejects_outside_hand_end(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
|
||||||
|
def test_hand_summary_has_no_napola(self) -> None:
|
||||||
|
state = make_state([["02D"], []], table=[], deck=[], target=1,
|
||||||
|
captured=[["07D"], []])
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertNotIn("napola", state.hand_scores[-1])
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringTest(unittest.TestCase):
|
||||||
|
def test_primiera_values_and_all_suits_requirement(self) -> None:
|
||||||
|
self.assertEqual(70, engine.primiera_score(
|
||||||
|
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
|
||||||
|
self.assertEqual(0, engine.primiera_score(
|
||||||
|
[card(c) for c in ["07D", "06C", "01S"]]))
|
||||||
|
self.assertEqual(40, engine.primiera_score(
|
||||||
|
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
|
||||||
|
|
||||||
|
def test_two_player_hand_points(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], []],
|
||||||
|
table=[],
|
||||||
|
captured=[
|
||||||
|
["07D", "06C", "01S", "05B"],
|
||||||
|
["03D", "04C", "07S", "02B"],
|
||||||
|
],
|
||||||
|
scope=[1, 0],
|
||||||
|
)
|
||||||
|
points, details = engine.hand_points(state)
|
||||||
|
# Carte and denara tie; A takes settebello and primiera, plus one
|
||||||
|
# scopa: A+3.
|
||||||
|
self.assertEqual({"A": 4, "B": 4}, details["cards"])
|
||||||
|
self.assertEqual({"A": 1, "B": 1}, details["denara"])
|
||||||
|
self.assertEqual({"A": True, "B": False}, details["settebello"])
|
||||||
|
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
|
||||||
|
self.assertEqual({"A": 1, "B": 0}, details["scope"])
|
||||||
|
self.assertEqual([3, 0], points)
|
||||||
|
|
||||||
|
def test_ties_award_nothing(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], []],
|
||||||
|
table=[],
|
||||||
|
captured=[
|
||||||
|
["06C", "01S", "05B", "02D"],
|
||||||
|
["06S", "01B", "05D", "02C"],
|
||||||
|
],
|
||||||
|
scope=[0, 0],
|
||||||
|
)
|
||||||
|
points, _ = engine.hand_points(state)
|
||||||
|
# Equal cards, equal denara, equal primiera and no settebello:
|
||||||
|
# everything ties, so no points at all.
|
||||||
|
self.assertEqual([0, 0], points)
|
||||||
|
|
||||||
|
def test_four_player_pools_the_partners(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], []],
|
||||||
|
table=[],
|
||||||
|
captured=[
|
||||||
|
["07D", "06C", "01S", "05B"], # seat 0, team A
|
||||||
|
["03D", "04C", "07S", "02B"], # seat 1, team B
|
||||||
|
["02D"], # seat 2, team A
|
||||||
|
["10D", "10C", "10S", "10B"], # seat 3, team B
|
||||||
|
],
|
||||||
|
scope=[1, 0, 0, 2],
|
||||||
|
)
|
||||||
|
points, details = engine.hand_points(state)
|
||||||
|
self.assertEqual([3, 3], points)
|
||||||
|
self.assertEqual({"A": 5, "B": 8}, details["cards"])
|
||||||
|
self.assertEqual({"A": 2, "B": 2}, details["denara"])
|
||||||
|
self.assertEqual({"A": True, "B": False}, details["settebello"])
|
||||||
|
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
|
||||||
|
self.assertEqual({"A": 1, "B": 2}, details["scope"])
|
||||||
|
self.assertNotIn("napola", details)
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
state = engine.create_game(
|
||||||
|
"p0", "p0", player_count=4, target_score=16,
|
||||||
|
asso_pigliatutto=True,
|
||||||
|
)
|
||||||
|
for seat in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{seat}", f"p{seat}")
|
||||||
|
restored = ScopaState.from_json(state.to_json())
|
||||||
|
self.assertEqual(4, restored.player_count)
|
||||||
|
self.assertEqual(16, restored.target_score)
|
||||||
|
self.assertTrue(restored.asso_pigliatutto)
|
||||||
|
self.assertEqual(
|
||||||
|
[c.code for c in state.deck], [c.code for c in restored.deck]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[c.code for c in state.table], [c.code for c in restored.table]
|
||||||
|
)
|
||||||
|
self.assertEqual(state.phase, restored.phase)
|
||||||
|
self.assertEqual(state.turn, restored.turn)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in state.players], [p.sub for p in restored.players]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_asso_pigliatutto_defaults_off(self) -> None:
|
||||||
|
state = make_state([["02D"], ["03D"]], table=[])
|
||||||
|
data = state.to_json()
|
||||||
|
del data["asso_pigliatutto"]
|
||||||
|
self.assertFalse(ScopaState.from_json(data).asso_pigliatutto)
|
||||||
|
|
||||||
|
def test_player_count_defaults_to_two(self) -> None:
|
||||||
|
data = ScopaState().to_json()
|
||||||
|
del data["player_count"]
|
||||||
|
self.assertEqual(2, ScopaState.from_json(data).player_count)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoPlayTest(unittest.TestCase):
|
||||||
|
def test_auto_play_applies_a_legal_move(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
rng = random.Random(7)
|
||||||
|
player = engine._player_at(state, state.turn)
|
||||||
|
hand_size = len(player.hand)
|
||||||
|
engine.auto_play(state, rng)
|
||||||
|
self.assertEqual(hand_size - 1, len(player.hand))
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertEqual(player.seat, state.last_move.seat)
|
||||||
|
|
||||||
|
|
||||||
|
class FullMatchTest(unittest.TestCase):
|
||||||
|
def _play_out(self, player_count: int, target: int) -> ScopaState:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=player_count,
|
||||||
|
target_score=target)
|
||||||
|
for seat in range(1, player_count):
|
||||||
|
engine.join_game(state, f"p{seat}", f"p{seat}")
|
||||||
|
rng = random.Random(1234 + player_count)
|
||||||
|
steps = 0
|
||||||
|
while state.phase != PHASE_FINISHED and steps < 100_000:
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
continue
|
||||||
|
engine.auto_play(state, rng)
|
||||||
|
steps += 1
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertIsNotNone(state.winner)
|
||||||
|
self.assertGreaterEqual(max(state.scores), target)
|
||||||
|
self.assertNotEqual(state.scores[0], state.scores[1])
|
||||||
|
return state
|
||||||
|
|
||||||
|
def test_random_two_player_match_reaches_a_winner(self) -> None:
|
||||||
|
state = self._play_out(2, 5)
|
||||||
|
for hand in state.hand_scores:
|
||||||
|
self.assertIn("cards", hand)
|
||||||
|
self.assertIn("scope", hand)
|
||||||
|
|
||||||
|
def test_random_four_player_match_reaches_a_winner(self) -> None:
|
||||||
|
state = self._play_out(4, 5)
|
||||||
|
self.assertEqual(4, len(state.players))
|
||||||
|
|
||||||
|
def test_all_cards_are_captured_by_the_end_of_a_hand(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
rng = random.Random(99)
|
||||||
|
# Finish exactly one hand (target 100 keeps the match alive).
|
||||||
|
state.target_score = 100
|
||||||
|
while state.phase != PHASE_HAND_END:
|
||||||
|
engine.auto_play(state, rng)
|
||||||
|
self.assertIsNotNone(state.last_taker)
|
||||||
|
self.assertEqual(
|
||||||
|
40, sum(len(p.captured) for p in state.players)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Adapter tests: the platform contract over the pure scopa rules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
)
|
||||||
|
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
|
||||||
|
from tavolo.scopa import engine as rules
|
||||||
|
from tavolo.scopa.state import (
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
ScopaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(engine, **options) -> GameSession:
|
||||||
|
session = GameSession(
|
||||||
|
id="s1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="CODE01",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine.create(session, options)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _started(engine, **options) -> GameSession:
|
||||||
|
session = _session(engine, **options)
|
||||||
|
for seat in range(1, engine.max_players):
|
||||||
|
engine.join(session, f"p{seat}", f"P{seat}")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_to_end(engine, session: GameSession) -> None:
|
||||||
|
"""Play the match out with the first legal move each turn."""
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < 200_000:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
for player in state.players:
|
||||||
|
engine.handle_action(session, player.sub, "ack", {})
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = rules.legal_captures(
|
||||||
|
state.table, played, asso_pigliatutto=state.asso_pigliatutto
|
||||||
|
)
|
||||||
|
payload = {"card": played.code}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = [c.code for c in options[0]]
|
||||||
|
engine.handle_action(session, player.sub, "play", payload)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTest(unittest.TestCase):
|
||||||
|
def test_create_seats_creator_on_side_a(self) -> None:
|
||||||
|
for engine_type, target in ((ScopaEngine, 11), (ScopaQuattroEngine, 21)):
|
||||||
|
engine = engine_type()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual("A", session.players[0].team)
|
||||||
|
self.assertIsInstance(session.state, ScopaState)
|
||||||
|
# Two-player scopa is played to 11, the partnership game to 21.
|
||||||
|
self.assertEqual(target, session.state.target_score)
|
||||||
|
self.assertFalse(session.state.asso_pigliatutto)
|
||||||
|
|
||||||
|
def test_schema_defaults_follow_the_variant(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
11, ScopaEngine().options_schema["properties"]["target_score"]["default"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
21,
|
||||||
|
ScopaQuattroEngine().options_schema["properties"]["target_score"][
|
||||||
|
"default"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_options(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _session(engine, target_score=21, asso_pigliatutto=True)
|
||||||
|
self.assertEqual(21, session.state.target_score)
|
||||||
|
self.assertTrue(session.state.asso_pigliatutto)
|
||||||
|
|
||||||
|
def test_create_rejects_bad_options(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=0)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=101)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score="eleven")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=True)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, asso_pigliatutto="yes")
|
||||||
|
|
||||||
|
def test_timeouts_come_from_the_engine(self) -> None:
|
||||||
|
engine = ScopaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual(7, session.state.turn_timeout)
|
||||||
|
self.assertEqual(9, session.state.hand_ack_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinTest(unittest.TestCase):
|
||||||
|
def test_join_starts_two_player_game(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
self.assertFalse(engine.in_lobby(session))
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
self.assertEqual(["A", "B"], [p.team for p in session.players])
|
||||||
|
|
||||||
|
def test_join_starts_four_player_game_with_teams(self) -> None:
|
||||||
|
engine = ScopaQuattroEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
for seat, team in ((1, "B"), (2, "A"), (3, "B")):
|
||||||
|
engine.join(session, f"p{seat}", f"P{seat}")
|
||||||
|
self.assertEqual(team, session.players[-1].team)
|
||||||
|
self.assertFalse(engine.in_lobby(session))
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join(session, "alice", "Alice")
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "carol", "Carol")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "alice", "Alice")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionTest(unittest.TestCase):
|
||||||
|
def test_unknown_action_rejected(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "alice", "dance", {})
|
||||||
|
|
||||||
|
def test_play_validates_payload(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": 42})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(
|
||||||
|
session, "p1", "play", {"card": "01D", "capture": "02C"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_card_code_is_illegal_move(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": "nope"})
|
||||||
|
|
||||||
|
def test_finished_match_rejects_actions(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine, target_score=1)
|
||||||
|
_drive_to_end(engine, session)
|
||||||
|
self.assertTrue(engine.is_finished(session))
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.handle_action(session, "alice", "play", {"card": "01D"})
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_view_hides_other_hands_and_reports_the_deck(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, "alice")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertIn("hand", players[0])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
self.assertEqual(2, view["player_count"])
|
||||||
|
self.assertEqual(30, view["deck_count"])
|
||||||
|
self.assertEqual(4, len(view["table"]))
|
||||||
|
# The envelope is the platform's job, not the view's.
|
||||||
|
self.assertNotIn("id", view)
|
||||||
|
self.assertNotIn("join_code", view)
|
||||||
|
self.assertNotIn("game_type", view)
|
||||||
|
|
||||||
|
def test_view_for_spectator_hides_all_hands(self) -> None:
|
||||||
|
engine = ScopaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("legal_moves", view)
|
||||||
|
self.assertIn("table", view)
|
||||||
|
self.assertEqual(4, view["player_count"])
|
||||||
|
|
||||||
|
def test_lobby_view(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _session(engine, target_score=16, asso_pigliatutto=True)
|
||||||
|
lobby = engine.lobby_view(session)
|
||||||
|
self.assertEqual("lobby", lobby["phase"])
|
||||||
|
self.assertEqual(16, lobby["target_score"])
|
||||||
|
self.assertTrue(lobby["asso_pigliatutto"])
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
engine = ScopaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||||
|
self.assertIsInstance(restored, ScopaState)
|
||||||
|
self.assertEqual(session.state.phase, restored.phase)
|
||||||
|
self.assertEqual(session.state.turn, restored.turn)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in session.state.players],
|
||||||
|
[p.sub for p in restored.players],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[c.code for c in session.state.deck],
|
||||||
|
[c.code for c in restored.deck],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineTest(unittest.TestCase):
|
||||||
|
def test_no_deadline_in_lobby(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||||
|
|
||||||
|
def test_turn_deadline_and_revalidation(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("turn", deadline.kind)
|
||||||
|
# A forged token is stale.
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, "turn", "turn:1:1:0")
|
||||||
|
turn_before = session.state.turn
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertNotEqual(turn_before, session.state.turn)
|
||||||
|
|
||||||
|
def test_stale_deadline_after_play(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
state = session.state
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = rules.legal_captures(state.table, played)
|
||||||
|
payload = {"card": played.code}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = [c.code for c in options[0]]
|
||||||
|
engine.handle_action(session, player.sub, "play", payload)
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
|
||||||
|
def test_hand_end_deadline_advances(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
# Force the hand-end phase with an imminent deadline.
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||||
|
).isoformat()
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("hand_end", deadline.kind)
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
self.assertEqual(2, session.state.hand_number)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultTest(unittest.TestCase):
|
||||||
|
def test_result_of_finished_two_player_match(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine, target_score=1)
|
||||||
|
_drive_to_end(engine, session)
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual(2, len(result.teams))
|
||||||
|
self.assertEqual(1, len(result.teams[0]))
|
||||||
|
self.assertEqual(1, len(result.teams[1]))
|
||||||
|
self.assertIn(result.winner_team, (0, 1))
|
||||||
|
self.assertEqual(2, len(result.players))
|
||||||
|
for player in result.players:
|
||||||
|
self.assertEqual(
|
||||||
|
player.won,
|
||||||
|
player.team == ("A" if result.winner_team == 0 else "B"),
|
||||||
|
)
|
||||||
|
self.assertIn("team_a_score", result.summary)
|
||||||
|
self.assertIn("hands_played", result.summary)
|
||||||
|
self.assertIn("asso_pigliatutto", result.summary)
|
||||||
|
|
||||||
|
def test_result_of_finished_four_player_match(self) -> None:
|
||||||
|
engine = ScopaQuattroEngine()
|
||||||
|
session = _started(engine, target_score=1)
|
||||||
|
_drive_to_end(engine, session)
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual([2, 2], [len(team) for team in result.teams])
|
||||||
|
self.assertEqual(4, len(result.players))
|
||||||
|
|
||||||
|
def test_result_requires_finished_match(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.result(_started(engine))
|
||||||
|
|
||||||
|
def test_game_over_view(self) -> None:
|
||||||
|
engine = ScopaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
session.state.phase = PHASE_FINISHED
|
||||||
|
session.state.winner = 1
|
||||||
|
session.state.scores = [3, 11]
|
||||||
|
over = engine.game_over_view(session, "alice")
|
||||||
|
self.assertEqual({"A": 3, "B": 11}, over["scores"])
|
||||||
|
self.assertEqual("B", over["winner"])
|
||||||
|
|
||||||
|
def test_registry_metadata(self) -> None:
|
||||||
|
two = ScopaEngine()
|
||||||
|
self.assertEqual("scopa", two.id)
|
||||||
|
self.assertEqual("Scopa", two.name)
|
||||||
|
self.assertEqual(2, two.min_players)
|
||||||
|
self.assertEqual(2, two.max_players)
|
||||||
|
four = ScopaQuattroEngine()
|
||||||
|
self.assertEqual("scopa_a_quattro", four.id)
|
||||||
|
self.assertEqual("Scopa a quattro", four.name)
|
||||||
|
self.assertEqual(4, four.min_players)
|
||||||
|
self.assertEqual(4, four.max_players)
|
||||||
|
for engine in (two, four):
|
||||||
|
self.assertIn("target_score", engine.options_schema["properties"])
|
||||||
|
self.assertIn(
|
||||||
|
"asso_pigliatutto", engine.options_schema["properties"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,7 +19,8 @@ implementation.
|
|||||||
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
|
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
|
||||||
It translates create/join/websocket actions/deadlines into rules-engine
|
It translates create/join/websocket actions/deadlines into rules-engine
|
||||||
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
|
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
|
per-player scores and the match summary persisted as the match's JSON
|
||||||
`result`).
|
`result`).
|
||||||
|
|
||||||
@@ -30,8 +31,8 @@ application composition root.
|
|||||||
## Development (from `server/`)
|
## Development (from `server/`)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||||
.venv/bin/python -m mypy -p tavolo.scopone
|
uv run mypy -p tavolo.scopone
|
||||||
```
|
```
|
||||||
|
|
||||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ PHASE_PLAYING = "playing"
|
|||||||
PHASE_HAND_END = "hand_end"
|
PHASE_HAND_END = "hand_end"
|
||||||
PHASE_FINISHED = "finished"
|
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:
|
def team_of(seat: int) -> int:
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class CreateTest(unittest.TestCase):
|
|||||||
session = _session(engine)
|
session = _session(engine)
|
||||||
self.assertEqual("A", session.players[0].team)
|
self.assertEqual("A", session.players[0].team)
|
||||||
self.assertIsInstance(session.state, ScoponeState)
|
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)
|
self.assertTrue(session.state.napola)
|
||||||
|
|
||||||
def test_create_options(self) -> None:
|
def test_create_options(self) -> None:
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
# NOTE: the tavolo-app distribution lives in packages/tavolo-app/ (same
|
# NOTE: the five tavolo distributions live in packages/ (same kaya-style
|
||||||
# kaya-style layout as tavolo-platform and tavolo-scopone). This file
|
# layout as tavolo-platform and tavolo-scopone). This file is the root of
|
||||||
# exists only for tooling that reads configuration from the working
|
# the uv workspace and also holds tooling configuration that is read from
|
||||||
# directory:
|
# the working directory:
|
||||||
|
# - uv locks and syncs the whole workspace from here;
|
||||||
# - aerich reads [tool.aerich] from here (server/ locally, /app in the
|
# - aerich reads [tool.aerich] from here (server/ locally, /app in the
|
||||||
# db-migrate compose service), next to ./migrations;
|
# db-migrate compose service), next to ./migrations;
|
||||||
# - mypy reads [tool.mypy] from here when run from server/.
|
# - mypy reads [tool.mypy] from here when run from server/.
|
||||||
# See the Migrations section in README.md.
|
# 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]
|
[tool.aerich]
|
||||||
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
||||||
location = "./migrations"
|
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
|
|
||||||
@@ -23,14 +23,35 @@ from tavolo.platform import auth
|
|||||||
_PROVIDER = next(iter(oidc.providers))
|
_PROVIDER = next(iter(oidc.providers))
|
||||||
|
|
||||||
|
|
||||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
def make_user(
|
||||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
sub: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
picture: Optional[str] = None,
|
||||||
|
) -> OIDCUser:
|
||||||
|
claims = {"sub": sub, "preferred_username": name or sub}
|
||||||
|
if email:
|
||||||
|
claims["email"] = email
|
||||||
|
if picture:
|
||||||
|
claims["picture"] = picture
|
||||||
|
return OIDCUser(claims)
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
def oidc_user(
|
||||||
"""Context manager: make ``oidc.get_users`` return this user."""
|
sub: str,
|
||||||
user = make_user(sub, name)
|
name: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
picture: Optional[str] = None,
|
||||||
|
) -> Iterator[OIDCUser]:
|
||||||
|
"""Context manager: make ``oidc.get_users`` return this user.
|
||||||
|
|
||||||
|
``email`` is optional so tests can simulate an IdP that still returns
|
||||||
|
the claim: the app requests only the ``openid`` and ``profile`` scopes
|
||||||
|
and ignores email where present. ``picture`` simulates the profile
|
||||||
|
scope's avatar claim.
|
||||||
|
"""
|
||||||
|
user = make_user(sub, name, email, picture)
|
||||||
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||||
patcher.start()
|
patcher.start()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
|
||||||
|
|
||||||
|
The per-package suite covers the rules and the engine contract; these
|
||||||
|
tests wire the game through the full platform stack (REST, WebSocket,
|
||||||
|
deadline scheduler).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from httpx_ws import aconnect_ws
|
||||||
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
|
|
||||||
|
from tavolo.app import app, game_store, platform
|
||||||
|
from tavolo.briscola.chiamata import (
|
||||||
|
PHASE_AUCTION,
|
||||||
|
PHASE_CALLING,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
Auction,
|
||||||
|
Bid,
|
||||||
|
)
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_and_join(client: AsyncClient) -> dict:
|
||||||
|
"""Create a chiamata game and seat all five players; return the view."""
|
||||||
|
with oidc_user(PLAYERS[0]):
|
||||||
|
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
body = created.json()
|
||||||
|
for player in PLAYERS[1:]:
|
||||||
|
with oidc_user(player):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
assert joined.status_code == 200, joined.text
|
||||||
|
body = joined.json()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataRoutesTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_seat_five_players(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(PLAYERS[0]):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(4, body["seats_open"])
|
||||||
|
self.assertIsNone(body["players"][0]["team"])
|
||||||
|
code = body["join_code"]
|
||||||
|
|
||||||
|
for player in PLAYERS[1:4]:
|
||||||
|
with oidc_user(player):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual("lobby", joined.json()["phase"])
|
||||||
|
self.assertEqual(
|
||||||
|
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
|
||||||
|
)
|
||||||
|
|
||||||
|
with oidc_user(PLAYERS[4]):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
state = started.json()
|
||||||
|
self.assertEqual(PHASE_AUCTION, state["phase"])
|
||||||
|
self.assertEqual(0, state["seats_open"])
|
||||||
|
self.assertEqual(5, len(state["players"]))
|
||||||
|
for seat in state["players"]:
|
||||||
|
# Roles are hidden until the called card is played.
|
||||||
|
self.assertEqual("", seat["team"])
|
||||||
|
self.assertEqual(8, seat["cards_left"])
|
||||||
|
# The auction opens with the player after the dealer (seat 0).
|
||||||
|
self.assertEqual(1, state["auction"]["turn"])
|
||||||
|
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
|
||||||
|
self.assertIsNone(state["auction"]["current"])
|
||||||
|
self.assertIsNotNone(state["auction_deadline"])
|
||||||
|
# The last joiner's personalized view carries their own hand.
|
||||||
|
self.assertIn("hand", state["players"][4])
|
||||||
|
self.assertEqual(8, len(state["players"][4]["hand"]))
|
||||||
|
self.assertNotIn("hand", state["players"][0])
|
||||||
|
# There is no stock in this variant.
|
||||||
|
self.assertNotIn("stock_count", state)
|
||||||
|
|
||||||
|
# A non-player watches without hands or the join code.
|
||||||
|
with oidc_user("mallory"):
|
||||||
|
watched = await client.get(f"/api/games/{state['id']}")
|
||||||
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spectator = watched.json()
|
||||||
|
self.assertTrue(spectator["spectator"])
|
||||||
|
self.assertNotIn("join_code", spectator)
|
||||||
|
for seat in spectator["players"]:
|
||||||
|
self.assertNotIn("hand", seat)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preview_lists_chiamata(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||||
|
)
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
preview = await client.get(f"/api/games/preview?code={code}")
|
||||||
|
self.assertEqual(200, preview.status_code)
|
||||||
|
body = preview.json()
|
||||||
|
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||||
|
self.assertEqual("Briscola chiamata", body["game_name"])
|
||||||
|
self.assertTrue(body["game_description"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(4, body["seats_open"])
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataWebSocketTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_auction_call_and_play_over_the_socket(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await _create_and_join(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob"), make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
first = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||||
|
self.assertTrue(first["game"].get("your_turn"))
|
||||||
|
self.assertIn("legal_bids", first["game"])
|
||||||
|
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
await alice_ws.receive_json()
|
||||||
|
# Bob (seat 1) opens the bidding with the two.
|
||||||
|
await bob_ws.send_json({"action": "bid", "rank": 2})
|
||||||
|
update = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", update["type"])
|
||||||
|
current = update["game"]["auction"]["current"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"seat": 1, "rank": 2, "points": 61}, current
|
||||||
|
)
|
||||||
|
self.assertEqual(2, update["game"]["auction"]["turn"])
|
||||||
|
# Alice receives the broadcast of Bob's bid.
|
||||||
|
broadcast = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("state", broadcast["type"])
|
||||||
|
# Alice (seat 0) is not on turn: her bid is an error.
|
||||||
|
await alice_ws.send_json({"action": "bid", "rank": 3})
|
||||||
|
error = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
|
||||||
|
# The remaining players pass; Bob wins the auction and is
|
||||||
|
# asked to name the card.
|
||||||
|
engine = platform.registry.require("briscola_chiamata")
|
||||||
|
session = await game_store.load(game_id)
|
||||||
|
assert session is not None
|
||||||
|
for sub in ("carol", "dave", "erin", "alice"):
|
||||||
|
engine.handle_action(session, sub, "pass", {})
|
||||||
|
await game_store.save(session)
|
||||||
|
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||||
|
self.assertEqual(1, session.state.caller)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||||
|
self.assertTrue(first["game"].get("your_turn"))
|
||||||
|
self.assertEqual(
|
||||||
|
["02D", "02C", "02S", "02B"],
|
||||||
|
first["game"]["callable_cards"],
|
||||||
|
)
|
||||||
|
await ws.send_json(
|
||||||
|
{"action": "call", "card": "02D"}
|
||||||
|
)
|
||||||
|
update = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||||
|
self.assertEqual("D", update["game"]["briscola_suit"])
|
||||||
|
self.assertEqual("02D", update["game"]["called_card"])
|
||||||
|
self.assertEqual(1, update["game"]["turn"])
|
||||||
|
|
||||||
|
# Bob is first to play (player after the dealer).
|
||||||
|
bob = next(
|
||||||
|
p for p in update["game"]["players"] if p["seat"] == 1
|
||||||
|
)
|
||||||
|
card = bob["hand"][0]
|
||||||
|
await ws.send_json({"action": "play", "card": card})
|
||||||
|
update = await ws.receive_json()
|
||||||
|
self.assertEqual(
|
||||||
|
[{"seat": 1, "card": card}], update["game"]["trick"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_spectator_cannot_act(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await _create_and_join(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("mallory")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
for seat in first["game"]["players"]:
|
||||||
|
self.assertNotIn("hand", seat)
|
||||||
|
await ws.send_json({"action": "bid", "rank": 1})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataTimeoutTest(unittest.TestCase):
|
||||||
|
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
|
||||||
|
from tavolo.briscola import chiamata as rules
|
||||||
|
|
||||||
|
session = GameSession(
|
||||||
|
id=game_id,
|
||||||
|
game_type="briscola_chiamata",
|
||||||
|
join_code=join_code,
|
||||||
|
creator_sub=PLAYERS[0],
|
||||||
|
players=[
|
||||||
|
Seat(user_sub=name, display_name=name.capitalize())
|
||||||
|
for name in PLAYERS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
|
||||||
|
for name in PLAYERS[1:]:
|
||||||
|
rules.join_game(session.state, name, name.capitalize())
|
||||||
|
state_mutator(session.state)
|
||||||
|
await game_store.save(session)
|
||||||
|
return session.id
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_auction_timeout_auto_passes(self) -> None:
|
||||||
|
game_id = await self._seed(
|
||||||
|
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||||
|
)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||||
|
self.assertIsNotNone(first["game"]["auction_deadline"])
|
||||||
|
|
||||||
|
update = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
update = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
update.get("type") == "state"
|
||||||
|
and 1 in update["game"]["auction"]["passed"]
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(update)
|
||||||
|
assert update is not None
|
||||||
|
# The absent seat 1 passed: nobody bid and the turn
|
||||||
|
# moved to seat 2.
|
||||||
|
auction = update["game"]["auction"]
|
||||||
|
self.assertIn(1, auction["passed"])
|
||||||
|
self.assertIsNone(auction["current"])
|
||||||
|
self.assertEqual(2, auction["turn"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
|
||||||
|
def seed(state) -> None:
|
||||||
|
state.phase = PHASE_AUCTION
|
||||||
|
state.auction = Auction(
|
||||||
|
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
|
||||||
|
)
|
||||||
|
state.turn = 0
|
||||||
|
state.auction_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||||
|
).isoformat()
|
||||||
|
state.call_deadline = None
|
||||||
|
|
||||||
|
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||||
|
|
||||||
|
# The auction times out: seat 0 is forced to bid the
|
||||||
|
# ace; its call then times out too and a random ace
|
||||||
|
# suit is named.
|
||||||
|
played = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
played = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
played.get("type") == "state"
|
||||||
|
and played["game"]["phase"] == PHASE_PLAYING
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(played)
|
||||||
|
assert played is not None
|
||||||
|
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
|
||||||
|
self.assertEqual(0, played["game"]["caller"])
|
||||||
|
card = played["game"]["called_card"]
|
||||||
|
self.assertIsNotNone(card)
|
||||||
|
self.assertTrue(card.startswith("01"))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_call_and_turn_timeouts(self) -> None:
|
||||||
|
def seed(state) -> None:
|
||||||
|
state.phase = PHASE_CALLING
|
||||||
|
state.auction = Auction(
|
||||||
|
turn=1,
|
||||||
|
active=[1],
|
||||||
|
passed=[0, 2, 3, 4],
|
||||||
|
current=Bid(seat=1, rank=2, points=61),
|
||||||
|
)
|
||||||
|
state.caller = 1
|
||||||
|
state.turn = 1
|
||||||
|
state.auction_deadline = None
|
||||||
|
state.call_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
game_id = await self._seed(
|
||||||
|
"chiamata-call-timeout-1", "CH0002", seed
|
||||||
|
)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||||
|
|
||||||
|
called = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
called = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
called.get("type") == "state"
|
||||||
|
and called["game"]["phase"] == PHASE_PLAYING
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(called)
|
||||||
|
assert called is not None
|
||||||
|
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
|
||||||
|
card = called["game"]["called_card"]
|
||||||
|
self.assertIsNotNone(card)
|
||||||
|
self.assertTrue(card.startswith("02"))
|
||||||
|
|
||||||
|
played = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
played = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
played.get("type") == "state"
|
||||||
|
and len(played["game"]["trick"]) == 1
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(played)
|
||||||
|
assert played is not None
|
||||||
|
# Seat 1 was on turn and was auto-played for.
|
||||||
|
self.assertEqual(1, played["game"]["trick"][0]["seat"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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 httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from tavolo.app import app
|
from tavolo.app import app, oidc
|
||||||
from tests.helpers import async_test
|
from tests.helpers import async_test
|
||||||
|
|
||||||
|
|
||||||
@@ -33,3 +33,10 @@ class AuthRoutesTest(unittest.TestCase):
|
|||||||
response = await client.get("/auth/login", follow_redirects=False)
|
response = await client.get("/auth/login", follow_redirects=False)
|
||||||
self.assertEqual(302, response.status_code)
|
self.assertEqual(302, response.status_code)
|
||||||
self.assertEqual("/auth/default/login", response.headers["location"])
|
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))
|
||||||
|
|||||||
@@ -236,6 +236,52 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(400, text.status_code)
|
self.assertEqual(400, text.status_code)
|
||||||
self.assertEqual(400, huge.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_test
|
||||||
async def test_get_unknown_game(self) -> None:
|
async def test_get_unknown_game(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
@@ -254,7 +300,14 @@ class GameTypesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
self.assertEqual(
|
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],
|
[g["id"] for g in results],
|
||||||
)
|
)
|
||||||
scopone = results[0]
|
scopone = results[0]
|
||||||
@@ -263,22 +316,61 @@ class GameTypesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(4, scopone["min_players"])
|
self.assertEqual(4, scopone["min_players"])
|
||||||
self.assertEqual(4, scopone["max_players"])
|
self.assertEqual(4, scopone["max_players"])
|
||||||
self.assertIn("target_score", scopone["options_schema"]["properties"])
|
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("napola", scopone["options_schema"]["properties"])
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"asso_pigliatutto", scopone["options_schema"]["properties"]
|
"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.assertEqual("Briscola", briscola["name"])
|
||||||
self.assertTrue(briscola["description"])
|
self.assertTrue(briscola["description"])
|
||||||
self.assertEqual(2, briscola["min_players"])
|
self.assertEqual(2, briscola["min_players"])
|
||||||
self.assertEqual(2, briscola["max_players"])
|
self.assertEqual(2, briscola["max_players"])
|
||||||
self.assertIn("target_score", briscola["options_schema"]["properties"])
|
self.assertIn("target_score", briscola["options_schema"]["properties"])
|
||||||
quattro = results[2]
|
quattro = results[4]
|
||||||
self.assertEqual("Briscola a quattro", quattro["name"])
|
self.assertEqual("Briscola a quattro", quattro["name"])
|
||||||
self.assertTrue(quattro["description"])
|
self.assertTrue(quattro["description"])
|
||||||
self.assertEqual(4, quattro["min_players"])
|
self.assertEqual(4, quattro["min_players"])
|
||||||
self.assertEqual(4, quattro["max_players"])
|
self.assertEqual(4, quattro["max_players"])
|
||||||
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
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_test
|
||||||
async def test_create_defaults_game_type(self) -> None:
|
async def test_create_defaults_game_type(self) -> None:
|
||||||
|
|||||||
@@ -19,10 +19,36 @@ class MeRouteTest(unittest.TestCase):
|
|||||||
async def test_me_authenticated(self) -> None:
|
async def test_me_authenticated(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
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")
|
response = await client.get("/api/me")
|
||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
# The email claim the IdP returns is neither stored nor exposed;
|
||||||
|
# no picture claim in this request.
|
||||||
|
self.assertEqual(
|
||||||
|
{"sub": "alice", "name": "alice", "picture": None},
|
||||||
|
response.json(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_me_includes_the_profile_picture(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice", picture="https://cdn.example.com/alice.png"):
|
||||||
|
response = await client.get("/api/me")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://cdn.example.com/alice.png",
|
||||||
|
response.json()["picture"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_me_rejects_non_http_picture_claims(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice", picture="javascript:alert(1)"):
|
||||||
|
response = await client.get("/api/me")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertIsNone(response.json()["picture"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_me_unauthenticated(self) -> None:
|
async def test_me_unauthenticated(self) -> None:
|
||||||
@@ -55,6 +81,46 @@ class StaticRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, fallback.status_code)
|
self.assertEqual(200, fallback.status_code)
|
||||||
self.assertIn(b"spa", fallback.content)
|
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_test
|
||||||
async def test_missing_dist_returns_404(self) -> None:
|
async def test_missing_dist_returns_404(self) -> None:
|
||||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
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_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaRoutesTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_start_two_player_game(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games",
|
||||||
|
json={
|
||||||
|
"game_type": "scopa",
|
||||||
|
"options": {"target_score": 21, "asso_pigliatutto": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("scopa", body["game_type"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(1, body["seats_open"])
|
||||||
|
self.assertEqual(21, body["target_score"])
|
||||||
|
self.assertTrue(body["asso_pigliatutto"])
|
||||||
|
self.assertEqual("A", body["players"][0]["team"])
|
||||||
|
code = body["join_code"]
|
||||||
|
|
||||||
|
with oidc_user("bob"):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual(200, started.status_code)
|
||||||
|
state = started.json()
|
||||||
|
self.assertEqual("playing", state["phase"])
|
||||||
|
self.assertEqual(2, len(state["players"]))
|
||||||
|
self.assertEqual(["A", "B"], [p["team"] for p in state["players"]])
|
||||||
|
for participant in state["players"]:
|
||||||
|
self.assertEqual(3, participant["cards_left"])
|
||||||
|
self.assertEqual(4, len(state["table"]))
|
||||||
|
self.assertEqual(30, state["deck_count"])
|
||||||
|
self.assertEqual(1, state["turn"])
|
||||||
|
self.assertTrue(state["asso_pigliatutto"])
|
||||||
|
# Bob's view carries his own hand only.
|
||||||
|
self.assertIn("hand", state["players"][1])
|
||||||
|
self.assertNotIn("hand", state["players"][0])
|
||||||
|
|
||||||
|
# A non-player watches without a hand or the join code.
|
||||||
|
watched = await client.get(f"/api/games/{state['id']}")
|
||||||
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spectator = watched.json()
|
||||||
|
self.assertTrue(spectator["spectator"])
|
||||||
|
self.assertNotIn("join_code", spectator)
|
||||||
|
for participant in spectator["players"]:
|
||||||
|
self.assertNotIn("hand", participant)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_start_four_player_game(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "scopa_a_quattro"}
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("scopa_a_quattro", body["game_type"])
|
||||||
|
self.assertEqual(3, body["seats_open"])
|
||||||
|
code = body["join_code"]
|
||||||
|
|
||||||
|
for player in ("bob", "carol"):
|
||||||
|
with oidc_user(player):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual("lobby", joined.json()["phase"])
|
||||||
|
|
||||||
|
with oidc_user("dave"):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
state = started.json()
|
||||||
|
self.assertEqual("playing", state["phase"])
|
||||||
|
self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]])
|
||||||
|
# Partnership scopa defaults to the traditional 21 points.
|
||||||
|
self.assertEqual(21, state["target_score"])
|
||||||
|
for participant in state["players"]:
|
||||||
|
self.assertEqual(3, participant["cards_left"])
|
||||||
|
self.assertEqual(4, len(state["table"]))
|
||||||
|
self.assertEqual(24, state["deck_count"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preview_shows_scopa_options(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games",
|
||||||
|
json={
|
||||||
|
"game_type": "scopa",
|
||||||
|
"options": {"target_score": 21, "asso_pigliatutto": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
preview = await client.get(f"/api/games/preview?code={code}")
|
||||||
|
self.assertEqual(200, preview.status_code)
|
||||||
|
body = preview.json()
|
||||||
|
self.assertEqual("scopa", body["game_type"])
|
||||||
|
self.assertEqual("Scopa", body["game_name"])
|
||||||
|
self.assertTrue(body["game_description"])
|
||||||
|
self.assertEqual(21, body["target_score"])
|
||||||
|
self.assertTrue(body["asso_pigliatutto"])
|
||||||
|
self.assertEqual(1, body["seats_open"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_rejects_bad_options(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
for bad in (0, 101, "eleven", True):
|
||||||
|
with oidc_user("alice"):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/games",
|
||||||
|
json={"game_type": "scopa", "options": {"target_score": bad}},
|
||||||
|
)
|
||||||
|
self.assertEqual(400, response.status_code, bad)
|
||||||
|
with oidc_user("alice"):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/games",
|
||||||
|
json={"game_type": "scopa", "options": {"asso_pigliatutto": "yes"}},
|
||||||
|
)
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaWebSocketTest(unittest.TestCase):
|
||||||
|
async def _started_two_player(self, client: AsyncClient) -> dict:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post("/api/games", json={"game_type": "scopa"})
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
return started.json()
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_play_trails_or_captures(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_two_player(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
first = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
game = first["game"]
|
||||||
|
self.assertEqual(1, game["turn"])
|
||||||
|
self.assertTrue(game.get("your_turn"))
|
||||||
|
self.assertEqual(4, len(game["table"]))
|
||||||
|
card = game["players"][1]["hand"][0]
|
||||||
|
options = (game.get("legal_moves") or {}).get(card)
|
||||||
|
payload = {"action": "play", "card": card}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = options[0]
|
||||||
|
await bob_ws.send_json(payload)
|
||||||
|
|
||||||
|
update = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", update["type"])
|
||||||
|
game = update["game"]
|
||||||
|
self.assertEqual(0, game["turn"])
|
||||||
|
self.assertIsNotNone(game["last_move"])
|
||||||
|
self.assertEqual(1, game["last_move"]["seat"])
|
||||||
|
self.assertEqual(card, game["last_move"]["card"])
|
||||||
|
if options:
|
||||||
|
# The pile grows by the captured table cards
|
||||||
|
# plus the card just played.
|
||||||
|
self.assertEqual(
|
||||||
|
len(options[0]) + 1,
|
||||||
|
game["players"][1]["captured_count"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertEqual(5, len(game["table"]))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_illegal_move_returns_error(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_two_player(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
await alice_ws.receive_json()
|
||||||
|
# Bob (seat 1) is on turn, not Alice.
|
||||||
|
await alice_ws.send_json({"action": "play", "card": "01D"})
|
||||||
|
error = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_spectator_cannot_act(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_two_player(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("mallory")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
for player in first["game"]["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
await ws.send_json({"action": "play", "card": "01D"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_asso_pigliatutto_sweeps_the_table(self) -> None:
|
||||||
|
# A controlled scopa d'assi position: Alice's ace sweeps the whole
|
||||||
|
# table, scores no scopa and counts as the last capture.
|
||||||
|
state = ScopaState(
|
||||||
|
player_count=2,
|
||||||
|
target_score=100,
|
||||||
|
asso_pigliatutto=True,
|
||||||
|
phase=PHASE_PLAYING,
|
||||||
|
dealer=0,
|
||||||
|
turn=0,
|
||||||
|
players=[
|
||||||
|
PlayerState(
|
||||||
|
sub="alice", name="Alice", seat=0,
|
||||||
|
hand=[Card.parse("01D"), Card.parse("09C")],
|
||||||
|
),
|
||||||
|
PlayerState(
|
||||||
|
sub="bob", name="Bob", seat=1,
|
||||||
|
hand=[Card.parse("04D")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
table=[Card.parse("05C"), Card.parse("02D")],
|
||||||
|
)
|
||||||
|
engine = platform.registry.require("scopa")
|
||||||
|
session = GameSession(
|
||||||
|
id="scopa-asso-pigliatutto-1",
|
||||||
|
game_type="scopa",
|
||||||
|
join_code="SA0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||||
|
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session.state = state
|
||||||
|
await game_store.save(session)
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(0, first["game"]["turn"])
|
||||||
|
self.assertTrue(first["game"].get("your_turn"))
|
||||||
|
await ws.send_json(
|
||||||
|
{"action": "play", "card": "01D", "capture": ["05C", "02D"]}
|
||||||
|
)
|
||||||
|
update = await ws.receive_json()
|
||||||
|
game = update["game"]
|
||||||
|
self.assertEqual([], game["table"])
|
||||||
|
self.assertEqual(3, game["players"][0]["captured_count"])
|
||||||
|
self.assertEqual(0, game["players"][0]["scope"])
|
||||||
|
self.assertEqual(0, game["scores"]["A"])
|
||||||
|
self.assertTrue(game["last_move"]["pigliatutto"])
|
||||||
|
self.assertFalse(game["last_move"]["scopa"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_hand_end_ack_flow(self) -> None:
|
||||||
|
# A one-play hand with target 100 so the match continues to the
|
||||||
|
# scoring summary.
|
||||||
|
state = ScopaState(
|
||||||
|
player_count=2,
|
||||||
|
target_score=100,
|
||||||
|
phase=PHASE_PLAYING,
|
||||||
|
dealer=0,
|
||||||
|
turn=0,
|
||||||
|
players=[
|
||||||
|
PlayerState(
|
||||||
|
sub="alice", name="Alice", seat=0,
|
||||||
|
hand=[Card.parse("02D")],
|
||||||
|
),
|
||||||
|
PlayerState(sub="bob", name="Bob", seat=1),
|
||||||
|
],
|
||||||
|
table=[Card.parse("02C")],
|
||||||
|
)
|
||||||
|
session = GameSession(
|
||||||
|
id="scopa-hand-end-1",
|
||||||
|
game_type="scopa",
|
||||||
|
join_code="SH0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||||
|
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session.state = state
|
||||||
|
await game_store.save(session)
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
async with contextlib.AsyncExitStack() as stack:
|
||||||
|
with ws_users([make_user("alice"), make_user("bob")]):
|
||||||
|
sockets = [
|
||||||
|
await stack.enter_async_context(
|
||||||
|
aconnect_ws(f"/ws/games/{session.id}", ws_client)
|
||||||
|
)
|
||||||
|
for _ in range(2)
|
||||||
|
]
|
||||||
|
for ws in sockets:
|
||||||
|
await ws.receive_json()
|
||||||
|
|
||||||
|
# Alice captures the last table card: the hand ends.
|
||||||
|
await sockets[0].send_json(
|
||||||
|
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||||
|
)
|
||||||
|
summaries = [await ws.receive_json() for ws in sockets]
|
||||||
|
for summary in summaries:
|
||||||
|
self.assertEqual("state", summary["type"])
|
||||||
|
self.assertEqual(PHASE_HAND_END, summary["game"]["phase"])
|
||||||
|
self.assertEqual([], summary["game"]["acknowledged"])
|
||||||
|
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
|
||||||
|
hand = summary["game"]["last_hand"]
|
||||||
|
self.assertEqual({"A": 2, "B": 0}, hand["cards"])
|
||||||
|
self.assertEqual("A", hand["award"]["carte"])
|
||||||
|
|
||||||
|
# Both acknowledge; the second ack deals the next hand.
|
||||||
|
await sockets[0].send_json({"action": "ack"})
|
||||||
|
updates = [await ws.receive_json() for ws in sockets]
|
||||||
|
for update in updates:
|
||||||
|
self.assertEqual(PHASE_HAND_END, update["game"]["phase"])
|
||||||
|
self.assertEqual([0], update["game"]["acknowledged"])
|
||||||
|
await sockets[1].send_json({"action": "ack"})
|
||||||
|
updates = [await ws.receive_json() for ws in sockets]
|
||||||
|
for update in updates:
|
||||||
|
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||||
|
self.assertEqual(2, update["game"]["hand_number"])
|
||||||
|
for player in update["game"]["players"]:
|
||||||
|
self.assertEqual(3, player["cards_left"])
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaTurnTimeoutTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||||
|
engine = platform.registry.require("scopa")
|
||||||
|
session = GameSession(
|
||||||
|
id="scopa-turn-timeout-1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="ST0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine.create(session, {})
|
||||||
|
session.state.turn_timeout = 1
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
await game_store.save(session)
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
# Bob (seat 1) is first to act and never connects.
|
||||||
|
self.assertEqual(1, first["game"]["turn"])
|
||||||
|
self.assertIsNotNone(first["game"]["turn_deadline"])
|
||||||
|
|
||||||
|
update = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
update = await asyncio.wait_for(ws.receive_json(), timeout=2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
update.get("type") == "state"
|
||||||
|
and update["game"]["last_move"] is not None
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(update)
|
||||||
|
assert update is not None
|
||||||
|
# The absent player was auto-played: the move is on the
|
||||||
|
# board and the turn advanced.
|
||||||
|
self.assertEqual(1, update["game"]["last_move"]["seat"])
|
||||||
|
self.assertEqual(0, update["game"]["turn"])
|
||||||
|
players = {p["seat"]: p for p in update["game"]["players"]}
|
||||||
|
self.assertEqual(2, players[1]["cards_left"])
|
||||||
|
self.assertEqual(3, players[0]["cards_left"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -12,10 +12,14 @@ import unittest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from tavolo.app import app, platform, tortoise_mixin
|
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 import GameSession, Seat
|
||||||
from tavolo.platform.elo import INITIAL_RATING
|
from tavolo.platform.elo import INITIAL_RATING
|
||||||
|
from tavolo.platform.errors import IllegalMove
|
||||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||||
from tavolo.platform.stats import save_match_result
|
from tavolo.platform.stats import save_match_result
|
||||||
|
from tavolo.scopa import engine as scopa_rules
|
||||||
from tavolo.scopone import engine as rules
|
from tavolo.scopone import engine as rules
|
||||||
from tests.helpers import async_test, oidc_user
|
from tests.helpers import async_test, oidc_user
|
||||||
|
|
||||||
@@ -129,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase):
|
|||||||
self.assertIn("your_elo_delta", results[0])
|
self.assertIn("your_elo_delta", results[0])
|
||||||
self.assertEqual(4, len(results[0]["players"]))
|
self.assertEqual(4, len(results[0]["players"]))
|
||||||
|
|
||||||
|
with oidc_user("alice"):
|
||||||
board = await client.get("/api/leaderboard")
|
board = await client.get("/api/leaderboard")
|
||||||
self.assertEqual(200, board.status_code)
|
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.assertEqual(4, len(by_sub))
|
||||||
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
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:
|
async def _finished_briscola_session() -> GameSession:
|
||||||
@@ -222,5 +240,225 @@ class BriscolaStatsTest(unittest.TestCase):
|
|||||||
self.assertEqual(2, len(results[0]["players"]))
|
self.assertEqual(2, len(results[0]["players"]))
|
||||||
|
|
||||||
|
|
||||||
|
async def _finished_chiamata_session() -> GameSession:
|
||||||
|
engine = platform.registry.require("briscola_chiamata")
|
||||||
|
session = GameSession(
|
||||||
|
id="stats-chiamata-1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="BC0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub=name, display_name=name.capitalize())
|
||||||
|
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine.create(session, {})
|
||||||
|
for name in ("bob", "carol", "dave", "erin"):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == PHASE_AUCTION:
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
try:
|
||||||
|
engine.handle_action(session, player.sub, "pass", {})
|
||||||
|
except IllegalMove:
|
||||||
|
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||||
|
elif state.phase == PHASE_CALLING:
|
||||||
|
assert state.auction is not None and state.auction.current is not None
|
||||||
|
caller = next(p for p in state.players if p.seat == state.caller)
|
||||||
|
card = Card(rank=state.auction.current.rank, suit="D")
|
||||||
|
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||||
|
else:
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.handle_action(
|
||||||
|
session, player.sub, "play", {"card": player.hand[0].code}
|
||||||
|
)
|
||||||
|
moves += 1
|
||||||
|
assert engine.is_finished(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataStatsTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||||
|
await tortoise_mixin._bind()
|
||||||
|
ctx = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
engine = platform.registry.require("briscola_chiamata")
|
||||||
|
session = await _finished_chiamata_session()
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
self.assertEqual(5, await MatchPlayer.all().count())
|
||||||
|
|
||||||
|
match = await Match.all().first()
|
||||||
|
assert match is not None
|
||||||
|
self.assertEqual("briscola_chiamata", match.game_type)
|
||||||
|
summary = match.result
|
||||||
|
for key in (
|
||||||
|
"team_a_score",
|
||||||
|
"team_b_score",
|
||||||
|
"winner_team",
|
||||||
|
"declared_target",
|
||||||
|
"called_card",
|
||||||
|
"caller",
|
||||||
|
"solo",
|
||||||
|
"deals_played",
|
||||||
|
"deal_scores",
|
||||||
|
):
|
||||||
|
self.assertIn(key, summary)
|
||||||
|
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||||
|
|
||||||
|
players = await MatchPlayer.all()
|
||||||
|
roles = {p.team for p in players}
|
||||||
|
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||||
|
# Roles match the sides: exactly one caller, the rest defenders
|
||||||
|
# plus at most one partner.
|
||||||
|
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||||
|
winner_team = summary["winner_team"]
|
||||||
|
for row in players:
|
||||||
|
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||||
|
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||||
|
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||||
|
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||||
|
self.assertEqual(5, len(ratings))
|
||||||
|
winners = {p.user_sub for p in players if p.won}
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING - 16},
|
||||||
|
{ratings[sub] for sub in ratings if sub not in winners},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopaStatsTest(unittest.TestCase):
|
||||||
|
async def _finished_session(
|
||||||
|
self, player_count: int = 2, asso_pigliatutto: bool = False
|
||||||
|
) -> GameSession:
|
||||||
|
engine = platform.registry.require(
|
||||||
|
"scopa" if player_count == 2 else "scopa_a_quattro"
|
||||||
|
)
|
||||||
|
session = GameSession(
|
||||||
|
id=f"stats-scopa-{player_count}",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="SC0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine.create(
|
||||||
|
session,
|
||||||
|
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
|
||||||
|
)
|
||||||
|
for name in PLAYERS[1:player_count]:
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == "hand_end":
|
||||||
|
for player in state.players:
|
||||||
|
engine.handle_action(session, player.sub, "ack", {})
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = scopa_rules.legal_captures(
|
||||||
|
state.table, played,
|
||||||
|
asso_pigliatutto=state.asso_pigliatutto,
|
||||||
|
)
|
||||||
|
payload = {"card": played.code}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = [c.code for c in options[0]]
|
||||||
|
engine.handle_action(session, player.sub, "play", payload)
|
||||||
|
moves += 1
|
||||||
|
assert engine.is_finished(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_finished_two_player_match_persisted_with_summary(self) -> None:
|
||||||
|
await tortoise_mixin._bind()
|
||||||
|
ctx = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
engine = platform.registry.require("scopa")
|
||||||
|
session = await self._finished_session(2, asso_pigliatutto=True)
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
self.assertEqual(2, await MatchPlayer.all().count())
|
||||||
|
|
||||||
|
match = await Match.all().first()
|
||||||
|
assert match is not None
|
||||||
|
self.assertEqual("scopa", match.game_type)
|
||||||
|
summary = match.result
|
||||||
|
for key in (
|
||||||
|
"team_a_score",
|
||||||
|
"team_b_score",
|
||||||
|
"winner_team",
|
||||||
|
"target_score",
|
||||||
|
"asso_pigliatutto",
|
||||||
|
"hands_played",
|
||||||
|
"hand_scores",
|
||||||
|
):
|
||||||
|
self.assertIn(key, summary)
|
||||||
|
self.assertTrue(summary["asso_pigliatutto"])
|
||||||
|
|
||||||
|
winner_team = summary["winner_team"]
|
||||||
|
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
||||||
|
for row in players.values():
|
||||||
|
self.assertEqual(row.won, row.team == winner_team)
|
||||||
|
# A two-player match moves Elo by 16 points each.
|
||||||
|
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_finished_four_player_match_persisted_with_teams(self) -> None:
|
||||||
|
await tortoise_mixin._bind()
|
||||||
|
ctx = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
engine = platform.registry.require("scopa_a_quattro")
|
||||||
|
session = await self._finished_session(4)
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
self.assertEqual(4, await MatchPlayer.all().count())
|
||||||
|
match = await Match.all().first()
|
||||||
|
assert match is not None
|
||||||
|
self.assertEqual("scopa_a_quattro", match.game_type)
|
||||||
|
players = await MatchPlayer.all()
|
||||||
|
self.assertEqual({"A", "B"}, {p.team for p in players})
|
||||||
|
# Partnership Elo: the winning side gains 16 each.
|
||||||
|
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||||
|
winners = {p.user_sub for p in players if p.won}
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING - 16},
|
||||||
|
{ratings[sub] for sub in ratings if sub not in winners},
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_history_exposes_scopa_result(self) -> None:
|
||||||
|
await tortoise_mixin._bind()
|
||||||
|
ctx = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
engine = platform.registry.require("scopa")
|
||||||
|
session = await self._finished_session(2)
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
history = await client.get("/api/me/matches")
|
||||||
|
self.assertEqual(200, history.status_code)
|
||||||
|
results = history.json()["results"]
|
||||||
|
self.assertEqual(1, len(results))
|
||||||
|
self.assertEqual("scopa", results[0]["game_type"])
|
||||||
|
self.assertIn("team_a_score", results[0]["result"])
|
||||||
|
self.assertIn("your_elo_delta", results[0])
|
||||||
|
self.assertEqual(2, len(results[0]["players"]))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ serde_json = "1"
|
|||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
futures = "0.3"
|
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"
|
js-sys = "0.3"
|
||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
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 charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Tavolo</title>
|
<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="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. -->
|
<!-- 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="vendor/bootstrap.min.css">
|
||||||
<link data-trunk rel="css" href="theme.css">
|
<link data-trunk rel="css" href="theme.css">
|
||||||
<link data-trunk rel="css" href="style.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">
|
<link data-trunk rel="copy-dir" href="assets">
|
||||||
</head>
|
</head>
|
||||||
<body></body>
|
<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
|
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||||
//! session cookie automatically.
|
//! session cookie automatically.
|
||||||
|
use crate::auth;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::{Request, Response};
|
||||||
|
|
||||||
fn server_error(status: u16) -> String {
|
fn server_error(status: u16) -> String {
|
||||||
format!("server returned {status}")
|
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).
|
/// 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> {
|
pub async fn me() -> Result<Option<User>, String> {
|
||||||
let resp = Request::get("/api/me")
|
let resp = Request::get("/api/me")
|
||||||
.send()
|
.send()
|
||||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.providers)
|
Ok(page.providers)
|
||||||
}
|
}
|
||||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
Ok(page.results)
|
||||||
}
|
}
|
||||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == 401 {
|
||||||
|
auth::redirect_to_login();
|
||||||
|
return Err("login required".to_string());
|
||||||
|
}
|
||||||
if !resp.ok() {
|
if !resp.ok() {
|
||||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
let message = body
|
let message = body
|
||||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|||||||
if resp.status() == 404 {
|
if resp.status() == 404 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
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> {
|
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||||
let url = match cursor {
|
let url = match cursor {
|
||||||
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||||
None => "/api/me/matches?limit=10".to_string(),
|
None => "/api/me/matches?limit=10".to_string(),
|
||||||
};
|
};
|
||||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,19 +171,28 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
/// Fetch one page of the leaderboard for a game. With `focus_me` the
|
||||||
let resp = Request::get("/api/leaderboard")
|
/// server returns the page holding the caller's own ranking (page 1 for
|
||||||
.send()
|
/// anonymous or unrated callers).
|
||||||
.await
|
pub async fn leaderboard(
|
||||||
.map_err(|e| e.to_string())?;
|
game_type: &str,
|
||||||
if !resp.ok() {
|
page: Option<u32>,
|
||||||
return Err(server_error(resp.status()));
|
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())
|
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,183 @@
|
|||||||
|
//! User avatar: the provider's `picture` claim when available, a
|
||||||
|
//! deterministic identicon generated from the OIDC `sub` otherwise.
|
||||||
|
//!
|
||||||
|
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
|
||||||
|
//! FNV-1a hash. The hash is implemented here (instead of
|
||||||
|
//! `DefaultHasher`) so the picture is stable across Rust versions and
|
||||||
|
//! the helper functions stay unit-testable on the host, with no DOM.
|
||||||
|
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
/// FNV-1a 64-bit: a stable hash for identicon seeds.
|
||||||
|
fn fnv1a(input: &str) -> u64 {
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for byte in input.as_bytes() {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identicon hue (0..360) for a seed.
|
||||||
|
fn hue_for(seed: &str) -> u32 {
|
||||||
|
(fnv1a(seed) % 360) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identicon cell grid as 5 columns x 5 rows. Only 15 bits (3
|
||||||
|
/// columns) are derived from the hash; the left half is mirrored onto
|
||||||
|
/// the right so the icon is symmetric. The center cell is forced on so
|
||||||
|
/// an unlucky hash can never produce a blank icon.
|
||||||
|
fn cells_for(seed: &str) -> [[bool; 5]; 5] {
|
||||||
|
let hash = fnv1a(seed);
|
||||||
|
let mut half = [false; 15];
|
||||||
|
for (index, cell) in half.iter_mut().enumerate() {
|
||||||
|
*cell = (hash >> index) & 1 == 1;
|
||||||
|
}
|
||||||
|
if !half.iter().any(|set| *set) {
|
||||||
|
half[8] = true; // center of the half grid: row 2, column 2
|
||||||
|
}
|
||||||
|
let mut cells = [[false; 5]; 5];
|
||||||
|
for row in 0..5 {
|
||||||
|
for column in 0..3 {
|
||||||
|
let set = half[row * 3 + column];
|
||||||
|
cells[row][column] = set;
|
||||||
|
if column < 2 {
|
||||||
|
cells[row][4 - column] = set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only absolute http(s) picture URLs are rendered. The backend filters
|
||||||
|
/// the claim the same way; this is the client-side half of the rule.
|
||||||
|
fn safe_picture_url(url: &str) -> Option<String> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's avatar: their profile picture, or an identicon generated
|
||||||
|
/// from the `sub`. A picture that fails to load falls back to the
|
||||||
|
/// identicon as well.
|
||||||
|
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
|
||||||
|
let Some(url) = picture.and_then(safe_picture_url) else {
|
||||||
|
return identicon(sub);
|
||||||
|
};
|
||||||
|
let seed = sub.to_string();
|
||||||
|
let failed = create_signal(false);
|
||||||
|
view! {
|
||||||
|
(move || {
|
||||||
|
if failed.get() {
|
||||||
|
identicon(&seed)
|
||||||
|
} else {
|
||||||
|
let src = url.clone();
|
||||||
|
view! {
|
||||||
|
img(class="avatar", src=src, alt="",
|
||||||
|
on:error=move |_| failed.set(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The generated fallback avatar.
|
||||||
|
fn identicon(seed: &str) -> View {
|
||||||
|
let hue = hue_for(seed);
|
||||||
|
let background = format!("hsl({hue}, 45%, 22%)");
|
||||||
|
let foreground = format!("hsl({hue}, 65%, 62%)");
|
||||||
|
let cells = cells_for(seed);
|
||||||
|
let mut squares: Vec<View> = Vec::new();
|
||||||
|
for (row, line) in cells.iter().enumerate() {
|
||||||
|
for (column, set) in line.iter().enumerate() {
|
||||||
|
if *set {
|
||||||
|
let x = column.to_string();
|
||||||
|
let y = row.to_string();
|
||||||
|
let fill = foreground.clone();
|
||||||
|
squares.push(view! {
|
||||||
|
rect(x=x, y=y, width="1", height="1", fill=fill)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view! {
|
||||||
|
svg(class="avatar", viewBox="0 0 5 5", "aria-hidden"="true") {
|
||||||
|
rect(x="0", y="0", width="5", height="5", fill=background)
|
||||||
|
(squares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_is_deterministic_and_seed_dependent() {
|
||||||
|
assert_eq!(fnv1a("alice"), fnv1a("alice"));
|
||||||
|
assert_ne!(fnv1a("alice"), fnv1a("bob"));
|
||||||
|
// Pinned vectors: guard against accidental changes to the hash
|
||||||
|
// (it must stay stable for the identicons users see).
|
||||||
|
assert_eq!(149_266_183, fnv1a("alice") % 1_000_000_000);
|
||||||
|
assert_eq!(23, hue_for("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hue_stays_in_range() {
|
||||||
|
for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] {
|
||||||
|
assert!(hue_for(seed) < 360);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pattern_is_symmetric_and_never_blank() {
|
||||||
|
for seed in ["alice", "bob", "", "auth0|64f0c0ffee"] {
|
||||||
|
let cells = cells_for(seed);
|
||||||
|
assert!(
|
||||||
|
cells.iter().flatten().any(|cell| *cell),
|
||||||
|
"blank for {seed:?}"
|
||||||
|
);
|
||||||
|
for row in 0..5 {
|
||||||
|
for column in 0..5 {
|
||||||
|
assert_eq!(
|
||||||
|
cells[row][column],
|
||||||
|
cells[row][4 - column],
|
||||||
|
"asymmetric for {seed:?} at ({row}, {column})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_seeds_draw_different_patterns() {
|
||||||
|
assert_ne!(cells_for("alice"), cells_for("bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_absolute_http_picture_urls_are_rendered() {
|
||||||
|
assert_eq!(
|
||||||
|
Some("https://cdn.example.com/a.png".to_string()),
|
||||||
|
safe_picture_url("https://cdn.example.com/a.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Some("https://cdn.example.com/a.png".to_string()),
|
||||||
|
safe_picture_url(" https://cdn.example.com/a.png ")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Some("HTTPS://cdn.example.com/a.png".to_string()),
|
||||||
|
safe_picture_url("HTTPS://cdn.example.com/a.png")
|
||||||
|
);
|
||||||
|
assert_eq!(None, safe_picture_url("httpz://x"));
|
||||||
|
assert_eq!(None, safe_picture_url("/relative.png"));
|
||||||
|
assert_eq!(None, safe_picture_url("javascript:alert(1)"));
|
||||||
|
assert_eq!(
|
||||||
|
None,
|
||||||
|
safe_picture_url("data:image/svg+xml;base64,PHN2ZyB4bW")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,25 @@
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
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;
|
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.
|
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
||||||
fn summary_row(
|
fn summary_row(
|
||||||
icon: View,
|
icon: View,
|
||||||
@@ -31,9 +47,10 @@ fn summary_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The scoring rows of one completed deal (also used by the final screen).
|
/// 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 a = summary.points.a;
|
||||||
let b = summary.points.b;
|
let b = summary.points.b;
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
let cls = if summary.patta {
|
let cls = if summary.patta {
|
||||||
"score-row tie"
|
"score-row tie"
|
||||||
} else if a > b {
|
} 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."
|
"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(
|
let points_row = summary_row(
|
||||||
card_img("01D".to_string(), "score-mini"),
|
card_img("01D".to_string(), "score-mini"),
|
||||||
"Deal points",
|
"Deal points",
|
||||||
format!("Team A {a} · Team B {b}"),
|
format!("{name_a} {a} · {name_b} {b}"),
|
||||||
cls,
|
cls,
|
||||||
chip,
|
chip,
|
||||||
);
|
);
|
||||||
@@ -62,13 +98,14 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
|||||||
summary_row(
|
summary_row(
|
||||||
card_back("score-mini"),
|
card_back("score-mini"),
|
||||||
"Cards captured",
|
"Cards captured",
|
||||||
format!("Team A {} · Team B {}", c.a, c.b),
|
format!("{name_a} {} · {name_b} {}", c.a, c.b),
|
||||||
"score-row",
|
"score-row",
|
||||||
format!("{} · {}", c.a, c.b),
|
format!("{} · {}", c.a, c.b),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
view! {
|
view! {
|
||||||
div(class="score-rows") {
|
div(class="score-rows") {
|
||||||
|
(called_row)
|
||||||
(points_row)
|
(points_row)
|
||||||
(patta_note)
|
(patta_note)
|
||||||
(cards)
|
(cards)
|
||||||
@@ -79,33 +116,38 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
|||||||
/// Running totals with progress toward the target, including the points
|
/// Running totals with progress toward the target, including the points
|
||||||
/// gained in the deal just played (zero for a patta).
|
/// gained in the deal just played (zero for a patta).
|
||||||
fn totals(game: &GameView, summary: &DealSummary) -> View {
|
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 target = game.target_score.max(1);
|
||||||
let pct_a = (100 * summary.totals.a / target).min(100);
|
let pct_a = (100 * running.a / target).min(100);
|
||||||
let pct_b = (100 * summary.totals.b / target).min(100);
|
let pct_b = (100 * running.b / target).min(100);
|
||||||
let (gained_a, gained_b) = if summary.patta {
|
let (gained_a, gained_b) = if summary.patta {
|
||||||
(0, 0)
|
(0, 0)
|
||||||
} else {
|
} else {
|
||||||
(summary.points.a, summary.points.b)
|
(summary.points.a, summary.points.b)
|
||||||
};
|
};
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
view! {
|
view! {
|
||||||
div(class="totals") {
|
div(class="totals") {
|
||||||
div(class="total-row team-a") {
|
div(class="total-row team-a") {
|
||||||
span(class="total-label") { "Team A" }
|
span(class="total-label") { (name_a) }
|
||||||
div(class="progress") {
|
div(class="progress") {
|
||||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||||
}
|
}
|
||||||
span(class="total-value") {
|
span(class="total-value") {
|
||||||
(summary.totals.a) " / " (target)
|
(running.a) " / " (target)
|
||||||
span(class="gained") { "+" (gained_a) }
|
span(class="gained") { "+" (gained_a) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(class="total-row team-b") {
|
div(class="total-row team-b") {
|
||||||
span(class="total-label") { "Team B" }
|
span(class="total-label") { (name_b) }
|
||||||
div(class="progress") {
|
div(class="progress") {
|
||||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||||
}
|
}
|
||||||
span(class="total-value") {
|
span(class="total-value") {
|
||||||
(summary.totals.b) " / " (target)
|
(running.b) " / " (target)
|
||||||
span(class="gained") { "+" (gained_b) }
|
span(class="gained") { "+" (gained_b) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +193,7 @@ pub fn deal_summary_modal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let title = format!("Deal {} — results", summary.deal);
|
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 totals_view = totals(&game, &summary);
|
||||||
|
|
||||||
let action = if game.spectator {
|
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 briscola;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod copy;
|
pub mod copy;
|
||||||
|
pub mod login;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
|
pub mod notify;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
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
|
//! language, how each scoring category played out and how the running
|
||||||
//! totals moved toward the target. Every player must acknowledge it before
|
//! totals moved toward the target. Every player must acknowledge it before
|
||||||
//! the next hand is dealt (or the server-side timeout deals anyway).
|
//! 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 sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
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;
|
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.
|
/// 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(
|
fn award_row(
|
||||||
icon: View,
|
icon: View,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
text: String,
|
text: String,
|
||||||
winner: Option<String>,
|
team: Option<&str>,
|
||||||
|
name: &str,
|
||||||
points: &'static str,
|
points: &'static str,
|
||||||
) -> View {
|
) -> View {
|
||||||
let cls = match winner.as_deref() {
|
let cls = match team {
|
||||||
Some("A") => "score-row team-a",
|
Some("A") => "score-row team-a",
|
||||||
Some("B") => "score-row team-b",
|
Some("B") => "score-row team-b",
|
||||||
_ => "score-row tie",
|
_ => "score-row tie",
|
||||||
};
|
};
|
||||||
let chip = match &winner {
|
let chip = match team {
|
||||||
Some(t) => format!("Team {t} {points}"),
|
Some(_) => format!("{name} {points}"),
|
||||||
None => "tie".to_string(),
|
None => "tie".to_string(),
|
||||||
};
|
};
|
||||||
view! {
|
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.
|
/// The five scoring rows of a completed hand.
|
||||||
pub fn summary_rows(summary: HandSummary) -> View {
|
pub fn summary_rows(game: &GameView, summary: HandSummary) -> View {
|
||||||
let rows: Vec<View> = vec![
|
|
||||||
// Carte
|
// Carte
|
||||||
award_row(
|
let carte = summary.award.carte.clone();
|
||||||
card_back("score-mini"),
|
let carte_name = carte
|
||||||
"Carte",
|
.as_deref()
|
||||||
match &summary.award.carte {
|
.map(|t| side_label(game, t))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let carte_text = match &carte {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
|
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
|
||||||
format!("Team {t} captured more cards ({w} vs {l})")
|
format!(
|
||||||
|
"{} captured more cards ({w} vs {l})",
|
||||||
|
side_label(game, t)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
None => format!(
|
None => format!(
|
||||||
"Both teams captured {} cards — no point",
|
"Both sides captured {} cards — no point",
|
||||||
summary.cards.a
|
summary.cards.a
|
||||||
),
|
),
|
||||||
},
|
};
|
||||||
summary.award.carte.clone(),
|
|
||||||
"+1",
|
|
||||||
),
|
|
||||||
// Denara
|
// Denara
|
||||||
award_row(
|
let denara = summary.award.denara.clone();
|
||||||
card_img("02D".to_string(), "score-mini"),
|
let denara_name = denara
|
||||||
"Denari",
|
.as_deref()
|
||||||
match &summary.award.denara {
|
.map(|t| side_label(game, t))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let denara_text = match &denara {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
let (w, l) = winner_first(summary.denara.a, summary.denara.b, 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})")
|
format!(
|
||||||
|
"{} collected more denari cards ({w} vs {l})",
|
||||||
|
side_label(game, t)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
None => format!(
|
None => format!(
|
||||||
"Both teams collected {} denari cards — no point",
|
"Both sides collected {} denari cards — no point",
|
||||||
summary.denara.a
|
summary.denara.a
|
||||||
),
|
),
|
||||||
},
|
};
|
||||||
summary.award.denara.clone(),
|
|
||||||
"+1",
|
|
||||||
),
|
|
||||||
// Settebello
|
// Settebello
|
||||||
award_row(
|
let settebello = summary.award.settebello.clone();
|
||||||
card_img("07D".to_string(), "score-mini"),
|
let settebello_name = settebello
|
||||||
"Settebello",
|
.as_deref()
|
||||||
match &summary.award.settebello {
|
.map(|t| side_label(game, t))
|
||||||
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
|
.unwrap_or_default();
|
||||||
None => "Nobody captured the Settebello".to_string(),
|
let settebello_text = match &settebello {
|
||||||
},
|
Some(t) => format!(
|
||||||
summary.award.settebello.clone(),
|
"{} captured the 7 of denari — the Settebello",
|
||||||
"+1",
|
side_label(game, t)
|
||||||
),
|
),
|
||||||
|
None => "Nobody captured the Settebello".to_string(),
|
||||||
|
};
|
||||||
// Primiera
|
// Primiera
|
||||||
award_row(
|
let primiera = summary.award.primiera.clone();
|
||||||
card_img("10D".to_string(), "score-mini"),
|
let primiera_name = primiera
|
||||||
"Primiera",
|
.as_deref()
|
||||||
match &summary.award.primiera {
|
.map(|t| side_label(game, t))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let primiera_text = match &primiera {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
let (w, l) =
|
let (w, l) = winner_first(summary.primiera.a, summary.primiera.b, Some(t));
|
||||||
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
|
format!(
|
||||||
format!("Team {t} holds the strongest primiera ({w} vs {l})")
|
"{} holds the strongest primiera ({w} vs {l})",
|
||||||
|
side_label(game, t)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
None => format!(
|
None => format!(
|
||||||
"Both primiere are worth {} — no point",
|
"Both primiere are worth {} — no point",
|
||||||
summary.primiera.a
|
summary.primiera.a
|
||||||
),
|
),
|
||||||
},
|
};
|
||||||
summary.award.primiera.clone(),
|
|
||||||
|
let rows: Vec<View> = vec![
|
||||||
|
award_row(
|
||||||
|
card_back("score-mini"),
|
||||||
|
"Carte",
|
||||||
|
carte_text,
|
||||||
|
carte.as_deref(),
|
||||||
|
&carte_name,
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
award_row(
|
||||||
|
card_img("02D".to_string(), "score-mini"),
|
||||||
|
"Denara",
|
||||||
|
denara_text,
|
||||||
|
denara.as_deref(),
|
||||||
|
&denara_name,
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
award_row(
|
||||||
|
card_img("07D".to_string(), "score-mini"),
|
||||||
|
"Settebello",
|
||||||
|
settebello_text,
|
||||||
|
settebello.as_deref(),
|
||||||
|
&settebello_name,
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
award_row(
|
||||||
|
card_img("10D".to_string(), "score-mini"),
|
||||||
|
"Primiera",
|
||||||
|
primiera_text,
|
||||||
|
primiera.as_deref(),
|
||||||
|
&primiera_name,
|
||||||
"+1",
|
"+1",
|
||||||
),
|
),
|
||||||
// Napola (only when the rule is enabled for this match)
|
// 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) {
|
let text = match (&summary.award.napola, n) {
|
||||||
(Some(t), 10) => format!(
|
(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!(
|
(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(),
|
(None, _) => "No napola this hand".to_string(),
|
||||||
};
|
};
|
||||||
let chip = match &summary.award.napola {
|
let chip = match &summary.award.napola {
|
||||||
Some(t) => format!("Team {t} +{n}"),
|
Some(t) => format!("{} +{n}", side_label(game, t)),
|
||||||
None => "tie".to_string(),
|
None => "tie".to_string(),
|
||||||
};
|
};
|
||||||
let cls = match summary.award.napola.as_deref() {
|
let cls = match summary.award.napola.as_deref() {
|
||||||
@@ -154,12 +225,13 @@ pub fn summary_rows(summary: HandSummary) -> View {
|
|||||||
},
|
},
|
||||||
// Scope
|
// Scope
|
||||||
{
|
{
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
let a = summary.scope.a;
|
let a = summary.scope.a;
|
||||||
let b = summary.scope.b;
|
let b = summary.scope.b;
|
||||||
let text = if a == 0 && b == 0 {
|
let text = if a == 0 && b == 0 {
|
||||||
"No scope this hand".to_string()
|
"No scope this hand".to_string()
|
||||||
} else {
|
} 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}");
|
let chip = format!("+{a} · +{b}");
|
||||||
view! {
|
view! {
|
||||||
@@ -193,10 +265,11 @@ pub fn totals(game: &GameView) -> View {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| (s.team_a_points, s.team_b_points))
|
.map(|s| (s.team_a_points, s.team_b_points))
|
||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
view! {
|
view! {
|
||||||
div(class="totals") {
|
div(class="totals") {
|
||||||
div(class="total-row team-a") {
|
div(class="total-row team-a") {
|
||||||
span(class="total-label") { "Team A" }
|
span(class="total-label") { (name_a) }
|
||||||
div(class="progress") {
|
div(class="progress") {
|
||||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
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") {
|
div(class="total-row team-b") {
|
||||||
span(class="total-label") { "Team B" }
|
span(class="total-label") { (name_b) }
|
||||||
div(class="progress") {
|
div(class="progress") {
|
||||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
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 totals_view = totals(&game);
|
||||||
let title = format!("Hand {} — results", summary.hand);
|
let title = format!("Hand {} — results", summary.hand);
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
//! Application entry point.
|
//! Application entry point.
|
||||||
mod api;
|
mod api;
|
||||||
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod notify;
|
||||||
mod pages;
|
mod pages;
|
||||||
|
mod pwa;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
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::game::GamePage;
|
||||||
use pages::history::HistoryPage;
|
use pages::history::HistoryPage;
|
||||||
use pages::leaderboard::LeaderboardPage;
|
use pages::leaderboard::LeaderboardPage;
|
||||||
use pages::lobby::LobbyPage;
|
use pages::lobby::LobbyPage;
|
||||||
|
use pages::login::LoginPage;
|
||||||
|
|
||||||
#[derive(Route, Clone)]
|
#[derive(Route, Clone)]
|
||||||
enum AppRoutes {
|
enum AppRoutes {
|
||||||
#[to("/")]
|
#[to("/")]
|
||||||
Lobby,
|
Lobby,
|
||||||
|
#[to("/login")]
|
||||||
|
Login,
|
||||||
#[to("/game/<id>")]
|
#[to("/game/<id>")]
|
||||||
Game { id: String },
|
Game { id: String },
|
||||||
#[to("/history")]
|
#[to("/history")]
|
||||||
@@ -27,6 +34,31 @@ enum AppRoutes {
|
|||||||
NotFound,
|
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() {
|
fn main() {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
sycamore::render(|| {
|
sycamore::render(|| {
|
||||||
@@ -38,6 +70,7 @@ fn main() {
|
|||||||
div(class="app") {
|
div(class="app") {
|
||||||
(match route.get_clone() {
|
(match route.get_clone() {
|
||||||
AppRoutes::Lobby => view! { LobbyPage() },
|
AppRoutes::Lobby => view! { LobbyPage() },
|
||||||
|
AppRoutes::Login => view! { LoginPage() },
|
||||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||||
AppRoutes::History => view! { HistoryPage() },
|
AppRoutes::History => view! { HistoryPage() },
|
||||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||||
@@ -60,4 +93,6 @@ fn main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
pwa::register();
|
||||||
|
restore_page_after_login();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ fn default_true() -> bool {
|
|||||||
pub struct User {
|
pub struct User {
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub picture: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -19,7 +21,10 @@ pub struct PlayerView {
|
|||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub seat: usize,
|
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)]
|
#[serde(default)]
|
||||||
pub cards_left: usize,
|
pub cards_left: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -84,9 +89,21 @@ pub struct DealSummary {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub patta: bool,
|
pub patta: bool,
|
||||||
pub points: Scores,
|
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)]
|
#[serde(default)]
|
||||||
pub cards: Option<TeamCounts>,
|
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)]
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
@@ -178,6 +195,9 @@ pub struct GameView {
|
|||||||
pub winner: Option<String>,
|
pub winner: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub table: Vec<String>,
|
pub table: Vec<String>,
|
||||||
|
/// Scopa: face-down cards left in the deck for the current hand.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deck_count: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub players: Vec<PlayerView>,
|
pub players: Vec<PlayerView>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -227,6 +247,123 @@ pub struct GameView {
|
|||||||
/// Briscola: scoring breakdown of the most recent deal.
|
/// Briscola: scoring breakdown of the most recent deal.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_deal: Option<DealSummary>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -331,6 +468,10 @@ fn default_elo() -> i32 {
|
|||||||
1500
|
1500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct LeaderboardEntry {
|
pub struct LeaderboardEntry {
|
||||||
@@ -377,10 +518,30 @@ pub struct RatingsPage {
|
|||||||
pub results: Vec<PlayerRating>,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct LeaderboardPage {
|
pub struct LeaderboardPage {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub results: Vec<LeaderboardEntry>,
|
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).
|
/// A card game the platform can host (GET /api/game-types).
|
||||||
@@ -408,6 +569,62 @@ pub struct GameTypesPage {
|
|||||||
pub results: Vec<GameTypeInfo>,
|
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).
|
/// One configured OIDC login provider (GET /api/auth/providers).
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
pub struct AuthProvider {
|
pub struct AuthProvider {
|
||||||
@@ -454,6 +671,66 @@ pub struct PreviewPlayer {
|
|||||||
pub team: Option<String>,
|
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
|
/// One option to show in the join preview, rendered from the game's
|
||||||
/// schema and the engine-echoed value.
|
/// schema and the engine-echoed value.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -631,6 +908,43 @@ pub fn is_briscola(game_type: &str) -> bool {
|
|||||||
game_type == "briscola" || game_type == "briscola_a_quattro"
|
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").
|
/// Human-readable suit name for a one-letter code (`D` -> "denari").
|
||||||
pub fn suit_name(suit: &str) -> String {
|
pub fn suit_name(suit: &str) -> String {
|
||||||
match suit {
|
match suit {
|
||||||
@@ -825,6 +1139,31 @@ mod tests {
|
|||||||
assert_eq!(1500, e.elo);
|
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 {
|
fn scopone_game_type() -> GameTypeInfo {
|
||||||
serde_json::from_value(json!({
|
serde_json::from_value(json!({
|
||||||
"id": "scopone_scientifico",
|
"id": "scopone_scientifico",
|
||||||
@@ -837,7 +1176,7 @@ mod tests {
|
|||||||
"properties": {
|
"properties": {
|
||||||
"target_score": {
|
"target_score": {
|
||||||
"type": "integer", "minimum": 1, "maximum": 100,
|
"type": "integer", "minimum": 1, "maximum": 100,
|
||||||
"default": 11,
|
"default": 21,
|
||||||
},
|
},
|
||||||
"napola": {"type": "boolean", "default": true},
|
"napola": {"type": "boolean", "default": true},
|
||||||
"asso_pigliatutto": {"type": "boolean", "default": false},
|
"asso_pigliatutto": {"type": "boolean", "default": false},
|
||||||
@@ -858,7 +1197,7 @@ mod tests {
|
|||||||
target.kind,
|
target.kind,
|
||||||
OptionKind::Integer { min: Some(1), max: Some(100) }
|
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!(OptionKind::Boolean, by_key["napola"].kind);
|
||||||
assert_eq!(json!(true), by_key["napola"].default);
|
assert_eq!(json!(true), by_key["napola"].default);
|
||||||
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
|
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
|
||||||
@@ -892,7 +1231,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn default_options_collect_schema_defaults() {
|
fn default_options_collect_schema_defaults() {
|
||||||
let opts = scopone_game_type().default_options();
|
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!(true)), opts.get("napola"));
|
||||||
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
|
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
|
||||||
}
|
}
|
||||||
@@ -922,7 +1261,7 @@ mod tests {
|
|||||||
assert_eq!(16, g.target_score);
|
assert_eq!(16, g.target_score);
|
||||||
assert!(!g.napola);
|
assert!(!g.napola);
|
||||||
assert!(g.asso_pigliatutto);
|
assert!(g.asso_pigliatutto);
|
||||||
assert_eq!("A", g.players[0].team);
|
assert_eq!(Some("A".to_string()), g.players[0].team);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1134,7 +1473,10 @@ mod tests {
|
|||||||
fn is_briscola_matches_both_variants() {
|
fn is_briscola_matches_both_variants() {
|
||||||
assert!(is_briscola("briscola"));
|
assert!(is_briscola("briscola"));
|
||||||
assert!(is_briscola("briscola_a_quattro"));
|
assert!(is_briscola("briscola_a_quattro"));
|
||||||
|
assert!(!is_briscola("briscola_chiamata"));
|
||||||
assert!(!is_briscola("scopone_scientifico"));
|
assert!(!is_briscola("scopone_scientifico"));
|
||||||
|
assert!(is_chiamata("briscola_chiamata"));
|
||||||
|
assert!(!is_chiamata("briscola"));
|
||||||
assert_eq!("denari", suit_name("D"));
|
assert_eq!("denari", suit_name("D"));
|
||||||
assert_eq!("coppe", suit_name("C"));
|
assert_eq!("coppe", suit_name("C"));
|
||||||
assert_eq!("spade", suit_name("S"));
|
assert_eq!("spade", suit_name("S"));
|
||||||
@@ -1142,6 +1484,106 @@ mod tests {
|
|||||||
assert_eq!("?", suit_name("?"));
|
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]
|
#[test]
|
||||||
fn game_view_parses_briscola_payload() {
|
fn game_view_parses_briscola_payload() {
|
||||||
let g: GameView = serde_json::from_value(json!({
|
let g: GameView = serde_json::from_value(json!({
|
||||||
@@ -1217,7 +1659,7 @@ mod tests {
|
|||||||
assert_eq!(1, deal.deal);
|
assert_eq!(1, deal.deal);
|
||||||
assert!(!deal.patta);
|
assert!(!deal.patta);
|
||||||
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
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();
|
let cards = deal.cards.unwrap();
|
||||||
assert_eq!(19, cards.a);
|
assert_eq!(19, cards.a);
|
||||||
assert_eq!(21, cards.b);
|
assert_eq!(21, cards.b);
|
||||||
@@ -1237,4 +1679,223 @@ mod tests {
|
|||||||
assert_eq!(0, d.deal);
|
assert_eq!(0, d.deal);
|
||||||
assert!(d.cards.is_none());
|
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::card::{card_back, card_img};
|
||||||
use crate::components::copy::CopyButton;
|
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;
|
use crate::pages::game::watch_link;
|
||||||
|
|
||||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
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 { "" }
|
if active { " active" } else { "" }
|
||||||
);
|
);
|
||||||
let name = player.name.clone();
|
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 points = player.points;
|
||||||
let captured = player.captured_count;
|
let captured = player.captured_count;
|
||||||
let backs = (0..player.cards_left)
|
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=cls) {
|
||||||
div(class="seat-name") {
|
div(class="seat-name") {
|
||||||
(name)
|
(name)
|
||||||
span(class="team-badge") { "Team " (team) }
|
(team_badge)
|
||||||
}
|
}
|
||||||
div(class="seat-cards") { (backs) }
|
div(class="seat-cards") { (backs) }
|
||||||
div(class="seat-stats") {
|
div(class="seat-stats") {
|
||||||
@@ -115,6 +122,7 @@ pub fn briscola_table(
|
|||||||
game: GameView,
|
game: GameView,
|
||||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||||
now: Signal<f64>,
|
now: Signal<f64>,
|
||||||
|
notify: NotifyCtx,
|
||||||
) -> View {
|
) -> View {
|
||||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||||
// hand, so they watch from seat 0's perspective.
|
// 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 scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
let target = game.target_score;
|
let target = game.target_score;
|
||||||
let deal = game.hand_number;
|
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 {
|
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! {
|
let spectator_badge = game.spectator.then(|| view! {
|
||||||
span(class="spectator-badge") { "Spectating" }
|
span(class="spectator-badge") { "Spectating" }
|
||||||
});
|
});
|
||||||
|
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||||
let watch_link = watch_link(&game.id);
|
let watch_link = watch_link(&game.id);
|
||||||
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
|
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
|
||||||
let suit = suit_name(&game.briscola_suit);
|
let suit = suit_name(&game.briscola_suit);
|
||||||
@@ -234,13 +256,11 @@ pub fn briscola_table(
|
|||||||
span { "Deal " (deal) }
|
span { "Deal " (deal) }
|
||||||
(trump_badge)
|
(trump_badge)
|
||||||
(spectator_badge)
|
(spectator_badge)
|
||||||
span(class="hud-scores") {
|
span(class="hud-scores") { (score_line) }
|
||||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
|
||||||
(target) ")"
|
|
||||||
}
|
|
||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
(countdown)
|
(countdown)
|
||||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
|
(bell)
|
||||||
}
|
}
|
||||||
div(class=grid_cls) {
|
div(class=grid_cls) {
|
||||||
(top)
|
(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::briscola::{deal_rows, deal_summary_modal};
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
use crate::components::copy::CopyButton;
|
use crate::components::copy::CopyButton;
|
||||||
|
use crate::components::notify::{notify_toggle, NotifyCtx};
|
||||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores,
|
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, is_scopa, GameView,
|
||||||
ServerMessage,
|
MoveView, PlayerView, Scores, ServerMessage,
|
||||||
};
|
};
|
||||||
|
use crate::notify::{self, Permission};
|
||||||
use crate::pages::briscola::briscola_table;
|
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};
|
use crate::ws::{self, GameSocket};
|
||||||
|
|
||||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
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 { "" }
|
if active { " active" } else { "" }
|
||||||
);
|
);
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
let team = player.team.clone();
|
let team = player.team.clone().unwrap_or_default();
|
||||||
let captured = player.captured_count;
|
let captured = player.captured_count;
|
||||||
let scope = player.scope;
|
let scope = player.scope;
|
||||||
let backs = (0..player.cards_left)
|
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.
|
/// 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 name = mv.name.clone();
|
||||||
let action = if mv.captured.is_empty() {
|
let action = if mv.captured.is_empty() {
|
||||||
"played"
|
"played"
|
||||||
@@ -94,7 +98,7 @@ fn move_banner(mv: MoveView) -> View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Signals shared by the websocket connection and its reconnect attempts.
|
/// Signals shared by the websocket connection and its reconnect attempts.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone)]
|
||||||
struct ConnCtx {
|
struct ConnCtx {
|
||||||
socket: Signal<Option<GameSocket>>,
|
socket: Signal<Option<GameSocket>>,
|
||||||
game: Signal<Option<GameView>>,
|
game: Signal<Option<GameView>>,
|
||||||
@@ -109,6 +113,12 @@ struct ConnCtx {
|
|||||||
attempts: Signal<u32>,
|
attempts: Signal<u32>,
|
||||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||||
selected: Signal<Option<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.
|
/// 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
|
/// The server pushes a full state snapshot on connect, so a reconnect is
|
||||||
/// also a resync: no client-side state merging is needed.
|
/// also a resync: no client-side state merging is needed.
|
||||||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
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 on_message = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
let alive = alive.clone();
|
let alive = alive.clone();
|
||||||
move |msg: ServerMessage| {
|
move |msg: ServerMessage| {
|
||||||
if !alive.get() {
|
if !alive.get() {
|
||||||
@@ -132,6 +146,19 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
|||||||
}
|
}
|
||||||
match msg {
|
match msg {
|
||||||
ServerMessage::State { game: g } => {
|
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.capture_choice.set(None);
|
||||||
ctx.selected.set(None);
|
ctx.selected.set(None);
|
||||||
// A received state proves the (re)connection works.
|
// 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 on_close = {
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
|
let ctx = ctx.clone();
|
||||||
let alive = alive.clone();
|
let alive = alive.clone();
|
||||||
move |_code: Option<u16>| {
|
move |_code: Option<u16>| {
|
||||||
if !alive.get() {
|
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
|
// transient. Only on the first failure: later attempts
|
||||||
// are governed by the retry backoff.
|
// are governed by the retry backoff.
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
|
let ctx = ctx.clone();
|
||||||
let alive = alive.clone();
|
let alive = alive.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::probe_game(&id).await {
|
match api::probe_game(&id).await {
|
||||||
@@ -176,7 +205,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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 id = Rc::new(id);
|
||||||
let ctx = ConnCtx {
|
let ctx = ConnCtx {
|
||||||
socket,
|
socket,
|
||||||
@@ -291,13 +331,16 @@ pub fn GamePage(id: String) -> View {
|
|||||||
attempts,
|
attempts,
|
||||||
capture_choice,
|
capture_choice,
|
||||||
selected,
|
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 || {
|
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||||
ctx.attempts.set(0);
|
ctx.attempts.set(0);
|
||||||
ctx.gave_up.set(false);
|
ctx.gave_up.set(false);
|
||||||
ctx.closed.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.
|
// 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();
|
let reconnect_banner = reconnect.clone();
|
||||||
view! {
|
view! {
|
||||||
div(class="game-page") {
|
div(class="game-page") {
|
||||||
@@ -392,22 +461,54 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
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.
|
// Briscola has its own table view for both variants.
|
||||||
Some(g) if is_briscola(&g.game_type) => {
|
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
|
// The live table is scopone-specific; other games get a
|
||||||
// fallback until they ship their own play view.
|
// fallback until they ship their own play view.
|
||||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
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),
|
Some(g) => unsupported_game_view(g),
|
||||||
})
|
})
|
||||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||||
capture_picker(card, options, socket, capture_choice)
|
capture_picker(card, options, socket, capture_choice)
|
||||||
}))
|
}))
|
||||||
|
// Briscola chiamata: the auction winner names the called card.
|
||||||
(move || match game.get_clone() {
|
(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)
|
hand_summary_modal(g, socket, now)
|
||||||
}
|
}
|
||||||
Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => {
|
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 join_code = game.join_code.clone();
|
||||||
let watch_link = watch_link(&game.id);
|
let watch_link = watch_link(&game.id);
|
||||||
|
|
||||||
|
let show_teams = !is_individual_game(&game.game_type);
|
||||||
let players = game
|
let players = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let name = p.name.clone();
|
let name = p.name.clone();
|
||||||
let seat = p.seat;
|
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! {
|
view! {
|
||||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||||
strong { (name) }
|
strong { (name) }
|
||||||
span {
|
span {
|
||||||
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
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,
|
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||||
selected: Signal<Option<String>>,
|
selected: Signal<Option<String>>,
|
||||||
now: Signal<f64>,
|
now: Signal<f64>,
|
||||||
|
notify: NotifyCtx,
|
||||||
) -> View {
|
) -> View {
|
||||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||||
// hand, so they watch from seat 0's perspective.
|
// hand, so they watch from seat 0's perspective.
|
||||||
@@ -656,6 +763,7 @@ fn table_view(
|
|||||||
let spectator_badge = game.spectator.then(|| view! {
|
let spectator_badge = game.spectator.then(|| view! {
|
||||||
span(class="spectator-badge") { "Spectating" }
|
span(class="spectator-badge") { "Spectating" }
|
||||||
});
|
});
|
||||||
|
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||||
let watch_link = watch_link(&game.id);
|
let watch_link = watch_link(&game.id);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
@@ -672,6 +780,7 @@ fn table_view(
|
|||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
(countdown)
|
(countdown)
|
||||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
|
(bell)
|
||||||
}
|
}
|
||||||
div(class="table-grid") {
|
div(class="table-grid") {
|
||||||
(top)
|
(top)
|
||||||
@@ -730,8 +839,10 @@ fn capture_picker(
|
|||||||
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
/// 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 {
|
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 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 let Some(g) = &game {
|
||||||
if g.game_type != "scopone_scientifico" && !briscola {
|
if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa {
|
||||||
return view! {};
|
return view! {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,19 +855,32 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
None => view! {},
|
None => view! {},
|
||||||
Some((scores, winner)) => {
|
Some((scores, winner)) => {
|
||||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
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.
|
// Spectators have no match history to look at.
|
||||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||||
// Explain the final hand's (scopone) or deal's (briscola)
|
// Explain the final hand's (scopone, scopa) or deal's
|
||||||
// scoring before the result.
|
// (briscola, briscola chiamata) scoring before the result.
|
||||||
let final_summary: View = if briscola {
|
let final_summary: View = if briscola || chiamata {
|
||||||
match game.as_ref().and_then(|g| g.last_deal.clone()) {
|
match game.as_ref() {
|
||||||
Some(deal) => deal_rows(&deal),
|
Some(g) => match g.last_deal.clone() {
|
||||||
|
Some(deal) => deal_rows(g, &deal),
|
||||||
|
None => view! {},
|
||||||
|
},
|
||||||
None => view! {},
|
None => view! {},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match game.as_ref().and_then(|g| g.last_hand.clone()) {
|
match game.as_ref().and_then(|g| g.last_hand.clone().map(|h| (g.clone(), h))) {
|
||||||
Some(hand) => summary_rows(hand),
|
Some((g, hand)) => summary_rows(&g, hand),
|
||||||
None => view! {},
|
None => view! {},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,34 +12,58 @@ use crate::api;
|
|||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||||
|
|
||||||
/// Distinct team labels in seat order; players without a team render in
|
/// The match's teams, each as a `(label, names)` pair, in a readable
|
||||||
/// a shared "Players" column.
|
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
|
||||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
/// then other team labels alphabetically (A, B, ...), with team-less
|
||||||
let mut teams = BTreeSet::new();
|
/// players last.
|
||||||
|
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
|
||||||
|
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
|
||||||
for p in &m.players {
|
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();
|
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
|
||||||
// Seated teams first (in first-seat order), then the team-less column.
|
ordered.sort_by_key(team_sort_key);
|
||||||
ordered.sort_by_key(|t| match t {
|
|
||||||
Some(_) => (0, String::new()),
|
|
||||||
None => (1, String::new()),
|
|
||||||
});
|
|
||||||
ordered
|
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 {
|
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||||
m.players
|
let names = m
|
||||||
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| &p.team == team)
|
.filter(|p| &p.team == team)
|
||||||
.map(|p| p.display_name.clone())
|
.map(|p| p.display_name.clone())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
.join(" & ")
|
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 {
|
fn team_header(team: &Option<String>) -> String {
|
||||||
match team {
|
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(),
|
None => "Players".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +79,11 @@ fn score_text(m: &MatchSummary) -> String {
|
|||||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||||
fn winner_text(m: &MatchSummary) -> String {
|
fn winner_text(m: &MatchSummary) -> String {
|
||||||
if let Some(team) = m.result_str("winner_team") {
|
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())
|
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||||
}
|
}
|
||||||
@@ -98,30 +126,48 @@ pub fn HistoryPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::my_matches(next.as_deref()).await {
|
match api::my_matches(next.as_deref()).await {
|
||||||
Ok(p) => {
|
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());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||||
page.set(Some(p));
|
page.set(Some(p));
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e)),
|
Err(e) => {
|
||||||
|
if error.is_alive() {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(types) = api::game_types().await {
|
if let Ok(types) = api::game_types().await {
|
||||||
|
if game_types.is_alive() {
|
||||||
game_types.set(types);
|
game_types.set(types);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let load2 = load;
|
let load2 = load;
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::my_matches(None).await {
|
match api::my_matches(None).await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
if !page.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cursor.set(p.next_cursor.clone());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.set(p.results.clone());
|
rows.set(p.results.clone());
|
||||||
page.set(Some(p));
|
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| {
|
.map(|m| {
|
||||||
let finished = local_finished(&m.finished_at);
|
let finished = local_finished(&m.finished_at);
|
||||||
let game = game_name(&names, &m.game_type);
|
let game = game_name(&names, &m.game_type);
|
||||||
let mut cells: Vec<View> = team_columns(&m)
|
let teams: Vec<View> = team_groups(&m)
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|t| team_names(&m, t))
|
.map(|(label, names)| {
|
||||||
.map(|c| view! { td { (c) } })
|
view! {
|
||||||
|
div(class="match-team") {
|
||||||
|
span(class="text-secondary") {
|
||||||
|
(label) ": "
|
||||||
|
}
|
||||||
|
(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let score = score_text(&m);
|
let score = score_text(&m);
|
||||||
let winner = winner_text(&m);
|
let winner = winner_text(&m);
|
||||||
@@ -173,37 +227,18 @@ pub fn HistoryPage() -> View {
|
|||||||
} else {
|
} else {
|
||||||
"text-secondary"
|
"text-secondary"
|
||||||
};
|
};
|
||||||
let mut row_cells = vec![
|
let row_cells = vec![
|
||||||
view! { td { (finished) } },
|
view! { td { (finished) } },
|
||||||
view! { td { (game) } },
|
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) } }
|
view! { tr { (row_cells) } }
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.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! {
|
view! {
|
||||||
div(class="table-responsive") {
|
div(class="table-responsive") {
|
||||||
table(class="table table-striped table-hover align-middle") {
|
table(class="table table-striped table-hover align-middle") {
|
||||||
@@ -211,7 +246,7 @@ pub fn HistoryPage() -> View {
|
|||||||
tr {
|
tr {
|
||||||
th { "Finished" }
|
th { "Finished" }
|
||||||
th { "Game" }
|
th { "Game" }
|
||||||
(header_cells)
|
th { "Teams" }
|
||||||
th { "Score" }
|
th { "Score" }
|
||||||
th { "Winner" }
|
th { "Winner" }
|
||||||
th { "You" }
|
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,30 +1,183 @@
|
|||||||
//! 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 wasm_bindgen_futures::spawn_local;
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::toast::toast;
|
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]
|
#[component]
|
||||||
pub fn LeaderboardPage() -> View {
|
pub fn LeaderboardPage() -> View {
|
||||||
let page = create_signal(Option::<LeaderboardPage>::None);
|
let page = create_signal(Option::<LeaderboardPage>::None);
|
||||||
let error = create_signal(Option::<String>::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 {
|
spawn_local(async move {
|
||||||
match api::leaderboard().await {
|
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)),
|
Ok(p) => page.set(Some(p)),
|
||||||
Err(e) => error.set(Some(e)),
|
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::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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div {
|
div {
|
||||||
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
||||||
div(class="container page-shell py-4") {
|
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))
|
(toast(error))
|
||||||
(move || match page.get_clone() {
|
(move || {
|
||||||
|
let busy = loading.get();
|
||||||
|
match page.get_clone() {
|
||||||
None => view! {
|
None => view! {
|
||||||
div(class="d-flex justify-content-center p-5") {
|
div(class="d-flex justify-content-center p-5") {
|
||||||
div(class="spinner-border text-warning", role="status") {
|
div(class="spinner-border text-warning", role="status") {
|
||||||
@@ -44,10 +197,29 @@ pub fn LeaderboardPage() -> View {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, e)| {
|
.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! {
|
view! {
|
||||||
tr {
|
tr(class=cls) {
|
||||||
td { (i + 1) }
|
td { (rank) }
|
||||||
td { (e.display_name.clone()) }
|
td {
|
||||||
|
(e.display_name.clone())
|
||||||
|
(is_me.then(|| view! {
|
||||||
|
span(
|
||||||
|
class="badge text-bg-warning ms-2"
|
||||||
|
) {
|
||||||
|
"you"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
td { (e.elo) }
|
td { (e.elo) }
|
||||||
td { (e.wins) }
|
td { (e.wins) }
|
||||||
td { (e.matches) }
|
td { (e.matches) }
|
||||||
@@ -56,9 +228,19 @@ pub fn LeaderboardPage() -> View {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.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! {
|
view! {
|
||||||
|
p(class="text-secondary small mb-2") { (rated) }
|
||||||
div(class="table-responsive") {
|
div(class="table-responsive") {
|
||||||
table(class="table table-striped table-hover align-middle") {
|
table(
|
||||||
|
class="table table-striped table-hover \
|
||||||
|
align-middle"
|
||||||
|
) {
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
th { "#" }
|
th { "#" }
|
||||||
@@ -72,6 +254,8 @@ pub fn LeaderboardPage() -> View {
|
|||||||
tbody { (rows) }
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,51 +12,15 @@ use sycamore::prelude::*;
|
|||||||
use sycamore_router::navigate;
|
use sycamore_router::navigate;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::components::avatar::avatar;
|
||||||
use crate::components::card::card_back;
|
use crate::components::card::card_back;
|
||||||
|
use crate::components::login::login_card;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User,
|
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||||
|
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Used when the game-types fetch fails: match creation must still work.
|
|
||||||
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: "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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Human-readable label for a provider name ("google" -> "Google").
|
|
||||||
fn provider_label(name: &str) -> String {
|
|
||||||
let mut chars = name.chars();
|
|
||||||
match chars.next() {
|
|
||||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_to_string(value: &serde_json::Value) -> String {
|
fn json_to_string(value: &serde_json::Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
@@ -266,15 +230,20 @@ fn join_preview_panel(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let seated = p.players.len();
|
let seated = p.players.len();
|
||||||
|
let show_teams = !is_individual_game(&p.game_type);
|
||||||
let players = p
|
let players = p
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|player| {
|
.map(|player| {
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
let team = player.team.as_ref().map(|t| {
|
let team = if show_teams {
|
||||||
|
player.team.as_ref().map(|t| {
|
||||||
let label = t.clone();
|
let label = t.clone();
|
||||||
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
||||||
});
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
view! {
|
view! {
|
||||||
li(
|
li(
|
||||||
class="list-group-item d-flex justify-content-between \
|
class="list-group-item d-flex justify-content-between \
|
||||||
@@ -316,6 +285,8 @@ pub fn LobbyPage() -> View {
|
|||||||
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
||||||
// The player's Elo ratings, one row per game type played.
|
// The player's Elo ratings, one row per game type played.
|
||||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||||
|
// Live games the player is seated in, for the "Continue" list.
|
||||||
|
let my_games = create_signal(Vec::<MyGame>::new());
|
||||||
let error = create_signal(Option::<String>::None);
|
let error = create_signal(Option::<String>::None);
|
||||||
let code = create_signal(String::new());
|
let code = create_signal(String::new());
|
||||||
let game_types = create_signal(fallback_game_types());
|
let game_types = create_signal(fallback_game_types());
|
||||||
@@ -408,22 +379,40 @@ pub fn LobbyPage() -> View {
|
|||||||
if me.is_some() {
|
if me.is_some() {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(p) = api::my_ratings().await {
|
if let Ok(p) = api::my_ratings().await {
|
||||||
|
if ratings.is_alive() {
|
||||||
ratings.set(p.results);
|
ratings.set(p.results);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if let Ok(games) = api::my_games().await {
|
||||||
|
if my_games.is_alive() {
|
||||||
|
my_games.set(games);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The page may have been unmounted (e.g. by the login
|
||||||
|
// redirect triggered while this request was in flight).
|
||||||
|
if user.is_alive() {
|
||||||
user.set(Some(me));
|
user.set(Some(me));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if error.is_alive() {
|
||||||
error.set(Some(e));
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
if user.is_alive() {
|
||||||
user.set(Some(None));
|
user.set(Some(None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::game_types().await {
|
match api::game_types().await {
|
||||||
Ok(types) if !types.is_empty() => {
|
Ok(types) if !types.is_empty() => {
|
||||||
|
if !game_types.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
selected_game.set(types[0].id.clone());
|
selected_game.set(types[0].id.clone());
|
||||||
game_types.set(types);
|
game_types.set(types);
|
||||||
}
|
}
|
||||||
@@ -433,8 +422,10 @@ pub fn LobbyPage() -> View {
|
|||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(list) = api::auth_providers().await {
|
if let Ok(list) = api::auth_providers().await {
|
||||||
|
if auth_providers.is_alive() {
|
||||||
auth_providers.set(list);
|
auth_providers.set(list);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let on_create = move |_| {
|
let on_create = move |_| {
|
||||||
@@ -489,63 +480,23 @@ pub fn LobbyPage() -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(None) => view! {
|
Some(None) => view! { (login_card(auth_providers)) },
|
||||||
div(class="status-card") {
|
|
||||||
div(class="card") {
|
|
||||||
div(class="card-body p-4") {
|
|
||||||
h1(class="h3 mb-3") {
|
|
||||||
span(class="text-warning") { "♠" }
|
|
||||||
" tavolo"
|
|
||||||
}
|
|
||||||
p(class="text-secondary") { "Log in with your account to play." }
|
|
||||||
// rel="external" opts out of the SPA router's click
|
|
||||||
// interception: these must be full page navigations
|
|
||||||
// to the backend's OIDC login endpoints.
|
|
||||||
(move || {
|
|
||||||
let providers = auth_providers.get_clone();
|
|
||||||
if providers.is_empty() {
|
|
||||||
// Providers could not be fetched: the
|
|
||||||
// generic route redirects to the first
|
|
||||||
// configured provider.
|
|
||||||
view! {
|
|
||||||
a(
|
|
||||||
class="btn btn-warning btn-lg w-100",
|
|
||||||
href="/auth/login",
|
|
||||||
rel="external",
|
|
||||||
) {
|
|
||||||
"Log in"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
div(class="d-grid gap-2") {
|
|
||||||
(providers.iter().cloned().map(|p| view! {
|
|
||||||
a(
|
|
||||||
class="btn btn-warning btn-lg w-100",
|
|
||||||
href=p.login_url,
|
|
||||||
rel="external",
|
|
||||||
) {
|
|
||||||
"Log in with " (provider_label(&p.name))
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Some(Some(_me)) => {
|
Some(Some(_me)) => {
|
||||||
view! {
|
view! {
|
||||||
(crate::components::nav::app_nav("/", move || {
|
(crate::components::nav::app_nav("/", move || {
|
||||||
let name = user
|
let me = user.get_clone().flatten();
|
||||||
.get_clone()
|
let name = me
|
||||||
.flatten()
|
.as_ref()
|
||||||
.map(|u| u.name)
|
.map(|u| u.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let avatar_view = me
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| avatar(&u.sub, u.picture.as_deref()))
|
||||||
|
.unwrap_or_else(|| view! {});
|
||||||
view! {
|
view! {
|
||||||
span(class="navbar-text me-3") {
|
span(class="navbar-text me-3 d-flex align-items-center gap-2") {
|
||||||
|
(avatar_view)
|
||||||
|
span() {
|
||||||
"Signed in as " strong { (name) }
|
"Signed in as " strong { (name) }
|
||||||
(move || ratings
|
(move || ratings
|
||||||
.get_clone()
|
.get_clone()
|
||||||
@@ -557,6 +508,7 @@ pub fn LobbyPage() -> View {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
a(
|
a(
|
||||||
class="btn btn-sm btn-outline-light",
|
class="btn btn-sm btn-outline-light",
|
||||||
href="/auth/logout",
|
href="/auth/logout",
|
||||||
@@ -570,9 +522,72 @@ pub fn LobbyPage() -> View {
|
|||||||
div(class="lobby-hero text-center") {
|
div(class="lobby-hero text-center") {
|
||||||
h1(class="display-5 fw-bold") { "tavolo" }
|
h1(class="display-5 fw-bold") { "tavolo" }
|
||||||
p(class="lead text-secondary mb-0") {
|
p(class="lead text-secondary mb-0") {
|
||||||
"Play scopone scientifico and briscola with friends."
|
"Play scopone, scopa and briscola with friends."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ongoing games: a "Continue" link back into a
|
||||||
|
// match whose page was accidentally closed.
|
||||||
|
(move || {
|
||||||
|
let games = my_games.get_clone();
|
||||||
|
if games.is_empty() {
|
||||||
|
return view! {};
|
||||||
|
}
|
||||||
|
let types = game_types.get_clone();
|
||||||
|
let rows = games
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| {
|
||||||
|
let name = if g.game_name.is_empty() {
|
||||||
|
types
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == g.game_type)
|
||||||
|
.map(|t| t.name.clone())
|
||||||
|
.unwrap_or_else(|| g.game_type.clone())
|
||||||
|
} else {
|
||||||
|
g.game_name.clone()
|
||||||
|
};
|
||||||
|
let phase = g.phase_label();
|
||||||
|
let seats = g.seats_text();
|
||||||
|
let players = g.players_text();
|
||||||
|
let players_note = if players.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" · {players}")
|
||||||
|
};
|
||||||
|
let href = format!("/game/{}", g.id);
|
||||||
|
view! {
|
||||||
|
li(
|
||||||
|
class="list-group-item d-flex flex-wrap \
|
||||||
|
justify-content-between \
|
||||||
|
align-items-center gap-2"
|
||||||
|
) {
|
||||||
|
div {
|
||||||
|
div(class="fw-semibold") { (name) }
|
||||||
|
div(class="text-secondary small") {
|
||||||
|
(phase) " · " (seats)
|
||||||
|
(players_note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
class="btn btn-sm btn-warning",
|
||||||
|
href=href,
|
||||||
|
) {
|
||||||
|
"Continue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class="card mb-4") {
|
||||||
|
div(class="card-header") { "Your games" }
|
||||||
|
div(class="card-body p-0") {
|
||||||
|
ul(class="list-group list-group-flush") {
|
||||||
|
(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
div(class="row g-4") {
|
div(class="row g-4") {
|
||||||
div(class="col-lg-7") {
|
div(class="col-lg-7") {
|
||||||
div(class="card h-100") {
|
div(class="card h-100") {
|
||||||
@@ -627,13 +642,21 @@ pub fn LobbyPage() -> View {
|
|||||||
div(class="mb-3") { (players_note) }
|
div(class="mb-3") { (players_note) }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
Keyed(
|
// Recreate the option inputs on every
|
||||||
list=fields,
|
// game switch: keyed rendering kept
|
||||||
view=move |f| view! {
|
// the previous game's field (value,
|
||||||
OptionInput(field=f.clone(), options=options)
|
// min/max) for shared keys such as
|
||||||
},
|
// target_score.
|
||||||
key=|f| f.key.clone(),
|
(move || {
|
||||||
)
|
let _game = selected_game.get_clone();
|
||||||
|
fields
|
||||||
|
.get_clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| view! {
|
||||||
|
OptionInput(field=f, options=options)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
button(
|
button(
|
||||||
class="btn btn-warning btn-lg w-100 mt-auto",
|
class="btn btn-warning btn-lg w-100 mt-auto",
|
||||||
on:click=on_create,
|
on:click=on_create,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Login page, shown after the API answered `401` (see [`crate::auth`]):
|
||||||
|
//! the session expired, or the visitor tried to open a page that needs an
|
||||||
|
//! account.
|
||||||
|
//!
|
||||||
|
//! The backend's OIDC callback lands the SPA back on the configured
|
||||||
|
//! post-login redirect; [`crate::auth`] then returns the user to the page
|
||||||
|
//! they were on before this one.
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
use sycamore_router::navigate_replace;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::auth;
|
||||||
|
use crate::components::login::login_card;
|
||||||
|
use crate::model::AuthProvider;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoginPage() -> View {
|
||||||
|
let providers = create_signal(Vec::<AuthProvider>::new());
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
// Already signed in: no business here — return to where the user
|
||||||
|
// was headed, or the lobby.
|
||||||
|
if matches!(api::me().await, Ok(Some(_))) {
|
||||||
|
let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string());
|
||||||
|
auth::clear_return_to();
|
||||||
|
navigate_replace(&target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(list) = api::auth_providers().await {
|
||||||
|
// The page can unmount while the fetch is in flight.
|
||||||
|
if providers.is_alive() {
|
||||||
|
providers.set(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
(login_card(providers))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
pub mod briscola;
|
pub mod briscola;
|
||||||
|
pub mod chiamata;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod leaderboard;
|
pub mod leaderboard;
|
||||||
pub mod lobby;
|
pub mod lobby;
|
||||||
|
pub mod login;
|
||||||
|
pub mod scopa;
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Scopa table view: classic two-player and four-player partnership.
|
||||||
|
//!
|
||||||
|
//! The two variants share this view: four seats render in the usual cross
|
||||||
|
//! (partners opposite), two seats stack opponent over player. The center
|
||||||
|
//! holds the cards on the table, the face-down deck and the last-move
|
||||||
|
//! announcement.
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::card::{card_back, card_img};
|
||||||
|
use crate::components::copy::CopyButton;
|
||||||
|
use crate::components::notify::{notify_toggle, NotifyCtx};
|
||||||
|
use crate::model::{
|
||||||
|
hand_sort_key, is_individual_game, GameView, PlayerView, Scores,
|
||||||
|
};
|
||||||
|
use crate::pages::game::{move_banner, watch_link};
|
||||||
|
|
||||||
|
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||||
|
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||||
|
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||||
|
let Some(player) = player_for_seat(&game, seat) else {
|
||||||
|
return view! {};
|
||||||
|
};
|
||||||
|
let active = game.phase == "playing" && game.turn == seat;
|
||||||
|
let cls = format!(
|
||||||
|
"seat seat-{position}{}",
|
||||||
|
if active { " active" } else { "" }
|
||||||
|
);
|
||||||
|
let name = player.name.clone();
|
||||||
|
let team_badge = (!is_individual_game(&game.game_type))
|
||||||
|
.then(|| player.team.clone())
|
||||||
|
.flatten()
|
||||||
|
.filter(|team| !team.is_empty())
|
||||||
|
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
|
||||||
|
let captured = player.captured_count;
|
||||||
|
let scope = player.scope;
|
||||||
|
let backs = (0..player.cards_left)
|
||||||
|
.map(|_| card_back("mini"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class=cls) {
|
||||||
|
div(class="seat-name") {
|
||||||
|
(name)
|
||||||
|
(team_badge)
|
||||||
|
}
|
||||||
|
div(class="seat-cards") { (backs) }
|
||||||
|
div(class="seat-stats") {
|
||||||
|
(captured) " captured · " (scope) " scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face-down deck with the cards left in the current hand.
|
||||||
|
fn deck_view(game: &GameView) -> View {
|
||||||
|
let backs = (0..game.deck_count.min(3))
|
||||||
|
.map(|_| card_back("stock-card"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let label = format!("{} in deck", game.deck_count);
|
||||||
|
view! {
|
||||||
|
div(class="stock") {
|
||||||
|
div(class="stock-pile") { (backs) }
|
||||||
|
span(class="stock-count") { (label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main scopa table.
|
||||||
|
pub fn scopa_table(
|
||||||
|
game: GameView,
|
||||||
|
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||||
|
now: Signal<f64>,
|
||||||
|
notify: NotifyCtx,
|
||||||
|
) -> View {
|
||||||
|
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||||
|
// hand, so they watch from seat 0's perspective.
|
||||||
|
let viewer_seat = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.hand.is_some())
|
||||||
|
.map(|p| p.seat)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let player_count = if game.player_count > 0 {
|
||||||
|
game.player_count
|
||||||
|
} else {
|
||||||
|
game.players.len().max(1)
|
||||||
|
};
|
||||||
|
let my_turn = game.your_turn == Some(true);
|
||||||
|
let turn_note = if game.phase == "finished" {
|
||||||
|
"Match finished".to_string()
|
||||||
|
} else if game.phase == "hand_end" {
|
||||||
|
"Hand finished".to_string()
|
||||||
|
} else if my_turn {
|
||||||
|
"Your turn".to_string()
|
||||||
|
} else {
|
||||||
|
let name = player_for_seat(&game, game.turn)
|
||||||
|
.map(|p| p.name)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{name}'s turn")
|
||||||
|
};
|
||||||
|
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||||||
|
let countdown = game.turn_deadline.as_ref().map(|deadline| {
|
||||||
|
// A dynamic closure so only the ticking number re-renders.
|
||||||
|
let deadline_ms = js_sys::Date::parse(deadline);
|
||||||
|
view! {
|
||||||
|
span(class="turn-timer") {
|
||||||
|
"Auto-play in "
|
||||||
|
(move || {
|
||||||
|
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||||||
|
})
|
||||||
|
"s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
|
let target = game.target_score;
|
||||||
|
let hand_number = game.hand_number;
|
||||||
|
// Two-player scopa has no partnerships: show the players' names.
|
||||||
|
let score_line = if is_individual_game(&game.game_type) {
|
||||||
|
format!(
|
||||||
|
"{} {} — {} {} (target {})",
|
||||||
|
game.player_name_for_team("A"),
|
||||||
|
scores.a,
|
||||||
|
scores.b,
|
||||||
|
game.player_name_for_team("B"),
|
||||||
|
target
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (grid_cls, top, left, right) = if player_count == 2 {
|
||||||
|
(
|
||||||
|
"table-grid duo",
|
||||||
|
seat_panel(game.clone(), (viewer_seat + 1) % 2, "top"),
|
||||||
|
view! {},
|
||||||
|
view! {},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"table-grid",
|
||||||
|
seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"),
|
||||||
|
seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"),
|
||||||
|
seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let viewer = player_for_seat(&game, viewer_seat);
|
||||||
|
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
|
||||||
|
let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
|
||||||
|
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
|
||||||
|
hand.sort_by_key(|c| hand_sort_key(c));
|
||||||
|
let hand_cards = hand
|
||||||
|
.into_iter()
|
||||||
|
.map(|code| {
|
||||||
|
let card_view = card_img(code.clone(), "hand-card");
|
||||||
|
view! {
|
||||||
|
button(class="hand-slot", on:click=move |_| {
|
||||||
|
if my_turn {
|
||||||
|
on_hand_card(code.clone());
|
||||||
|
}
|
||||||
|
}) { (card_view) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let hint = my_turn.then(|| view! {
|
||||||
|
p(class="text-secondary small text-center") {
|
||||||
|
"Click a card to play it. If it can capture in several ways you "
|
||||||
|
"will be asked to choose."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spectators have no hand: render the pivot seat as a regular panel
|
||||||
|
// so its captured count and scope stay visible where the hand would be.
|
||||||
|
let bottom = if game.spectator {
|
||||||
|
seat_panel(game.clone(), viewer_seat, "bottom")
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
div(class="seat-bottom") {
|
||||||
|
div(class="seat-stats own-stats") {
|
||||||
|
(my_captured) " captured · " (my_scope) " scope"
|
||||||
|
}
|
||||||
|
div(class="hand") { (hand_cards) }
|
||||||
|
(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let spectator_badge = game.spectator.then(|| view! {
|
||||||
|
span(class="spectator-badge") { "Spectating" }
|
||||||
|
});
|
||||||
|
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||||
|
let rule_badge = game.asso_pigliatutto.then(|| view! {
|
||||||
|
span(class="pigliatutto-badge") { "Asso pigliatutto" }
|
||||||
|
});
|
||||||
|
let watch_link = watch_link(&game.id);
|
||||||
|
// Precomputed so the final view's closures only capture owned views.
|
||||||
|
let banner = game.last_move.clone().map(move_banner);
|
||||||
|
let table_cards = game
|
||||||
|
.table
|
||||||
|
.iter()
|
||||||
|
.map(|c| card_img(c.clone(), "table-card"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let empty_table = game.table.is_empty().then(|| view! {
|
||||||
|
p(class="table-empty") { "Empty table" }
|
||||||
|
});
|
||||||
|
let deck = deck_view(&game);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
div(class="table-wrap") {
|
||||||
|
div(class="hud") {
|
||||||
|
a(href="/") { "← Lobby" }
|
||||||
|
span { "Hand " (hand_number) }
|
||||||
|
(rule_badge)
|
||||||
|
(spectator_badge)
|
||||||
|
span(class="hud-scores") { (score_line) }
|
||||||
|
span(class=turn_cls) { (turn_note) }
|
||||||
|
(countdown)
|
||||||
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
|
(bell)
|
||||||
|
}
|
||||||
|
div(class=grid_cls) {
|
||||||
|
(top)
|
||||||
|
(left)
|
||||||
|
div(class="center") {
|
||||||
|
(banner)
|
||||||
|
div(class="table-cards") {
|
||||||
|
(table_cards)
|
||||||
|
(empty_table)
|
||||||
|
}
|
||||||
|
(deck)
|
||||||
|
}
|
||||||
|
(right)
|
||||||
|
(bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Service worker registration.
|
||||||
|
//!
|
||||||
|
//! Registering `/sw.js` is what makes the app installable as a PWA; the
|
||||||
|
//! worker itself is caching-free (see `web/sw.js`). Registration is
|
||||||
|
//! best-effort: browsers without service worker support, and insecure
|
||||||
|
//! origins other than localhost, simply skip it.
|
||||||
|
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||||
|
|
||||||
|
/// Register the service worker, if the browser supports it.
|
||||||
|
pub fn register() {
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let container = window.navigator().service_worker();
|
||||||
|
spawn_local(async move {
|
||||||
|
// Rejections (e.g. insecure origin) are ignored: the app works
|
||||||
|
// without a service worker, it just is not installable.
|
||||||
|
let _ = JsFuture::from(container.register("/sw.js")).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,6 +45,26 @@ impl GameSocket {
|
|||||||
self.send_json(serde_json::json!({ "action": "ack" }));
|
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: bid a rank in the calling auction.
|
||||||
|
pub fn bid_rank(&self, rank: i32) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: raise the points target (standing bid is the 2).
|
||||||
|
pub fn bid_points(&self, points: i32) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: leave the auction.
|
||||||
|
pub fn pass(&self) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "pass" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: name the called card.
|
||||||
|
pub fn call_card(&self, card: &str) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "call", "card": card }));
|
||||||
|
}
|
||||||
|
|
||||||
fn send_json(&self, value: serde_json::Value) {
|
fn send_json(&self, value: serde_json::Value) {
|
||||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,21 @@ body {
|
|||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- game table ---------- */
|
/* ---------- game table ---------- */
|
||||||
|
|
||||||
.hud {
|
.hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
@@ -76,7 +86,10 @@ body {
|
|||||||
.table-grid {
|
.table-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2.4fr 1fr;
|
/* minmax(0, …) lets the tracks shrink below their content's
|
||||||
|
* min-content width; without it the center's move banner and the
|
||||||
|
* seat panels push the grid wider than the screen on phones. */
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr) minmax(0, 1fr);
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
". top ."
|
". top ."
|
||||||
@@ -114,6 +127,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: 116px;
|
min-height: 116px;
|
||||||
|
min-width: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -126,7 +140,8 @@ body {
|
|||||||
.seat-name {
|
.seat-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.15rem 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -167,6 +182,7 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 230px;
|
min-height: 230px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cards {
|
.table-cards {
|
||||||
@@ -240,8 +256,11 @@ body {
|
|||||||
|
|
||||||
.move-banner {
|
.move-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
max-width: 100%;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.35rem 1rem;
|
padding: 0.35rem 1rem;
|
||||||
@@ -282,8 +301,12 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* Tall dialogs (the chiamata call picker, the scoring summaries) can
|
||||||
|
* exceed short windows: allow scrolling instead of clipping them. */
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +315,11 @@ body {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 320px;
|
/* Centers the dialog when it fits and keeps it reachable (top
|
||||||
|
* aligned, scrollable via the overlay) when it does not. */
|
||||||
|
margin: auto;
|
||||||
|
min-width: min(320px, calc(100vw - 3rem));
|
||||||
|
max-width: calc(100vw - 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-options {
|
.capture-options {
|
||||||
@@ -327,8 +354,8 @@ body {
|
|||||||
/* ---------- hand-end scoring summary ---------- */
|
/* ---------- hand-end scoring summary ---------- */
|
||||||
|
|
||||||
.summary-panel {
|
.summary-panel {
|
||||||
min-width: 420px;
|
min-width: min(420px, calc(100vw - 1.5rem));
|
||||||
max-width: 560px;
|
max-width: min(560px, calc(100vw - 1.5rem));
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +526,7 @@ body {
|
|||||||
|
|
||||||
.playing-card.stock-card {
|
.playing-card.stock-card {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
margin-left: -34px;
|
margin-left: -18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playing-card.stock-card:first-child {
|
.playing-card.stock-card:first-child {
|
||||||
@@ -540,3 +567,324 @@ body {
|
|||||||
"center"
|
"center"
|
||||||
"bottom";
|
"bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- phones ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.hud {
|
||||||
|
gap: 0.3rem 0.75rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-grid {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat {
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-stats {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex-wrap lets the hidden-hand backs drop to the next row instead
|
||||||
|
* of forcing the seat wider than its track. */
|
||||||
|
.seat-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
min-height: 180px;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-banner {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.mini {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.table-card {
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.move-card {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.hand-card {
|
||||||
|
height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand {
|
||||||
|
min-height: 100px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.stock-card {
|
||||||
|
height: 38px;
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.briscola-card {
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-icon {
|
||||||
|
width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.score-mini {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
grid-template-columns: 3.5rem 1fr 3.2rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- briscola chiamata ---------- */
|
||||||
|
|
||||||
|
/* Five seats: two across the top and one on each side of the viewer. */
|
||||||
|
.table-grid.cinque {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"tl . tr"
|
||||||
|
"left center right"
|
||||||
|
"bottom bottom bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-tl {
|
||||||
|
grid-area: tl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-tr {
|
||||||
|
grid-area: tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.called-badge {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--felt-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-banner {
|
||||||
|
background: rgba(232, 197, 71, 0.15);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patta-banner {
|
||||||
|
background: rgba(217, 83, 79, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The calling auction: standing bid, trail and the bid buttons. */
|
||||||
|
.auction-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-standing {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-history {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-chip {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-button {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--felt-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-button.points {
|
||||||
|
border-color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The auction winner's call picker: the four cards of the won rank,
|
||||||
|
* laid out in a compact grid so the dialog stays short. */
|
||||||
|
.call-picker {
|
||||||
|
max-width: min(34rem, calc(100vw - 3rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
.call-options {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-option {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 1px solid #4c6a54;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.4rem 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-option:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smaller than a hand card: the picker must fit short laptop windows. */
|
||||||
|
.playing-card.call-card {
|
||||||
|
height: 84px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note.partner {
|
||||||
|
color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note.solo {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chiamata-row .score-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.table-grid.cinque {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"tl tr"
|
||||||
|
"left right"
|
||||||
|
"center center"
|
||||||
|
"bottom bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-panel {
|
||||||
|
padding: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- match history ---------- */
|
||||||
|
|
||||||
|
/* One line per team inside the "Teams" cell of the history table. */
|
||||||
|
.match-team {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Tavolo service worker: installability and notification clicks, no
|
||||||
|
// offline caching.
|
||||||
|
//
|
||||||
|
// The app is a thin client for a live game server: state arrives over
|
||||||
|
// /api and /ws and is never safe to replay from a cache, and a new build
|
||||||
|
// changes the hashed asset names, so stale cached files would break the
|
||||||
|
// app until the cache expired. The worker therefore only performs the
|
||||||
|
// passthrough fetch below — enough for the browser to treat the app as
|
||||||
|
// installable — and leaves every request's caching to the network stack.
|
||||||
|
// It also handles clicks on the move notifications raised by the game
|
||||||
|
// page (see src/notify.rs).
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
// A new worker takes over on the next reload instead of waiting for
|
||||||
|
// every tab of the old version to close.
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clicking a move notification focuses the game: an existing window on
|
||||||
|
// the game is focused as is, any other window of the app is navigated
|
||||||
|
// there, and when the app is closed a new window is opened.
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = (event.notification.data && event.notification.data.url) || "/";
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const target = new URL(url, self.location.origin);
|
||||||
|
const windows = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
includeUncontrolled: true,
|
||||||
|
});
|
||||||
|
const onGame = windows.find((client) => {
|
||||||
|
const current = new URL(client.url);
|
||||||
|
return current.origin === target.origin && current.pathname === target.pathname;
|
||||||
|
});
|
||||||
|
if (onGame) {
|
||||||
|
return onGame.focus();
|
||||||
|
}
|
||||||
|
const anyWindow = windows.find(
|
||||||
|
(client) => new URL(client.url).origin === target.origin,
|
||||||
|
);
|
||||||
|
if (anyWindow) {
|
||||||
|
await anyWindow.focus();
|
||||||
|
try {
|
||||||
|
return await anyWindow.navigate(url);
|
||||||
|
} catch {
|
||||||
|
return anyWindow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
// Let the browser handle other origins and the live endpoints directly
|
||||||
|
// (respondWith would only add a hop; /auth navigations must be allowed
|
||||||
|
// to follow their redirects untouched).
|
||||||
|
if (
|
||||||
|
url.origin !== self.location.origin ||
|
||||||
|
url.pathname.startsWith("/api/") ||
|
||||||
|
url.pathname.startsWith("/auth/") ||
|
||||||
|
url.pathname.startsWith("/ws")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
});
|
||||||
@@ -40,11 +40,27 @@
|
|||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--felt-dark);
|
background-color: var(--felt-dark);
|
||||||
background-image: radial-gradient(
|
/* Casino-table felt: a fine grain texture tiled over the green
|
||||||
|
* gradient, lit from above and darkened towards the edges. The first
|
||||||
|
* layer is the topmost one. */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 45%,
|
||||||
|
transparent 55%,
|
||||||
|
rgba(0, 0, 0, 0.38) 100%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% -10%,
|
||||||
|
rgba(255, 255, 255, 0.07),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
url("/assets/felt.svg"),
|
||||||
|
radial-gradient(
|
||||||
ellipse at 50% -10%,
|
ellipse at 50% -10%,
|
||||||
var(--felt) 0%,
|
var(--felt) 0%,
|
||||||
var(--felt-dark) 65%
|
var(--felt-dark) 65%
|
||||||
);
|
);
|
||||||
|
background-repeat: no-repeat, no-repeat, repeat, no-repeat;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|||||||