Compare commits

..
Author SHA1 Message Date
woggioni f59064e580 Upgrade kaya to 0.0.7
CI / Build and push docker image (push) Successful in 1m56s
2026-10-01 23:43:55 +08:00
woggioni a89bd06010 Support multiple OIDC providers
CI / Build and push docker image (push) Successful in 2m27s
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

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

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

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

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

As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
2026-10-01 20:38:21 +08:00
woggioni 6e9d1a4652 Wake deadline consumers on enqueue instead of polling every second
CI / Build and push docker image (push) Successful in 1m15s
2026-09-30 07:40:04 +08:00
woggioni 723b3b11cf Upgrade kaya to 0.0.5
CI / Build and push docker image (push) Successful in 1m20s
kaya 0.0.5 surfaces a vanished websocket peer as
kaya.core.WebSocketDisconnect instead of leaking granian's
RSGIProtocolClosed: receive() reports a close message so iteration ends
normally, sends raise the new exception, and KayaApp.handle_websocket
swallows it as normal termination. Adopt it: drop the granian-specific
workaround from the platform websocket handler, catch
WebSocketDisconnect in the send helper, and model the new semantics in
the abrupt-disconnect test (with coverage that a send on a vanished
peer is ignored).

Regenerate the lock: the eight kaya pins move to 0.0.5 (declared
metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a
security release with no breaking changes for kaya-oidc's jwt.decode
usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports
WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5.
2026-09-30 06:38:02 +08:00
woggioni ed850aa73c Sort the player's hand by rank then suit 2026-09-30 00:58:31 +08:00
51 changed files with 300 additions and 4861 deletions
+14 -26
View File
@@ -1,16 +1,14 @@
# tavolo
A platform for multiplayer card games. Two games are included:
**scopone scientifico** — the four-player, fixed-partnership Italian card
game — and **briscola**, in its classic two-player and four-player
partnership (*briscola a quattro*) variants:
A platform for multiplayer card games. The first game is **scopone
scientifico** — the four-player, fixed-partnership Italian card game:
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres.
A monorepo of four distributions sharing the `tavolo` namespace: the
game-independent `tavolo-platform`, the `tavolo-scopone` and
`tavolo-briscola` games, and the `tavolo-app` composition root wiring
them together. See [server/README.md](server/README.md).
A monorepo of three distributions sharing the `tavolo` namespace: the
game-independent `tavolo-platform`, the scopone game `tavolo-scopone`,
and the `tavolo-app` composition root wiring them together.
See [server/README.md](server/README.md).
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
@@ -39,30 +37,21 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
## Between hands
When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: scopone players see how each category was won
**scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled), briscola players see the deal's points and any
*patta*; both see the running totals and must click "Understood" before
the next hand is dealt. If someone is away the next hand is dealt
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
match-ending hand is explained on the final screen.
pigliatutto when enabled)
with the running totals and
must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
(default 30s). The match-ending hand is explained on the final screen.
## On your turn
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (choosing among the legal captures when the
random legal card for them (randomizing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
## Watching
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
spectators see the table, the score and every player's captured cards
(and scope for scopone), but never a hand and never the join code. The
"Copy watch link" button in the waiting room and in the game HUD copies
the URL to share. A spectator who opens the URL before the match starts
sees a "game not started yet" waiting room.
## Development
Backend (from `server/`):
@@ -71,11 +60,10 @@ Backend (from `server/`):
cd server
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
```
Frontend (from `web/`), with the backend running on :8080:
+2 -2
View File
@@ -58,13 +58,13 @@ RUN --mount=type=cache,target=/root/.cache/pip \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt
# The four sibling distributions, installed from the local tree (PEP 420
# The three sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency — including the tavolo-app `otel` extra — is
# already pinned in requirements.txt.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-briscola ./packages/tavolo-app
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app
# server/pyproject.toml only carries the [tool.aerich] section (the
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
# next to ./migrations).
+27 -89
View File
@@ -1,12 +1,10 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. It is a monorepo of four distributions
[kaya](../kaya) framework. It is a monorepo of three distributions
sharing the `tavolo` namespace (see
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)
and
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md)
and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
game-independent: the lobby API, the live-game store, the WebSocket
@@ -15,12 +13,8 @@ and
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
scientifico, the four-player, fixed-partnership variant of the classic
Italian card game: pure rules plus the platform adapter.
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
Italian trick-taking game, in its classic two-player (`briscola`) and
four-player partnership (`briscola_a_quattro`) variants: pure rules
plus the platform adapter.
- **`tavolo-app`** (`packages/tavolo-app/`) — the
composition root wiring the platform to the games, with the
composition root wiring the platform to the scopone game, with the
environment-driven settings and the SPA shell.
Players authenticate with the configured **OIDC** provider. Live game
@@ -80,8 +74,8 @@ All configuration comes from environment variables (see `.env.example`):
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements (wired into the game engines) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them (wired into the game engines) |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
@@ -218,7 +212,7 @@ aggregate with `max`, not `sum`.
signals; every open WebSocket reloads the session and pushes the
personalized view to its player.
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
timeouts (e.g. the games' turn auto-plays and hand-end auto-continues).
timeouts (e.g. scopone's turn auto-plays and hand-end auto-continues).
Every worker runs a consumer that fires due entries under the per-game
lock by calling the engine's `fire_deadline`, so timeouts do not depend
on any player being connected and survive the death of any worker
@@ -235,8 +229,7 @@ aggregate with `max`, not `sum`.
scoped per game), start/finish timestamps, and the game-specific outcome
as reported by the engine (`result` JSON — for scopone: both teams'
final scores, winner, target score, hands played and the per-hand
audit; for briscola: the final scores, winner, target score, deals
played and the per-deal audit).
audit).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and
@@ -269,47 +262,40 @@ existed):
## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
`/api/game-types`, `/api/leaderboard` and the game snapshot
(`GET /api/games/{id}`) require authentication.
`/api/game-types` and `/api/leaderboard` require authentication.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; 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": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
who knows the game id is accepted: seated players get their personalized
view, everyone else — anonymous users and authenticated non-players — is
a read-only **spectator**. Only an unknown game id is refused before the
handshake completes, with `404`. (Browsers report a refused handshake as
an abnormal close — there is no close code to read.)
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; the upgrade is refused before the handshake
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
a refused handshake as an abnormal close — there is no close code to
read.)
Server → client messages are JSON objects with a `type`:
- `{"type": "state", "game": {...}}` — personalized game view. Your own
hand is included; other players expose only their card counts.
Spectators get no hand at all, `spectator: true` marks the view and
`join_code` is omitted for them. `your_turn: true` is present when it
is your move. `seats_open` reports the free chairs (0 once the match
started).
`your_turn: true` is present when it is your move.
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages are JSON objects with an `action`. The
platform handles `state` (resend the snapshot) itself; every other action
is dispatched to the session's game engine with the rest of the message
as its payload. Spectators may only send `state`/`sync`: any other
action is answered with an `error` whose `code` is `forbidden`. For
scopone scientifico:
as its payload. For scopone scientifico:
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -328,21 +314,7 @@ scopone scientifico:
after `HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot.
For briscola (`briscola`, `briscola_a_quattro`) the game actions are
`play` (with `card` only — every card in hand is legal) and `ack`:
```json
{"action": "play", "card": "04C"}
{"action": "ack"}
```
- `card` uses the same `RRSUIT` convention.
- `ack` acknowledges the deal-end summary; the next deal is dealt once
every player has acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
After every accepted move the new state is broadcast to every connected
client, spectators included.
After every accepted move the new state is broadcast to all four players.
### Turn timeout
@@ -358,18 +330,15 @@ match keeps progressing even with every player disconnected.
### Hand-end summary
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries the
scoring breakdown of the hand just played (`last_hand` with an `award`
map naming the team that won each scopone category; `last_deal` with the
deal's points and running totals for briscola), the `acknowledged` seats
and a `hand_end_deadline`. The frontend renders this as a screen every
player must dismiss. A play attempted in this phase is rejected with an
`hand_end` phase instead of dealing immediately: the state carries
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), the `acknowledged` seats and a
`hand_end_deadline`. The frontend renders this as a screen every player
must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
## Rules implemented
### Scopone scientifico
- 40-card Italian deck, ten cards per player, empty table at hand start.
- A card captures a **single card of equal rank** (mandatory when present)
or a **combination of table cards whose ranks sum to its own**.
@@ -393,45 +362,20 @@ player must dismiss. A play attempted in this phase is rejected with an
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.
### Briscola
- 40-card Italian deck, three cards per player. The card turned up after
the deal fixes the trump (*briscola*) suit for the whole deal and is the
last card drawn from the stock.
- Trick play: the highest trump wins; when no trump was played, the
highest card of the led suit wins. The winner takes the trick and leads
the next one.
- After each trick every player draws back up to three cards, starting
with the trick winner; the face-up briscola card goes to whoever's turn
to draw it is.
- Card points: ace 11, three 10, king 4, horse 3, jack 2 — 120 points per
deal. Trick strength is unrelated to points: ace > three > king > horse
> jack > 7 > 6 > 5 > 4 > 2.
- Match points accumulate across deals; a 60–60 deal is a *patta*: its
points are discarded and the deal is replayed. The match ends when a
team reaches the target score (default 61, configurable per game via
the `target_score` creation option) with a clear lead; a tie at or
above the target is broken by another deal.
- `briscola` is the classic two-player game; `briscola_a_quattro` seats
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
opposite).
## Development
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
# per-package suites (each package is self-contained):
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
# type checks (see packages/tavolo-app/README.md for the app modules):
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
.venv/bin/python -m mypy -p tavolo.briscola
```
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
@@ -489,12 +433,6 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
├── engine.py # pure scopone scientifico rules
├── errors.py # scopone-specific rule violations
└── plugin.py # ScoponeEngine: the GameEngine adapter
packages/tavolo-briscola/ # briscola game (own pyproject + tests)
src/tavolo/briscola/
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
├── engine.py # pure briscola rules (2- and 4-player)
├── errors.py # briscola-specific rule violations
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
packages/tavolo-app/ # deployable app: composition root (own pyproject)
src/tavolo/
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
@@ -505,5 +443,5 @@ packages/tavolo-app/ # deployable app: composition root (own pyproject)
└── static.py # SPA shell hosting
pyproject.toml # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + games wired together)
tests/ # app integration suite (platform + scopone wired together)
```
+3 -4
View File
@@ -2,15 +2,14 @@
The deployable tavolo application: the composition root wiring
[`tavolo-platform`](../tavolo-platform/README.md) to the
[`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
[`tavolo-scopone`](../tavolo-scopone/README.md) game.
## Contents
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
mixins plus `PlatformMixin` and the deadline scheduler), registers the
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
their configured timeouts, and exposes `tavolo.app:app` for granian.
`ScoponeEngine` with its configured timeouts, and exposes
`tavolo.app:app` for granian.
- `config.py` — environment-driven frozen `Settings`.
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
meter/tracer/logger providers, the
+1 -2
View File
@@ -5,13 +5,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "tavolo-app"
version = "0.1.0"
description = "Tavolo multiplayer card-game application: platform + games wiring"
description = "Tavolo multiplayer card-game application: platform + scopone game wiring"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
"tavolo-scopone",
"tavolo-briscola",
"kaya-core",
"kaya-cors",
"kaya-session",
+1 -14
View File
@@ -39,7 +39,6 @@ from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.openapi import OpenAPIMixin
from kaya.session import SessionMixin
from redis.asyncio import Redis
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.platform import GameRegistry, Platform, PlatformMixin
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
@@ -132,21 +131,9 @@ registry.register(
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaQuattroEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
log.debug(
"game timeouts: hand_ack=%ds turn=%ds",
"scopone timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
@@ -215,12 +215,12 @@ class Settings:
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway (wired into the game engines;
# before dealing the next hand anyway (wired into the ScoponeEngine;
# see tavolo.app).
hand_ack_timeout_seconds: int
# Seconds a player has to play before the server plays a random legal
# card for them, covering disconnects and idle players (wired into the
# game engines; see tavolo.app).
# ScoponeEngine; see tavolo.app).
turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls
# when nothing else wakes it. Enqueued deadlines are announced over a
-58
View File
@@ -1,58 +0,0 @@
# tavolo-briscola
Briscola — the Italian trick-taking game — as a
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
Two variants share one rules core:
- `briscola` — the classic two-player game;
- `briscola_a_quattro` — four-player fixed-partnership briscola.
## Contents
- `state.py` — `BriscolaState` (pure game data: phases, players, hands,
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, deals, trick resolution
(trump first, then the led suit; strength ace > three > king > horse >
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
jack 2), patta re-deals and the target-score match end, deal-end
acknowledgements and auto-play. Deterministic and I/O-free apart from
logging, so the whole rule set is unit-testable.
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
(`GameEngine`): the platform-facing adapters. They validate the
`target_score` creation option (61–120, default 61) and extract the
`MatchResult` (teams, winner, per-player scores and the match summary
persisted as the match's JSON `result`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
## Rules
- Three cards per player; the next card is turned face up: its suit is
the trump (briscola) for the deal and it is the last card drawn.
- The winner of a trick is the highest trump, or the highest card of the
led suit when no trump was played. The winner takes the trick and leads
the next one.
- After each trick everyone draws back up to three cards, starting with
the trick winner.
- Card points total 120 per deal; a 60–60 deal is a *patta*, its points
are discarded and the deal is replayed. The first team to the target
score wins; a tie at or above the target is broken by another deal.
- Between deals of an unfinished match every player acknowledges the
summary (or the hand-end timeout deals on), exactly like scopone.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
.venv/bin/python -m mypy -p tavolo.briscola
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
@@ -1,22 +0,0 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-briscola"
version = "0.1.0"
description = "Briscola game implementation for the tavolo platform"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -1,20 +0,0 @@
"""Briscola: tavolo's trick-taking game implementation.
Two variants share the same rules core and platform adapter:
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
briscola (``briscola``);
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
fixed-partnership briscola a quattro (``briscola_a_quattro``).
The pure rules (:mod:`tavolo.briscola.engine`,
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
:mod:`tavolo.briscola.plugin` adapts them to the platform's
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them.
"""
from __future__ import annotations
from .plugin import BriscolaEngine, BriscolaQuattroEngine
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
@@ -1,469 +0,0 @@
"""Pure rules engine for briscola.
Every function here is deterministic and I/O-free (the only side effect is
logging): it mutates (or reads)
:class:`~tavolo.briscola.state.BriscolaState` and raises
:class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
This makes the whole rule set unit-testable without Redis, Postgres or
HTTP. The platform-facing adapter is
:class:`~tavolo.briscola.plugin.BriscolaEngine`.
Rules implemented
-----------------
* 40-card Italian deck (4 suits x ranks 1-10), three cards per player.
* The next card after the deal is turned face up: its suit is the
**briscola** (trump) for the whole deal, and it is the last card drawn
from the stock (the stock is drawn from the bottom up).
* Trick play: the player to the dealer's left leads; each other player
plays one card. The highest trump wins; when no trump was played, the
highest card of the led suit wins. The winner takes the trick and leads
the next one.
* After each trick every player draws back up to three cards, starting
with the trick winner and continuing in turn order; the face-up
briscola card is drawn last, so whoever's turn to draw when only it
remains takes it.
* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else
0 — 120 points per deal. Trick strength is unrelated to points:
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
* Match points accumulate across deals. A 60-60 deal is a **patta**: its
points are discarded and the deal is replayed (the dealer rotates).
The first team to reach the target score (default 61) wins; a tie at or
above the target is broken by another deal.
* Between deals of an unfinished match the game pauses in the
``hand_end`` phase for the deal summary, exactly like scopone: every
player acknowledges (or the hand-end timeout elapses) before the next
deal.
"""
from __future__ import annotations
import random
from datetime import datetime, timedelta, timezone
from logging import getLogger
from typing import Any, Dict, List, Optional, Tuple
from tavolo.platform.errors import (
AlreadyJoined,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .errors import CardNotInHand
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
DEFAULT_TURN_TIMEOUT_SECONDS,
HAND_SIZE,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
PLAYER_COUNTS,
SUITS,
TEAM_NAMES,
BriscolaState,
Card,
PlayerState,
Trick,
TrickCard,
parse_card,
)
log = getLogger(__name__)
# Trick strength: higher wins. Ace > three > king > horse > jack, then the
# remaining ranks in descending order. All ranks are distinct within a
# suit, so a trick never ties.
TRICK_STRENGTH: Dict[int, int] = {
1: 9,
3: 8,
10: 7,
9: 6,
8: 5,
7: 4,
6: 3,
5: 2,
4: 1,
2: 0,
}
# Card points: 120 per deal in total.
CARD_POINTS: Dict[int, int] = {
1: 11,
3: 10,
10: 4,
9: 3,
8: 2,
}
_rng = random.SystemRandom()
def full_deck() -> List[Card]:
"""Return the 40 cards of the Italian deck in canonical order."""
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
deck = full_deck()
(rng or _rng).shuffle(deck)
return deck
def card_points(card: Card) -> int:
"""The point value of a single card."""
return CARD_POINTS.get(card.rank, 0)
def create_game(
creator_sub: str,
creator_name: str,
player_count: int = 2,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> BriscolaState:
"""Create a lobby game with the creator seated first."""
if player_count not in PLAYER_COUNTS:
raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}")
if (
isinstance(target_score, bool)
or not isinstance(target_score, int)
or target_score < MIN_TARGET_SCORE
or target_score > MAX_TARGET_SCORE
):
raise IllegalMove(
f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}"
)
return BriscolaState(
player_count=player_count,
target_score=target_score,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
)
def join_game(state: BriscolaState, sub: str, name: str) -> None:
"""Seat ``sub`` in the next free chair, starting the match when full."""
if state.phase != PHASE_LOBBY:
raise GameNotStarted("game has already started")
if state.seated(sub):
raise AlreadyJoined("already joined this game")
if len(state.players) >= state.player_count:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
if len(state.players) == state.player_count:
start_game(state)
def start_game(state: BriscolaState) -> None:
"""Deal the first hand and switch the game to playing."""
if len(state.players) != state.player_count:
raise GameNotStarted(
f"need exactly {state.player_count} players to start"
)
state.phase = PHASE_PLAYING
_deal(state)
def _set_turn_deadline(state: BriscolaState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _deal(state: BriscolaState) -> None:
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
state.trick = []
state.last_trick = None
state.briscola_card = None
# Dealer rotates each deal; the first card is played by the player to
# the dealer's left.
state.turn = (state.dealer + 1) % state.player_count
state.trick_leader = state.turn
for _ in range(HAND_SIZE):
for offset in range(state.player_count):
player = _player_at(
state, (state.dealer + 1 + offset) % state.player_count
)
player.hand.append(deck.pop())
# The turned-up card is the last card drawn: keep it separate from the
# face-down stock and hand it out once the stock is exhausted.
state.briscola_card = deck.pop()
state.briscola_suit = state.briscola_card.suit
state.stock = deck
_set_turn_deadline(state)
log.debug(
"deal %d dealt (dealer seat %d, briscola %s)",
state.hand_number,
state.dealer,
state.briscola_card.code,
)
def _player_at(state: BriscolaState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def play(state: BriscolaState, sub: str, card_code: str) -> None:
"""Apply one move by the player identified by ``sub``.
Any card in hand is a legal play; only turn order and phase are
validated. Raises a :class:`~tavolo.platform.errors.GameError`
subclass on any violation.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase == PHASE_HAND_END:
raise IllegalMove("the deal is over; acknowledge the summary to continue")
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = state.player_for(sub)
if player is None or player.seat != state.turn:
raise NotYourTurn("it is not your turn")
card = parse_card(card_code)
if card not in player.hand:
raise CardNotInHand(f"card {card.code} is not in your hand")
player.hand.remove(card)
state.trick.append(TrickCard(seat=player.seat, card=card.code))
log.debug(
"deal %d: seat %d played %s (%d/%d in the trick)",
state.hand_number,
player.seat,
card.code,
len(state.trick),
state.player_count,
)
if len(state.trick) == state.player_count:
_resolve_trick(state)
else:
state.turn = (state.turn + 1) % state.player_count
_set_turn_deadline(state)
def _resolve_trick(state: BriscolaState) -> None:
"""Score the completed trick, draw and hand the lead to the winner."""
winner = _trick_winner(state)
taken = [parse_card(t.card) for t in state.trick]
_player_at(state, winner).captured.extend(taken)
state.last_trick = Trick(winner=winner, cards=list(state.trick))
state.trick = []
state.trick_leader = winner
state.turn = winner
log.debug(
"deal %d: seat %d takes the trick with %s",
state.hand_number,
winner,
[t.card for t in state.last_trick.cards],
)
if all(not player.hand for player in state.players):
_end_deal(state)
return
_draw(state, winner)
_set_turn_deadline(state)
def _trick_winner(state: BriscolaState) -> int:
"""The seat winning the current (complete) trick."""
played = [(t.seat, parse_card(t.card)) for t in state.trick]
leader_suit = played[0][1].suit
trumps = [(seat, card) for seat, card in played if card.suit == state.briscola_suit]
pool = trumps or [
(seat, card) for seat, card in played if card.suit == leader_suit
]
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
def _draw(state: BriscolaState, winner: int) -> None:
"""Draw back up to ``HAND_SIZE`` cards, winner first, then turn order."""
for offset in range(state.player_count):
player = _player_at(state, (winner + offset) % state.player_count)
if state.stock:
player.hand.append(state.stock.pop())
elif state.briscola_card is not None:
# The face-up briscola is the last card of the stock: the first
# player who needs a card when only it remains takes it.
player.hand.append(state.briscola_card)
state.briscola_card = None
def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None:
"""Play a random card for the player currently on turn.
Every card in hand is legal, so one is drawn at random. Delegates to
:func:`play`, so the move is fully validated and can end the deal or
the match. Pass ``rng`` for deterministic tests.
"""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
play(state, player.sub, card.code)
def _end_deal(state: BriscolaState) -> None:
"""Score the deal and either finish the match or pause for the summary.
A 60-60 deal is a patta: its points do not count and the deal is
replayed. If the match continues, the game pauses in the ``hand_end``
phase so every player can read the summary; the next deal is dealt by
:func:`acknowledge_hand` once all players have acknowledged (or by the
hand-end timeout fired by the platform's deadline scheduler).
"""
state.turn_deadline = None
points, details = deal_points(state)
patta = points[0] == points[1]
if not patta:
state.scores[0] += points[0]
state.scores[1] += points[1]
entry: Dict[str, Any] = {
"deal": state.hand_number,
"patta": patta,
"points": details["points"],
"cards": details["cards"],
"totals": {"A": state.scores[0], "B": state.scores[1]},
}
state.hand_scores.append(entry)
a, b = state.scores
log.debug(
"deal %d scored A+%d B+%d%s (totals %d-%d)",
state.hand_number,
points[0],
points[1],
" patta" if patta else "",
a,
b,
)
if not patta and max(a, b) >= state.target_score and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
log.info("match ended, team %s wins", TEAM_NAMES[state.winner])
return
# Pause for the deal summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: BriscolaState, sub: str) -> None:
"""Record that ``sub`` has read the deal summary.
When every player has acknowledged, the next deal is dealt.
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
phase raises an error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no deal summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < state.player_count:
return
state.hand_number += 1
state.dealer = (state.dealer + 1) % state.player_count
state.acked = []
state.hand_end_deadline = None
state.phase = PHASE_PLAYING
_deal(state)
def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the deal points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []]
for player in state.players:
piles[player.team].extend(player.captured)
points = [
sum(card_points(card) for card in pile) for pile in piles
]
details: Dict[str, object] = {
"points": {"A": points[0], "B": points[1]},
"cards": {"A": len(piles[0]), "B": len(piles[1])},
}
return points, details
def state_for_player(state: BriscolaState, sub: Optional[str]) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
for player in state.players:
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": TEAM_NAMES[player.team],
"cards_left": len(player.hand),
"captured_count": len(player.captured),
"points": sum(card_points(card) for card in player.captured),
}
if viewer is not None and viewer.seat == player.seat:
view["hand"] = [c.code for c in player.hand]
players.append(view)
payload: Dict[str, object] = {
"phase": state.phase,
"target_score": state.target_score,
"player_count": state.player_count,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
"briscola_suit": state.briscola_suit,
"briscola_card": (
state.briscola_card.code if state.briscola_card is not None else None
),
"stock_count": len(state.stock),
"trick": [t.to_json() for t in state.trick],
"trick_leader": state.trick_leader,
"last_trick": state.last_trick.to_json() if state.last_trick else None,
"players": players,
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
}
if (
viewer is not None
and state.phase == PHASE_PLAYING
and viewer.seat == state.turn
):
payload["your_turn"] = True
return payload
@@ -1,34 +0,0 @@
"""Briscola errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely briscola-specific errors
are defined here.
"""
from __future__ import annotations
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
__all__ = [
"AlreadyJoined",
"CardNotInHand",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"IllegalMove",
"LobbyFull",
"NotYourTurn",
]
class CardNotInHand(GameError):
"""The played card is not held by the player."""
@@ -1,279 +0,0 @@
"""The platform-facing adapter for briscola.
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
rules core (:mod:`tavolo.briscola.engine`): classic two-player
:class:`BriscolaEngine` and four-player fixed-partnership
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
join, websocket actions, deadlines) into rules-engine calls and back,
keeping all briscola knowledge inside this package. Nothing outside
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
"""
from __future__ import annotations
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Mapping, Optional
from tavolo.platform.engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
DEFAULT_TURN_TIMEOUT_SECONDS,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
BriscolaState,
)
log = getLogger(__name__)
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
class _BriscolaBase(GameEngine):
"""Shared platform adapter for the two briscola table sizes."""
#: 2 (classic) or 4 (fixed partnerships); set by the subclasses.
player_count: int
options_schema = {
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": MIN_TARGET_SCORE,
"maximum": MAX_TARGET_SCORE,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
},
}
def __init__(
self,
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
player_count=self.player_count,
target_score=target_score,
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
)
# The creator takes seat 0, i.e. team A.
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team=TEAM_NAMES[0],
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
seat = len(session.players)
engine.join_game(session.state, user_sub, display_name)
# join_game raises before appending on any violation, so the seat
# list stays in sync with the engine's players.
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team=TEAM_NAMES[seat % 2],
)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: BriscolaState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: BriscolaState = session.state
if action == "play":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
engine.play(state, user_sub, card)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "ack":
engine.acknowledge_hand(state, user_sub)
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return engine.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state: BriscolaState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, BriscolaState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return BriscolaState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: BriscolaState = session.state
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
if due_ms is None:
return None
return Deadline(
kind="turn",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
if due_ms is None:
return None
return Deadline(
kind="hand_end",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"hand_end:{state.hand_number}:{due_ms}",
)
return None
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
# Overtaken by events (a play landed in time, the deal was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: BriscolaState = session.state
if kind == "turn":
seat = state.turn
engine.auto_play(state)
log.info(
"auto-played for seat %s (turn timeout, deal %d)",
seat,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"deal %d auto-advanced after the acknowledgement timeout",
state.hand_number,
)
else: # pragma: no cover - next_deadline never emits other kinds
raise GameError(f"unknown deadline kind: {kind!r}")
# -- results -------------------------------------------------------------
def result(self, session: GameSession) -> MatchResult:
state: BriscolaState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
teams = [
[p.sub for p in state.players if p.team == 0],
[p.sub for p in state.players if p.team == 1],
]
return MatchResult(
teams=teams,
winner_team=state.winner,
players=[
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=player.team == state.winner,
team=TEAM_NAMES[player.team],
score=float(state.scores[player.team]),
details={"captured_cards": len(player.captured)},
)
for player in state.players
],
summary={
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[state.winner],
"target_score": state.target_score,
"deals_played": state.hand_number,
"deal_scores": state.hand_scores,
},
)
class BriscolaEngine(_BriscolaBase):
"""Classic two-player briscola as a platform game engine."""
id = "briscola"
name = "Briscola"
description = (
"The classic two-player Italian trick-taking game: three cards "
"each, a trump suit and 120 points per deal. First to the target "
"score wins."
)
min_players = 2
max_players = 2
player_count = 2
class BriscolaQuattroEngine(_BriscolaBase):
"""Four-player fixed-partnership briscola as a platform game engine."""
id = "briscola_a_quattro"
name = "Briscola a quattro"
description = (
"Briscola played in two fixed partnerships of two, three cards "
"each and a trump suit. First team to the target score wins."
)
min_players = 4
max_players = 4
player_count = 4
@@ -1,279 +0,0 @@
"""In-memory representation of a briscola game.
The whole mutable game lives in :class:`BriscolaState`, which is
serialized to and from plain JSON for storage inside the platform's
session envelope (see :mod:`tavolo.platform.store`). Keeping the
representation JSON-native means the store needs no custom codecs and the
state is inspectable with ``redis-cli``.
The state contains only game data: the platform owns the session envelope
(id, join code, seats, timestamps, stats persistence) — see
:class:`tavolo.platform.engine.GameSession`.
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
A card is rendered as ``RRSUIT`` (e.g. ``07D``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
SUITS = ("D", "C", "S", "B")
RANKS = tuple(range(1, 11))
# Teams are derived from the seat: seats 0 and 2 form team A (index 0),
# seats 1 and 3 form team B (index 1). In the two-player variant the teams
# are simply the two players; in briscola a quattro the partners sit
# opposite each other.
TEAM_A = 0
TEAM_B = 1
TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing"
# Between deals of an unfinished match: deal summary shown to every
# player; the next deal is dealt once all players acknowledge (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
# Supported table sizes: classic head-to-head and four-player partnerships.
PLAYER_COUNTS = (2, 4)
DEFAULT_TARGET_SCORE = 61
MIN_TARGET_SCORE = 61
MAX_TARGET_SCORE = 120
# Cards dealt to each player at the start of a deal.
HAND_SIZE = 3
# Default seconds the between-deals summary waits before dealing anyway.
# Games carry their own copy in ``BriscolaState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# card for them. Games carry their own copy in
# ``BriscolaState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
def team_of(seat: int) -> int:
return seat % 2
@dataclass(frozen=True)
class Card:
rank: int
suit: str
def __post_init__(self) -> None:
if self.suit not in SUITS:
raise ValueError(f"invalid suit: {self.suit!r}")
if self.rank not in RANKS:
raise ValueError(f"invalid rank: {self.rank!r}")
@property
def code(self) -> str:
return f"{self.rank:02d}{self.suit}"
@staticmethod
def parse(code: str) -> "Card":
code = str(code).upper()
if len(code) != 3 or not code[:2].isdigit():
raise ValueError(f"invalid card code: {code!r}")
return Card(rank=int(code[:2]), suit=code[2])
def to_json(self) -> str:
return self.code
@staticmethod
def from_json(value: Any) -> "Card":
return Card.parse(str(value))
def parse_card(code: Any) -> Card:
"""Parse a card code, raising :class:`ValueError` on malformed input."""
return Card.parse(str(code))
@dataclass
class TrickCard:
"""One card played in the current trick, in play order."""
seat: int
card: str
def to_json(self) -> Dict[str, Any]:
return {"seat": self.seat, "card": self.card}
@staticmethod
def from_json(data: Dict[str, Any]) -> "TrickCard":
return TrickCard(seat=int(data["seat"]), card=str(data["card"]))
@dataclass
class Trick:
"""A completed trick: the winner's seat and the cards it captured."""
winner: int
cards: List[TrickCard] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"winner": self.winner,
"cards": [c.to_json() for c in self.cards],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Trick":
return Trick(
winner=int(data["winner"]),
cards=[TrickCard.from_json(c) for c in data.get("cards", [])],
)
@dataclass
class PlayerState:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
@property
def team(self) -> int:
return team_of(self.seat)
def to_json(self) -> Dict[str, Any]:
return {
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": [c.to_json() for c in self.hand],
"captured": [c.to_json() for c in self.captured],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "PlayerState":
return PlayerState(
sub=str(data["sub"]),
name=str(data["name"]),
seat=int(data["seat"]),
hand=[Card.from_json(c) for c in data.get("hand", [])],
captured=[Card.from_json(c) for c in data.get("captured", [])],
)
@dataclass
class BriscolaState:
player_count: int = 2
target_score: int = DEFAULT_TARGET_SCORE
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
# Face-down draw pile; cards are drawn from the end.
stock: List[Card] = field(default_factory=list)
# The face-up card turned at deal time: its suit is the trump suit for
# the whole deal, and it is the last card drawn from the stock.
briscola_card: Optional[Card] = None
briscola_suit: str = ""
dealer: int = 0
turn: int = 0
# 1-based deal counter, used in the audit trail and deadlines.
hand_number: int = 1
# Cards of the trick in progress, in play order.
trick: List[TrickCard] = field(default_factory=list)
trick_leader: int = 0
# The most recently completed trick, for the table announcement.
last_trick: Optional[Trick] = None
# Match points accumulated across deals (patta deals do not count).
scores: List[int] = field(default_factory=lambda: [0, 0])
winner: Optional[int] = None
# Per-deal points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the deal summary waits before dealing anyway.
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS
# While phase == "playing": when the server plays a random card for
# the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"player_count": self.player_count,
"target_score": self.target_score,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"stock": [c.to_json() for c in self.stock],
"briscola_card": (
self.briscola_card.to_json() if self.briscola_card else None
),
"briscola_suit": self.briscola_suit,
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"trick": [t.to_json() for t in self.trick],
"trick_leader": self.trick_leader,
"last_trick": self.last_trick.to_json() if self.last_trick else None,
"scores": list(self.scores),
"winner": self.winner,
"hand_scores": list(self.hand_scores),
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "BriscolaState":
return BriscolaState(
player_count=int(data.get("player_count", 2)),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
stock=[Card.from_json(c) for c in data.get("stock", [])],
briscola_card=(
Card.from_json(data["briscola_card"])
if data.get("briscola_card")
else None
),
briscola_suit=str(data.get("briscola_suit", "")),
dealer=int(data.get("dealer", 0)),
turn=int(data.get("turn", 0)),
hand_number=int(data.get("hand_number", 1)),
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
trick_leader=int(data.get("trick_leader", 0)),
last_trick=(
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
),
scores=[int(x) for x in data.get("scores", [0, 0])],
winner=data.get("winner"),
hand_scores=list(data.get("hand_scores", [])),
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(
data.get("hand_ack_timeout", DEFAULT_HAND_ACK_TIMEOUT_SECONDS)
),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
return None
def seated(self, sub: str) -> bool:
return self.player_for(sub) is not None
@@ -1,691 +0,0 @@
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import engine
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import (
HAND_SIZE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
Card,
PlayerState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
*,
briscola_suit: str = "S",
briscola_card: str | None = None,
stock=None,
captured=None,
turn: int = 0,
dealer: int = 0,
target: int = 61,
scores=None,
phase: str = PHASE_PLAYING,
) -> BriscolaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = BriscolaState(
player_count=len(hands),
target_score=target,
phase=phase,
dealer=dealer,
turn=turn,
briscola_suit=briscola_suit,
briscola_card=card(briscola_card) if briscola_card else None,
stock=[card(c) for c in (stock or [])],
scores=list(scores or [0, 0]),
)
for seat, hand in enumerate(hands):
state.players.append(
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand])
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
return state
def trick_cards(state: BriscolaState) -> list[str]:
return [t.card for t in state.trick]
def play_all(state: BriscolaState) -> None:
"""Play every card of the current trick in turn order."""
for _ in range(state.player_count):
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
class DeckTest(unittest.TestCase):
def test_full_deck_has_40_unique_cards(self) -> None:
deck = engine.full_deck()
self.assertEqual(40, len(deck))
self.assertEqual(40, len({c.code for c in deck}))
self.assertEqual(4, len({c.suit for c in deck}))
def test_shuffled_deck_is_permutation(self) -> None:
deck = engine.shuffled_deck()
self.assertEqual(
sorted(c.code for c in engine.full_deck()),
sorted(c.code for c in deck),
)
def test_card_points_add_up_to_120(self) -> None:
self.assertEqual(
120, sum(engine.card_points(c) for c in engine.full_deck())
)
self.assertEqual(11, engine.card_points(card("01D")))
self.assertEqual(10, engine.card_points(card("03C")))
self.assertEqual(4, engine.card_points(card("10S")))
self.assertEqual(3, engine.card_points(card("09B")))
self.assertEqual(2, engine.card_points(card("08D")))
self.assertEqual(0, engine.card_points(card("07C")))
class DealTest(unittest.TestCase):
def test_two_player_deal(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
# 40 - 3*2 - 1 face-up = 33 face-down.
self.assertEqual(33, len(state.stock))
self.assertIsNotNone(state.briscola_card)
assert state.briscola_card is not None
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
self.assertEqual(1, state.turn) # dealer is seat 0
self.assertEqual([], state.trick)
def test_four_player_deal(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
self.assertEqual(1, state.turn)
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
dealt = [c.code for p in state.players for c in p.hand]
assert state.briscola_card is not None
dealt.append(state.briscola_card.code)
dealt.extend(c.code for c in state.stock)
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
def test_unsupported_player_count(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=3)
def test_target_score_bounds(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=60)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=121)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=True)
state = engine.create_game("p0", "p0", target_score=101)
self.assertEqual(101, state.target_score)
class TrickTest(unittest.TestCase):
def test_higher_card_of_led_suit_wins(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
self.assertEqual(1, state.last_trick.winner)
self.assertEqual(["02D", "05D"],
[c.code for c in state.players[1].captured])
def test_trump_beats_a_higher_led_card(self) -> None:
state = make_state([["07D"], ["02C"]], briscola_suit="C")
engine.play(state, "p0", "07D")
engine.play(state, "p1", "02C")
self.assertEqual(1, state.last_trick.winner)
def test_higher_trump_wins_among_trumps(self) -> None:
state = make_state([["02C"], ["05C"]], briscola_suit="C")
engine.play(state, "p0", "02C")
engine.play(state, "p1", "05C")
self.assertEqual(1, state.last_trick.winner)
def test_off_suit_non_trump_loses(self) -> None:
state = make_state([["04D"], ["01C"]], briscola_suit="B")
engine.play(state, "p0", "04D")
engine.play(state, "p1", "01C")
self.assertEqual(0, state.last_trick.winner)
def test_rank_strength_order(self) -> None:
# (winner card, loser card) pairs of the led suit, strongest first:
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
pairs = [
("01D", "03D"), # ace beats three
("03D", "10D"), # three beats king
("10D", "09D"), # king beats horse
("09D", "08D"), # horse beats jack
("08D", "07D"), # jack beats seven
("07D", "06D"),
("06D", "05D"),
("05D", "04D"),
("04D", "02D"),
]
for winner, loser in pairs:
state = make_state([["02D"], ["05D"]], briscola_suit="B")
state.players[0].hand = [card(winner)]
state.players[1].hand = [card(loser)]
engine.play(state, "p0", winner)
engine.play(state, "p1", loser)
self.assertEqual(
0, state.last_trick.winner, f"{winner} should beat {loser}"
)
def test_four_player_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.last_trick.winner)
def test_four_player_trump_beats_all(self) -> None:
state = make_state(
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(1, state.last_trick.winner)
def test_winner_leads_the_next_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.turn)
self.assertEqual(3, state.trick_leader)
self.assertEqual([], state.trick)
def test_turn_advances_within_a_trick(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
self.assertEqual(["02D"], trick_cards(state))
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_and_hand_end_reject_plays(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
class DrawTest(unittest.TestCase):
def test_winner_draws_first(self) -> None:
# The stock is drawn from its end: 04B first, then 03B, ...
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
self.assertEqual(["09C", "03B"], hands[0])
self.assertEqual(["09B", "04B"], hands[1])
def test_face_up_briscola_is_drawn_last(self) -> None:
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="C",
briscola_card="05C",
stock=["03B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
# The winner draws first and takes the last face-down card; the
# other player then takes the face-up briscola.
self.assertEqual(["09B", "03B"], hands[1])
self.assertEqual(["09C", "05C"], hands[0])
self.assertIsNone(state.briscola_card)
def test_no_draw_when_stock_is_empty(self) -> None:
state = make_state(
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
# p1 wins the trick; with no stock nobody draws and the hands
# shrink for the rest of the deal.
for player in state.players:
self.assertEqual(1, len(player.hand))
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
while state.stock or state.briscola_card is not None:
play_all(state)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(0, len(state.stock))
self.assertIsNone(state.briscola_card)
class ScoringTest(unittest.TestCase):
def test_deal_points_group_by_team(self) -> None:
state = make_state(
[[], [], [], []],
briscola_suit="S",
captured=[
["01D", "07C"], # seat 0, team A: 11
["03C", "08D"], # seat 1, team B: 10
["10S"], # seat 2, team A: 4
["09B"], # seat 3, team B: 3
],
)
points, details = engine.deal_points(state)
self.assertEqual([15, 15], points)
self.assertEqual({"A": 15, "B": 15}, details["points"])
self.assertEqual({"A": 3, "B": 3}, details["cards"])
def test_full_deal_scores_120(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
while state.phase == PHASE_PLAYING:
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
points, _ = engine.deal_points(state)
self.assertEqual(120, sum(points))
def test_match_ends_when_target_reached(self) -> None:
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], ["10C"]],
scores=[60, 50],
target=61,
)
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
state.players[0].hand = [card("05D")]
state.players[1].hand = [card("02D")]
engine.play(state, "p0", "05D")
engine.play(state, "p1", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertEqual([71, 54], state.scores)
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
# 120 and tie, so another deal must be played.
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
f"{rank:02d}S" for rank in range(1, 11)
] + [f"{rank:02d}B" for rank in range(1, 11)]
state = make_state(
[[], []],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[65, 55],
target=120,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual([120, 120], state.scores)
self.assertIsNone(state.winner)
def test_patta_is_discarded_and_replayed(self) -> None:
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
f"{rank:02d}C" for rank in range(1, 11)
]
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
f"{rank:02d}B" for rank in range(1, 11)
]
state = make_state(
[["02D"], ["02S"]],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[10, 20],
target=61,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
# The 60-60 deal does not count: totals are unchanged.
self.assertEqual([10, 20], state.scores)
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
# The next deal is played at the same cumulative totals.
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual([10, 20], state.scores)
def test_audit_trail_records_each_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
scores=[0, 0],
)
engine._end_deal(state)
entry = state.hand_scores[-1]
self.assertEqual(1, entry["deal"])
self.assertFalse(entry["patta"])
self.assertEqual({"A": 11, "B": 0}, entry["points"])
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 3):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual("lobby", state.phase)
engine.join_game(state, "p3", "p3")
self.assertEqual(PHASE_PLAYING, state.phase)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_full_random_two_player_match(self) -> None:
self._random_match(player_count=2)
def test_full_random_four_player_match(self) -> None:
self._random_match(player_count=4)
def _random_match(self, player_count: int) -> None:
state = engine.create_game("p0", "p0", player_count=player_count)
for i in range(1, player_count):
engine.join_game(state, f"p{i}", f"p{i}")
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, rng.choice(player.hand).code)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
self.assertEqual([], state.trick)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
# Every counted deal totals 120 points, and the winner reached the
# target with a clear lead.
winner_score = state.scores[state.winner]
loser_score = state.scores[1 - state.winner]
self.assertGreaterEqual(winner_score, state.target_score)
self.assertGreater(winner_score, loser_score)
self.assertEqual(0, (winner_score + loser_score) % 120)
class AckTest(unittest.TestCase):
def _hand_end_state(self) -> BriscolaState:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
return state
def test_ack_all_deals_the_next_deal(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
self.assertEqual(PHASE_HAND_END, state.phase)
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual((dealer_before + 1) % 2, state.dealer)
self.assertEqual((dealer_before + 2) % 2, state.turn)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual([], player.captured)
def test_double_ack_is_idempotent(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
def test_ack_outside_hand_end_is_rejected(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_ack_by_non_player_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(NotYourTurn):
engine.acknowledge_hand(state, "mallory")
class AutoPlayTest(unittest.TestCase):
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"]],
briscola_suit="S",
stock=["01B", "02B"],
briscola_card="03B",
)
engine.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.players[0].hand))
self.assertEqual(1, len(state.trick))
def test_auto_play_can_end_the_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
# Both players are on their last card: two auto-plays complete the
# trick and the deal, scored A=11 / B=0.
engine.auto_play(state)
engine.auto_play(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.turn_deadline)
self.assertTrue(state.hand_end_deadline)
def test_auto_play_requires_playing_phase(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(GameNotStarted):
engine.auto_play(state)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
briscola_suit="D",
briscola_card="07D",
stock=["01B", "02B"],
)
view = engine.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(["02D", "03C"], players[0]["hand"])
self.assertNotIn("hand", players[1])
self.assertEqual(1, players[1]["cards_left"])
self.assertEqual("D", view["briscola_suit"])
self.assertEqual("07D", view["briscola_card"])
self.assertEqual(2, view["stock_count"])
self.assertTrue(view.get("your_turn"))
def test_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
briscola_card="07D",
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual("07D", view["briscola_card"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
captured=[["01C", "03B"], ["10D"]],
)
view = engine.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(21, players[0]["points"])
self.assertEqual(4, players[1]["points"])
self.assertEqual(2, players[0]["captured_count"])
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
view = engine.state_for_player(state, "p0")
self.assertEqual(PHASE_HAND_END, view["phase"])
self.assertEqual(
{
"deal": 1,
"patta": False,
"points": {"A": 11, "B": 0},
"cards": {"A": 1, "B": 0},
"totals": {"A": 11, "B": 0},
},
view["last_deal"],
)
self.assertTrue(view["hand_end_deadline"])
def test_trick_is_public_in_play_order(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
view = engine.state_for_player(state, None)
self.assertEqual(
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
view["trick"],
)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
restored = BriscolaState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.target_score, restored.target_score)
self.assertEqual(state.briscola_suit, restored.briscola_suit)
self.assertEqual(
state.briscola_card.code if state.briscola_card else None,
restored.briscola_card.code if restored.briscola_card else None,
)
self.assertEqual(
[c.code for c in state.stock], [c.code for c in restored.stock]
)
self.assertEqual(
[(t.seat, t.card) for t in state.trick],
[(t.seat, t.card) for t in restored.trick],
)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
self.assertEqual(
[[c.code for c in p.hand] for p in state.players],
[[c.code for c in p.hand] for p in restored.players],
)
def test_legacy_state_defaults(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
data = state.to_json()
del data["player_count"]
del data["briscola_card"]
del data["last_trick"]
restored = BriscolaState.from_json(data)
self.assertEqual(2, restored.player_count)
self.assertIsNone(restored.briscola_card)
self.assertIsNone(restored.last_trick)
if __name__ == "__main__":
unittest.main()
@@ -1,349 +0,0 @@
"""Adapter tests: the platform contract over the pure briscola rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotStarted,
IllegalMove,
)
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.briscola import engine as rules
from tavolo.briscola.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="b1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for i in range(1, engine.max_players):
name = f"p{i}"
engine.join(session, name, name.capitalize())
return session
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
moves = 0
while not engine.is_finished(session) and moves < limit:
state = session.state
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
class MetadataTest(unittest.TestCase):
def test_two_player_metadata(self) -> None:
engine = BriscolaEngine()
self.assertEqual("briscola", engine.id)
self.assertEqual("Briscola", engine.name)
self.assertTrue(engine.description)
self.assertEqual(2, engine.min_players)
self.assertEqual(2, engine.max_players)
target = engine.options_schema["properties"]["target_score"]
self.assertEqual("integer", target["type"])
self.assertEqual(61, target["minimum"])
self.assertEqual(120, target["maximum"])
self.assertEqual(61, target["default"])
def test_four_player_metadata(self) -> None:
engine = BriscolaQuattroEngine()
self.assertEqual("briscola_a_quattro", engine.id)
self.assertEqual("Briscola a quattro", engine.name)
self.assertTrue(engine.description)
self.assertEqual(4, engine.min_players)
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_team_a(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, BriscolaState)
self.assertEqual(61, session.state.target_score)
self.assertEqual(2, session.state.player_count)
def test_create_options(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine, target_score=101)
self.assertEqual(101, session.state.target_score)
self.assertEqual(4, session.state.player_count)
def test_create_rejects_bad_options(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=60)
with self.assertRaises(IllegalMove):
_session(engine, target_score=121)
with self.assertRaises(IllegalMove):
_session(engine, target_score="sixtyone")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
def test_timeouts_come_from_the_engine(self) -> None:
engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_two_player_join_starts(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertEqual("B", session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_four_player_teams_alternate(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine)
teams = []
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
teams.append(session.players[-1].team)
self.assertEqual(["B", "A", "B"], teams)
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
engine.join(session, "bob", "Bob")
with self.assertRaises(GameNotStarted):
engine.join(session, "carol", "Carol")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": "nope"})
def test_play_advances_the_trick(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
self.assertEqual(1, state.turn)
card = state.players[1].hand[0].code
engine.handle_action(session, "p1", "play", {"card": card})
self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"])
def test_play_out_of_turn_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
card = state.players[0].hand[0].code
with self.assertRaises(GameError):
engine.handle_action(session, "alice", "play", {"card": card})
def test_ack_outside_hand_end_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "ack", {})
def test_finished_match_rejects_actions(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
def test_ack_all_deals_next(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
# Force the deal-end phase: both players keep 3 cards, so the
# match continues after the summary.
state.phase = PHASE_HAND_END
state.acked = []
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
engine.handle_action(session, "alice", "ack", {})
self.assertEqual(PHASE_HAND_END, state.phase)
engine.handle_action(session, "p1", "ack", {})
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
class ViewTest(unittest.TestCase):
def test_view_for_hides_other_hands(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
self.assertIn("briscola_suit", view)
self.assertIn("stock_count", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = BriscolaEngine()
session = _session(engine, target_score=101)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(101, lobby["target_score"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, BriscolaState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = BriscolaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code})
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(4, len(result.players))
winner_name = "A" if result.winner_team == 0 else "B"
for player in result.players:
self.assertEqual(player.won, player.team == winner_name)
self.assertIn("captured_cards", player.details)
self.assertIn("team_a_score", result.summary)
self.assertIn("team_b_score", result.summary)
self.assertIn("winner_team", result.summary)
self.assertIn("target_score", result.summary)
self.assertIn("deals_played", result.summary)
self.assertIn("deal_scores", result.summary)
def test_two_player_result(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams)
self.assertEqual(2, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [55, 65]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 55, "B": 65}, over["scores"])
self.assertEqual("B", over["winner"])
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -48,8 +48,8 @@ itself ships no game.
Implement `GameEngine` (see `engine.py` for the full contract), register
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
`KayaApp` — see the composition root in `tavolo.app`. The canonical
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md).
example is
[`tavolo-scopone`](../tavolo-scopone/README.md).
## Development (from `server/`)
@@ -47,13 +47,8 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
def display_name(user: OIDCUser) -> str:
"""Best-effort human-readable name for a user.
``preferred_username`` is preferred: it is the account's stable handle,
while ``name`` is a free-form display name a user may change (and two
accounts can share). ``email`` is a last resort before the ``sub``.
"""
for key in ("preferred_username", "name", "email"):
"""Best-effort human-readable name for a user."""
for key in ("name", "preferred_username", "email"):
value = user.get(key)
if isinstance(value, str) and value:
return value
@@ -191,17 +191,12 @@ class GameEngine(ABC):
"""
@abstractmethod
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
"""The personalized game view for one viewer.
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""The personalized game view for one player.
Must hide information the viewer is not entitled to (e.g. other
players' hands). ``user_sub`` is ``None`` for a spectator — an
anonymous user or an authenticated user who is not seated — and
the view must then contain no privileged information either (no
hand, no legal moves: only what everyone at the table can see).
The platform merges in the envelope fields (``id``,
``join_code`` for seated players, ``game_type``, ``spectator``)
itself.
Must hide information the player is not entitled to (e.g. other
players' hands). The platform merges in the envelope fields
(``id``, ``join_code``, ``game_type``) itself.
"""
@abstractmethod
@@ -253,13 +248,6 @@ class GameEngine(ABC):
``kind``/``token`` no longer match the live state.
"""
def game_over_view(
self, session: GameSession, user_sub: Optional[str]
) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message.
``user_sub`` is ``None`` for a spectator (see :meth:`view_for`);
the default fields are public, so the default implementation
ignores it.
"""
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message."""
return {}
@@ -16,7 +16,7 @@ import secrets
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
@@ -71,29 +71,19 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
}
for index, seat in enumerate(session.players)
],
"seats_open": max(0, engine.max_players - len(session.players)),
"seats_open": engine.max_players - len(session.players),
**engine.lobby_view(session),
}
def _view_payload(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
return {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
**engine.view_for(session, sub),
}
if seated:
# The join code is a credential: only players get it.
payload["join_code"] = session.join_code
payload.update(engine.view_for(session, sub))
return payload
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
@@ -108,34 +98,6 @@ def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, An
}
def _ongoing_game_payload(
platform: "Platform", session: GameSession, sub: str
) -> Dict[str, Any]:
"""One entry of the caller's ongoing-games list.
Reuses the lobby payload (players, open seats, option echo) but drops
the join code — the Continue link only needs the id, and the game page
shows the code to its players anyway.
"""
engine = platform.registry.require(session.game_type)
payload = _lobby_payload(platform, session)
payload.pop("join_code", None)
payload["game_name"] = engine.name
payload["game_description"] = engine.description
payload["your_seat"] = next(
(
index
for index, seat in enumerate(session.players)
if seat.user_sub == sub
),
None,
)
payload["created_at"] = (
session.created_at.isoformat() if session.created_at else None
)
return payload
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/game-types")
@operation(summary="List available games",
@@ -348,48 +310,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Anyone who knows the game id may read a game. "
"Seated players get the personalized view (their "
"own hand plus the join code); everyone else — "
"anonymous users and authenticated non-players — "
"gets the read-only spectator view: no hands, no "
"join code, just what is public at the table.",
description="Only seated players may read a game; the view is "
"personalized by the engine (e.g. hidden hands).",
tags=["games"],
responses={
200: {"description": "The personalized (player) or "
"read-only (spectator) game state"},
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
404: {"description": "Game not found"},
})
@require_auth(platform.oidc)
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
if session is None:
await send_error(ctx, 404, "game not found")
return
user = current_user(platform.oidc, ctx)
await send_json(
ctx, 200, _view_payload(platform, session, user.sub if user else None)
)
@app.GET("/api/me/games")
@operation(summary="List my ongoing games",
description="Live games the caller is seated in — waiting "
"lobbies and matches in progress or between "
"hands — newest first, for the lobby's Continue "
"list. Finished matches are not included.",
tags=["games"],
responses={
200: {"description": "The caller's ongoing games"},
401: {"description": "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)
]
})
assert user is not None
if not session.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@@ -41,7 +41,7 @@ import contextlib
import json
import time
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from datetime import datetime
from logging import getLogger
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
@@ -72,15 +72,6 @@ def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
return value.isoformat() if value is not None else None
def _created_at_key(session: GameSession) -> datetime:
"""Sort key for :meth:`GameStore.list_ongoing` (newest first).
Sessions without a creation timestamp sort last; the epoch sentinel is
timezone-aware so it never mixes with the stored UTC datetimes.
"""
return session.created_at or datetime.min.replace(tzinfo=timezone.utc)
def _dt_from_json(value: Any) -> Optional[datetime]:
if not value:
return None
@@ -146,15 +137,6 @@ class GameStore(ABC):
still retained for the final-board view do not.
"""
@abstractmethod
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
"""Return up to ``limit`` live games that have not finished yet.
Newest ``created_at`` first. Sessions whose key has expired are
skipped, and so are matches that are over but still retained for
the final-board view.
"""
@abstractmethod
def lock(self, game_id: str) -> AsyncContextManager[None]:
"""Async context manager serializing mutations of one game."""
@@ -259,27 +241,6 @@ class RedisGameStore(GameStore):
_, count = await pipe.execute()
return int(count)
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
await self._redis.zremrangebyscore(
ONGOING_GAMES_KEY, "-inf", time.time()
)
members = await self._redis.zrange(ONGOING_GAMES_KEY, 0, -1)
sessions: List[GameSession] = []
for member in members:
game_id = (
member.decode("utf-8") if isinstance(member, bytes) else str(member)
)
session = await self.load(game_id)
if session is None:
# The game key expired but the ongoing entry had not been
# pruned yet: skip it.
continue
if self._registry.require(session.game_type).is_finished(session):
continue
sessions.append(session)
sessions.sort(key=_created_at_key, reverse=True)
return sessions[:limit]
@contextlib.asynccontextmanager
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
pubsub = self._redis.pubsub()
@@ -391,18 +352,6 @@ class InMemoryGameStore(GameStore):
async def count_ongoing(self) -> int:
return len(self._ongoing)
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
sessions: List[GameSession] = []
for game_id in list(self._ongoing):
session = await self.load(game_id)
if session is None:
continue
if self._registry.require(session.game_type).is_finished(session):
continue
sessions.append(session)
sessions.sort(key=_created_at_key, reverse=True)
return sessions[:limit]
@contextlib.asynccontextmanager
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
queue: asyncio.Queue = asyncio.Queue()
@@ -2,21 +2,12 @@
Clients connect to ``/ws/games/{game_id}`` using their session cookie
(the OIDC login stores the user in the session, which the session mixin
loads onto the websocket). Anyone who knows the game id is accepted:
seated players get their personalized view, everyone else — anonymous
users and authenticated users who are not seated — is a read-only
**spectator** receiving the view without privileged information (no
hands, no legal moves). The game view carries ``spectator: true`` for
them and never carries the join code. Spectator actions other than
``state``/``sync`` are rejected with a ``forbidden`` error; seating is
re-checked under the game lock so a viewer who joins mid-connection
immediately gains (and, if they somehow left, would lose) the right to
act.
loads onto the websocket). Only seated players are accepted.
The platform owns the connection lifecycle — authentication, the
subscription/publish fan-out and the message envelope — and delegates
the game-specific actions to the engine registered for the session's
``game_type``.
The platform owns the connection lifecycle — authentication, seat
check, the subscription/publish fan-out and the message envelope — and
delegates the game-specific actions to the engine registered for the
session's ``game_type``.
Protocol
--------
@@ -36,10 +27,9 @@ Client -> server messages are JSON objects::
For scopone scientifico the game actions are ``play`` (with ``card``
and optionally ``capture``) and ``ack`` — see
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
message (minus ``action``) is handed to the engine as the payload, games
define their own fields freely.
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
``action``) is handed to the engine as the payload, games define their
own fields freely.
Mutations run under the per-game lock; after a successful action the new
session is saved and a change signal is published. Every connected
@@ -58,7 +48,7 @@ import asyncio
import json
from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
@@ -93,24 +83,17 @@ async def _reject(ws: WebSocket, status: int) -> None:
await ws.close(status)
def _state_message(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
return {
"type": "state",
"game": {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
},
}
if seated:
# The join code is a credential: only players get it.
payload["join_code"] = session.join_code
payload.update(engine.view_for(session, sub))
return {"type": "state", "game": payload}
def register(app: KayaApp, platform: "Platform") -> None:
@@ -118,19 +101,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
# Anonymous and non-seated viewers are welcome as read-only
# spectators; only an unknown game id is refused.
user = auth.get_ws_user(ws)
sub: Optional[str] = user.sub if user is not None else None
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await _reject(ws, 401)
return
session = await platform.game_store.load(game_id)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await _reject(ws, 404)
return
if not session.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await _reject(ws, 403)
return
await ws.accept()
log.info("%s connected to game %s", sub or "spectator", game_id)
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock()
@@ -144,7 +132,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
# ends momentarily.
pass
await send(_state_message(platform, session, sub))
await send(_state_message(platform, session, user.sub))
# Backstop: make sure the current deadline is queued even if its
# entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
@@ -152,7 +140,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
async with platform.game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(platform, events, game_id, sub, send)
_forward(platform, events, game_id, user.sub, send)
)
try:
async for message in ws:
@@ -161,19 +149,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(platform, send, game_id, sub, message.data)
await _handle_message(platform, send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", sub or "spectator", game_id)
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward(
platform: "Platform",
events,
game_id: str,
sub: Optional[str],
sub: str,
send: Send,
) -> None:
async for _ in events:
@@ -193,7 +181,7 @@ async def _forward(
async def _handle_message(
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
) -> None:
try:
data = json.loads(raw)
@@ -222,7 +210,7 @@ async def _handle_action(
platform: "Platform",
send: Send,
game_id: str,
sub: Optional[str],
sub: str,
action: str,
data: Dict[str, Any],
) -> None:
@@ -231,13 +219,6 @@ async def _handle_action(
if session is None:
await send(_error("game not found", code="not_found"))
return
# Re-check seating under the lock: an anonymous spectator stays a
# spectator forever, while a logged-in viewer who joins the game
# mid-connection becomes a player and may act from then on.
if sub is None or not session.seated(sub):
log.debug("game %s: rejected %r from a spectator", game_id, action)
await send(_error("spectators cannot act on the game", code="forbidden"))
return
engine = platform.registry.require(session.game_type)
payload = {key: value for key, value in data.items() if key != "action"}
try:
@@ -138,7 +138,7 @@ class DummyEngine(GameEngine):
state["finished"] = True
state["winner"] = user_sub
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
state = session.state
return {
"started": state["started"],
@@ -146,7 +146,7 @@ class DummyEngine(GameEngine):
"winner": state["winner"],
"plays": len(state["plays"]),
"target": state["target"],
"viewer_seated": user_sub is not None and session.seated(user_sub),
"viewer_seated": session.seated(user_sub),
}
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
@@ -11,17 +11,11 @@ import unittest
from types import SimpleNamespace
from typing import Any
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.session import InMemorySessionStore, Session, SessionMixin
from helpers import async_test
from tavolo.platform.auth import (
current_user,
display_name,
get_ws_user,
require_auth,
session_user,
)
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
def _providers(*names: str) -> OIDCProviders:
@@ -93,32 +87,6 @@ class GetWsUserTest(unittest.TestCase):
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_falls_back_to_email(self) -> None:
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
self.assertEqual("alice@example.com", 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 CurrentUserTest(unittest.TestCase):
def test_anonymous_session_has_no_user(self) -> None:
self.assertIsNone(current_user(_providers("default"), _ctx({})))
@@ -44,24 +44,10 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(game_id, snap["id"])
self.assertEqual("dummy", snap["game_type"])
self.assertTrue(snap["viewer_seated"])
self.assertFalse(snap["spectator"])
self.assertIn("join_code", snap)
# A logged-in non-player is a read-only spectator: the view
# marks it and the join code is withheld.
with oidc_user(platform.oidc, "mallory"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertFalse(spect["viewer_seated"])
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
@async_test
async def test_join_starts_game(self) -> None:
@@ -188,63 +174,6 @@ class GamesRouteTest(unittest.TestCase):
response = await client.get("/api/games/does-not-exist")
self.assertEqual(404, response.status_code)
@async_test
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
anonymous = await client.get("/api/me/games")
self.assertEqual(401, anonymous.status_code)
with oidc_user(platform.oidc, "alice"):
mine = await client.post(
"/api/games", json={"options": {"target": 5}}
)
with oidc_user(platform.oidc, "bob"):
theirs = await client.post("/api/games", json={})
self.assertEqual(201, mine.status_code)
self.assertEqual(201, theirs.status_code)
with oidc_user(platform.oidc, "alice"):
listed = await client.get("/api/me/games")
self.assertEqual(200, listed.status_code)
results = listed.json()["results"]
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
entry = results[0]
self.assertEqual("dummy", entry["game_type"])
self.assertEqual("Dummy game", entry["game_name"])
self.assertTrue(entry["game_description"])
self.assertEqual("lobby", entry["phase"])
self.assertEqual(5, entry["target"])
self.assertEqual(1, entry["seats_open"])
self.assertEqual(0, entry["your_seat"])
self.assertNotIn("join_code", entry)
self.assertEqual("alice", entry["players"][0]["sub"])
# Bob's own game is the only one he sees; Alice's is not his.
with oidc_user(platform.oidc, "bob"):
bobs = await client.get("/api/me/games")
self.assertEqual(
[theirs.json()["id"]],
[g["id"] for g in bobs.json()["results"]],
)
# Once Bob joins Alice's game it appears for both, now in the
# playing phase.
with oidc_user(platform.oidc, "bob"):
await client.post(
"/api/games/join", json={"code": mine.json()["join_code"]}
)
with oidc_user(platform.oidc, "bob"):
bobs = await client.get("/api/me/games")
by_id = {g["id"]: g for g in bobs.json()["results"]}
self.assertEqual(
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
)
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
class GameTypesRouteTest(unittest.TestCase):
@async_test
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import time
import unittest
from datetime import datetime, timezone
from typing import Any, Optional, cast
from tavolo.platform import GameSession, Seat
@@ -49,7 +48,7 @@ class _FakePipeline:
return False
def set(self, key: str, value: str, ex: object = None) -> None:
self._ops.append(("set", key, value))
self._ops.append(("set", key))
def zadd(self, key: str, mapping: dict) -> None:
self._ops.append(("zadd", key, mapping))
@@ -89,14 +88,6 @@ class _FakeRedis:
async def zrem(self, key: str, member: str) -> int:
return cast(int, self.apply(("zrem", key, member)))
async def get(self, key: str) -> object:
return self.keys.get(key)
async def zremrangebyscore(
self, key: str, minimum: object, maximum: object
) -> int:
return cast(int, self.apply(("zremrangebyscore", key)))
async def zrange(
self,
name: str,
@@ -122,7 +113,7 @@ class _FakeRedis:
def apply(self, op: tuple) -> object:
name, *args = op
if name == "set":
self.keys[args[0]] = args[1]
self.keys[args[0]] = "payload"
return True
if name == "zadd":
self.zsets.setdefault(args[0], {}).update(args[1])
@@ -256,28 +247,6 @@ class InMemoryGameStoreTest(unittest.TestCase):
await store.save(other)
self.assertEqual(0, await store.count_ongoing())
@async_test
async def test_list_ongoing_newest_first_and_unfinished_only(self) -> None:
store = InMemoryGameStore(_registry())
old = _session("old", "CODE01")
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
new = _session("new", "CODE02")
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
await store.save(old)
await store.save(new)
self.assertEqual(
["new", "old"], [s.id for s in await store.list_ongoing()]
)
self.assertEqual(
["new"], [s.id for s in await store.list_ongoing(limit=1)]
)
# A finished match retained for the final board is not listed.
new.state["finished"] = True
await store.save(new)
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
@async_test
async def test_deadline_queue(self) -> None:
store = InMemoryGameStore(_registry())
@@ -342,33 +311,6 @@ class RedisGameStoreTest(unittest.TestCase):
self.assertEqual(1, await store.count_ongoing())
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
@async_test
async def test_list_ongoing_loads_unfinished_sessions(self) -> None:
redis = _FakeRedis()
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
old = _session("old", "CODE01")
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
new = _session("new", "CODE02")
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
await store.save(old)
await store.save(new)
self.assertEqual(
["new", "old"], [s.id for s in await store.list_ongoing()]
)
self.assertEqual(
["new"], [s.id for s in await store.list_ongoing(limit=1)]
)
# A finished match retained for the final board is not listed.
new.state["finished"] = True
await store.save(new)
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
# An ongoing entry whose game key expired is skipped.
redis.zsets[ONGOING_GAMES_KEY]["gone"] = time.time() + 100
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
@async_test
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
redis = _FakeRedis()
@@ -11,6 +11,7 @@ from httpx_ws.transport import ASGIWebSocketTransport
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
from tavolo.platform import GameSession, Seat
class AbruptCloseSocket(WebSocket):
@@ -116,9 +117,6 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("dummy", first["game"]["game_type"])
self.assertEqual(0, first["game"]["plays"])
self.assertEqual(game_id, first["game"]["id"])
# Seated players get the join code; spectators don't.
self.assertIn("join_code", first["game"])
self.assertFalse(first["game"]["spectator"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
@@ -132,33 +130,6 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("state", update["type"])
self.assertEqual(1, update["game"]["plays"])
@async_test
async def test_state_reports_open_seats(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
game_id = created.json()["id"]
code = created.json()["join_code"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(1, first["game"]["seats_open"])
# Once the second player joins the match starts and no seat is
# left open.
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(0, first["game"]["seats_open"])
@async_test
async def test_unknown_action_returns_error(self) -> None:
app, platform, _ = make_platform()
@@ -266,7 +237,7 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_watches_as_spectator(self) -> None:
async def test_non_player_is_closed(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
@@ -276,38 +247,21 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertFalse(first["game"]["viewer_seated"])
self.assertNotIn("join_code", first["game"])
# Spectators may not act.
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(403, caught.exception.code)
@async_test
async def test_anonymous_user_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
async def test_unauthenticated_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(401, caught.exception.code)
class RejectionStatusTest(unittest.TestCase):
@@ -317,19 +271,33 @@ class RejectionStatusTest(unittest.TestCase):
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
close code like 4401 is not a valid status and panics the worker
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
dies). Drive the rejection through the app with a recording socket
so the invariant is checked without a real server. Spectators are
accepted, so the only pre-accept rejection left is an unknown game.
dies). Drive the rejections through the app with a recording socket
so the invariant is checked without a real server.
"""
@async_test
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
app, _, _ = make_platform()
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
ws = RecordingSocket("/ws/games/no-such-game")
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
app, platform, _ = make_platform()
session = GameSession(
id="seated-game",
game_type="dummy",
join_code="SEAT01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine = platform.registry.require("dummy")
engine.create(session, {})
await platform.game_store.save(session)
cases = [
("unauthenticated", [], "/ws/games/whatever", 401),
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
]
for label, users, path, expected in cases:
ws = RecordingSocket(path)
with ws_users(users):
await app.handle_websocket(ws)
self.assertEqual([404], ws.close_codes, label)
self.assertEqual([expected], ws.close_codes, label)
for code in ws.close_codes:
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
@@ -528,14 +528,13 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
return points, details
def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]:
def state_for_player(state: ScoponeState, sub: 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.
hand. Non-seated viewers simply see no hand at all. The platform
merges the session envelope (``id``, ``join_code``, ``game_type``)
into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
@@ -171,13 +171,13 @@ class ScoponeEngine(GameEngine):
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
def view_for(self, session: GameSession, user_sub: 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]:
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
state: ScoponeState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
@@ -239,7 +239,7 @@ class ScoponeState:
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
def player_for(self, sub: str) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
@@ -574,27 +574,6 @@ class MatchFlowTest(unittest.TestCase):
self.assertEqual(["07C"], view["table"])
self.assertTrue(view.get("your_turn"))
def test_state_for_player_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"],
captured=[["01D"], [], ["02C"], ["03B"]],
scope=[1, 0, 2, 0],
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
# The public table state is still fully visible.
self.assertEqual(["07C"], view["table"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(1, players[0]["captured_count"])
self.assertEqual(1, players[0]["scope"])
self.assertEqual(2, players[2]["scope"])
self.assertEqual(1, players[1]["cards_left"])
def test_legal_moves_only_for_player_on_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"])
@@ -160,17 +160,6 @@ class ViewTest(unittest.TestCase):
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16)
+4 -5
View File
@@ -4,11 +4,10 @@
#
# 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
# NOTE: the tavolo-platform, tavolo-scopone 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-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
-368
View File
@@ -1,368 +0,0 @@
"""Briscola integration tests: lobby, websocket play, timeouts and summaries.
The per-package suites cover the rules and the engine contract; these
tests wire both briscola 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.briscola.state import (
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
Card,
PlayerState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
class BriscolaRoutesTest(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": "briscola", "options": {"target_score": 101}},
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("briscola", body["game_type"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(1, body["seats_open"])
self.assertEqual(101, body["target_score"])
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(1, state["turn"])
self.assertEqual(33, state["stock_count"])
self.assertTrue(state["briscola_suit"])
self.assertIsNotNone(state["briscola_card"])
# 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": "briscola_a_quattro"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("briscola_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"]])
for participant in state["players"]:
self.assertEqual(3, participant["cards_left"])
self.assertEqual(40 - 12 - 1, state["stock_count"])
@async_test
async def test_preview_shows_briscola_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": "briscola", "options": {"target_score": 101}},
)
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", body["game_type"])
self.assertEqual("Briscola", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual(101, body["target_score"])
self.assertEqual(1, body["seats_open"])
@async_test
async def test_create_rejects_bad_target_score(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
for bad in (60, 121, "sixtyone", True):
with oidc_user("alice"):
response = await client.post(
"/api/games",
json={"game_type": "briscola", "options": {"target_score": bad}},
)
self.assertEqual(400, response.status_code, bad)
class BriscolaWebSocketTest(unittest.TestCase):
async def _started_two_player(self, client: AsyncClient) -> dict:
with oidc_user("alice"):
created = await client.post("/api/games", json={"game_type": "briscola"})
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_moves_resolve_the_trick(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"]
bob_card = state["players"][1]["hand"][0]
with oidc_user("alice"):
alice_view = await client.get(f"/api/games/{game_id}")
alice_card = alice_view.json()["players"][0]["hand"][0]
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(1, first["game"]["turn"])
self.assertTrue(first["game"].get("your_turn"))
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
await bob_ws.send_json(
{"action": "play", "card": bob_card}
)
bob_update = await bob_ws.receive_json()
alice_update = await alice_ws.receive_json()
for update in (bob_update, alice_update):
self.assertEqual("state", update["type"])
self.assertEqual(0, update["game"]["turn"])
self.assertEqual(
[{"seat": 1, "card": bob_card}],
update["game"]["trick"],
)
await alice_ws.send_json(
{"action": "play", "card": alice_card}
)
bob_update = await bob_ws.receive_json()
alice_update = await alice_ws.receive_json()
for update in (bob_update, alice_update):
self.assertEqual([], update["game"]["trick"])
last = update["game"]["last_trick"]
self.assertIsNotNone(last)
self.assertIn(last["winner"], (0, 1))
self.assertEqual(2, len(last["cards"]))
self.assertEqual(last["winner"], update["game"]["turn"])
@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"])
class BriscolaTurnTimeoutTest(unittest.TestCase):
@async_test
async def test_turn_timeout_auto_plays_a_card(self) -> None:
engine = platform.registry.require("briscola")
session = GameSession(
id="briscola-turn-timeout-1",
game_type=engine.id,
join_code="BT0001",
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_trick"] is not None
):
break
self.assertIsNotNone(update)
assert update is not None
# Both absent players were auto-played; the trick
# resolved and the winner leads with a full hand.
self.assertIn(update["game"]["last_trick"]["winner"], (0, 1))
self.assertEqual([], update["game"]["trick"])
for participant in update["game"]["players"]:
self.assertEqual(3, participant["cards_left"])
async def _seed_hand_end_state() -> str:
"""Seed a game where a single trick ends the deal with target 120 so
the match continues to the summary."""
engine = platform.registry.require("briscola")
session = GameSession(
id="briscola-hand-end-1",
game_type=engine.id,
join_code="BH0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
],
)
session.state = BriscolaState(
player_count=2,
target_score=120,
phase=PHASE_PLAYING,
dealer=0,
turn=0,
briscola_suit="S",
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
),
],
hand_ack_timeout=30,
)
await game_store.save(session)
return session.id
class BriscolaHandEndTest(unittest.TestCase):
@async_test
async def test_deal_end_ack_flow(self) -> None:
game_id = await _seed_hand_end_state()
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/{game_id}", ws_client)
)
for _ in range(2)
]
for ws in sockets:
await ws.receive_json() # initial state
# Alice leads, Bob takes the trick: the deal ends.
await sockets[0].send_json(
{"action": "play", "card": "02D"}
)
for ws in sockets:
await ws.receive_json()
await sockets[1].send_json(
{"action": "play", "card": "05D"}
)
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"])
deal = summary["game"]["last_deal"]
self.assertEqual({"A": 11, "B": 4}, deal["points"])
self.assertEqual({"A": 11, "B": 4}, deal["totals"])
self.assertFalse(deal["patta"])
# Both acknowledge; the second ack deals the next deal.
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"])
if __name__ == "__main__":
unittest.main()
+12 -100
View File
@@ -1,4 +1,4 @@
"""Game lobby route tests via kaya's ASGI transport (all registered games)."""
"""Game lobby route tests via kaya's ASGI transport (scopone game)."""
from __future__ import annotations
import unittest
@@ -39,19 +39,9 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(200, snapshot.status_code)
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
# A logged-in non-player is a read-only spectator: no join
# code, and the view is marked as a spectator view.
with oidc_user("mallory"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
self.assertTrue(watched.json()["spectator"])
self.assertNotIn("join_code", watched.json())
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
@async_test
async def test_join_fills_seats_and_starts_game(self) -> None:
@@ -71,7 +61,6 @@ class GamesRouteTest(unittest.TestCase):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
game_id = state["id"]
self.assertEqual("playing", state["phase"])
self.assertEqual(4, len(state["players"]))
self.assertEqual([], state["table"])
@@ -83,21 +72,6 @@ class GamesRouteTest(unittest.TestCase):
self.assertNotIn("hand", state["players"][0])
self.assertEqual(1, state["turn"])
# A spectator sees the table, the score and each player's
# captured count and scope — but no hand at all.
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
self.assertEqual([], spect["table"])
self.assertEqual({"A": 0, "B": 0}, spect["scores"])
for participant in spect["players"]:
self.assertNotIn("hand", participant)
self.assertEqual(10, participant["cards_left"])
self.assertEqual(0, participant["captured_count"])
self.assertEqual(0, participant["scope"])
@async_test
async def test_create_napola_option(self) -> None:
transport = ASGITransport(app=app)
@@ -236,52 +210,6 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(400, text.status_code)
self.assertEqual(400, huge.status_code)
@async_test
async def test_my_games_lists_my_ongoing_games(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "briscola"}
)
game_id = created.json()["id"]
with oidc_user("alice"):
listed = await client.get("/api/me/games")
self.assertEqual(200, listed.status_code)
# The app suite shares one in-memory store, so other tests'
# games may be listed too: pick ours by id.
by_id = {g["id"]: g for g in listed.json()["results"]}
self.assertIn(game_id, by_id)
entry = by_id[game_id]
self.assertEqual("briscola", entry["game_type"])
self.assertEqual("Briscola", entry["game_name"])
self.assertTrue(entry["game_description"])
self.assertEqual("lobby", entry["phase"])
self.assertEqual(1, entry["seats_open"])
self.assertEqual(0, entry["your_seat"])
self.assertNotIn("join_code", entry)
# A logged-in non-player does not see the game.
with oidc_user("mallory"):
others = await client.get("/api/me/games")
self.assertNotIn(
game_id, [g["id"] for g in others.json()["results"]]
)
# Once the match starts it stays listed, for both players.
code = created.json()["join_code"]
with oidc_user("bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user("bob"):
started = await client.get("/api/me/games")
bob_entry = next(
g for g in started.json()["results"] if g["id"] == game_id
)
self.assertEqual("playing", bob_entry["phase"])
self.assertEqual(1, bob_entry["your_seat"])
self.assertEqual(0, bob_entry["seats_open"])
@async_test
async def test_get_unknown_game(self) -> None:
transport = ASGITransport(app=app)
@@ -299,32 +227,16 @@ class GameTypesRouteTest(unittest.TestCase):
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(
["scopone_scientifico", "briscola", "briscola_a_quattro"],
[g["id"] for g in results],
)
scopone = results[0]
self.assertEqual("Scopone scientifico", scopone["name"])
self.assertTrue(scopone["description"])
self.assertEqual(4, scopone["min_players"])
self.assertEqual(4, scopone["max_players"])
self.assertIn("target_score", scopone["options_schema"]["properties"])
self.assertIn("napola", scopone["options_schema"]["properties"])
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
self.assertEqual("Scopone scientifico", results[0]["name"])
self.assertTrue(results[0]["description"])
self.assertEqual(4, results[0]["min_players"])
self.assertEqual(4, results[0]["max_players"])
self.assertIn("target_score", results[0]["options_schema"]["properties"])
self.assertIn("napola", results[0]["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", scopone["options_schema"]["properties"]
"asso_pigliatutto", results[0]["options_schema"]["properties"]
)
briscola = results[1]
self.assertEqual("Briscola", briscola["name"])
self.assertTrue(briscola["description"])
self.assertEqual(2, briscola["min_players"])
self.assertEqual(2, briscola["max_players"])
self.assertIn("target_score", briscola["options_schema"]["properties"])
quattro = results[2]
self.assertEqual("Briscola a quattro", quattro["name"])
self.assertTrue(quattro["description"])
self.assertEqual(4, quattro["min_players"])
self.assertEqual(4, quattro["max_players"])
self.assertIn("target_score", quattro["options_schema"]["properties"])
@async_test
async def test_create_defaults_game_type(self) -> None:
@@ -356,7 +268,7 @@ class GameTypesRouteTest(unittest.TestCase):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
unknown = await client.post("/api/games", json={"game_type": "tresette"})
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
-86
View File
@@ -136,91 +136,5 @@ class ScoponeStatsTest(unittest.TestCase):
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
async def _finished_briscola_session() -> GameSession:
engine = platform.registry.require("briscola")
session = GameSession(
id="stats-briscola-1",
game_type=engine.id,
join_code="BS0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, {})
engine.join(session, "bob", "Bob")
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)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
assert engine.is_finished(session)
return session
class BriscolaStatsTest(unittest.TestCase):
@async_test
async def test_finished_briscola_match_persisted_with_summary(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("briscola")
session = await _finished_briscola_session()
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("briscola", match.game_type)
summary = match.result
self.assertIn("team_a_score", summary)
self.assertIn("team_b_score", summary)
self.assertIn("winner_team", summary)
self.assertIn("target_score", summary)
self.assertIn("deals_played", summary)
self.assertIn("deal_scores", summary)
winner_team = summary["winner_team"]
players = {p.user_sub: p for p in await MatchPlayer.all()}
for row in players.values():
if row.won:
self.assertEqual(winner_team, row.team)
else:
self.assertNotEqual(winner_team, row.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(2, len(ratings))
self.assertEqual(
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
)
@async_test
async def test_history_exposes_briscola_result(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("briscola")
session = await _finished_briscola_session()
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("briscola", results[0]["game_type"])
self.assertIn("team_a_score", results[0]["result"])
self.assertEqual(2, len(results[0]["players"]))
if __name__ == "__main__":
unittest.main()
+13 -35
View File
@@ -134,7 +134,7 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_watches_as_spectator(self) -> None:
async def test_non_player_is_closed(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_game(client)
@@ -143,42 +143,20 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
self.assertIn("table", first["game"])
self.assertEqual({"A": 0, "B": 0}, first["game"]["scores"])
for player in first["game"]["players"]:
self.assertNotIn("hand", player)
# Spectators may not act.
await ws.send_json({"action": "play", "card": "01D"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(403, caught.exception.code)
@async_test
async def test_anonymous_user_watches_as_spectator(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_game(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([]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
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("error", error["type"])
self.assertEqual("forbidden", error["code"])
async def test_unauthenticated_is_closed(self) -> None:
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(401, caught.exception.code)
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
+9 -24
View File
@@ -105,11 +105,11 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
/// A refused WebSocket handshake is invisible to the browser: the close
/// event reports an abnormal closure (1006) with no HTTP status, so the
/// game page asks the REST API to tell a deliberate rejection from a
/// transient network problem. Anonymous users and non-players are
/// welcome as spectators, so the only deliberate refusal is an unknown
/// game.
/// transient network problem.
pub enum GameEntry {
/// Unknown game (`404`): the id is wrong or the game's TTL expired.
/// Not logged in (`401` from `GET /api/games/{id}`).
Unauthenticated,
/// Unknown game (`404`) or the user is not seated (`403`).
Unavailable,
/// The API could not be asked (or answered unexpectedly): treat the
/// connection loss as transient and retry.
@@ -117,31 +117,18 @@ pub enum GameEntry {
}
/// Classify a failed game connection via `GET /api/games/{game_id}`,
/// mirroring the status used to refuse the websocket upgrade.
/// mirroring the statuses used to refuse the websocket upgrade.
pub async fn probe_game(game_id: &str) -> GameEntry {
match Request::get(&format!("/api/games/{game_id}")).send().await {
Ok(resp) => match resp.status() {
404 => GameEntry::Unavailable,
401 => GameEntry::Unauthenticated,
403 | 404 => GameEntry::Unavailable,
_ => GameEntry::Transient,
},
Err(_) => GameEntry::Transient,
}
}
/// 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())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
let url = match cursor {
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
@@ -166,10 +153,8 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
resp.json().await.map_err(|e| e.to_string())
}
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
/// are all scoped to that game.
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
let resp = Request::get("/api/leaderboard")
.send()
.await
.map_err(|e| e.to_string())?;
-206
View File
@@ -1,206 +0,0 @@
//! The briscola deal-end summary screen.
//!
//! Shown when a deal finishes but the match continues: the points won in
//! the deal just played, the running totals toward the target and the
//! acknowledgement button (the server deals on when everyone is ready, or
//! after the hand-end timeout).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{is_individual_game, DealSummary, GameView};
use crate::ws::GameSocket;
/// Names of the two sides: the players' names for games without
/// partnerships (classic two-player briscola), "Team A"/"Team B"
/// otherwise.
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())
}
}
/// One explanatory row: icon, title, plain-language sentence, points chip.
fn summary_row(
icon: View,
title: &'static str,
text: String,
cls: &'static str,
chip: String,
) -> View {
view! {
div(class=cls) {
div(class="score-icon") { (icon) }
div(class="score-body") {
div(class="score-title") { (title) }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
}
/// The scoring rows of one completed deal (also used by the final screen).
pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
let a = summary.points.a;
let b = summary.points.b;
let (name_a, name_b) = side_names(game);
let cls = if summary.patta {
"score-row tie"
} else if a > b {
"score-row team-a"
} else {
"score-row team-b"
};
let chip = if summary.patta {
"patta".to_string()
} else {
format!("+{a} · +{b}")
};
let patta_note = summary.patta.then(|| view! {
p(class="text-warning small mb-0") {
"60–60: patta — the deal is replayed and its points do not count."
}
});
let points_row = summary_row(
card_img("01D".to_string(), "score-mini"),
"Deal points",
format!("{name_a} {a} · {name_b} {b}"),
cls,
chip,
);
let cards = summary.cards.map(|c| {
summary_row(
card_back("score-mini"),
"Cards captured",
format!("{name_a} {} · {name_b} {}", c.a, c.b),
"score-row",
format!("{} · {}", c.a, c.b),
)
});
view! {
div(class="score-rows") {
(points_row)
(patta_note)
(cards)
}
}
}
/// Running totals with progress toward the target, including the points
/// gained in the deal just played (zero for a patta).
fn totals(game: &GameView, summary: &DealSummary) -> View {
let target = game.target_score.max(1);
let pct_a = (100 * summary.totals.a / target).min(100);
let pct_b = (100 * summary.totals.b / target).min(100);
let (gained_a, gained_b) = if summary.patta {
(0, 0)
} else {
(summary.points.a, summary.points.b)
};
let (name_a, name_b) = side_names(game);
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { (name_a) }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(summary.totals.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
div(class="total-row team-b") {
span(class="total-label") { (name_b) }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(summary.totals.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
}
}
}
/// The full deal-end modal: points + totals + acknowledgement button.
pub fn deal_summary_modal(
game: GameView,
socket: Signal<Option<GameSocket>>,
now: Signal<f64>,
) -> View {
let Some(summary) = game.last_deal.clone() else {
return view! {};
};
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat);
let acked = game.acknowledged.clone();
let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s));
let waiting: Vec<String> = game
.players
.iter()
.filter(|p| !acked.contains(&p.seat))
.map(|p| p.name.clone())
.collect();
let countdown = game.hand_end_deadline.as_ref().map(|deadline| {
// A dynamic closure so only the ticking number re-renders, not the
// whole modal (which would swap DOM nodes under the user's cursor).
let deadline_ms = js_sys::Date::parse(deadline);
view! {
p(class="text-secondary small") {
"Auto-continuing in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let title = format!("Deal {} — results", summary.deal);
let rows = deal_rows(&game, &summary);
let totals_view = totals(&game, &summary);
let action = if game.spectator {
// Spectators cannot acknowledge; they just watch the summary.
view! {
p(class="text-secondary small mb-0") {
"Spectating — the next deal is dealt when every player is ready."
}
}
} else if already_acked {
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
view! {
button(class="btn btn-warning", disabled=true) { (waiting_text) }
}
} else {
view! {
button(class="btn btn-warning", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.ack();
}
}) { "Understood, next deal" }
}
};
view! {
div(class="overlay") {
div(class="picker summary-panel") {
h2 { (title) }
(rows)
(totals_view)
div(class="summary-actions") { (action) }
(countdown)
}
}
}
}
-45
View File
@@ -1,45 +0,0 @@
//! Clipboard copy button, shared by the join-code and watch-link actions.
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
/// A button that copies `text` to the clipboard and shows "Copied!" for a
/// moment. `label` is the idle caption.
#[component(inline_props)]
pub fn CopyButton(text: String, label: String) -> View {
let copied = create_signal(false);
// Held so the reset timer is cancelled if the button is rebuilt (e.g.
// another player joins) while the "Copied!" feedback is showing:
// firing it afterwards would set a disposed signal and panic.
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
Rc::new(RefCell::new(None));
on_cleanup({
let reset_timer = reset_timer.clone();
move || {
reset_timer.borrow_mut().take();
}
});
let on_click = {
let text = text.clone();
move |_| {
if let Some(win) = web_sys::window() {
let _ = win.navigator().clipboard().write_text(&text);
}
copied.set(true);
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
1500,
move || {
copied.set(false);
},
));
}
};
view! {
button(class="btn btn-outline-light", on:click=on_click) {
(move || if copied.get() { "Copied!".to_string() } else { label.clone() })
}
}
}
-2
View File
@@ -1,6 +1,4 @@
pub mod briscola;
pub mod card;
pub mod copy;
pub mod nav;
pub mod summary;
pub mod toast;
+1 -8
View File
@@ -260,14 +260,7 @@ pub fn hand_summary_modal(
let totals_view = totals(&game);
let title = format!("Hand {} — results", summary.hand);
let action = if game.spectator {
// Spectators cannot acknowledge; they just watch the summary.
view! {
p(class="text-secondary small mb-0") {
"Spectating — the next hand is dealt when every player is ready."
}
}
} else if already_acked {
let action = if already_acked {
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
view! {
button(class="btn btn-warning", disabled=true) { (waiting_text) }
+1 -400
View File
@@ -26,15 +26,12 @@ pub struct PlayerView {
pub captured_count: usize,
#[serde(default)]
pub scope: i32,
/// Briscola: point value of the player's captured pile.
#[serde(default)]
pub points: i32,
/// Own hand only; absent for the other players.
#[serde(default)]
pub hand: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Scores {
#[serde(rename = "A")]
pub a: i32,
@@ -57,38 +54,6 @@ pub struct MoveView {
pub pigliatutto: bool,
}
/// One card played in the current briscola trick, in play order.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct TrickCard {
pub seat: usize,
pub card: String,
}
/// The most recently completed briscola trick.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LastTrick {
pub winner: usize,
#[serde(default)]
pub cards: Vec<TrickCard>,
}
/// The scoring breakdown of one completed briscola deal.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct DealSummary {
#[serde(default)]
pub deal: i32,
/// A 60-60 deal: its points are discarded and the deal is replayed.
#[serde(default)]
pub patta: bool,
pub points: Scores,
pub totals: Scores,
#[serde(default)]
pub cards: Option<TeamCounts>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct TeamCounts {
#[serde(rename = "A")]
@@ -147,16 +112,11 @@ pub struct HandSummary {
#[allow(dead_code)]
pub struct GameView {
pub id: String,
/// The lobby join code; withheld by the server from spectators.
#[serde(default)]
pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
/// Whether the viewer is watching without a seat: no hand, no join
/// code, and actions are rejected by the server.
#[serde(default)]
pub spectator: bool,
/// Whether the napola rule is scored in this match.
#[serde(default = "default_true")]
pub napola: bool,
@@ -203,42 +163,6 @@ pub struct GameView {
/// Legal captures per hand card; present only for the player on turn.
#[serde(default)]
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
/// Briscola: number of seats at the table (2 or 4).
#[serde(default)]
pub player_count: usize,
/// Briscola: trump suit of the current deal (empty for other games).
#[serde(default)]
pub briscola_suit: String,
/// Briscola: the face-up trump card, until it is drawn.
#[serde(default)]
pub briscola_card: Option<String>,
/// Briscola: face-down cards left in the stock.
#[serde(default)]
pub stock_count: usize,
/// Briscola: cards of the trick in progress, in play order.
#[serde(default)]
pub trick: Vec<TrickCard>,
/// Briscola: seat that led the current trick.
#[serde(default)]
pub trick_leader: Option<usize>,
/// Briscola: the most recently completed trick.
#[serde(default)]
pub last_trick: Option<LastTrick>,
/// Briscola: scoring breakdown of the most recent deal.
#[serde(default)]
pub last_deal: Option<DealSummary>,
}
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 == team)
.map(|p| p.name.clone())
.unwrap_or_else(|| team.to_string())
}
}
#[derive(Debug, Deserialize)]
@@ -420,38 +344,6 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>,
}
/// The games to show in the lobby's and leaderboard's game selectors when
/// the `/api/game-types` fetch fails: match creation and per-game views
/// must still work.
pub fn fallback_game_types() -> Vec<GameTypeInfo> {
vec![
GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "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,
},
]
}
/// One configured OIDC login provider (GET /api/auth/providers).
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AuthProvider {
@@ -498,66 +390,6 @@ pub struct PreviewPlayer {
pub team: Option<String>,
}
/// One ongoing game the caller is seated in (GET /api/me/games): the
/// lobby payload plus the game's display name, for the "Continue" list.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct MyGame {
pub id: String,
#[serde(default)]
pub game_type: String,
#[serde(default)]
pub game_name: String,
#[serde(default)]
pub game_description: String,
#[serde(default)]
pub phase: String,
#[serde(default)]
pub players: Vec<PreviewPlayer>,
#[serde(default)]
pub seats_open: Option<usize>,
#[serde(default)]
pub your_seat: Option<usize>,
#[serde(default)]
pub created_at: Option<String>,
}
impl MyGame {
/// Human-readable phase for the lobby list.
pub fn phase_label(&self) -> &'static str {
match self.phase.as_str() {
"lobby" => "Waiting for players",
"hand_end" => "Between hands",
_ => "In progress",
}
}
/// Seated/total note, e.g. `"2/4 seated"`.
pub fn seats_text(&self) -> String {
match self.seats_open {
Some(open) => {
format!("{}/{} seated", self.players.len(), self.players.len() + open)
}
None => format!("{} seated", self.players.len()),
}
}
/// The seated players' names, for the list subtitle.
pub fn players_text(&self) -> String {
self.players
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MyGamesPage {
#[serde(default)]
pub results: Vec<MyGame>,
}
/// One option to show in the join preview, rendered from the game's
/// schema and the engine-echoed value.
#[derive(Debug, Clone, PartialEq)]
@@ -730,29 +562,6 @@ pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg")
}
/// Whether a game type is one of the briscola variants.
pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro"
}
/// Whether a game is played by two individuals rather than partnerships
/// (classic two-player briscola): team labels and team messages are
/// replaced by the players' names.
pub fn is_individual_game(game_type: &str) -> bool {
game_type == "briscola"
}
/// Human-readable suit name for a one-letter code (`D` -> "denari").
pub fn suit_name(suit: &str) -> String {
match suit {
"D" => "denari".to_string(),
"C" => "coppe".to_string(),
"S" => "spade".to_string(),
"B" => "bastoni".to_string(),
_ => suit.to_string(),
}
}
pub const CARD_BACK: &str = "/assets/cards/back.svg";
/// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then
@@ -1071,39 +880,11 @@ mod tests {
.unwrap();
assert_eq!("playing", g.phase);
assert!(g.asso_pigliatutto);
assert!(!g.spectator);
assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn game_view_parses_spectator_snapshot() {
// A spectator payload: no hand anywhere, no join code, but the
// public table state is present.
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"game_type": "scopone_scientifico",
"spectator": true,
"phase": "playing",
"target_score": 11,
"scores": {"A": 2, "B": 1},
"table": ["07C"],
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 5, "captured_count": 12, "scope": 1},
],
}))
.unwrap();
assert!(g.spectator);
assert_eq!("", g.join_code);
assert_eq!(Some(Scores { a: 2, b: 1 }), g.scores);
assert_eq!(vec!["07C".to_string()], g.table);
assert_eq!(None, g.players[0].hand);
assert_eq!(12, g.players[0].captured_count);
assert_eq!(1, g.players[0].scope);
}
#[test]
fn move_view_parses_pigliatutto() {
let mv: MoveView = serde_json::from_value(json!({
@@ -1240,184 +1021,4 @@ mod tests {
assert!(g.default_options().is_empty());
assert_eq!(0, g.min_players);
}
#[test]
fn is_briscola_matches_both_variants() {
assert!(is_briscola("briscola"));
assert!(is_briscola("briscola_a_quattro"));
assert!(!is_briscola("scopone_scientifico"));
assert_eq!("denari", suit_name("D"));
assert_eq!("coppe", suit_name("C"));
assert_eq!("spade", suit_name("S"));
assert_eq!("bastoni", suit_name("B"));
assert_eq!("?", suit_name("?"));
}
#[test]
fn is_individual_game_only_matches_two_player_briscola() {
assert!(is_individual_game("briscola"));
assert!(!is_individual_game("briscola_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", "briscola", "briscola_a_quattro"],
ids
);
// The lobby's first game (the creation default) must exist.
assert_eq!("scopone_scientifico", types[0].id);
}
#[test]
fn game_view_parses_briscola_payload() {
let g: GameView = serde_json::from_value(json!({
"id": "b1",
"join_code": "ABC123",
"game_type": "briscola",
"phase": "playing",
"target_score": 61,
"player_count": 2,
"hand_number": 1,
"dealer": 0,
"turn": 1,
"scores": {"A": 0, "B": 0},
"briscola_suit": "D",
"briscola_card": "07D",
"stock_count": 33,
"trick": [{"seat": 1, "card": "04C"}],
"trick_leader": 1,
"last_trick": null,
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 3, "captured_count": 0, "points": 0,
"hand": ["01D"]},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
"cards_left": 3, "captured_count": 0, "points": 0},
],
"last_deal": null,
"acknowledged": [],
"hand_end_deadline": null,
"turn_deadline": "2026-01-01T00:00:30+00:00",
"your_turn": true,
}))
.unwrap();
assert!(is_briscola(&g.game_type));
assert_eq!("D", g.briscola_suit);
assert_eq!(Some("07D".to_string()), g.briscola_card);
assert_eq!(33, g.stock_count);
assert_eq!(1, g.trick.len());
assert_eq!(1, g.trick[0].seat);
assert_eq!("04C", g.trick[0].card);
assert_eq!(Some(1), g.trick_leader);
assert_eq!(0, g.players[0].points);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn game_view_parses_briscola_deal_summary() {
let g: GameView = serde_json::from_value(json!({
"id": "b1",
"game_type": "briscola_a_quattro",
"phase": "hand_end",
"player_count": 4,
"scores": {"A": 55, "B": 65},
"last_deal": {
"deal": 1,
"patta": false,
"points": {"A": 55, "B": 65},
"totals": {"A": 55, "B": 65},
"cards": {"A": 19, "B": 21},
},
"last_trick": {
"winner": 3,
"cards": [
{"seat": 0, "card": "02D"},
{"seat": 1, "card": "05D"},
{"seat": 2, "card": "04D"},
{"seat": 3, "card": "07D"},
],
},
}))
.unwrap();
let deal = g.last_deal.unwrap();
assert_eq!(1, deal.deal);
assert!(!deal.patta);
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
let cards = deal.cards.unwrap();
assert_eq!(19, cards.a);
assert_eq!(21, cards.b);
let trick = g.last_trick.unwrap();
assert_eq!(3, trick.winner);
assert_eq!(4, trick.cards.len());
}
#[test]
fn deal_summary_defaults() {
let d: DealSummary = serde_json::from_value(json!({
"points": {"A": 60, "B": 60},
"totals": {"A": 10, "B": 20},
}))
.unwrap();
assert!(!d.patta);
assert_eq!(0, d.deal);
assert!(d.cards.is_none());
}
}
-273
View File
@@ -1,273 +0,0 @@
//! Briscola 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 trick in progress, the stock pile and the face-up briscola.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::model::{
hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores,
};
use crate::pages::game::watch_link;
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
/// Panel for one player seat (name, team, hidden card count, 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();
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
let team = player.team.clone();
view! { span(class="team-badge") { "Team " (team) } }
});
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)
(team_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(points) " points · " (captured) " cards"
}
}
}
}
/// The stock pile with the face-up briscola card beside it.
fn stock_view(game: &GameView) -> View {
let backs = (0..game.stock_count.min(3))
.map(|_| card_back("stock-card"))
.collect::<Vec<_>>();
let trump = game
.briscola_card
.clone()
.map(|code| card_img(code, "briscola-card"));
let label = format!("{} in stock", game.stock_count);
view! {
div(class="stock") {
div(class="stock-pile") { (backs) }
(trump)
span(class="stock-count") { (label) }
}
}
}
/// 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_for_seat(game, tc.seat)
.map(|p| p.name)
.unwrap_or_default();
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_for_seat(game, trick.winner)
.map(|p| p.name)
.unwrap_or_default();
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)
}
}
}
/// The main briscola table.
pub fn briscola_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
now: Signal<f64>,
) -> 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" {
"Deal 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 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 {
(
"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_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");
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."
}
});
// 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 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) } }
});
// Precomputed so the final view's closures only capture owned views.
let banner = last_trick_banner(&game);
let trick = trick_view(&game);
let stock = stock_view(&game);
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Deal " (deal) }
(trump_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())
}
div(class=grid_cls) {
(top)
(left)
div(class="center") {
(banner)
div(class="table-cards") { (trick) }
(stock)
}
(right)
(bottom)
}
}
}
}
+62 -119
View File
@@ -1,21 +1,16 @@
//! Live game page: table view over the websocket.
use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::briscola::{deal_rows, deal_summary_modal};
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
PlayerView, Scores, ServerMessage,
};
use crate::pages::briscola::briscola_table;
use crate::model::{card_label, hand_sort_key, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -24,14 +19,6 @@ fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<V
}
}
/// The absolute, shareable URL of this game's page, for the clipboard.
pub(crate) fn watch_link(id: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
format!("{origin}/game/{id}")
}
/// 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) = game.players.iter().find(|p| p.seat == seat).cloned() else {
@@ -167,6 +154,11 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let alive = alive.clone();
spawn_local(async move {
match api::probe_game(&id).await {
api::GameEntry::Unauthenticated => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
api::GameEntry::Unavailable => {
ctx.fatal.set(true);
ctx.error
@@ -392,10 +384,6 @@ pub fn GamePage(id: String) -> View {
}
}
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// Briscola has its own table view for both variants.
Some(g) if is_briscola(&g.game_type) => {
briscola_table(g, on_hand_card, now)
}
// The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
@@ -410,9 +398,6 @@ pub fn GamePage(id: String) -> View {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
}
Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => {
deal_summary_modal(g, socket, now)
}
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone()))
@@ -422,34 +407,52 @@ pub fn GamePage(id: String) -> View {
/// Lobby view while waiting for the remaining players. The seat count
/// comes from the payload (`seats_open`), so games with other player
/// counts render correctly. Spectators (no seat, no join code) get a
/// "not started yet" note and the watch link instead.
/// counts render correctly.
#[component(inline_props)]
fn LobbyView(game: GameView) -> View {
let seats_open = game
.seats_open
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
let spectator = game.spectator;
let join_code = game.join_code.clone();
let watch_link = watch_link(&game.id);
let copied = create_signal(false);
// Held so the reset timer is cancelled if the waiting room is rebuilt
// (e.g. another player joins) while the "Copied!" feedback is showing:
// firing it afterwards would set a disposed signal and panic.
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
Rc::new(RefCell::new(None));
on_cleanup({
let reset_timer = reset_timer.clone();
move || {
reset_timer.borrow_mut().take();
}
});
let copy = {
let join_code = join_code.clone();
move |_| {
if let Some(win) = web_sys::window() {
let _ = win.navigator().clipboard().write_text(&join_code);
}
copied.set(true);
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(1500, move || {
copied.set(false);
}));
}
};
let show_teams = !is_individual_game(&game.game_type);
let players = game
.players
.iter()
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team_badge = show_teams.then(|| {
let team = p.team.clone();
view! { span(class="badge text-bg-info") { "Team " (team) } }
});
let team = p.team.clone();
view! {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
span {
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
(team_badge)
span(class="badge text-bg-info") { "Team " (team) }
}
}
}
@@ -469,39 +472,18 @@ fn LobbyView(game: GameView) -> View {
};
let percent_style = format!("width: {percent}%");
// The server withholds the join code from spectators; they get a
// "not started yet" note and can share the watch link instead.
let share = if spectator {
view! {
p(class="text-secondary") {
"This game hasn't started yet — waiting for the players to take their seats."
}
div(class="d-grid mb-4") {
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
}
} else {
// Separate clones: each interpolation/prop captures its own copy.
let display_code = join_code.clone();
let copy_code = join_code.clone();
view! {
p(class="text-secondary") { "Share this join code with your friends:" }
p(class="code-display") { (display_code) }
div(class="d-grid gap-2 mb-4") {
CopyButton(text=copy_code, label="Copy code".to_string())
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
}
};
view! {
div(class="container page-shell py-4") {
div(class="card status-card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") {
(if spectator { "Game not started yet" } else { "Waiting for players" })
h1(class="h4 mb-3") { "Waiting for players" }
p(class="text-secondary") { "Share this join code with your friends:" }
p(class="code-display") { (join_code) }
div(class="d-grid mb-4") {
button(class="btn btn-outline-light", on:click=copy) {
(move || if copied.get() { "Copied!" } else { "Copy code" })
}
}
(share)
ul(class="list-group list-group-flush text-start mb-3") {
(players)
(open_seats)
@@ -554,8 +536,7 @@ fn table_view(
selected: Signal<Option<String>>,
now: Signal<f64>,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
// Own seat: the only player entry carrying a hand.
let viewer_seat = game
.players
.iter()
@@ -641,41 +622,18 @@ fn table_view(
}
});
// 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 watch_link = watch_link(&game.id);
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Hand " (hand_number) }
(rule_badge)
(spectator_badge)
span(class="hud-scores") {
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target_score) ")"
}
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
div(class="table-grid") {
(top)
@@ -688,7 +646,13 @@ fn table_view(
}
}
(right)
(bottom)
div(class="seat-bottom") {
div(class="seat-stats own-stats") {
(my_captured) " captured · " (my_scope) " scope"
}
div(class="hand") { (hand_cards) }
(hint)
}
}
}
}
@@ -731,11 +695,11 @@ fn capture_picker(
}
}
/// End-of-match overlay: the final hand/deal breakdown and the winner.
/// End-of-match overlay (scopone's team score; other games render
/// nothing until they ship their own play 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));
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" && !briscola {
if g.game_type != "scopone_scientifico" {
return view! {};
}
}
@@ -748,35 +712,14 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
None => view! {},
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
// Two-player briscola has no teams: name the winner instead.
let winner_label = match game.as_ref() {
Some(g) if is_individual_game(&g.game_type) => {
g.player_name_for_team(&winner)
}
_ => format!("Team {winner}"),
};
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
// Spectators have no match history to look at.
let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result.
let final_summary: View = if briscola {
match game.as_ref() {
Some(g) => match g.last_deal.clone() {
Some(deal) => deal_rows(g, &deal),
None => view! {},
},
None => view! {},
}
} else {
match game.as_ref().and_then(|g| g.last_hand.clone()) {
Some(hand) => summary_rows(hand),
None => view! {},
}
};
let my_matches = (!spectator).then(|| view! {
a(class="btn btn-outline-light", href="/history") { "My matches" }
});
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
// Explain the final hand's scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
view! {
div(class="overlay") {
div(class="picker summary-panel") {
@@ -785,7 +728,7 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
p(class="final-score") { (line) }
div(class="gameover-actions") {
a(class="btn btn-warning", href="/") { "Back to lobby" }
(my_matches)
a(class="btn btn-outline-light", href="/history") { "My matches" }
}
}
}
+6 -62
View File
@@ -1,53 +1,20 @@
//! Per-game leaderboard page.
//!
//! Elo ratings are stored per game type, so the board is scoped to the
//! game chosen in the dropdown: Elo, wins, matches and points all come
//! from that game's matches only.
//! Global leaderboard page.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::components::toast::toast;
use crate::model::{fallback_game_types, fmt_points, LeaderboardPage};
use crate::model::{fmt_points, LeaderboardPage};
#[component]
pub fn LeaderboardPage() -> View {
let page = create_signal(Option::<LeaderboardPage>::None);
let error = create_signal(Option::<String>::None);
let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string());
// Reload whenever the selection changes; a response for a game the
// user has already switched away from is dropped.
create_effect(move || {
let game_type = selected_game.get_clone();
page.set(None);
spawn_local(async move {
match api::leaderboard(&game_type).await {
Ok(p) => {
if selected_game.get_clone() == game_type {
page.set(Some(p));
}
}
Err(e) => {
if selected_game.get_clone() == game_type {
error.set(Some(e));
}
}
}
});
});
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
match api::leaderboard().await {
Ok(p) => page.set(Some(p)),
Err(e) => error.set(Some(e)),
}
});
@@ -55,30 +22,7 @@ pub fn LeaderboardPage() -> View {
div {
(crate::components::nav::app_nav("/leaderboard", || view! {}))
div(class="container page-shell py-4") {
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(),
)
}
}
}
h1(class="h3 mb-4") { "Leaderboard" }
(toast(error))
(move || match page.get_clone() {
None => view! {
+18 -80
View File
@@ -15,10 +15,21 @@ use crate::api;
use crate::components::card::card_back;
use crate::components::toast::toast;
use crate::model::{
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
MyGame, OptionField, OptionKind, PlayerRating, User,
AuthProvider, GameTypeInfo, JoinPreview, 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,
}]
}
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
@@ -237,20 +248,15 @@ fn join_preview_panel(
})
.collect::<Vec<_>>();
let seated = p.players.len();
let show_teams = !is_individual_game(&p.game_type);
let players = p
.players
.iter()
.map(|player| {
let name = player.name.clone();
let team = if show_teams {
player.team.as_ref().map(|t| {
let label = t.clone();
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
})
} else {
None
};
let team = player.team.as_ref().map(|t| {
let label = t.clone();
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
});
view! {
li(
class="list-group-item d-flex justify-content-between \
@@ -292,8 +298,6 @@ pub fn LobbyPage() -> View {
let auth_providers = create_signal(Vec::<AuthProvider>::new());
// The player's Elo ratings, one row per game type played.
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 code = create_signal(String::new());
let game_types = create_signal(fallback_game_types());
@@ -388,9 +392,6 @@ pub fn LobbyPage() -> View {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
}
if let Ok(games) = api::my_games().await {
my_games.set(games);
}
});
}
user.set(Some(me));
@@ -551,72 +552,9 @@ pub fn LobbyPage() -> View {
div(class="lobby-hero text-center") {
h1(class="display-5 fw-bold") { "tavolo" }
p(class="lead text-secondary mb-0") {
"Play scopone scientifico and briscola with friends."
"Play scopone scientifico 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="col-lg-7") {
div(class="card h-100") {
-1
View File
@@ -1,4 +1,3 @@
pub mod briscola;
pub mod game;
pub mod history;
pub mod leaderboard;
+2 -8
View File
@@ -83,14 +83,8 @@ pub fn connect(
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
match serde_json::from_str::<ServerMessage>(&text) {
Ok(parsed) => on_message(parsed),
// A silently dropped message would leave the page
// waiting forever; surface the parse error so it is
// visible in the browser console.
Err(err) => web_sys::console::error_1(
&format!("unparseable server message: {err}").into(),
),
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
on_message(parsed);
}
}
Ok(Message::Bytes(_)) => {}
+7 -209
View File
@@ -1,4 +1,4 @@
/* tavolo frontend — game tables, cards and scoring summaries.
/* Scopone scientifico frontend — game table, cards and scoring summary.
*
* The felt palette and the Bootstrap theme overrides live in theme.css,
* which is loaded before this file. */
@@ -37,8 +37,7 @@ body {
.hud {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.5rem;
gap: 1.5rem;
align-items: center;
justify-content: center;
padding: 0.6rem 1rem;
@@ -77,10 +76,7 @@ body {
.table-grid {
flex: 1;
display: grid;
/* 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-columns: 1fr 2.4fr 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
". top ."
@@ -118,7 +114,6 @@ body {
flex-direction: column;
justify-content: flex-start;
min-height: 116px;
min-width: 0;
align-self: center;
width: 100%;
}
@@ -131,8 +126,7 @@ body {
.seat-name {
font-weight: 600;
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.5rem;
gap: 0.5rem;
align-items: center;
justify-content: center;
}
@@ -173,7 +167,6 @@ body {
justify-content: center;
gap: 0.75rem;
min-height: 230px;
min-width: 0;
}
.table-cards {
@@ -247,11 +240,8 @@ body {
.move-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem;
max-width: 100%;
background: rgba(0, 0, 0, 0.35);
border-radius: 999px;
padding: 0.35rem 1rem;
@@ -276,15 +266,6 @@ body {
margin-left: 0.25rem;
}
.spectator-badge {
background: var(--panel-light);
color: var(--muted);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--muted);
}
/* ---------- overlays ---------- */
.overlay {
@@ -302,8 +283,7 @@ body {
border-radius: 14px;
padding: 1.5rem 2rem;
text-align: center;
min-width: min(320px, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem);
min-width: 320px;
}
.capture-options {
@@ -338,8 +318,8 @@ body {
/* ---------- hand-end scoring summary ---------- */
.summary-panel {
min-width: min(420px, calc(100vw - 1.5rem));
max-width: min(560px, calc(100vw - 1.5rem));
min-width: 420px;
max-width: 560px;
text-align: left;
}
@@ -485,185 +465,3 @@ body {
font-size: 1.25rem;
font-weight: 700;
}
/* ---------- briscola ---------- */
.briscola-badge {
background: var(--panel-light);
color: var(--accent);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--accent);
}
/* The face-down stock with the face-up trump card beside it. */
.stock {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stock-pile {
display: flex;
}
.playing-card.stock-card {
height: 48px;
margin-left: -18px;
}
.playing-card.stock-card:first-child {
margin-left: 0;
}
.playing-card.briscola-card {
height: 64px;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.stock-count {
color: var(--muted);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
/* Current-trick cards with the player's name above each. */
.trick-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.trick-name {
font-size: 0.7rem;
color: var(--muted);
}
/* Two-player table: opponent on top, player at the bottom. */
.table-grid.duo {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"top"
"center"
"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;
}
}