Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s

New tavolo-briscola distribution implementing briscola on the platform:

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

Platform fixes surfaced by a two-player lobby:

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

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

Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
This commit is contained in:
2026-10-06 14:08:25 +08:00
parent b5219062f2
commit 7bb6b74fdc
34 changed files with 3610 additions and 90 deletions
+22 -19
View File
@@ -1,14 +1,16 @@
# tavolo
A platform for multiplayer card games. The first game is **scopone
scientifico** — the four-player, fixed-partnership Italian card game:
A platform for multiplayer card games. 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:
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres.
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).
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).
- **`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
@@ -37,29 +39,29 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
## Between hands
When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won
**scoring summary screen**: scopone players see how each category was won
(carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled)
with the running totals and
must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
(default 30s). The match-ending hand is explained on the final screen.
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.
## On your turn
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (randomizing among the legal captures when the
random legal card for them (choosing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
## 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, 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.
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
@@ -69,10 +71,11 @@ 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-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
```
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 three sibling distributions, installed from the local tree (PEP 420
# The four sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency — including the tavolo-app `otel` extra — is
# already pinned in requirements.txt.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-briscola ./packages/tavolo-app
# server/pyproject.toml only carries the [tool.aerich] section (the
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
# next to ./migrations).
+72 -17
View File
@@ -1,10 +1,12 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. It is a monorepo of three distributions
[kaya](../kaya) framework. It is a monorepo of four distributions
sharing the `tavolo` namespace (see
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md)
and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
[`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)):
- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
game-independent: the lobby API, the live-game store, the WebSocket
@@ -13,8 +15,12 @@ and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
- **`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 scopone game, with the
composition root wiring the platform to the games, with the
environment-driven settings and the SPA shell.
Players authenticate with the configured **OIDC** provider. Live game
@@ -74,8 +80,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 scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements (wired into the game engines) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them (wired into the game engines) |
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
| `LOGGING_CONFIG` | unset | 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 |
@@ -212,7 +218,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. scopone's turn auto-plays and hand-end auto-continues).
timeouts (e.g. the games' turn auto-plays and hand-end auto-continues).
Every worker runs a consumer that fires due entries under the per-game
lock by calling the engine's `fire_deadline`, so timeouts do not depend
on any player being connected and survive the death of any worker
@@ -229,7 +235,8 @@ 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).
audit; for briscola: the final scores, winner, target score, deals
played and the per-deal audit).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and
@@ -268,7 +275,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. 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}}`; 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/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 |
@@ -291,7 +298,8 @@ Server → client messages are JSON objects with a `type`:
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.
is your move. `seats_open` reports the free chairs (0 once the match
started).
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
@@ -319,6 +327,19 @@ 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.
@@ -336,15 +357,18 @@ 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
`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
`hand_end` phase instead of dealing immediately: the state carries the
scoring breakdown of the hand just played (`last_hand` with an `award`
map naming the team that won each scopone category; `last_deal` with the
deal's points and running totals for briscola), the `acknowledged` seats
and a `hand_end_deadline`. The frontend renders this as a screen every
player must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
## 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**.
@@ -368,20 +392,45 @@ 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-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -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
@@ -439,6 +488,12 @@ 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
@@ -449,5 +504,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 + scopone wired together)
tests/ # app integration suite (platform + games wired together)
```
+4 -3
View File
@@ -2,14 +2,15 @@
The deployable tavolo application: the composition root wiring
[`tavolo-platform`](../tavolo-platform/README.md) to the
[`tavolo-scopone`](../tavolo-scopone/README.md) game.
[`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
## Contents
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
mixins plus `PlatformMixin` and the deadline scheduler), registers the
`ScoponeEngine` with its configured timeouts, and exposes
`tavolo.app:app` for granian.
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
their configured timeouts, and exposes `tavolo.app:app` for granian.
- `config.py` — environment-driven frozen `Settings`.
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
meter/tracer/logger providers, the
+2 -1
View File
@@ -5,12 +5,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "tavolo-app"
version = "0.1.0"
description = "Tavolo multiplayer card-game application: platform + scopone game wiring"
description = "Tavolo multiplayer card-game application: platform + games wiring"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
"tavolo-scopone",
"tavolo-briscola",
"kaya-core",
"kaya-cors",
"kaya-session",
+14 -1
View File
@@ -39,6 +39,7 @@ 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
@@ -131,9 +132,21 @@ 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(
"scopone timeouts: hand_ack=%ds turn=%ds",
"game 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 ScoponeEngine;
# before dealing the next hand anyway (wired into the game engines;
# see tavolo.app).
hand_ack_timeout_seconds: int
# Seconds a player has to play before the server plays a random legal
# card for them, covering disconnects and idle players (wired into the
# ScoponeEngine; see tavolo.app).
# game engines; see tavolo.app).
turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls
# when nothing else wakes it. Enqueued deadlines are announced over a
+58
View File
@@ -0,0 +1,58 @@
# 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).
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-briscola"
version = "0.1.0"
description = "Briscola game implementation for the tavolo platform"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -0,0 +1,20 @@
"""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"]
@@ -0,0 +1,469 @@
"""Pure rules engine for briscola.
Every function here is deterministic and I/O-free (the only side effect is
logging): it mutates (or reads)
:class:`~tavolo.briscola.state.BriscolaState` and raises
:class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
This makes the whole rule set unit-testable without Redis, Postgres or
HTTP. The platform-facing adapter is
:class:`~tavolo.briscola.plugin.BriscolaEngine`.
Rules implemented
-----------------
* 40-card Italian deck (4 suits x ranks 1-10), three cards per player.
* The next card after the deal is turned face up: its suit is the
**briscola** (trump) for the whole deal, and it is the last card drawn
from the stock (the stock is drawn from the bottom up).
* Trick play: the player to the dealer's left leads; each other player
plays one card. The highest trump wins; when no trump was played, the
highest card of the led suit wins. The winner takes the trick and leads
the next one.
* After each trick every player draws back up to three cards, starting
with the trick winner and continuing in turn order; the face-up
briscola card is drawn last, so whoever's turn to draw when only it
remains takes it.
* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else
0 — 120 points per deal. Trick strength is unrelated to points:
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
* Match points accumulate across deals. A 60-60 deal is a **patta**: its
points are discarded and the deal is replayed (the dealer rotates).
The first team to reach the target score (default 61) wins; a tie at or
above the target is broken by another deal.
* Between deals of an unfinished match the game pauses in the
``hand_end`` phase for the deal summary, exactly like scopone: every
player acknowledges (or the hand-end timeout elapses) before the next
deal.
"""
from __future__ import annotations
import random
from datetime import datetime, timedelta, timezone
from logging import getLogger
from typing import Any, Dict, List, Optional, Tuple
from tavolo.platform.errors import (
AlreadyJoined,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .errors import CardNotInHand
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
DEFAULT_TURN_TIMEOUT_SECONDS,
HAND_SIZE,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
PLAYER_COUNTS,
SUITS,
TEAM_NAMES,
BriscolaState,
Card,
PlayerState,
Trick,
TrickCard,
parse_card,
)
log = getLogger(__name__)
# Trick strength: higher wins. Ace > three > king > horse > jack, then the
# remaining ranks in descending order. All ranks are distinct within a
# suit, so a trick never ties.
TRICK_STRENGTH: Dict[int, int] = {
1: 9,
3: 8,
10: 7,
9: 6,
8: 5,
7: 4,
6: 3,
5: 2,
4: 1,
2: 0,
}
# Card points: 120 per deal in total.
CARD_POINTS: Dict[int, int] = {
1: 11,
3: 10,
10: 4,
9: 3,
8: 2,
}
_rng = random.SystemRandom()
def full_deck() -> List[Card]:
"""Return the 40 cards of the Italian deck in canonical order."""
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
deck = full_deck()
(rng or _rng).shuffle(deck)
return deck
def card_points(card: Card) -> int:
"""The point value of a single card."""
return CARD_POINTS.get(card.rank, 0)
def create_game(
creator_sub: str,
creator_name: str,
player_count: int = 2,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> BriscolaState:
"""Create a lobby game with the creator seated first."""
if player_count not in PLAYER_COUNTS:
raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}")
if (
isinstance(target_score, bool)
or not isinstance(target_score, int)
or target_score < MIN_TARGET_SCORE
or target_score > MAX_TARGET_SCORE
):
raise IllegalMove(
f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}"
)
return BriscolaState(
player_count=player_count,
target_score=target_score,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
)
def join_game(state: BriscolaState, sub: str, name: str) -> None:
"""Seat ``sub`` in the next free chair, starting the match when full."""
if state.phase != PHASE_LOBBY:
raise GameNotStarted("game has already started")
if state.seated(sub):
raise AlreadyJoined("already joined this game")
if len(state.players) >= state.player_count:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
if len(state.players) == state.player_count:
start_game(state)
def start_game(state: BriscolaState) -> None:
"""Deal the first hand and switch the game to playing."""
if len(state.players) != state.player_count:
raise GameNotStarted(
f"need exactly {state.player_count} players to start"
)
state.phase = PHASE_PLAYING
_deal(state)
def _set_turn_deadline(state: BriscolaState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _deal(state: BriscolaState) -> None:
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
state.trick = []
state.last_trick = None
state.briscola_card = None
# Dealer rotates each deal; the first card is played by the player to
# the dealer's left.
state.turn = (state.dealer + 1) % state.player_count
state.trick_leader = state.turn
for _ in range(HAND_SIZE):
for offset in range(state.player_count):
player = _player_at(
state, (state.dealer + 1 + offset) % state.player_count
)
player.hand.append(deck.pop())
# The turned-up card is the last card drawn: keep it separate from the
# face-down stock and hand it out once the stock is exhausted.
state.briscola_card = deck.pop()
state.briscola_suit = state.briscola_card.suit
state.stock = deck
_set_turn_deadline(state)
log.debug(
"deal %d dealt (dealer seat %d, briscola %s)",
state.hand_number,
state.dealer,
state.briscola_card.code,
)
def _player_at(state: BriscolaState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def play(state: BriscolaState, sub: str, card_code: str) -> None:
"""Apply one move by the player identified by ``sub``.
Any card in hand is a legal play; only turn order and phase are
validated. Raises a :class:`~tavolo.platform.errors.GameError`
subclass on any violation.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase == PHASE_HAND_END:
raise IllegalMove("the deal is over; acknowledge the summary to continue")
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = state.player_for(sub)
if player is None or player.seat != state.turn:
raise NotYourTurn("it is not your turn")
card = parse_card(card_code)
if card not in player.hand:
raise CardNotInHand(f"card {card.code} is not in your hand")
player.hand.remove(card)
state.trick.append(TrickCard(seat=player.seat, card=card.code))
log.debug(
"deal %d: seat %d played %s (%d/%d in the trick)",
state.hand_number,
player.seat,
card.code,
len(state.trick),
state.player_count,
)
if len(state.trick) == state.player_count:
_resolve_trick(state)
else:
state.turn = (state.turn + 1) % state.player_count
_set_turn_deadline(state)
def _resolve_trick(state: BriscolaState) -> None:
"""Score the completed trick, draw and hand the lead to the winner."""
winner = _trick_winner(state)
taken = [parse_card(t.card) for t in state.trick]
_player_at(state, winner).captured.extend(taken)
state.last_trick = Trick(winner=winner, cards=list(state.trick))
state.trick = []
state.trick_leader = winner
state.turn = winner
log.debug(
"deal %d: seat %d takes the trick with %s",
state.hand_number,
winner,
[t.card for t in state.last_trick.cards],
)
if all(not player.hand for player in state.players):
_end_deal(state)
return
_draw(state, winner)
_set_turn_deadline(state)
def _trick_winner(state: BriscolaState) -> int:
"""The seat winning the current (complete) trick."""
played = [(t.seat, parse_card(t.card)) for t in state.trick]
leader_suit = played[0][1].suit
trumps = [(seat, card) for seat, card in played if card.suit == state.briscola_suit]
pool = trumps or [
(seat, card) for seat, card in played if card.suit == leader_suit
]
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
def _draw(state: BriscolaState, winner: int) -> None:
"""Draw back up to ``HAND_SIZE`` cards, winner first, then turn order."""
for offset in range(state.player_count):
player = _player_at(state, (winner + offset) % state.player_count)
if state.stock:
player.hand.append(state.stock.pop())
elif state.briscola_card is not None:
# The face-up briscola is the last card of the stock: the first
# player who needs a card when only it remains takes it.
player.hand.append(state.briscola_card)
state.briscola_card = None
def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None:
"""Play a random card for the player currently on turn.
Every card in hand is legal, so one is drawn at random. Delegates to
:func:`play`, so the move is fully validated and can end the deal or
the match. Pass ``rng`` for deterministic tests.
"""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
play(state, player.sub, card.code)
def _end_deal(state: BriscolaState) -> None:
"""Score the deal and either finish the match or pause for the summary.
A 60-60 deal is a patta: its points do not count and the deal is
replayed. If the match continues, the game pauses in the ``hand_end``
phase so every player can read the summary; the next deal is dealt by
:func:`acknowledge_hand` once all players have acknowledged (or by the
hand-end timeout fired by the platform's deadline scheduler).
"""
state.turn_deadline = None
points, details = deal_points(state)
patta = points[0] == points[1]
if not patta:
state.scores[0] += points[0]
state.scores[1] += points[1]
entry: Dict[str, Any] = {
"deal": state.hand_number,
"patta": patta,
"points": details["points"],
"cards": details["cards"],
"totals": {"A": state.scores[0], "B": state.scores[1]},
}
state.hand_scores.append(entry)
a, b = state.scores
log.debug(
"deal %d scored A+%d B+%d%s (totals %d-%d)",
state.hand_number,
points[0],
points[1],
" patta" if patta else "",
a,
b,
)
if not patta and max(a, b) >= state.target_score and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
log.info("match ended, team %s wins", TEAM_NAMES[state.winner])
return
# Pause for the deal summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: BriscolaState, sub: str) -> None:
"""Record that ``sub`` has read the deal summary.
When every player has acknowledged, the next deal is dealt.
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
phase raises an error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no deal summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < state.player_count:
return
state.hand_number += 1
state.dealer = (state.dealer + 1) % state.player_count
state.acked = []
state.hand_end_deadline = None
state.phase = PHASE_PLAYING
_deal(state)
def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the deal points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []]
for player in state.players:
piles[player.team].extend(player.captured)
points = [
sum(card_points(card) for card in pile) for pile in piles
]
details: Dict[str, object] = {
"points": {"A": points[0], "B": points[1]},
"cards": {"A": len(piles[0]), "B": len(piles[1])},
}
return points, details
def state_for_player(state: BriscolaState, sub: Optional[str]) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
for player in state.players:
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": TEAM_NAMES[player.team],
"cards_left": len(player.hand),
"captured_count": len(player.captured),
"points": sum(card_points(card) for card in player.captured),
}
if viewer is not None and viewer.seat == player.seat:
view["hand"] = [c.code for c in player.hand]
players.append(view)
payload: Dict[str, object] = {
"phase": state.phase,
"target_score": state.target_score,
"player_count": state.player_count,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
"briscola_suit": state.briscola_suit,
"briscola_card": (
state.briscola_card.code if state.briscola_card is not None else None
),
"stock_count": len(state.stock),
"trick": [t.to_json() for t in state.trick],
"trick_leader": state.trick_leader,
"last_trick": state.last_trick.to_json() if state.last_trick else None,
"players": players,
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
}
if (
viewer is not None
and state.phase == PHASE_PLAYING
and viewer.seat == state.turn
):
payload["your_turn"] = True
return payload
@@ -0,0 +1,34 @@
"""Briscola errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely briscola-specific errors
are defined here.
"""
from __future__ import annotations
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
__all__ = [
"AlreadyJoined",
"CardNotInHand",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"IllegalMove",
"LobbyFull",
"NotYourTurn",
]
class CardNotInHand(GameError):
"""The played card is not held by the player."""
@@ -0,0 +1,279 @@
"""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
@@ -0,0 +1,279 @@
"""In-memory representation of a briscola game.
The whole mutable game lives in :class:`BriscolaState`, which is
serialized to and from plain JSON for storage inside the platform's
session envelope (see :mod:`tavolo.platform.store`). Keeping the
representation JSON-native means the store needs no custom codecs and the
state is inspectable with ``redis-cli``.
The state contains only game data: the platform owns the session envelope
(id, join code, seats, timestamps, stats persistence) — see
:class:`tavolo.platform.engine.GameSession`.
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
A card is rendered as ``RRSUIT`` (e.g. ``07D``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
SUITS = ("D", "C", "S", "B")
RANKS = tuple(range(1, 11))
# Teams are derived from the seat: seats 0 and 2 form team A (index 0),
# seats 1 and 3 form team B (index 1). In the two-player variant the teams
# are simply the two players; in briscola a quattro the partners sit
# opposite each other.
TEAM_A = 0
TEAM_B = 1
TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing"
# Between deals of an unfinished match: deal summary shown to every
# player; the next deal is dealt once all players acknowledge (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
# Supported table sizes: classic head-to-head and four-player partnerships.
PLAYER_COUNTS = (2, 4)
DEFAULT_TARGET_SCORE = 61
MIN_TARGET_SCORE = 61
MAX_TARGET_SCORE = 120
# Cards dealt to each player at the start of a deal.
HAND_SIZE = 3
# Default seconds the between-deals summary waits before dealing anyway.
# Games carry their own copy in ``BriscolaState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# card for them. Games carry their own copy in
# ``BriscolaState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
def team_of(seat: int) -> int:
return seat % 2
@dataclass(frozen=True)
class Card:
rank: int
suit: str
def __post_init__(self) -> None:
if self.suit not in SUITS:
raise ValueError(f"invalid suit: {self.suit!r}")
if self.rank not in RANKS:
raise ValueError(f"invalid rank: {self.rank!r}")
@property
def code(self) -> str:
return f"{self.rank:02d}{self.suit}"
@staticmethod
def parse(code: str) -> "Card":
code = str(code).upper()
if len(code) != 3 or not code[:2].isdigit():
raise ValueError(f"invalid card code: {code!r}")
return Card(rank=int(code[:2]), suit=code[2])
def to_json(self) -> str:
return self.code
@staticmethod
def from_json(value: Any) -> "Card":
return Card.parse(str(value))
def parse_card(code: Any) -> Card:
"""Parse a card code, raising :class:`ValueError` on malformed input."""
return Card.parse(str(code))
@dataclass
class TrickCard:
"""One card played in the current trick, in play order."""
seat: int
card: str
def to_json(self) -> Dict[str, Any]:
return {"seat": self.seat, "card": self.card}
@staticmethod
def from_json(data: Dict[str, Any]) -> "TrickCard":
return TrickCard(seat=int(data["seat"]), card=str(data["card"]))
@dataclass
class Trick:
"""A completed trick: the winner's seat and the cards it captured."""
winner: int
cards: List[TrickCard] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"winner": self.winner,
"cards": [c.to_json() for c in self.cards],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Trick":
return Trick(
winner=int(data["winner"]),
cards=[TrickCard.from_json(c) for c in data.get("cards", [])],
)
@dataclass
class PlayerState:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
@property
def team(self) -> int:
return team_of(self.seat)
def to_json(self) -> Dict[str, Any]:
return {
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": [c.to_json() for c in self.hand],
"captured": [c.to_json() for c in self.captured],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "PlayerState":
return PlayerState(
sub=str(data["sub"]),
name=str(data["name"]),
seat=int(data["seat"]),
hand=[Card.from_json(c) for c in data.get("hand", [])],
captured=[Card.from_json(c) for c in data.get("captured", [])],
)
@dataclass
class BriscolaState:
player_count: int = 2
target_score: int = DEFAULT_TARGET_SCORE
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
# Face-down draw pile; cards are drawn from the end.
stock: List[Card] = field(default_factory=list)
# The face-up card turned at deal time: its suit is the trump suit for
# the whole deal, and it is the last card drawn from the stock.
briscola_card: Optional[Card] = None
briscola_suit: str = ""
dealer: int = 0
turn: int = 0
# 1-based deal counter, used in the audit trail and deadlines.
hand_number: int = 1
# Cards of the trick in progress, in play order.
trick: List[TrickCard] = field(default_factory=list)
trick_leader: int = 0
# The most recently completed trick, for the table announcement.
last_trick: Optional[Trick] = None
# Match points accumulated across deals (patta deals do not count).
scores: List[int] = field(default_factory=lambda: [0, 0])
winner: Optional[int] = None
# Per-deal points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the deal summary waits before dealing anyway.
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS
# While phase == "playing": when the server plays a random card for
# the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"player_count": self.player_count,
"target_score": self.target_score,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"stock": [c.to_json() for c in self.stock],
"briscola_card": (
self.briscola_card.to_json() if self.briscola_card else None
),
"briscola_suit": self.briscola_suit,
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"trick": [t.to_json() for t in self.trick],
"trick_leader": self.trick_leader,
"last_trick": self.last_trick.to_json() if self.last_trick else None,
"scores": list(self.scores),
"winner": self.winner,
"hand_scores": list(self.hand_scores),
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "BriscolaState":
return BriscolaState(
player_count=int(data.get("player_count", 2)),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
stock=[Card.from_json(c) for c in data.get("stock", [])],
briscola_card=(
Card.from_json(data["briscola_card"])
if data.get("briscola_card")
else None
),
briscola_suit=str(data.get("briscola_suit", "")),
dealer=int(data.get("dealer", 0)),
turn=int(data.get("turn", 0)),
hand_number=int(data.get("hand_number", 1)),
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
trick_leader=int(data.get("trick_leader", 0)),
last_trick=(
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
),
scores=[int(x) for x in data.get("scores", [0, 0])],
winner=data.get("winner"),
hand_scores=list(data.get("hand_scores", [])),
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(
data.get("hand_ack_timeout", DEFAULT_HAND_ACK_TIMEOUT_SECONDS)
),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
return None
def seated(self, sub: str) -> bool:
return self.player_for(sub) is not None
@@ -0,0 +1,691 @@
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import engine
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import (
HAND_SIZE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
Card,
PlayerState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
*,
briscola_suit: str = "S",
briscola_card: str | None = None,
stock=None,
captured=None,
turn: int = 0,
dealer: int = 0,
target: int = 61,
scores=None,
phase: str = PHASE_PLAYING,
) -> BriscolaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = BriscolaState(
player_count=len(hands),
target_score=target,
phase=phase,
dealer=dealer,
turn=turn,
briscola_suit=briscola_suit,
briscola_card=card(briscola_card) if briscola_card else None,
stock=[card(c) for c in (stock or [])],
scores=list(scores or [0, 0]),
)
for seat, hand in enumerate(hands):
state.players.append(
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand])
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
return state
def trick_cards(state: BriscolaState) -> list[str]:
return [t.card for t in state.trick]
def play_all(state: BriscolaState) -> None:
"""Play every card of the current trick in turn order."""
for _ in range(state.player_count):
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
class DeckTest(unittest.TestCase):
def test_full_deck_has_40_unique_cards(self) -> None:
deck = engine.full_deck()
self.assertEqual(40, len(deck))
self.assertEqual(40, len({c.code for c in deck}))
self.assertEqual(4, len({c.suit for c in deck}))
def test_shuffled_deck_is_permutation(self) -> None:
deck = engine.shuffled_deck()
self.assertEqual(
sorted(c.code for c in engine.full_deck()),
sorted(c.code for c in deck),
)
def test_card_points_add_up_to_120(self) -> None:
self.assertEqual(
120, sum(engine.card_points(c) for c in engine.full_deck())
)
self.assertEqual(11, engine.card_points(card("01D")))
self.assertEqual(10, engine.card_points(card("03C")))
self.assertEqual(4, engine.card_points(card("10S")))
self.assertEqual(3, engine.card_points(card("09B")))
self.assertEqual(2, engine.card_points(card("08D")))
self.assertEqual(0, engine.card_points(card("07C")))
class DealTest(unittest.TestCase):
def test_two_player_deal(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
# 40 - 3*2 - 1 face-up = 33 face-down.
self.assertEqual(33, len(state.stock))
self.assertIsNotNone(state.briscola_card)
assert state.briscola_card is not None
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
self.assertEqual(1, state.turn) # dealer is seat 0
self.assertEqual([], state.trick)
def test_four_player_deal(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
self.assertEqual(1, state.turn)
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
dealt = [c.code for p in state.players for c in p.hand]
assert state.briscola_card is not None
dealt.append(state.briscola_card.code)
dealt.extend(c.code for c in state.stock)
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
def test_unsupported_player_count(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=3)
def test_target_score_bounds(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=60)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=121)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=True)
state = engine.create_game("p0", "p0", target_score=101)
self.assertEqual(101, state.target_score)
class TrickTest(unittest.TestCase):
def test_higher_card_of_led_suit_wins(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
self.assertEqual(1, state.last_trick.winner)
self.assertEqual(["02D", "05D"],
[c.code for c in state.players[1].captured])
def test_trump_beats_a_higher_led_card(self) -> None:
state = make_state([["07D"], ["02C"]], briscola_suit="C")
engine.play(state, "p0", "07D")
engine.play(state, "p1", "02C")
self.assertEqual(1, state.last_trick.winner)
def test_higher_trump_wins_among_trumps(self) -> None:
state = make_state([["02C"], ["05C"]], briscola_suit="C")
engine.play(state, "p0", "02C")
engine.play(state, "p1", "05C")
self.assertEqual(1, state.last_trick.winner)
def test_off_suit_non_trump_loses(self) -> None:
state = make_state([["04D"], ["01C"]], briscola_suit="B")
engine.play(state, "p0", "04D")
engine.play(state, "p1", "01C")
self.assertEqual(0, state.last_trick.winner)
def test_rank_strength_order(self) -> None:
# (winner card, loser card) pairs of the led suit, strongest first:
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
pairs = [
("01D", "03D"), # ace beats three
("03D", "10D"), # three beats king
("10D", "09D"), # king beats horse
("09D", "08D"), # horse beats jack
("08D", "07D"), # jack beats seven
("07D", "06D"),
("06D", "05D"),
("05D", "04D"),
("04D", "02D"),
]
for winner, loser in pairs:
state = make_state([["02D"], ["05D"]], briscola_suit="B")
state.players[0].hand = [card(winner)]
state.players[1].hand = [card(loser)]
engine.play(state, "p0", winner)
engine.play(state, "p1", loser)
self.assertEqual(
0, state.last_trick.winner, f"{winner} should beat {loser}"
)
def test_four_player_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.last_trick.winner)
def test_four_player_trump_beats_all(self) -> None:
state = make_state(
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(1, state.last_trick.winner)
def test_winner_leads_the_next_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.turn)
self.assertEqual(3, state.trick_leader)
self.assertEqual([], state.trick)
def test_turn_advances_within_a_trick(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
self.assertEqual(["02D"], trick_cards(state))
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_and_hand_end_reject_plays(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
class DrawTest(unittest.TestCase):
def test_winner_draws_first(self) -> None:
# The stock is drawn from its end: 04B first, then 03B, ...
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
self.assertEqual(["09C", "03B"], hands[0])
self.assertEqual(["09B", "04B"], hands[1])
def test_face_up_briscola_is_drawn_last(self) -> None:
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="C",
briscola_card="05C",
stock=["03B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
# The winner draws first and takes the last face-down card; the
# other player then takes the face-up briscola.
self.assertEqual(["09B", "03B"], hands[1])
self.assertEqual(["09C", "05C"], hands[0])
self.assertIsNone(state.briscola_card)
def test_no_draw_when_stock_is_empty(self) -> None:
state = make_state(
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
# p1 wins the trick; with no stock nobody draws and the hands
# shrink for the rest of the deal.
for player in state.players:
self.assertEqual(1, len(player.hand))
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
while state.stock or state.briscola_card is not None:
play_all(state)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(0, len(state.stock))
self.assertIsNone(state.briscola_card)
class ScoringTest(unittest.TestCase):
def test_deal_points_group_by_team(self) -> None:
state = make_state(
[[], [], [], []],
briscola_suit="S",
captured=[
["01D", "07C"], # seat 0, team A: 11
["03C", "08D"], # seat 1, team B: 10
["10S"], # seat 2, team A: 4
["09B"], # seat 3, team B: 3
],
)
points, details = engine.deal_points(state)
self.assertEqual([15, 15], points)
self.assertEqual({"A": 15, "B": 15}, details["points"])
self.assertEqual({"A": 3, "B": 3}, details["cards"])
def test_full_deal_scores_120(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
while state.phase == PHASE_PLAYING:
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
points, _ = engine.deal_points(state)
self.assertEqual(120, sum(points))
def test_match_ends_when_target_reached(self) -> None:
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], ["10C"]],
scores=[60, 50],
target=61,
)
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
state.players[0].hand = [card("05D")]
state.players[1].hand = [card("02D")]
engine.play(state, "p0", "05D")
engine.play(state, "p1", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertEqual([71, 54], state.scores)
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
# 120 and tie, so another deal must be played.
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
f"{rank:02d}S" for rank in range(1, 11)
] + [f"{rank:02d}B" for rank in range(1, 11)]
state = make_state(
[[], []],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[65, 55],
target=120,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual([120, 120], state.scores)
self.assertIsNone(state.winner)
def test_patta_is_discarded_and_replayed(self) -> None:
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
f"{rank:02d}C" for rank in range(1, 11)
]
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
f"{rank:02d}B" for rank in range(1, 11)
]
state = make_state(
[["02D"], ["02S"]],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[10, 20],
target=61,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
# The 60-60 deal does not count: totals are unchanged.
self.assertEqual([10, 20], state.scores)
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
# The next deal is played at the same cumulative totals.
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual([10, 20], state.scores)
def test_audit_trail_records_each_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
scores=[0, 0],
)
engine._end_deal(state)
entry = state.hand_scores[-1]
self.assertEqual(1, entry["deal"])
self.assertFalse(entry["patta"])
self.assertEqual({"A": 11, "B": 0}, entry["points"])
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 3):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual("lobby", state.phase)
engine.join_game(state, "p3", "p3")
self.assertEqual(PHASE_PLAYING, state.phase)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_full_random_two_player_match(self) -> None:
self._random_match(player_count=2)
def test_full_random_four_player_match(self) -> None:
self._random_match(player_count=4)
def _random_match(self, player_count: int) -> None:
state = engine.create_game("p0", "p0", player_count=player_count)
for i in range(1, player_count):
engine.join_game(state, f"p{i}", f"p{i}")
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, rng.choice(player.hand).code)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
self.assertEqual([], state.trick)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
# Every counted deal totals 120 points, and the winner reached the
# target with a clear lead.
winner_score = state.scores[state.winner]
loser_score = state.scores[1 - state.winner]
self.assertGreaterEqual(winner_score, state.target_score)
self.assertGreater(winner_score, loser_score)
self.assertEqual(0, (winner_score + loser_score) % 120)
class AckTest(unittest.TestCase):
def _hand_end_state(self) -> BriscolaState:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
return state
def test_ack_all_deals_the_next_deal(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
self.assertEqual(PHASE_HAND_END, state.phase)
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual((dealer_before + 1) % 2, state.dealer)
self.assertEqual((dealer_before + 2) % 2, state.turn)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual([], player.captured)
def test_double_ack_is_idempotent(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
def test_ack_outside_hand_end_is_rejected(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_ack_by_non_player_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(NotYourTurn):
engine.acknowledge_hand(state, "mallory")
class AutoPlayTest(unittest.TestCase):
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"]],
briscola_suit="S",
stock=["01B", "02B"],
briscola_card="03B",
)
engine.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.players[0].hand))
self.assertEqual(1, len(state.trick))
def test_auto_play_can_end_the_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
# Both players are on their last card: two auto-plays complete the
# trick and the deal, scored A=11 / B=0.
engine.auto_play(state)
engine.auto_play(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.turn_deadline)
self.assertTrue(state.hand_end_deadline)
def test_auto_play_requires_playing_phase(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(GameNotStarted):
engine.auto_play(state)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
briscola_suit="D",
briscola_card="07D",
stock=["01B", "02B"],
)
view = engine.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(["02D", "03C"], players[0]["hand"])
self.assertNotIn("hand", players[1])
self.assertEqual(1, players[1]["cards_left"])
self.assertEqual("D", view["briscola_suit"])
self.assertEqual("07D", view["briscola_card"])
self.assertEqual(2, view["stock_count"])
self.assertTrue(view.get("your_turn"))
def test_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
briscola_card="07D",
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual("07D", view["briscola_card"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
captured=[["01C", "03B"], ["10D"]],
)
view = engine.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(21, players[0]["points"])
self.assertEqual(4, players[1]["points"])
self.assertEqual(2, players[0]["captured_count"])
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
view = engine.state_for_player(state, "p0")
self.assertEqual(PHASE_HAND_END, view["phase"])
self.assertEqual(
{
"deal": 1,
"patta": False,
"points": {"A": 11, "B": 0},
"cards": {"A": 1, "B": 0},
"totals": {"A": 11, "B": 0},
},
view["last_deal"],
)
self.assertTrue(view["hand_end_deadline"])
def test_trick_is_public_in_play_order(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
view = engine.state_for_player(state, None)
self.assertEqual(
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
view["trick"],
)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
restored = BriscolaState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.target_score, restored.target_score)
self.assertEqual(state.briscola_suit, restored.briscola_suit)
self.assertEqual(
state.briscola_card.code if state.briscola_card else None,
restored.briscola_card.code if restored.briscola_card else None,
)
self.assertEqual(
[c.code for c in state.stock], [c.code for c in restored.stock]
)
self.assertEqual(
[(t.seat, t.card) for t in state.trick],
[(t.seat, t.card) for t in restored.trick],
)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
self.assertEqual(
[[c.code for c in p.hand] for p in state.players],
[[c.code for c in p.hand] for p in restored.players],
)
def test_legacy_state_defaults(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
data = state.to_json()
del data["player_count"]
del data["briscola_card"]
del data["last_trick"]
restored = BriscolaState.from_json(data)
self.assertEqual(2, restored.player_count)
self.assertIsNone(restored.briscola_card)
self.assertIsNone(restored.last_trick)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,349 @@
"""Adapter tests: the platform contract over the pure briscola rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotStarted,
IllegalMove,
)
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.briscola import engine as rules
from tavolo.briscola.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="b1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for i in range(1, engine.max_players):
name = f"p{i}"
engine.join(session, name, name.capitalize())
return session
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
moves = 0
while not engine.is_finished(session) and moves < limit:
state = session.state
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
class MetadataTest(unittest.TestCase):
def test_two_player_metadata(self) -> None:
engine = BriscolaEngine()
self.assertEqual("briscola", engine.id)
self.assertEqual("Briscola", engine.name)
self.assertTrue(engine.description)
self.assertEqual(2, engine.min_players)
self.assertEqual(2, engine.max_players)
target = engine.options_schema["properties"]["target_score"]
self.assertEqual("integer", target["type"])
self.assertEqual(61, target["minimum"])
self.assertEqual(120, target["maximum"])
self.assertEqual(61, target["default"])
def test_four_player_metadata(self) -> None:
engine = BriscolaQuattroEngine()
self.assertEqual("briscola_a_quattro", engine.id)
self.assertEqual("Briscola a quattro", engine.name)
self.assertTrue(engine.description)
self.assertEqual(4, engine.min_players)
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_team_a(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, BriscolaState)
self.assertEqual(61, session.state.target_score)
self.assertEqual(2, session.state.player_count)
def test_create_options(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine, target_score=101)
self.assertEqual(101, session.state.target_score)
self.assertEqual(4, session.state.player_count)
def test_create_rejects_bad_options(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=60)
with self.assertRaises(IllegalMove):
_session(engine, target_score=121)
with self.assertRaises(IllegalMove):
_session(engine, target_score="sixtyone")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
def test_timeouts_come_from_the_engine(self) -> None:
engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_two_player_join_starts(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertEqual("B", session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_four_player_teams_alternate(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine)
teams = []
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
teams.append(session.players[-1].team)
self.assertEqual(["B", "A", "B"], teams)
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
engine.join(session, "bob", "Bob")
with self.assertRaises(GameNotStarted):
engine.join(session, "carol", "Carol")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": "nope"})
def test_play_advances_the_trick(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
self.assertEqual(1, state.turn)
card = state.players[1].hand[0].code
engine.handle_action(session, "p1", "play", {"card": card})
self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"])
def test_play_out_of_turn_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
card = state.players[0].hand[0].code
with self.assertRaises(GameError):
engine.handle_action(session, "alice", "play", {"card": card})
def test_ack_outside_hand_end_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "ack", {})
def test_finished_match_rejects_actions(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
def test_ack_all_deals_next(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
# Force the deal-end phase: both players keep 3 cards, so the
# match continues after the summary.
state.phase = PHASE_HAND_END
state.acked = []
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
engine.handle_action(session, "alice", "ack", {})
self.assertEqual(PHASE_HAND_END, state.phase)
engine.handle_action(session, "p1", "ack", {})
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
class ViewTest(unittest.TestCase):
def test_view_for_hides_other_hands(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
self.assertIn("briscola_suit", view)
self.assertIn("stock_count", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = BriscolaEngine()
session = _session(engine, target_score=101)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(101, lobby["target_score"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, BriscolaState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = BriscolaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code})
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(4, len(result.players))
winner_name = "A" if result.winner_team == 0 else "B"
for player in result.players:
self.assertEqual(player.won, player.team == winner_name)
self.assertIn("captured_cards", player.details)
self.assertIn("team_a_score", result.summary)
self.assertIn("team_b_score", result.summary)
self.assertIn("winner_team", result.summary)
self.assertIn("target_score", result.summary)
self.assertIn("deals_played", result.summary)
self.assertIn("deal_scores", result.summary)
def test_two_player_result(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams)
self.assertEqual(2, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [55, 65]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 55, "B": 65}, over["scores"])
self.assertEqual("B", over["winner"])
if __name__ == "__main__":
unittest.main()
+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
example is
[`tavolo-scopone`](../tavolo-scopone/README.md).
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md).
## Development (from `server/`)
@@ -71,7 +71,7 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
}
for index, seat in enumerate(session.players)
],
"seats_open": engine.max_players - len(session.players),
"seats_open": max(0, engine.max_players - len(session.players)),
**engine.lobby_view(session),
}
@@ -85,6 +85,9 @@ def _view_payload(
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
@@ -36,9 +36,10 @@ 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`. Because the whole message (minus
``action``) is handed to the engine as the payload, games define their
own fields freely.
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
message (minus ``action``) is handed to the engine as the payload, games
define their own fields freely.
Mutations run under the per-game lock; after a successful action the new
session is saved and a change signal is published. Every connected
@@ -101,6 +102,9 @@ def _state_message(
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
@@ -132,6 +132,33 @@ 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()
+5 -4
View File
@@ -4,10 +4,11 @@
#
# 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 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
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-briscola and tavolo-app
# sibling packages are NOT pinned here: they are installed from ./packages in
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
# -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola
# -e ./packages/tavolo-app). All of their
# third-party dependencies are direct dependencies of tavolo-app, so this
# lock covers the full closure. The otel extra (kaya-otel and its
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
+368
View File
@@ -0,0 +1,368 @@
"""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()
+27 -11
View File
@@ -1,4 +1,4 @@
"""Game lobby route tests via kaya's ASGI transport (scopone game)."""
"""Game lobby route tests via kaya's ASGI transport (all registered games)."""
from __future__ import annotations
import unittest
@@ -253,16 +253,32 @@ 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"], [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", results[0]["options_schema"]["properties"]
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.assertIn(
"asso_pigliatutto", scopone["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:
@@ -294,7 +310,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": "briscola"})
unknown = await client.post("/api/games", json={"game_type": "tresette"})
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,5 +136,91 @@ 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()
+190
View File
@@ -0,0 +1,190 @@
//! 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::{DealSummary, GameView};
use crate::ws::GameSocket;
/// 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(summary: &DealSummary) -> View {
let a = summary.points.a;
let b = summary.points.b;
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!("Team A {a} · Team B {b}"),
cls,
chip,
);
let cards = summary.cards.map(|c| {
summary_row(
card_back("score-mini"),
"Cards captured",
format!("Team A {} · Team 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)
};
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team 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") { "Team 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(&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)
}
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod briscola;
pub mod card;
pub mod copy;
pub mod nav;
+183
View File
@@ -26,6 +26,9 @@ 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>>,
@@ -54,6 +57,38 @@ 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")]
@@ -168,6 +203,30 @@ 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>,
}
#[derive(Debug, Deserialize)]
@@ -567,6 +626,22 @@ 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"
}
/// 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
@@ -1054,4 +1129,112 @@ 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 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());
}
}
+258
View File
@@ -0,0 +1,258 @@
//! 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, 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 = player.team.clone();
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)
span(class="team-badge") { "Team " (team) }
}
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;
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") {
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target) ")"
}
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)
}
}
}
}
+30 -12
View File
@@ -6,11 +6,16 @@ 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, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::model::{
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores,
ServerMessage,
};
use crate::pages::briscola::briscola_table;
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -20,7 +25,7 @@ fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<V
}
/// The absolute, shareable URL of this game's page, for the clipboard.
fn watch_link(id: &str) -> String {
pub(crate) fn watch_link(id: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
@@ -387,6 +392,10 @@ 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" => {
@@ -401,6 +410,9 @@ 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()))
@@ -715,11 +727,11 @@ fn capture_picker(
}
}
/// End-of-match overlay (scopone's team score; other games render
/// nothing until they ship their own play view).
/// End-of-match overlay: the final hand/deal breakdown and the winner.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" {
if g.game_type != "scopone_scientifico" && !briscola {
return view! {};
}
}
@@ -735,13 +747,19 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
let line = format!("Team {winner} 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 scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
// Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result.
let final_summary: View = if briscola {
match game.as_ref().and_then(|g| g.last_deal.clone()) {
Some(deal) => deal_rows(&deal),
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" }
});
+27 -9
View File
@@ -20,14 +20,32 @@ use crate::model::{
/// 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,
}]
vec![
GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola".to_string(),
name: "Briscola".to_string(),
description: String::new(),
min_players: 2,
max_players: 2,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola_a_quattro".to_string(),
name: "Briscola a quattro".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
]
}
/// Human-readable label for a provider name ("google" -> "Google").
@@ -552,7 +570,7 @@ 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 with friends."
"Play scopone scientifico and briscola with friends."
}
}
div(class="row g-4") {
+1
View File
@@ -1,3 +1,4 @@
pub mod briscola;
pub mod game;
pub mod history;
pub mod leaderboard;
+8 -2
View File
@@ -83,8 +83,14 @@ pub fn connect(
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
on_message(parsed);
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(),
),
}
}
Ok(Message::Bytes(_)) => {}
+67 -1
View File
@@ -1,4 +1,4 @@
/* Scopone scientifico frontend — game table, cards and scoring summary.
/* tavolo frontend — game tables, cards and scoring summaries.
*
* The felt palette and the Bootstrap theme overrides live in theme.css,
* which is loaded before this file. */
@@ -474,3 +474,69 @@ 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: -34px;
}
.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";
}