Add scopa: classic two-player and scopa a quattro

New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

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

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
This commit is contained in:
2026-10-09 14:45:20 +08:00
parent 9eea293471
commit 68200bbe62
27 changed files with 3401 additions and 116 deletions
+58 -13
View File
@@ -1,10 +1,11 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. It is a monorepo of four distributions
[kaya](../kaya) framework. It is a monorepo of five distributions
sharing the `tavolo` namespace (see
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md),
[`packages/tavolo-scopa/README.md`](packages/tavolo-scopa/README.md)
and
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
@@ -15,6 +16,10 @@ and
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
scientifico, the four-player, fixed-partnership variant of the classic
Italian card game: pure rules plus the platform adapter.
- **`tavolo-scopa`** (`packages/tavolo-scopa/`) — scopa, the classic
Italian fishing game, in its two-player (`scopa`) and four-player
fixed-partnership (`scopa_a_quattro`) variants, with the optional asso
pigliatutto rule (scopa d'assi): pure rules plus the platform adapters.
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
Italian trick-taking game, in its classic two-player (`briscola`),
four-player partnership (`briscola_a_quattro`) and five-player
@@ -236,9 +241,11 @@ aggregate with `max`, not `sum`.
scoped per game), start/finish timestamps, and the game-specific outcome
as reported by the engine (`result` JSON — for scopone: both teams'
final scores, winner, target score, hands played and the per-hand
audit; for briscola: the final scores, winner, target score, deals
played and the per-deal audit; for briscola chiamata: the calling side's
points against the defenders', the declared target, the called card and
audit; for scopa: both sides' final scores, winner, target score, the
asso pigliatutto flag, hands played and the per-hand audit; for
briscola: the final scores, winner, target score, deals played and the
per-deal audit; for briscola chiamata: the calling side's points
against the defenders', the declared target, the called card and
whether the caller played alone).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points
@@ -278,7 +285,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}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` or `{"game_type": "scopa_a_quattro"}` with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
@@ -312,7 +319,7 @@ platform handles `state` (resend the snapshot) itself; every other action
is dispatched to the session's game engine with the rest of the message
as its payload. Spectators may only send `state`/`sync`: any other
action is answered with an `error` whose `code` is `forbidden`. For
scopone scientifico:
scopone scientifico and scopa (both variants):
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -384,11 +391,11 @@ for the called-card choice (a random suit of the won rank).
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries the
scoring breakdown of the hand just played (`last_hand` with an `award`
map naming the team that won each scopone category; `last_deal` with the
deal's points and running totals for briscola), the `acknowledged` seats
and a `hand_end_deadline`. The frontend renders this as a screen every
player must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
map naming the side that won each scopone/scopa category; `last_deal`
with the deal's points and running totals for briscola), the
`acknowledged` seats and a `hand_end_deadline`. The frontend renders this
as a screen every player must dismiss. A play attempted in this phase is
rejected with an `illegal_move` error.
## Rules implemented
@@ -417,6 +424,36 @@ player must dismiss. A play attempted in this phase is rejected with an
configurable per game via the `target_score` creation option) with a
clear lead; a tie at or above the target is broken by another hand.
### Scopa
- 40-card Italian deck; three cards per player and four cards face up on
the table at the start of every hand. When every hand is empty the
dealer deals three more cards to each player (no new table cards) until
the deck is exhausted: two players play six rounds per hand, four
players three.
- A card captures a **single card of equal rank** (mandatory when present)
or a **combination of table cards whose ranks sum to its own**; when any
capture exists it must be taken. Remaining table cards at the end of the
hand go to the player who made the last capture.
- Emptying the table scores a *scopa* (+1), except on the last play of the
hand (a sweep on the last play of a round still scores when the deck has
cards left).
- Hand points: `carte`, `denara`, `settebello` and `primiera` (same values
and all-four-suits requirement as scopone), plus one point per scopa.
Ties award nothing. There is no napola.
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
option, default off; scopa d'assi): an ace played when no equal rank is
on the table sweeps every card on the table — or captures itself when
the table is empty. The capture never scores a scopa, but it counts as
the last capture, and the mandatory equal-rank capture still applies
when another ace is on the table.
- The match ends when a side reaches the target score (default 11,
configurable per game via the `target_score` creation option) with a
clear lead; a tie at or above the target is broken by another hand.
- `scopa` is the classic two-player game; `scopa_a_quattro` seats four
players in two fixed partnerships (seats 0/2 vs 1/3, partners
opposite).
### Briscola
- 40-card Italian deck, three cards per player. The card turned up after
@@ -470,16 +507,18 @@ player must dismiss. A play attempted in this phase is rejected with an
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa -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-scopa/tests
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
# type checks (see packages/tavolo-app/README.md for the app modules):
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
.venv/bin/python -m mypy -p tavolo.scopa
.venv/bin/python -m mypy -p tavolo.briscola
```
@@ -538,6 +577,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-scopa/ # scopa game (own pyproject + tests)
src/tavolo/scopa/
├── state.py # ScopaState / PlayerState / Card / Move, JSON (de)serialization
├── engine.py # pure scopa rules (two- and four-player)
├── errors.py # scopa-specific rule violations
└── plugin.py # ScopaEngine + ScopaQuattroEngine adapters
packages/tavolo-briscola/ # briscola game (own pyproject + tests)
src/tavolo/briscola/
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization