Five-player briscola with a descending calling auction and a secret partner. Starting with the player after the dealer, each player bids a lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the standing bid is the two the auction continues on points (62, 64, ...). The last player standing must bid, and the winner names any card of the won rank: its holder is the secret partner, or the caller plays alone when it is one of their own cards, and its suit becomes the trump. The calling side must reach 61 (or the raised target); a 60-60 deal with the plain target is a patta and is replayed. A match is a single deal, so the caller and partner (or the lone caller) against the other players map onto the platform's two-team result, Elo and match history. The rules live in tavolo.briscola.chiamata with the BriscolaChiamataEngine adapter registered alongside the other briscola variants; the partner's role and the side totals stay hidden until the called card is played. The web client gets a five-seat table, the auction panel with legal bids and point raises, the call picker, the reveal bands and a chiamata-aware final summary.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# tavolo
|
||||
|
||||
A platform for multiplayer card games. Two games are included:
|
||||
A platform for multiplayer card games. Three games are included:
|
||||
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||
game — and **briscola**, in its classic two-player and four-player
|
||||
partnership (*briscola a quattro*) variants:
|
||||
game — **briscola**, in its classic two-player and four-player
|
||||
partnership (*briscola a quattro*) variants, and **briscola chiamata** —
|
||||
the five-player variant with a calling auction and a secret partner:
|
||||
|
||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||
@@ -45,7 +46,9 @@ 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.
|
||||
match-ending hand is explained on the final screen. Briscola chiamata
|
||||
plays a single deal per match, so it goes straight from the last trick to
|
||||
the final scoring screen; a 60–60 *patta* is replayed immediately.
|
||||
|
||||
## On your turn
|
||||
|
||||
@@ -53,12 +56,17 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
||||
player does not move — disconnected or fallen asleep — the server plays a
|
||||
random legal card for them (choosing among the legal captures when the
|
||||
rules require a capture), so one absent player cannot stall the table.
|
||||
Briscola chiamata arms the same countdown for its auction bids and for
|
||||
the auction winner's called-card choice (auto-bid or auto-pass, then a
|
||||
random suit of the won rank).
|
||||
|
||||
## Watching
|
||||
|
||||
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||||
spectators see the table, the score and every player's captured cards
|
||||
(and scope for scopone), but never a hand and never the join code. The
|
||||
(and scope for scopone), but never a hand and never the join code. In
|
||||
briscola chiamata they also see no role labels and no side totals until
|
||||
the called card is played, so the secret partner stays secret. The
|
||||
"Copy watch link" button in the waiting room and in the game HUD copies
|
||||
the URL to share. A spectator who opens the URL before the match starts
|
||||
sees a "game not started yet" waiting room.
|
||||
|
||||
+56
-7
@@ -16,9 +16,10 @@ and
|
||||
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.
|
||||
Italian trick-taking game, in its classic two-player (`briscola`),
|
||||
four-player partnership (`briscola_a_quattro`) and five-player
|
||||
called-partner (`briscola_chiamata`) variants: pure rules plus the
|
||||
platform adapter.
|
||||
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||
composition root wiring the platform to the games, with the
|
||||
environment-driven settings and the SPA shell.
|
||||
@@ -236,7 +237,9 @@ aggregate with `max`, not `sum`.
|
||||
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).
|
||||
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
|
||||
they scored, the Elo change the match produced (`elo_delta`) and
|
||||
@@ -275,7 +278,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}`. 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}`, 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 |
|
||||
@@ -341,6 +344,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
|
||||
every player has acknowledged, or automatically after
|
||||
`HAND_ACK_TIMEOUT_SECONDS`.
|
||||
|
||||
For briscola chiamata the game actions are the calling auction plus play:
|
||||
|
||||
```json
|
||||
{"action": "bid", "rank": 3}
|
||||
{"action": "bid", "points": 64}
|
||||
{"action": "pass"}
|
||||
{"action": "call", "card": "03D"}
|
||||
{"action": "play", "card": "04C"}
|
||||
```
|
||||
|
||||
- `bid` with a `rank` calls a lower rank than the standing bid
|
||||
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
|
||||
target once the standing bid is the two.
|
||||
- `pass` leaves the auction; the last player standing must bid.
|
||||
- `call` names any card of the won rank; naming one of your own cards
|
||||
means playing alone.
|
||||
- `play` follows briscola: any card in hand is legal.
|
||||
|
||||
After every accepted move the new state is broadcast to every connected
|
||||
client, spectators included.
|
||||
|
||||
@@ -353,7 +374,10 @@ is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||
queue (see above), not from timers tied to client connections, so the
|
||||
match keeps progressing even with every player disconnected.
|
||||
match keeps progressing even with every player disconnected. Briscola
|
||||
chiamata uses the same timeout for its auction bids (auto-bid or
|
||||
auto-pass) and for the called-card choice (a random suit of the won
|
||||
rank).
|
||||
|
||||
### Hand-end summary
|
||||
|
||||
@@ -416,6 +440,30 @@ player must dismiss. A play attempted in this phase is rejected with an
|
||||
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||
opposite).
|
||||
|
||||
### Briscola chiamata
|
||||
|
||||
- 40-card Italian deck, eight cards each for five players: the whole deck
|
||||
is dealt, so there is no stock and no face-up card.
|
||||
- The calling auction starts with the player after the dealer: each
|
||||
player either passes (leaving the auction) or bids a lower rank than
|
||||
the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the
|
||||
standing bid is the two, the auction continues on points (62, 64, …).
|
||||
The last player standing must bid, so every deal gets a caller.
|
||||
- The winner names any card of the won rank. If the winner holds it, the
|
||||
caller plays alone against the other four; otherwise its holder is the
|
||||
secret partner and the two form the calling side. The called card's
|
||||
suit is the trump; the partner's identity stays hidden until the
|
||||
called card is played.
|
||||
- Play and trick resolution follow briscola; eight tricks exhaust every
|
||||
hand. The calling side must reach the declared target (61 unless
|
||||
raised); with the plain target a 60–60 deal is a *patta* and is
|
||||
replayed with the next dealer. A match is a single deal, so the caller
|
||||
and partner (or the lone caller) against the other players map onto
|
||||
the platform's two-team result, Elo and history.
|
||||
- Auction bids, the called-card choice and the plays all have their own
|
||||
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid,
|
||||
auto-called or auto-played for).
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
@@ -493,8 +541,9 @@ packages/tavolo-briscola/ # briscola game (own pyproject + tests)
|
||||
src/tavolo/briscola/
|
||||
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
||||
├── engine.py # pure briscola rules (2- and 4-player)
|
||||
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
|
||||
├── errors.py # briscola-specific rule violations
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
|
||||
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||
src/tavolo/
|
||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||
|
||||
@@ -39,7 +39,11 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||
from tavolo.briscola import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
@@ -144,6 +148,12 @@ registry.register(
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||
log.debug(
|
||||
"game timeouts: hand_ack=%ds turn=%ds",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
Briscola — the Italian trick-taking game — as a
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||
Two variants share one rules core:
|
||||
Three variants share one rules core:
|
||||
|
||||
- `briscola` — the classic two-player game;
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola;
|
||||
- `briscola_chiamata` — five-player briscola with a calling auction and a
|
||||
secret partner.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -13,27 +15,40 @@ Two variants share one rules core:
|
||||
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||
envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
||||
(trump first, then the led suit; strength ace > three > king > horse >
|
||||
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
||||
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
||||
jack 2), patta re-deals and the target-score match end, deal-end
|
||||
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
||||
logging, so the whole rule set is unit-testable.
|
||||
- `engine.py` — the pure rules engine for two- and four-player briscola:
|
||||
deck, deals, trick resolution (trump first, then the led suit; strength
|
||||
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
|
||||
(trick winner first, the face-up briscola last), card points (ace 11,
|
||||
three 10, king 4, horse 3, jack 2), patta re-deals and the
|
||||
target-score match end, deal-end acknowledgements and auto-play.
|
||||
Deterministic and I/O-free apart from logging, so the whole rule set is
|
||||
unit-testable.
|
||||
- `chiamata.py` — the pure rules engine for five-player briscola
|
||||
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
|
||||
round-trip), the 8-card deal with no stock, the descending calling
|
||||
auction with point raises, the winner's call (including calling one's
|
||||
own card, which means playing alone), the called-card reveal, single-
|
||||
deal scoring with the 60–60 patta and the auto-actions for timeouts.
|
||||
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
||||
(`GameEngine`): the platform-facing adapters. They validate the
|
||||
`target_score` creation option (61–120, default 61) and extract the
|
||||
`MatchResult` (teams, winner, per-player scores and the match summary
|
||||
persisted as the match's JSON `result`).
|
||||
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||
The first two validate the `target_score` creation option (61–120,
|
||||
default 61) and extract the `MatchResult` (teams, winner, per-player
|
||||
scores and the match summary persisted as the match's JSON `result`).
|
||||
Briscola chiamata takes no creation options.
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root.
|
||||
application composition root. Briscola chiamata uses the turn timeout
|
||||
for its auction bids, the called-card choice and the plays; it never
|
||||
enters the between-deals acknowledgement phase (a single deal ends the
|
||||
match), so the hand-ack timeout is accepted but unused.
|
||||
|
||||
## Rules
|
||||
|
||||
### Briscola (two-player and four-player)
|
||||
|
||||
- Three cards per player; the next card is turned face up: its suit is
|
||||
the trump (briscola) for the deal and it is the last card drawn.
|
||||
- The winner of a trick is the highest trump, or the highest card of the
|
||||
@@ -47,6 +62,31 @@ application composition root.
|
||||
- Between deals of an unfinished match every player acknowledges the
|
||||
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||
|
||||
### Briscola chiamata (five-player)
|
||||
|
||||
- The whole 40-card deck is dealt out, eight cards each: there is no
|
||||
stock and no face-up card.
|
||||
- Starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a **lower** rank than the
|
||||
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
|
||||
the first bidder may bid any rank. Once the standing bid is the two,
|
||||
the auction continues on points instead (62, 64, …): the calling side
|
||||
then has to reach the raised target. The last player standing must
|
||||
bid, so a deal always gets a caller.
|
||||
- The auction winner names any card of the winning rank. If the winner
|
||||
holds it, they **play alone** against the other four; otherwise its
|
||||
holder is the **secret partner** and the two form the calling side.
|
||||
The called card's suit is the trump for the deal. The partner's
|
||||
identity is hidden until the called card is played.
|
||||
- Play follows briscola: the player after the dealer leads, the highest
|
||||
trump (or highest led-suit card) takes the trick and leads the next
|
||||
one; eight tricks exhaust every hand.
|
||||
- The calling side (caller plus partner, or the lone caller) must reach
|
||||
the declared target — 61 unless the auction raised it. With the plain
|
||||
target a 60–60 deal is a patta and is replayed with the next dealer.
|
||||
A match is a single deal, so the two sides map onto the platform's
|
||||
two-team result (Elo rating, match history).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
@@ -54,5 +94,5 @@ application composition root.
|
||||
.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).
|
||||
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"""Briscola: tavolo's trick-taking game implementation.
|
||||
|
||||
Two variants share the same rules core and platform adapter:
|
||||
Three variants share the same rules core and platform adapter:
|
||||
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||
briscola (``briscola``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
|
||||
briscola chiamata (``briscola_chiamata``).
|
||||
|
||||
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
||||
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
||||
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
|
||||
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
|
||||
adapts them to the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||
game-independent platform can host them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
||||
from .plugin import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
|
||||
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
||||
__all__ = [
|
||||
"BriscolaChiamataEngine",
|
||||
"BriscolaEngine",
|
||||
"BriscolaQuattroEngine",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
"""Pure rules engine for briscola chiamata.
|
||||
|
||||
Briscola chiamata is the five-player variant of briscola. The 40-card
|
||||
deck is dealt out entirely (8 cards each, no stock): there is no face-up
|
||||
briscola card. Instead the players auction off the right to call a
|
||||
partner:
|
||||
|
||||
* starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a rank lower than the standing
|
||||
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
|
||||
bidder may bid any rank. Once the standing bid is the 2, further bids
|
||||
raise the points the calling side must reach instead (62, 64, ...).
|
||||
* the last remaining bidder wins and names the exact card of the winning
|
||||
rank (any suit). If the winner holds the named card they play alone
|
||||
against the other four; otherwise its holder is the **secret partner**
|
||||
and the two form the calling side. The called card's suit is the trump
|
||||
(*briscola*) for the whole deal.
|
||||
* play and scoring follow briscola. The calling side must reach the
|
||||
declared target (61 unless raised); a 60-60 deal with the plain target
|
||||
is a **patta** and is replayed.
|
||||
|
||||
A match is a single deal (patta replays aside), so the two sides map
|
||||
onto the platform's two-team result (Elo rating, match history). The
|
||||
partner's identity stays hidden until the called card is played, at
|
||||
which point the roles are revealed.
|
||||
|
||||
Like :mod:`tavolo.briscola.engine` this module is deterministic and
|
||||
I/O-free apart from logging: it mutates (or reads)
|
||||
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
|
||||
:class:`~tavolo.platform.errors.GameError` subclasses on rule
|
||||
violations. The platform-facing adapter is
|
||||
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
NotYourTurn,
|
||||
)
|
||||
|
||||
from .engine import TRICK_STRENGTH, card_points, shuffled_deck
|
||||
from .errors import CardNotInHand
|
||||
from .state import (
|
||||
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
SUITS,
|
||||
Card,
|
||||
Trick,
|
||||
TrickCard,
|
||||
parse_card,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Five seats, the whole 40-card deck dealt out.
|
||||
PLAYER_COUNT = 5
|
||||
HAND_SIZE = 8
|
||||
|
||||
PHASE_AUCTION = "auction"
|
||||
PHASE_CALLING = "calling"
|
||||
|
||||
# The two sides of the match: the caller (plus the partner, when there is
|
||||
# one) against the other players.
|
||||
SIDE_CALLER = 0
|
||||
SIDE_DEFENDERS = 1
|
||||
SIDE_NAMES = ("Chiamante", "Difensori")
|
||||
|
||||
# Role labels carried by the personalized view once they are public.
|
||||
ROLE_CALLER = "Chiamante"
|
||||
ROLE_PARTNER = "Socio"
|
||||
ROLE_DEFENDER = "Difensori"
|
||||
|
||||
# Points the calling side must reach: 61 unless the auction raised it.
|
||||
DEFAULT_TARGET = 61
|
||||
MIN_TARGET = 61
|
||||
MAX_TARGET = 120
|
||||
|
||||
# Rank ladder of the auction, strongest first: ace, three, king, horse,
|
||||
# jack, then the plain ranks downwards.
|
||||
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
|
||||
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
|
||||
|
||||
_rng = random.SystemRandom()
|
||||
|
||||
|
||||
# -- state -----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataPlayer:
|
||||
sub: str
|
||||
name: str
|
||||
seat: int
|
||||
hand: List[Card] = field(default_factory=list)
|
||||
captured: List[Card] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sub": self.sub,
|
||||
"name": self.name,
|
||||
"seat": self.seat,
|
||||
"hand": [c.to_json() for c in self.hand],
|
||||
"captured": [c.to_json() for c in self.captured],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataPlayer":
|
||||
return ChiamataPlayer(
|
||||
sub=str(data["sub"]),
|
||||
name=str(data["name"]),
|
||||
seat=int(data["seat"]),
|
||||
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bid:
|
||||
"""One auction bid: a rank, or (at the 2) a raised points target."""
|
||||
|
||||
seat: int
|
||||
rank: int
|
||||
points: int
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {"seat": self.seat, "rank": self.rank, "points": self.points}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Bid":
|
||||
return Bid(
|
||||
seat=int(data["seat"]),
|
||||
rank=int(data["rank"]),
|
||||
points=int(data["points"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Auction:
|
||||
"""The state of the calling auction."""
|
||||
|
||||
# Seat to act; always one of ``active``.
|
||||
turn: int = 0
|
||||
active: List[int] = field(default_factory=list)
|
||||
passed: List[int] = field(default_factory=list)
|
||||
current: Optional[Bid] = None
|
||||
history: List[Bid] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"active": list(self.active),
|
||||
"passed": list(self.passed),
|
||||
"current": self.current.to_json() if self.current else None,
|
||||
"history": [b.to_json() for b in self.history],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Auction":
|
||||
return Auction(
|
||||
turn=int(data.get("turn", 0)),
|
||||
active=[int(s) for s in data.get("active", [])],
|
||||
passed=[int(s) for s in data.get("passed", [])],
|
||||
current=(
|
||||
Bid.from_json(data["current"]) if data.get("current") else None
|
||||
),
|
||||
history=[Bid.from_json(b) for b in data.get("history", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataState:
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[ChiamataPlayer] = field(default_factory=list)
|
||||
dealer: int = 0
|
||||
# 1-based deal counter; grows with patta replays.
|
||||
hand_number: int = 1
|
||||
# 0 = the calling side (caller plus partner, when there is one),
|
||||
# 1 = the defenders. Only set once the match is finished.
|
||||
winner: Optional[int] = None
|
||||
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||
# The calling auction of the current deal.
|
||||
auction: Optional[Auction] = None
|
||||
# Seat that won the auction (public from then on).
|
||||
caller: Optional[int] = None
|
||||
# The card named by the caller; its suit is the trump.
|
||||
called_card: Optional[Card] = None
|
||||
briscola_suit: str = ""
|
||||
# Points target from the auction (61 unless raised).
|
||||
declared_target: int = DEFAULT_TARGET
|
||||
# True when the caller named a card of their own hand: no partner.
|
||||
solo: bool = False
|
||||
# True once the called card has been played; roles become public.
|
||||
called_played: bool = False
|
||||
# The partner's seat, recorded when the called card is played; stays
|
||||
# ``None`` for a solo call.
|
||||
partner_seat: Optional[int] = None
|
||||
# Cards of the trick in progress, in play order.
|
||||
trick: List[TrickCard] = field(default_factory=list)
|
||||
trick_leader: int = 0
|
||||
last_trick: Optional[Trick] = None
|
||||
# Seat currently to act (auction bidder, caller, or player on turn).
|
||||
turn: int = 0
|
||||
# Per-deal audit trail, patta entries included.
|
||||
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||
# While phase == "auction"/"playing": when the server acts for the
|
||||
# player on turn.
|
||||
turn_deadline: Optional[str] = None
|
||||
auction_deadline: Optional[str] = None
|
||||
# While phase == "calling": when the server names a card for the
|
||||
# auction winner.
|
||||
call_deadline: Optional[str] = None
|
||||
# Seconds a player has to act before the server acts for them.
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"dealer": self.dealer,
|
||||
"hand_number": self.hand_number,
|
||||
"winner": self.winner,
|
||||
"scores": list(self.scores),
|
||||
"auction": self.auction.to_json() if self.auction else None,
|
||||
"caller": self.caller,
|
||||
"called_card": (
|
||||
self.called_card.to_json() if self.called_card else None
|
||||
),
|
||||
"briscola_suit": self.briscola_suit,
|
||||
"declared_target": self.declared_target,
|
||||
"solo": self.solo,
|
||||
"called_played": self.called_played,
|
||||
"partner_seat": self.partner_seat,
|
||||
"trick": [t.to_json() for t in self.trick],
|
||||
"trick_leader": self.trick_leader,
|
||||
"last_trick": self.last_trick.to_json() if self.last_trick else None,
|
||||
"turn": self.turn,
|
||||
"hand_scores": list(self.hand_scores),
|
||||
"turn_deadline": self.turn_deadline,
|
||||
"auction_deadline": self.auction_deadline,
|
||||
"call_deadline": self.call_deadline,
|
||||
"turn_timeout": self.turn_timeout,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataState":
|
||||
return ChiamataState(
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[
|
||||
ChiamataPlayer.from_json(p) for p in data.get("players", [])
|
||||
],
|
||||
dealer=int(data.get("dealer", 0)),
|
||||
hand_number=int(data.get("hand_number", 1)),
|
||||
winner=data.get("winner"),
|
||||
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||
auction=(
|
||||
Auction.from_json(data["auction"]) if data.get("auction") else None
|
||||
),
|
||||
caller=data.get("caller"),
|
||||
called_card=(
|
||||
Card.from_json(data["called_card"])
|
||||
if data.get("called_card")
|
||||
else None
|
||||
),
|
||||
briscola_suit=str(data.get("briscola_suit", "")),
|
||||
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
|
||||
solo=bool(data.get("solo", False)),
|
||||
called_played=bool(data.get("called_played", False)),
|
||||
partner_seat=data.get("partner_seat"),
|
||||
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
|
||||
trick_leader=int(data.get("trick_leader", 0)),
|
||||
last_trick=(
|
||||
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
|
||||
),
|
||||
turn=int(data.get("turn", 0)),
|
||||
hand_scores=list(data.get("hand_scores", [])),
|
||||
turn_deadline=data.get("turn_deadline"),
|
||||
auction_deadline=data.get("auction_deadline"),
|
||||
call_deadline=data.get("call_deadline"),
|
||||
turn_timeout=int(
|
||||
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||
),
|
||||
)
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
|
||||
for player in self.players:
|
||||
if player.sub == sub:
|
||||
return player
|
||||
return None
|
||||
|
||||
def seated(self, sub: str) -> bool:
|
||||
return self.player_for(sub) is not None
|
||||
|
||||
|
||||
# -- game setup --------------------------------------------------------------
|
||||
|
||||
|
||||
def create_game(
|
||||
creator_sub: str,
|
||||
creator_name: str,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
) -> ChiamataState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
return ChiamataState(
|
||||
phase=PHASE_LOBBY,
|
||||
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
|
||||
turn_timeout=turn_timeout,
|
||||
)
|
||||
|
||||
|
||||
def join_game(state: ChiamataState, sub: str, name: str) -> None:
|
||||
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
||||
if state.phase != PHASE_LOBBY:
|
||||
raise GameNotStarted("game has already started")
|
||||
if state.seated(sub):
|
||||
raise AlreadyJoined("already joined this game")
|
||||
if len(state.players) >= PLAYER_COUNT:
|
||||
raise LobbyFull("game is full")
|
||||
seat = len(state.players)
|
||||
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
|
||||
if len(state.players) == PLAYER_COUNT:
|
||||
start_game(state)
|
||||
|
||||
|
||||
def start_game(state: ChiamataState) -> None:
|
||||
"""Deal the first hand and open the auction."""
|
||||
if len(state.players) != PLAYER_COUNT:
|
||||
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
|
||||
_deal(state)
|
||||
|
||||
|
||||
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise IllegalMove(f"no player in seat {seat}")
|
||||
|
||||
|
||||
def _set_deadline(seconds: int) -> str:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
||||
return deadline.isoformat()
|
||||
|
||||
|
||||
def _set_auction_deadline(state: ChiamataState) -> None:
|
||||
state.auction_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_call_deadline(state: ChiamataState) -> None:
|
||||
state.call_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_turn_deadline(state: ChiamataState) -> None:
|
||||
state.turn_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _deal(state: ChiamataState) -> None:
|
||||
"""Deal 8 cards each and open the auction."""
|
||||
deck = shuffled_deck()
|
||||
for player in state.players:
|
||||
player.hand = []
|
||||
player.captured = []
|
||||
state.trick = []
|
||||
state.last_trick = None
|
||||
state.turn_deadline = None
|
||||
state.call_deadline = None
|
||||
state.called_card = None
|
||||
state.briscola_suit = ""
|
||||
state.declared_target = DEFAULT_TARGET
|
||||
state.solo = False
|
||||
state.called_played = False
|
||||
state.partner_seat = None
|
||||
state.caller = None
|
||||
state.winner = None
|
||||
state.scores = [0, 0]
|
||||
# The whole deck is dealt, one card at a time, starting with the
|
||||
# player after the dealer.
|
||||
for _ in range(HAND_SIZE):
|
||||
for offset in range(PLAYER_COUNT):
|
||||
_player_at(
|
||||
state, (state.dealer + 1 + offset) % PLAYER_COUNT
|
||||
).hand.append(deck.pop())
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.auction = Auction(
|
||||
turn=first,
|
||||
active=[player.seat for player in state.players],
|
||||
)
|
||||
state.phase = PHASE_AUCTION
|
||||
_set_auction_deadline(state)
|
||||
log.debug(
|
||||
"deal %d dealt (dealer seat %d), auction opens with seat %d",
|
||||
state.hand_number,
|
||||
state.dealer,
|
||||
first,
|
||||
)
|
||||
|
||||
|
||||
# -- auction -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_auction(
|
||||
state: ChiamataState, sub: str
|
||||
) -> Tuple[Auction, ChiamataPlayer]:
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = state.player_for(sub)
|
||||
if player is None:
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - auction always exists here
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if auction.turn != player.seat:
|
||||
raise NotYourTurn("it is not your turn to bid")
|
||||
return auction, player
|
||||
|
||||
|
||||
def legal_bid_ranks(state: ChiamataState) -> List[int]:
|
||||
"""The ranks the player on turn may bid, empty at the point stage."""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return []
|
||||
current = auction.current
|
||||
if current is None:
|
||||
return list(BID_LADDER)
|
||||
if current.rank == 2:
|
||||
return []
|
||||
index = _BID_INDEX[current.rank]
|
||||
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
|
||||
|
||||
|
||||
def legal_bid_points(state: ChiamataState) -> Optional[int]:
|
||||
"""The lowest legal points raise, ``None`` when there is none.
|
||||
|
||||
Points may only be raised once the standing bid is the 2; any value
|
||||
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
|
||||
"""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return None
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
return None
|
||||
if current.points >= MAX_TARGET:
|
||||
return None
|
||||
return current.points + 2
|
||||
|
||||
|
||||
def _finish_auction(state: ChiamataState) -> None:
|
||||
"""The last active bidder wins and must name the called card."""
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
|
||||
raise IllegalMove("the auction cannot be concluded yet")
|
||||
winner = auction.active[0]
|
||||
state.caller = winner
|
||||
state.declared_target = auction.current.points
|
||||
state.turn = winner
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction_deadline = None
|
||||
_set_call_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d wins the auction (rank %d, target %d)",
|
||||
state.hand_number,
|
||||
winner,
|
||||
auction.current.rank,
|
||||
auction.current.points,
|
||||
)
|
||||
|
||||
|
||||
def _advance_auction(state: ChiamataState) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
return
|
||||
for offset in range(1, PLAYER_COUNT + 1):
|
||||
seat = (auction.turn + offset) % PLAYER_COUNT
|
||||
if seat in auction.active:
|
||||
auction.turn = seat
|
||||
break
|
||||
state.turn = auction.turn
|
||||
_set_auction_deadline(state)
|
||||
|
||||
|
||||
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction.current = bid
|
||||
auction.history.append(bid)
|
||||
log.debug(
|
||||
"deal %d: seat %d bids rank %d at %d",
|
||||
state.hand_number,
|
||||
bid.seat,
|
||||
bid.rank,
|
||||
bid.points,
|
||||
)
|
||||
if len(auction.active) == 1:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
|
||||
"""Bid ``rank``; it must be weaker than the standing bid."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if rank not in _BID_INDEX:
|
||||
raise IllegalMove(f"invalid rank: {rank!r}")
|
||||
current = auction.current
|
||||
if current is not None:
|
||||
if current.rank == 2:
|
||||
raise IllegalMove("the auction is at the 2: raise the points or pass")
|
||||
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
|
||||
raise IllegalMove("a bid must call a lower rank than the standing bid")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
|
||||
|
||||
|
||||
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
|
||||
"""Raise the points target; only legal once the standing bid is the 2."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
raise IllegalMove("points can only be raised after a bid of the 2")
|
||||
if points <= current.points:
|
||||
raise IllegalMove(
|
||||
f"the points must be higher than the standing bid ({current.points})"
|
||||
)
|
||||
if points > MAX_TARGET:
|
||||
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
|
||||
|
||||
|
||||
def pass_auction(state: ChiamataState, sub: str) -> None:
|
||||
"""Leave the auction for good."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if player.seat not in auction.active: # pragma: no cover - turn implies active
|
||||
raise IllegalMove("you already passed")
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
# Everyone else passed before any bid: the last player must bid.
|
||||
raise IllegalMove("you must bid: everyone else has passed")
|
||||
auction.active.remove(player.seat)
|
||||
auction.passed.append(player.seat)
|
||||
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
|
||||
if len(auction.active) == 1 and auction.current is not None:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
# -- calling -------------------------------------------------------------------
|
||||
|
||||
|
||||
def call(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""The auction winner names the called card (any suit of the bid rank).
|
||||
|
||||
If the caller holds the named card they play alone; the card's suit
|
||||
becomes the trump for the deal.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = None if state.caller is None else _player_at(state, state.caller)
|
||||
player = state.player_for(sub)
|
||||
if player is None or caller is None or player.seat != caller.seat:
|
||||
raise NotYourTurn("only the auction winner calls the card")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
card = parse_card(card_code)
|
||||
if card.rank != auction.current.rank:
|
||||
raise IllegalMove(
|
||||
"the called card must match the rank won in the auction"
|
||||
)
|
||||
state.called_card = card
|
||||
state.briscola_suit = card.suit
|
||||
state.solo = card in caller.hand
|
||||
state.phase = PHASE_PLAYING
|
||||
# The player after the dealer opens the play, whichever seat won the
|
||||
# auction.
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.call_deadline = None
|
||||
_set_turn_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d calls %s%s",
|
||||
state.hand_number,
|
||||
caller.seat,
|
||||
card.code,
|
||||
" (playing alone)" if state.solo else "",
|
||||
)
|
||||
|
||||
|
||||
# -- play ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def play(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""Apply one move by the player identified by ``sub``.
|
||||
|
||||
Any card in hand is a legal play; only turn order and phase are
|
||||
validated. Raises a :class:`~tavolo.platform.errors.GameError`
|
||||
subclass on any violation.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase == PHASE_AUCTION:
|
||||
raise IllegalMove("the auction is still open")
|
||||
if state.phase == PHASE_CALLING:
|
||||
raise IllegalMove("the called card has not been named yet")
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
|
||||
player = state.player_for(sub)
|
||||
if player is None or player.seat != state.turn:
|
||||
raise NotYourTurn("it is not your turn")
|
||||
|
||||
card = parse_card(card_code)
|
||||
if card not in player.hand:
|
||||
raise CardNotInHand(f"card {card.code} is not in your hand")
|
||||
|
||||
player.hand.remove(card)
|
||||
if state.called_card is not None and card == state.called_card:
|
||||
state.called_played = True
|
||||
state.partner_seat = (
|
||||
None if player.seat == state.caller else player.seat
|
||||
)
|
||||
log.info(
|
||||
"deal %d: the called card %s was played by seat %d (%s)",
|
||||
state.hand_number,
|
||||
card.code,
|
||||
player.seat,
|
||||
"the caller plays alone" if state.partner_seat is None else "partner revealed",
|
||||
)
|
||||
state.trick.append(TrickCard(seat=player.seat, card=card.code))
|
||||
log.debug(
|
||||
"deal %d: seat %d played %s (%d/%d in the trick)",
|
||||
state.hand_number,
|
||||
player.seat,
|
||||
card.code,
|
||||
len(state.trick),
|
||||
PLAYER_COUNT,
|
||||
)
|
||||
|
||||
if len(state.trick) == PLAYER_COUNT:
|
||||
_resolve_trick(state)
|
||||
else:
|
||||
state.turn = (state.turn + 1) % PLAYER_COUNT
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _resolve_trick(state: ChiamataState) -> None:
|
||||
"""Score the completed trick, draw no cards (deck already exhausted)."""
|
||||
winner = _trick_winner(state)
|
||||
taken = [parse_card(t.card) for t in state.trick]
|
||||
_player_at(state, winner).captured.extend(taken)
|
||||
state.last_trick = Trick(winner=winner, cards=list(state.trick))
|
||||
state.trick = []
|
||||
state.trick_leader = winner
|
||||
state.turn = winner
|
||||
log.debug(
|
||||
"deal %d: seat %d takes the trick with %s",
|
||||
state.hand_number,
|
||||
winner,
|
||||
[t.card for t in state.last_trick.cards],
|
||||
)
|
||||
|
||||
if all(not player.hand for player in state.players):
|
||||
_end_deal(state)
|
||||
return
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _trick_winner(state: ChiamataState) -> int:
|
||||
"""The seat winning the current (complete) trick."""
|
||||
played = [(t.seat, parse_card(t.card)) for t in state.trick]
|
||||
leader_suit = played[0][1].suit
|
||||
trumps = [
|
||||
(seat, card)
|
||||
for seat, card in played
|
||||
if card.suit == state.briscola_suit
|
||||
]
|
||||
pool = trumps or [
|
||||
(seat, card) for seat, card in played if card.suit == leader_suit
|
||||
]
|
||||
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
|
||||
|
||||
|
||||
# -- scoring -------------------------------------------------------------------
|
||||
|
||||
|
||||
def side_points(state: ChiamataState) -> Tuple[int, int]:
|
||||
"""The points captured by the calling side and the defenders.
|
||||
|
||||
The partner's pile only joins the caller's once the partner is known
|
||||
(the called card has been played); until then this is only used to
|
||||
score a finished deal, where the reveal has always happened.
|
||||
"""
|
||||
if state.caller is None:
|
||||
return (0, 0)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_points = 0
|
||||
defender_points = 0
|
||||
for player in state.players:
|
||||
points = sum(card_points(card) for card in player.captured)
|
||||
if player.seat in caller_seats:
|
||||
caller_points += points
|
||||
else:
|
||||
defender_points += points
|
||||
return (caller_points, defender_points)
|
||||
|
||||
|
||||
def _end_deal(state: ChiamataState) -> None:
|
||||
"""Score the deal: finish the match, or replay after a patta.
|
||||
|
||||
A 60-60 deal with the plain 61 target is a patta: its points do not
|
||||
count, the dealer rotates and the deal is replayed (a new auction).
|
||||
Otherwise the calling side wins by reaching the declared target.
|
||||
"""
|
||||
state.turn_deadline = None
|
||||
caller_points, defender_points = side_points(state)
|
||||
patta = (
|
||||
state.declared_target == DEFAULT_TARGET
|
||||
and caller_points == 60
|
||||
and defender_points == 60
|
||||
)
|
||||
caller_name = (
|
||||
_player_at(state, state.caller).name if state.caller is not None else None
|
||||
)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_cards = sum(
|
||||
len(player.captured)
|
||||
for player in state.players
|
||||
if player.seat in caller_seats
|
||||
)
|
||||
entry: Dict[str, Any] = {
|
||||
"deal": state.hand_number,
|
||||
"patta": patta,
|
||||
"points": {"A": caller_points, "B": defender_points},
|
||||
"cards": {"A": caller_cards, "B": 40 - caller_cards},
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": caller_name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
state.hand_scores.append(entry)
|
||||
log.debug(
|
||||
"deal %d scored caller %d - defenders %d%s (target %d)",
|
||||
state.hand_number,
|
||||
caller_points,
|
||||
defender_points,
|
||||
" patta" if patta else "",
|
||||
state.declared_target,
|
||||
)
|
||||
if patta:
|
||||
state.hand_number += 1
|
||||
state.dealer = (state.dealer + 1) % PLAYER_COUNT
|
||||
_deal(state)
|
||||
return
|
||||
|
||||
state.scores = [caller_points, defender_points]
|
||||
state.winner = (
|
||||
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
|
||||
)
|
||||
state.phase = PHASE_FINISHED
|
||||
log.info(
|
||||
"match ended, %s win %d-%d",
|
||||
SIDE_NAMES[state.winner],
|
||||
caller_points,
|
||||
defender_points,
|
||||
)
|
||||
|
||||
|
||||
# -- server-side auto actions ---------------------------------------------------
|
||||
|
||||
|
||||
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Act for the bidder on turn: bid a random legal rank, else pass.
|
||||
|
||||
The only player who cannot afford to pass — the last one standing
|
||||
before any bid — always has every rank available, so a bid is always
|
||||
possible there.
|
||||
"""
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = _player_at(state, auction.turn)
|
||||
ranks = legal_bid_ranks(state)
|
||||
if ranks:
|
||||
chooser = rng or _rng
|
||||
bid_rank(state, player.sub, chooser.choice(ranks))
|
||||
return
|
||||
pass_auction(state, player.sub)
|
||||
|
||||
|
||||
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Name a random suit of the auction rank for the winner."""
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = _player_at(state, state.caller) if state.caller is not None else None
|
||||
if caller is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("no call is waiting")
|
||||
chooser = rng or _rng
|
||||
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
|
||||
call(state, caller.sub, card.code)
|
||||
|
||||
|
||||
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Play a random card for the player currently on turn."""
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
player = _player_at(state, state.turn)
|
||||
if not player.hand:
|
||||
raise IllegalMove("the player on turn has no cards")
|
||||
chooser = rng or _rng
|
||||
card = chooser.choice(player.hand)
|
||||
play(state, player.sub, card.code)
|
||||
|
||||
|
||||
# -- views ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _roles_public(state: ChiamataState) -> bool:
|
||||
return state.called_played
|
||||
|
||||
|
||||
def state_for_player(
|
||||
state: ChiamataState, sub: Optional[str]
|
||||
) -> Dict[str, object]:
|
||||
"""Serialize ``state`` hiding other players' hands and secret roles.
|
||||
|
||||
Hands are reduced to a count, except for the requesting player's own
|
||||
hand. The partner's role only appears once the called card has been
|
||||
played; before that only the caller is labeled. The calling side's
|
||||
running total is withheld until then too: it would let onlookers
|
||||
deduce the partner's identity from who captured the points.
|
||||
"""
|
||||
viewer = state.player_for(sub)
|
||||
revealed = _roles_public(state)
|
||||
players: List[Dict[str, object]] = []
|
||||
for player in state.players:
|
||||
role = ""
|
||||
if state.caller is not None and player.seat == state.caller:
|
||||
role = ROLE_CALLER
|
||||
if revealed:
|
||||
if state.partner_seat is not None and player.seat == state.partner_seat:
|
||||
role = ROLE_PARTNER
|
||||
elif player.seat != state.caller:
|
||||
role = ROLE_DEFENDER
|
||||
view: Dict[str, object] = {
|
||||
"sub": player.sub,
|
||||
"name": player.name,
|
||||
"seat": player.seat,
|
||||
"team": role,
|
||||
"cards_left": len(player.hand),
|
||||
"captured_count": len(player.captured),
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
}
|
||||
if viewer is not None and viewer.seat == player.seat:
|
||||
view["hand"] = [c.code for c in player.hand]
|
||||
players.append(view)
|
||||
|
||||
auction = state.auction
|
||||
auction_view: Optional[Dict[str, object]] = None
|
||||
if auction is not None:
|
||||
auction_view = {
|
||||
"turn": auction.turn,
|
||||
"active": list(auction.active),
|
||||
"passed": list(auction.passed),
|
||||
"current": auction.current.to_json() if auction.current else None,
|
||||
"history": [b.to_json() for b in auction.history],
|
||||
}
|
||||
|
||||
payload: Dict[str, object] = {
|
||||
"phase": state.phase,
|
||||
"player_count": PLAYER_COUNT,
|
||||
"dealer": state.dealer,
|
||||
"hand_number": state.hand_number,
|
||||
"turn": state.turn,
|
||||
"auction": auction_view,
|
||||
"caller": state.caller,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"briscola_suit": state.briscola_suit,
|
||||
"declared_target": state.declared_target,
|
||||
"called_played": state.called_played,
|
||||
"solo": state.solo if revealed else False,
|
||||
"partner": (
|
||||
state.partner_seat if revealed and state.partner_seat is not None else None
|
||||
),
|
||||
"players": players,
|
||||
"trick": [t.to_json() for t in state.trick],
|
||||
"trick_leader": state.trick_leader,
|
||||
"last_trick": state.last_trick.to_json() if state.last_trick else None,
|
||||
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
|
||||
"auction_deadline": state.auction_deadline,
|
||||
"call_deadline": state.call_deadline,
|
||||
"turn_deadline": state.turn_deadline,
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
}
|
||||
if revealed and state.caller is not None:
|
||||
caller_points, defender_points = side_points(state)
|
||||
payload["scores"] = {"A": caller_points, "B": defender_points}
|
||||
|
||||
if viewer is not None:
|
||||
if state.phase == PHASE_AUCTION and auction is not None:
|
||||
if auction.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
payload["legal_bids"] = {
|
||||
"ranks": legal_bid_ranks(state),
|
||||
"points_min": legal_bid_points(state),
|
||||
"points_max": MAX_TARGET,
|
||||
}
|
||||
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
current = auction.current if auction is not None else None
|
||||
if current is not None:
|
||||
payload["callable_cards"] = [
|
||||
Card(rank=current.rank, suit=suit).code for suit in SUITS
|
||||
]
|
||||
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
return payload
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The platform-facing adapter for briscola.
|
||||
|
||||
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
||||
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
||||
:class:`BriscolaEngine` and four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
||||
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||
:class:`BriscolaEngine`, four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine` and five-player
|
||||
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
|
||||
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||
keeping all briscola knowledge inside this package. Nothing outside
|
||||
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
|
||||
)
|
||||
from tavolo.platform.errors import GameError, IllegalMove
|
||||
|
||||
from . import engine
|
||||
from . import chiamata, engine
|
||||
from .chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_NAMES,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
)
|
||||
from .engine import card_points
|
||||
from .state import (
|
||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
DEFAULT_TARGET_SCORE,
|
||||
@@ -277,3 +290,291 @@ class BriscolaQuattroEngine(_BriscolaBase):
|
||||
min_players = 4
|
||||
max_players = 4
|
||||
player_count = 4
|
||||
|
||||
|
||||
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise GameError(f"no player in seat {seat}")
|
||||
|
||||
|
||||
class BriscolaChiamataEngine(GameEngine):
|
||||
"""Five-player briscola chiamata as a platform game engine.
|
||||
|
||||
One deal per match: the five players auction the call, the winner
|
||||
names a card (playing alone when it is in their own hand) and the
|
||||
calling side must reach the declared target (61 unless the auction
|
||||
raised it). A 60-60 deal with the plain target is a patta and is
|
||||
replayed. This keeps the match result a clean two-sided affair —
|
||||
caller and secret partner against the other three, or the lone
|
||||
caller against the other four — which the platform can rate,
|
||||
persist and display like any other game.
|
||||
"""
|
||||
|
||||
id = "briscola_chiamata"
|
||||
name = "Briscola chiamata"
|
||||
description = (
|
||||
"Five-player briscola with a descending auction: the winner calls "
|
||||
"a card, whose holder becomes the secret partner (or, when the "
|
||||
"caller holds it, the caller plays alone) and whose suit becomes "
|
||||
"the trump. The calling side must reach 61 points; one deal per "
|
||||
"match, replayed after a patta."
|
||||
)
|
||||
min_players = 5
|
||||
max_players = 5
|
||||
|
||||
options_schema: Mapping[str, Any] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self._turn_timeout = turn_timeout_seconds
|
||||
# A chiamata match is a single deal, so there is no between-deals
|
||||
# acknowledgement phase to time out; the parameter is accepted
|
||||
# for symmetry with the other briscola engines and wired by the
|
||||
# application root.
|
||||
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||
|
||||
# -- lobby ------------------------------------------------------------
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
creator = session.players[0]
|
||||
session.state = chiamata.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
creator_name=creator.display_name,
|
||||
turn_timeout=self._turn_timeout,
|
||||
)
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
chiamata.join_game(session.state, user_sub, display_name)
|
||||
session.players.append(
|
||||
Seat(user_sub=user_sub, display_name=display_name)
|
||||
)
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_LOBBY
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"declared_target": state.declared_target,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ChiamataState = session.state
|
||||
if action == "bid":
|
||||
rank = payload.get("rank")
|
||||
points = payload.get("points")
|
||||
if rank is not None:
|
||||
if isinstance(rank, bool) or not isinstance(rank, int):
|
||||
raise IllegalMove("'rank' must be an integer")
|
||||
chiamata.bid_rank(state, user_sub, rank)
|
||||
elif points is not None:
|
||||
if isinstance(points, bool) or not isinstance(points, int):
|
||||
raise IllegalMove("'points' must be an integer")
|
||||
chiamata.bid_points(state, user_sub, points)
|
||||
else:
|
||||
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
|
||||
elif action == "pass":
|
||||
chiamata.pass_auction(state, user_sub)
|
||||
elif action == "call":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.call(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
elif action == "play":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.play(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
return chiamata.state_for_player(session.state, user_sub)
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_FINISHED
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
caller = (
|
||||
_chiamata_player(state, state.caller) if state.caller is not None else None
|
||||
)
|
||||
partner = (
|
||||
_chiamata_player(state, state.partner_seat)
|
||||
if state.partner_seat is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": None if caller is None else caller.name,
|
||||
"partner": None if partner is None else partner.name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
|
||||
# -- (de)serialization -------------------------------------------------
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
assert isinstance(state, ChiamataState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ChiamataState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ChiamataState = session.state
|
||||
if state.phase == PHASE_AUCTION and state.auction_deadline:
|
||||
due_ms = _deadline_ms(state.auction_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="auction",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_CALLING and state.call_deadline:
|
||||
due_ms = _deadline_ms(state.call_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="call",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"call:{state.hand_number}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||
due_ms = _deadline_ms(state.turn_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="turn",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
return None
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
# Overtaken by events (a bid, call or play landed in time):
|
||||
# nothing to do.
|
||||
raise GameError("stale deadline")
|
||||
state: ChiamataState = session.state
|
||||
if kind == "auction":
|
||||
seat = state.turn
|
||||
chiamata.auto_bid(state)
|
||||
log.info(
|
||||
"auto-bid for seat %s (auction timeout, deal %d)",
|
||||
seat,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "call":
|
||||
caller = state.caller
|
||||
chiamata.auto_call(state)
|
||||
log.info(
|
||||
"auto-called for seat %s (call timeout, deal %d)",
|
||||
caller,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "turn":
|
||||
seat = state.turn
|
||||
chiamata.auto_play(state)
|
||||
log.info(
|
||||
"auto-played for seat %s (turn timeout, deal %d)",
|
||||
seat,
|
||||
state.hand_number,
|
||||
)
|
||||
else: # pragma: no cover - next_deadline never emits other kinds
|
||||
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||
|
||||
# -- results -------------------------------------------------------------
|
||||
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
state: ChiamataState = session.state
|
||||
if state.winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
if state.caller is None: # pragma: no cover - a winner implies a caller
|
||||
raise GameError("no result: the caller is unknown")
|
||||
caller = state.caller
|
||||
partner = state.partner_seat
|
||||
caller_seats = [caller] + ([partner] if partner is not None else [])
|
||||
teams = [
|
||||
[_chiamata_player(state, seat).sub for seat in caller_seats],
|
||||
[
|
||||
player.sub
|
||||
for player in state.players
|
||||
if player.seat not in caller_seats
|
||||
],
|
||||
]
|
||||
caller_points, defender_points = chiamata.side_points(state)
|
||||
side_scores = [caller_points, defender_points]
|
||||
players = []
|
||||
for player in state.players:
|
||||
if player.seat == caller:
|
||||
role = ROLE_CALLER
|
||||
side = 0
|
||||
elif partner is not None and player.seat == partner:
|
||||
role = ROLE_PARTNER
|
||||
side = 0
|
||||
else:
|
||||
role = ROLE_DEFENDER
|
||||
side = 1
|
||||
players.append(
|
||||
PlayerResult(
|
||||
user_sub=player.sub,
|
||||
seat=player.seat,
|
||||
won=side == state.winner,
|
||||
team=role,
|
||||
score=float(side_scores[side]),
|
||||
details={
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
"captured_cards": len(player.captured),
|
||||
},
|
||||
)
|
||||
)
|
||||
return MatchResult(
|
||||
teams=teams,
|
||||
winner_team=state.winner,
|
||||
players=players,
|
||||
summary={
|
||||
"team_a_score": caller_points,
|
||||
"team_b_score": defender_points,
|
||||
"winner_team": SIDE_NAMES[state.winner],
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": _chiamata_player(state, caller).name,
|
||||
"partner": (
|
||||
_chiamata_player(state, partner).name
|
||||
if partner is not None
|
||||
else None
|
||||
),
|
||||
"solo": state.solo,
|
||||
"deals_played": state.hand_number,
|
||||
"deal_scores": state.hand_scores,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
BID_LADDER,
|
||||
Bid,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_CALLER,
|
||||
SIDE_DEFENDERS,
|
||||
)
|
||||
from tavolo.briscola.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def started_state() -> ChiamataState:
|
||||
"""A five-player game whose auction has just opened (dealer seat 0)."""
|
||||
state = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
return state
|
||||
|
||||
|
||||
def bid(state: ChiamataState, seat: int, rank: int) -> None:
|
||||
rules.bid_rank(state, f"p{seat}", rank)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
*,
|
||||
briscola_suit: str = "S",
|
||||
called_card: str | None = None,
|
||||
caller: int | None = None,
|
||||
solo: bool = False,
|
||||
called_played: bool = False,
|
||||
partner_seat: int | None = None,
|
||||
captured=None,
|
||||
turn: int = 0,
|
||||
dealer: int = 0,
|
||||
declared_target: int = rules.DEFAULT_TARGET,
|
||||
phase: str = PHASE_PLAYING,
|
||||
hand_number: int = 1,
|
||||
) -> ChiamataState:
|
||||
"""Build a controlled playing state directly (bypassing the auction)."""
|
||||
state = ChiamataState(
|
||||
phase=phase,
|
||||
dealer=dealer,
|
||||
hand_number=hand_number,
|
||||
declared_target=declared_target,
|
||||
briscola_suit=briscola_suit,
|
||||
called_card=card(called_card) if called_card else None,
|
||||
caller=caller,
|
||||
solo=solo,
|
||||
called_played=called_played,
|
||||
partner_seat=partner_seat,
|
||||
turn=turn,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
ChiamataPlayer(
|
||||
sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||
hand=[card(c) for c in hand],
|
||||
)
|
||||
)
|
||||
if captured is not None:
|
||||
for player, codes in zip(state.players, captured):
|
||||
player.captured = [card(c) for c in codes]
|
||||
return state
|
||||
|
||||
|
||||
def play_all(state: ChiamataState) -> None:
|
||||
"""Play the first card of each hand in turn order until the trick resolves."""
|
||||
for _ in range(rules.PLAYER_COUNT):
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
rules.play(state, player.sub, player.hand[0].code)
|
||||
|
||||
|
||||
class SetupTest(unittest.TestCase):
|
||||
def test_five_player_deal(self) -> None:
|
||||
state = started_state()
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
|
||||
for player in state.players:
|
||||
self.assertEqual(rules.HAND_SIZE, len(player.hand))
|
||||
# The whole deck is dealt: no stock, no face-up card.
|
||||
dealt = [c.code for p in state.players for c in p.hand]
|
||||
self.assertEqual(40, len(dealt))
|
||||
self.assertEqual(40, len(set(dealt)))
|
||||
# The auction opens with the player after the dealer.
|
||||
assert state.auction is not None
|
||||
self.assertEqual(1, state.auction.turn)
|
||||
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
|
||||
self.assertIsNone(state.auction.current)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
|
||||
|
||||
state = rules.create_game("p0", "p0")
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
rules.join_game(state, "p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
with self.assertRaises(GameNotStarted):
|
||||
rules.join_game(state, "p5", "p5")
|
||||
|
||||
lobby = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(lobby, f"p{i}", f"p{i}")
|
||||
# Joining a sixth player is impossible once the fifth started the
|
||||
# match; exercise LobbyFull on an artificially unfilled start.
|
||||
unfilled = rules.create_game("p0", "p0")
|
||||
unfilled.phase = rules.PHASE_LOBBY
|
||||
for i in range(1, 5):
|
||||
unfilled.players.append(
|
||||
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
|
||||
)
|
||||
with self.assertRaises(LobbyFull):
|
||||
rules.join_game(unfilled, "p5", "p5")
|
||||
|
||||
|
||||
class AuctionTest(unittest.TestCase):
|
||||
def test_first_bid_accepts_any_rank(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
|
||||
|
||||
def test_bids_must_descend(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 2, 1) # same rank
|
||||
bid(state, 2, 3) # three, weaker than the ace
|
||||
bid(state, 3, 10) # king, weaker than the three
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 4, 3) # three is stronger than the king
|
||||
bid(state, 4, 9) # horse
|
||||
|
||||
def test_bid_ladder_ordering(self) -> None:
|
||||
# A bid must be weaker than the standing bid: descending strength
|
||||
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
|
||||
rules.bid_rank(state, f"p{opponent}", rank)
|
||||
# 3 and A are stronger than 6: rejected.
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 3)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 1)
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = started_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
bid(state, 2, 1)
|
||||
|
||||
def test_pass_eliminates_and_last_standing_bids(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
assert state.auction is not None
|
||||
# Only seat 0 remains and nobody has bid: it must bid.
|
||||
self.assertEqual([0], state.auction.active)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.bid_rank(state, "p0", 2)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(0, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_auction_ends_when_one_bidder_remains(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
rules.pass_auction(state, "p2")
|
||||
bid(state, 3, 10) # king
|
||||
rules.pass_auction(state, "p4")
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.pass_auction(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(3, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_point_raises_only_after_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p2", 62)
|
||||
bid(state, 2, 2)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p3", 2) # no ranks below the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 61) # not higher
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 121) # above the cap
|
||||
rules.bid_points(state, "p3", 63)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
|
||||
|
||||
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertTrue(view["your_turn"])
|
||||
legal = view["legal_bids"]
|
||||
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
|
||||
self.assertIsNone(legal["points_min"])
|
||||
# A player who is not on turn gets no legal bids.
|
||||
other = rules.state_for_player(state, "p3")
|
||||
self.assertNotIn("legal_bids", other)
|
||||
self.assertNotIn("your_turn", other)
|
||||
|
||||
def test_legal_points_exposed_at_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 2)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertEqual([], view["legal_bids"]["ranks"])
|
||||
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||
self.assertEqual(120, view["legal_bids"]["points_max"])
|
||||
|
||||
def test_auto_bid_bids_or_passes(self) -> None:
|
||||
state = started_state()
|
||||
rules.auto_bid(state, random.Random(1))
|
||||
assert state.auction is not None
|
||||
self.assertIsNotNone(state.auction.current)
|
||||
# The auction keeps moving and eventually concludes.
|
||||
for _ in range(50):
|
||||
if state.phase != PHASE_AUCTION:
|
||||
break
|
||||
rules.auto_bid(state, random.Random(2))
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertIsNotNone(state.caller)
|
||||
|
||||
|
||||
class CallTest(unittest.TestCase):
|
||||
def _called_state(self, rank: int = 1) -> ChiamataState:
|
||||
state = started_state()
|
||||
bid(state, 1, rank)
|
||||
for seat in (2, 3, 4, 0):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
return state
|
||||
|
||||
def test_call_must_match_the_winning_rank(self) -> None:
|
||||
state = self._called_state(1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.call(state, "p1", "03D")
|
||||
# Any suit of the winning rank is fine.
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual("D", state.briscola_suit)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_only_the_winner_calls(self) -> None:
|
||||
state = self._called_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.call(state, "p2", "01D")
|
||||
|
||||
def test_call_in_hand_means_playing_alone(self) -> None:
|
||||
state = self._called_state(1)
|
||||
# Seat 1 calls a card it holds: solo.
|
||||
state.players[1].hand[0] = card("01S")
|
||||
rules.call(state, "p1", "01S")
|
||||
self.assertTrue(state.solo)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
|
||||
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
|
||||
state = self._called_state(1)
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertNotEqual(state.caller, None)
|
||||
|
||||
def test_auto_call_names_the_bid_rank(self) -> None:
|
||||
state = self._called_state(10)
|
||||
rules.auto_call(state, random.Random(3))
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(10, state.called_card.rank)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_calling_phase_view(self) -> None:
|
||||
state = self._called_state(1)
|
||||
view = rules.state_for_player(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, view["phase"])
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertEqual(
|
||||
["01D", "01C", "01S", "01B"], view["callable_cards"]
|
||||
)
|
||||
other = rules.state_for_player(state, "p2")
|
||||
self.assertNotIn("callable_cards", other)
|
||||
|
||||
|
||||
class PlayTest(unittest.TestCase):
|
||||
def test_five_player_trick_resolution(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_trump_wins(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
|
||||
briscola_suit="C",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_called_card_reveals_the_partner(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
turn=1,
|
||||
)
|
||||
self.assertFalse(state.called_played)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertNotIn("scores", view)
|
||||
rules.play(state, "p1", "01D")
|
||||
# The called card has not been played yet.
|
||||
self.assertFalse(state.called_played)
|
||||
# Drive the trick until the called card appears: give it to seat 3.
|
||||
state.players[3].hand = [card("01S")]
|
||||
state.turn = 3
|
||||
rules.play(state, "p3", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertEqual(3, state.partner_seat)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_PARTNER, roles[3])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
self.assertIn("scores", view)
|
||||
|
||||
def test_called_card_played_by_the_caller_is_solo(self) -> None:
|
||||
state = make_state(
|
||||
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
solo=True,
|
||||
turn=0,
|
||||
)
|
||||
rules.play(state, "p0", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
view = rules.state_for_player(state, "p4")
|
||||
self.assertTrue(view["solo"])
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
|
||||
def test_play_validations(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
turn=0,
|
||||
)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.play(state, "p1", "05D")
|
||||
with self.assertRaises(CardNotInHand):
|
||||
rules.play(state, "p0", "01D")
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_play_rejected_before_the_call(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
caller=0,
|
||||
turn=0,
|
||||
phase=PHASE_CALLING,
|
||||
)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_auto_play_advances(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="09B",
|
||||
turn=0,
|
||||
)
|
||||
rules.auto_play(state, random.Random(7))
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertEqual(1, len(state.players[0].hand))
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def _piles(self):
|
||||
# 60 points in denari + coppe, 60 in spade + bastoni.
|
||||
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
return a_cards, b_cards
|
||||
|
||||
def test_side_points_split_caller_side_and_defenders(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=3,
|
||||
called_played=True,
|
||||
captured=[
|
||||
["01D"], # caller, side A: 11
|
||||
["03C", "01S"], # defender: 21
|
||||
["10D"], # defender: 4
|
||||
["01C"], # partner, side A: 11
|
||||
["03S"], # defender: 10
|
||||
],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(22, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_solo_side_points(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
solo=True,
|
||||
called_played=True,
|
||||
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(11, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_calling_side_wins_at_the_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
b_rest = [c for c in b_cards if c != "01S"]
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, ["01S"], [], b_rest, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_CALLER, state.winner)
|
||||
self.assertEqual([71, 49], state.scores)
|
||||
|
||||
def test_defenders_win_below_the_declared_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
# Calling side only 30 points of the 61 needed.
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards[:5], [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
self.assertEqual([30, 60], state.scores)
|
||||
|
||||
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(2, state.hand_number)
|
||||
self.assertEqual(1, state.dealer)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
self.assertIsNone(state.caller)
|
||||
self.assertIsNone(state.called_card)
|
||||
# The patta entry is kept in the audit trail.
|
||||
entry = state.hand_scores[-1]
|
||||
self.assertTrue(entry["patta"])
|
||||
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||
|
||||
def test_raised_target_disables_patta(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
declared_target=62,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
|
||||
def test_full_deal_scores_120(self) -> None:
|
||||
state = started_state()
|
||||
rng = random.Random(2026)
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||
if state.phase == PHASE_AUCTION:
|
||||
rules.auto_bid(state, rng)
|
||||
elif state.phase == PHASE_CALLING:
|
||||
rules.auto_call(state, rng)
|
||||
else:
|
||||
rules.auto_play(state, rng)
|
||||
moves += 1
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(120, sum(state.scores))
|
||||
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
|
||||
captured = sum(len(p.captured) for p in state.players)
|
||||
self.assertEqual(40, captured)
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_state_for_player_hides_other_hands(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
turn=0,
|
||||
)
|
||||
view = rules.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertEqual(1, players[1]["cards_left"])
|
||||
self.assertEqual("01D", view["called_card"])
|
||||
self.assertEqual(5, view["player_count"])
|
||||
|
||||
def test_spectator_sees_public_information_only(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=2,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
)
|
||||
view = rules.state_for_player(state, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[2])
|
||||
self.assertEqual("", roles[0])
|
||||
|
||||
def test_player_points_are_public(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
captured=[["01C", "03B"], ["10D"], [], [], []],
|
||||
)
|
||||
view = rules.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(21, players[0]["points"])
|
||||
self.assertEqual(4, players[1]["points"])
|
||||
self.assertEqual(2, players[0]["captured_count"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10)
|
||||
bid(state, 2, 7)
|
||||
restored = ChiamataState.from_json(state.to_json())
|
||||
self.assertEqual(state.phase, restored.phase)
|
||||
self.assertEqual(state.hand_number, restored.hand_number)
|
||||
self.assertEqual(state.turn, restored.turn)
|
||||
assert restored.auction is not None and state.auction is not None
|
||||
self.assertEqual(state.auction.active, restored.auction.active)
|
||||
self.assertEqual(state.auction.passed, restored.auction.passed)
|
||||
self.assertEqual(
|
||||
state.auction.history, restored.auction.history
|
||||
)
|
||||
self.assertEqual(
|
||||
[[c.code for c in p.hand] for p in state.players],
|
||||
[[c.code for c in p.hand] for p in restored.players],
|
||||
)
|
||||
|
||||
def test_legacy_state_defaults(self) -> None:
|
||||
state = started_state()
|
||||
data = state.to_json()
|
||||
del data["auction"]
|
||||
del data["called_card"]
|
||||
del data["declared_target"]
|
||||
restored = ChiamataState.from_json(data)
|
||||
self.assertIsNone(restored.auction)
|
||||
self.assertIsNone(restored.called_card)
|
||||
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
|
||||
self.assertFalse(restored.solo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Adapter tests: the platform contract over the briscola chiamata rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tavolo.briscola import BriscolaChiamataEngine
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
ChiamataState,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameError,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
)
|
||||
|
||||
|
||||
def _session(engine, **options) -> GameSession:
|
||||
session = GameSession(
|
||||
id="c1",
|
||||
game_type=engine.id,
|
||||
join_code="CODE01",
|
||||
creator_sub="p0",
|
||||
players=[Seat(user_sub="p0", display_name="P0")],
|
||||
)
|
||||
engine.create(session, options)
|
||||
return session
|
||||
|
||||
|
||||
def _started(engine, **options) -> GameSession:
|
||||
session = _session(engine, **options)
|
||||
for i in range(1, engine.max_players):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
return session
|
||||
|
||||
|
||||
def _player_on_turn(session: GameSession):
|
||||
state = session.state
|
||||
return next(p for p in state.players if p.seat == state.turn)
|
||||
|
||||
|
||||
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < limit:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = _player_on_turn(session)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
# The last player standing must bid.
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(
|
||||
p for p in state.players if p.seat == state.caller
|
||||
)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session), "match did not finish"
|
||||
|
||||
|
||||
class MetadataTest(unittest.TestCase):
|
||||
def test_metadata(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertEqual("briscola_chiamata", engine.id)
|
||||
self.assertEqual("Briscola chiamata", engine.name)
|
||||
self.assertTrue(engine.description)
|
||||
self.assertEqual(5, engine.min_players)
|
||||
self.assertEqual(5, engine.max_players)
|
||||
self.assertEqual({}, engine.options_schema)
|
||||
|
||||
|
||||
class CreateJoinTest(unittest.TestCase):
|
||||
def test_create_seats_creator_without_a_team(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertIsNone(session.players[0].team)
|
||||
self.assertIsInstance(session.state, ChiamataState)
|
||||
self.assertEqual(PHASE_LOBBY, session.state.phase)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
for i in range(1, 4):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
engine.join(session, "p4", "P4")
|
||||
self.assertFalse(engine.in_lobby(session))
|
||||
self.assertEqual(PHASE_AUCTION, session.state.phase)
|
||||
self.assertEqual(5, len(session.players))
|
||||
for seat in session.players:
|
||||
self.assertIsNone(seat.team)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
engine.join(session, "p0", "P0")
|
||||
for i in range(1, 5):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "p5", "P5")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
engine = BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
|
||||
)
|
||||
session = _session(engine)
|
||||
self.assertEqual(7, session.state.turn_timeout)
|
||||
|
||||
|
||||
class ActionTest(unittest.TestCase):
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "dance", {})
|
||||
|
||||
def test_bid_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": True})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": False})
|
||||
# points cannot be raised before a bid of the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": 64})
|
||||
|
||||
def test_auction_flow_through_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
engine.handle_action(session, "p2", "pass", {})
|
||||
engine.handle_action(session, "p3", "bid", {"rank": 2})
|
||||
engine.handle_action(session, "p4", "bid", {"points": 64})
|
||||
engine.handle_action(session, "p0", "pass", {})
|
||||
engine.handle_action(session, "p1", "pass", {})
|
||||
engine.handle_action(session, "p3", "pass", {})
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(4, state.caller)
|
||||
self.assertEqual(64, state.declared_target)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(2, state.auction.current.rank)
|
||||
|
||||
engine.handle_action(session, "p4", "call", {"card": "02S"})
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual("S", state.briscola_suit)
|
||||
|
||||
# Play the first card of the player on turn.
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
|
||||
def test_call_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": 42})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "nope"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "01D"})
|
||||
|
||||
def test_finished_match_rejects_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
with self.assertRaises(GameError):
|
||||
engine.handle_action(session, session.players[0].user_sub, "play", {})
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_view_hides_other_hands_and_roles(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertIn("hand", players[0])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
self.assertEqual(5, view["player_count"])
|
||||
# p1 opens the auction.
|
||||
auction = view["auction"]
|
||||
self.assertEqual(1, auction["turn"])
|
||||
self.assertIsNone(auction["current"])
|
||||
|
||||
def test_view_marks_the_acting_player(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p1")
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertIn("legal_bids", view)
|
||||
# The caller is labeled as soon as the auction is won.
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
view = engine.view_for(session, "p0")
|
||||
self.assertIn("callable_cards", engine.view_for(session, "p1"))
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[1])
|
||||
|
||||
def test_spectator_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
|
||||
def test_lobby_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
lobby = engine.lobby_view(session)
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(61, lobby["declared_target"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 10})
|
||||
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||
self.assertIsInstance(restored, ChiamataState)
|
||||
self.assertEqual(session.state.phase, restored.phase)
|
||||
self.assertEqual(session.state.turn, restored.turn)
|
||||
assert restored.auction is not None and session.state.auction is not None
|
||||
self.assertEqual(session.state.auction.history, restored.auction.history)
|
||||
self.assertEqual(
|
||||
[p.sub for p in session.state.players],
|
||||
[p.sub for p in restored.players],
|
||||
)
|
||||
|
||||
|
||||
class DeadlineTest(unittest.TestCase):
|
||||
def test_no_deadline_in_lobby(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||
|
||||
def test_auction_deadline_and_revalidation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("auction", deadline.kind)
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, "auction", "auction:1:1:0")
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
# The auto-bid moved the auction on: a fresh deadline exists.
|
||||
state = session.state
|
||||
self.assertIn(
|
||||
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
|
||||
)
|
||||
self.assertIsNotNone(engine.next_deadline(session))
|
||||
|
||||
def test_stale_auction_deadline_after_a_bid(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
|
||||
def test_call_deadline_auto_calls(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("call", deadline.kind)
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(2, state.called_card.rank)
|
||||
|
||||
def test_turn_deadline_auto_plays(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
engine.handle_action(session, "p1", "call", {"card": "02D"})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("turn", deadline.kind)
|
||||
state = session.state
|
||||
before = state.turn
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertNotEqual(before, state.turn)
|
||||
|
||||
|
||||
class ResultTest(unittest.TestCase):
|
||||
def test_result_of_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
state = session.state
|
||||
result = engine.result(session)
|
||||
sizes = sorted(len(team) for team in result.teams)
|
||||
self.assertIn(sizes, ([2, 3], [1, 4]))
|
||||
self.assertEqual(5, len(result.players))
|
||||
self.assertIn(result.winner_team, (0, 1))
|
||||
for player in result.players:
|
||||
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
|
||||
self.assertEqual(
|
||||
player.won,
|
||||
(player.team in (ROLE_CALLER, ROLE_PARTNER))
|
||||
== (result.winner_team == 0),
|
||||
)
|
||||
self.assertIn("points", player.details)
|
||||
summary = result.summary
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
self.assertEqual(state.hand_number, summary["deals_played"])
|
||||
|
||||
def test_result_requires_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
with self.assertRaises(GameError):
|
||||
engine.result(_started(engine))
|
||||
|
||||
def test_solo_result_teams(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
# Force a finished solo match: seat 0 called its own card.
|
||||
state.caller = 0
|
||||
state.solo = True
|
||||
state.partner_seat = None
|
||||
state.called_played = True
|
||||
state.called_card = Card.parse("01D")
|
||||
# Caller side captures 71 (60 + 11), defenders 49.
|
||||
state.players[0].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
|
||||
+ ["01S"]
|
||||
]
|
||||
state.players[1].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
|
||||
]
|
||||
state.scores = [71, 49]
|
||||
state.winner = 0
|
||||
state.phase = PHASE_FINISHED
|
||||
result = engine.result(session)
|
||||
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
|
||||
# The lone caller won: exactly one winner.
|
||||
self.assertEqual(1, sum(1 for p in result.players if p.won))
|
||||
caller = next(p for p in result.players if p.team == ROLE_CALLER)
|
||||
self.assertTrue(caller.won)
|
||||
self.assertEqual(71.0, caller.score)
|
||||
|
||||
def test_game_over_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = 1
|
||||
state.scores = [55, 65]
|
||||
state.caller = 2
|
||||
state.solo = False
|
||||
state.partner_seat = 4
|
||||
state.called_card = Card.parse("03C")
|
||||
over = engine.game_over_view(session, "p0")
|
||||
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||
self.assertEqual("Difensori", over["winner"])
|
||||
self.assertEqual("03C", over["called_card"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
|
||||
|
||||
The per-package suite covers the rules and the engine contract; these
|
||||
tests wire the game through the full platform stack (REST, WebSocket,
|
||||
deadline scheduler).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
|
||||
from tavolo.app import app, game_store, platform
|
||||
from tavolo.briscola.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_PLAYING,
|
||||
Auction,
|
||||
Bid,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||
|
||||
|
||||
async def _create_and_join(client: AsyncClient) -> dict:
|
||||
"""Create a chiamata game and seat all five players; return the view."""
|
||||
with oidc_user(PLAYERS[0]):
|
||||
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
|
||||
assert created.status_code == 201, created.text
|
||||
code = created.json()["join_code"]
|
||||
body = created.json()
|
||||
for player in PLAYERS[1:]:
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
assert joined.status_code == 200, joined.text
|
||||
body = joined.json()
|
||||
return body
|
||||
|
||||
|
||||
class ChiamataRoutesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_and_seat_five_players(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(PLAYERS[0]):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(4, body["seats_open"])
|
||||
self.assertIsNone(body["players"][0]["team"])
|
||||
code = body["join_code"]
|
||||
|
||||
for player in PLAYERS[1:4]:
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual("lobby", joined.json()["phase"])
|
||||
self.assertEqual(
|
||||
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
|
||||
)
|
||||
|
||||
with oidc_user(PLAYERS[4]):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
state = started.json()
|
||||
self.assertEqual(PHASE_AUCTION, state["phase"])
|
||||
self.assertEqual(0, state["seats_open"])
|
||||
self.assertEqual(5, len(state["players"]))
|
||||
for seat in state["players"]:
|
||||
# Roles are hidden until the called card is played.
|
||||
self.assertEqual("", seat["team"])
|
||||
self.assertEqual(8, seat["cards_left"])
|
||||
# The auction opens with the player after the dealer (seat 0).
|
||||
self.assertEqual(1, state["auction"]["turn"])
|
||||
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
|
||||
self.assertIsNone(state["auction"]["current"])
|
||||
self.assertIsNotNone(state["auction_deadline"])
|
||||
# The last joiner's personalized view carries their own hand.
|
||||
self.assertIn("hand", state["players"][4])
|
||||
self.assertEqual(8, len(state["players"][4]["hand"]))
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
# There is no stock in this variant.
|
||||
self.assertNotIn("stock_count", state)
|
||||
|
||||
# A non-player watches without hands or the join code.
|
||||
with oidc_user("mallory"):
|
||||
watched = await client.get(f"/api/games/{state['id']}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
spectator = watched.json()
|
||||
self.assertTrue(spectator["spectator"])
|
||||
self.assertNotIn("join_code", spectator)
|
||||
for seat in spectator["players"]:
|
||||
self.assertNotIn("hand", seat)
|
||||
|
||||
@async_test
|
||||
async def test_preview_lists_chiamata(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user("bob"):
|
||||
preview = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, preview.status_code)
|
||||
body = preview.json()
|
||||
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||
self.assertEqual("Briscola chiamata", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(4, body["seats_open"])
|
||||
|
||||
|
||||
class ChiamataWebSocketTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_auction_call_and_play_over_the_socket(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await _create_and_join(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob"), make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||
first = await bob_ws.receive_json()
|
||||
self.assertEqual("state", first["type"])
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
self.assertIn("legal_bids", first["game"])
|
||||
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
await alice_ws.receive_json()
|
||||
# Bob (seat 1) opens the bidding with the two.
|
||||
await bob_ws.send_json({"action": "bid", "rank": 2})
|
||||
update = await bob_ws.receive_json()
|
||||
self.assertEqual("state", update["type"])
|
||||
current = update["game"]["auction"]["current"]
|
||||
self.assertEqual(
|
||||
{"seat": 1, "rank": 2, "points": 61}, current
|
||||
)
|
||||
self.assertEqual(2, update["game"]["auction"]["turn"])
|
||||
# Alice receives the broadcast of Bob's bid.
|
||||
broadcast = await alice_ws.receive_json()
|
||||
self.assertEqual("state", broadcast["type"])
|
||||
# Alice (seat 0) is not on turn: her bid is an error.
|
||||
await alice_ws.send_json({"action": "bid", "rank": 3})
|
||||
error = await alice_ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
|
||||
# The remaining players pass; Bob wins the auction and is
|
||||
# asked to name the card.
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await game_store.load(game_id)
|
||||
assert session is not None
|
||||
for sub in ("carol", "dave", "erin", "alice"):
|
||||
engine.handle_action(session, sub, "pass", {})
|
||||
await game_store.save(session)
|
||||
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||
self.assertEqual(1, session.state.caller)
|
||||
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
self.assertEqual(
|
||||
["02D", "02C", "02S", "02B"],
|
||||
first["game"]["callable_cards"],
|
||||
)
|
||||
await ws.send_json(
|
||||
{"action": "call", "card": "02D"}
|
||||
)
|
||||
update = await ws.receive_json()
|
||||
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||
self.assertEqual("D", update["game"]["briscola_suit"])
|
||||
self.assertEqual("02D", update["game"]["called_card"])
|
||||
self.assertEqual(1, update["game"]["turn"])
|
||||
|
||||
# Bob is first to play (player after the dealer).
|
||||
bob = next(
|
||||
p for p in update["game"]["players"] if p["seat"] == 1
|
||||
)
|
||||
card = bob["hand"][0]
|
||||
await ws.send_json({"action": "play", "card": card})
|
||||
update = await ws.receive_json()
|
||||
self.assertEqual(
|
||||
[{"seat": 1, "card": card}], update["game"]["trick"]
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_spectator_cannot_act(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await _create_and_join(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertTrue(first["game"]["spectator"])
|
||||
self.assertNotIn("join_code", first["game"])
|
||||
for seat in first["game"]["players"]:
|
||||
self.assertNotIn("hand", seat)
|
||||
await ws.send_json({"action": "bid", "rank": 1})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
|
||||
class ChiamataTimeoutTest(unittest.TestCase):
|
||||
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
|
||||
from tavolo.briscola import chiamata as rules
|
||||
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type="briscola_chiamata",
|
||||
join_code=join_code,
|
||||
creator_sub=PLAYERS[0],
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
for name in PLAYERS
|
||||
],
|
||||
)
|
||||
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
|
||||
for name in PLAYERS[1:]:
|
||||
rules.join_game(session.state, name, name.capitalize())
|
||||
state_mutator(session.state)
|
||||
await game_store.save(session)
|
||||
return session.id
|
||||
|
||||
@async_test
|
||||
async def test_auction_timeout_auto_bids(self) -> None:
|
||||
game_id = await self._seed(
|
||||
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||
)
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
self.assertIsNotNone(first["game"]["auction_deadline"])
|
||||
|
||||
update = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
update = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
update.get("type") == "state"
|
||||
and update["game"]["auction"]["current"] is not None
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
# The absent seat 1 was auto-bid for.
|
||||
current = update["game"]["auction"]["current"]
|
||||
self.assertEqual(1, current["seat"])
|
||||
self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2))
|
||||
|
||||
@async_test
|
||||
async def test_call_and_turn_timeouts(self) -> None:
|
||||
def seed(state) -> None:
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction = Auction(
|
||||
turn=1,
|
||||
active=[1],
|
||||
passed=[0, 2, 3, 4],
|
||||
current=Bid(seat=1, rank=2, points=61),
|
||||
)
|
||||
state.caller = 1
|
||||
state.turn = 1
|
||||
state.auction_deadline = None
|
||||
state.call_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
|
||||
game_id = await self._seed(
|
||||
"chiamata-call-timeout-1", "CH0002", seed
|
||||
)
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("bob")]):
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||
|
||||
called = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
called = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
called.get("type") == "state"
|
||||
and called["game"]["phase"] == PHASE_PLAYING
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(called)
|
||||
assert called is not None
|
||||
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
|
||||
card = called["game"]["called_card"]
|
||||
self.assertIsNotNone(card)
|
||||
self.assertTrue(card.startswith("02"))
|
||||
|
||||
played = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
played = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
played.get("type") == "state"
|
||||
and len(played["game"]["trick"]) == 1
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(played)
|
||||
assert played is not None
|
||||
# Seat 1 was on turn and was auto-played for.
|
||||
self.assertEqual(1, played["game"]["trick"][0]["seat"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -300,7 +300,12 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(
|
||||
["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||
[
|
||||
"scopone_scientifico",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
[g["id"] for g in results],
|
||||
)
|
||||
scopone = results[0]
|
||||
@@ -325,6 +330,12 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(4, quattro["min_players"])
|
||||
self.assertEqual(4, quattro["max_players"])
|
||||
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
||||
chiamata = results[3]
|
||||
self.assertEqual("Briscola chiamata", chiamata["name"])
|
||||
self.assertTrue(chiamata["description"])
|
||||
self.assertEqual(5, chiamata["min_players"])
|
||||
self.assertEqual(5, chiamata["max_players"])
|
||||
self.assertEqual({}, chiamata["options_schema"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
|
||||
@@ -12,8 +12,11 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, platform, tortoise_mixin
|
||||
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.errors import IllegalMove
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopone import engine as rules
|
||||
@@ -222,5 +225,98 @@ class BriscolaStatsTest(unittest.TestCase):
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
async def _finished_chiamata_session() -> GameSession:
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = GameSession(
|
||||
id="stats-chiamata-1",
|
||||
game_type=engine.id,
|
||||
join_code="BC0001",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
for name in ("bob", "carol", "dave", "erin"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(p for p in state.players if p.seat == state.caller)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
class ChiamataStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await _finished_chiamata_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(5, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("briscola_chiamata", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
|
||||
players = await MatchPlayer.all()
|
||||
roles = {p.team for p in players}
|
||||
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||
# Roles match the sides: exactly one caller, the rest defenders
|
||||
# plus at most one partner.
|
||||
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||
winner_team = summary["winner_team"]
|
||||
for row in players:
|
||||
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(5, len(ratings))
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::model::{is_individual_game, DealSummary, GameView};
|
||||
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
/// Names of the two sides: the players' names for games without
|
||||
/// partnerships (classic two-player briscola), "Team A"/"Team B"
|
||||
/// otherwise.
|
||||
/// Names of the two sides: the caller and partner for briscola chiamata,
|
||||
/// the players' names for games without partnerships (classic two-player
|
||||
/// briscola), "Team A"/"Team B" otherwise.
|
||||
fn side_names(game: &GameView) -> (String, String) {
|
||||
if is_individual_game(&game.game_type) {
|
||||
if is_chiamata(&game.game_type) {
|
||||
game.chiamata_sides()
|
||||
} else if is_individual_game(&game.game_type) {
|
||||
(
|
||||
game.player_name_for_team("A"),
|
||||
game.player_name_for_team("B"),
|
||||
@@ -66,6 +68,25 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
"60–60: patta — the deal is replayed and its points do not count."
|
||||
}
|
||||
});
|
||||
let called_row = if is_chiamata(&game.game_type) {
|
||||
game.called_card.clone().map(|code| {
|
||||
let label = card_label(&code);
|
||||
let text = if game.solo {
|
||||
format!("{name_a} called {label} in hand and plays alone.")
|
||||
} else {
|
||||
format!("{label}: its holder is the secret partner; its suit is trump.")
|
||||
};
|
||||
summary_row(
|
||||
card_img(code, "score-mini"),
|
||||
"Chiamata",
|
||||
text,
|
||||
"score-row chiamata-row",
|
||||
label,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let points_row = summary_row(
|
||||
card_img("01D".to_string(), "score-mini"),
|
||||
"Deal points",
|
||||
@@ -84,6 +105,7 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
});
|
||||
view! {
|
||||
div(class="score-rows") {
|
||||
(called_row)
|
||||
(points_row)
|
||||
(patta_note)
|
||||
(cards)
|
||||
@@ -94,9 +116,13 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
/// Running totals with progress toward the target, including the points
|
||||
/// gained in the deal just played (zero for a patta).
|
||||
fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
// Briscola chiamata plays a single deal: there is no running total.
|
||||
let Some(running) = summary.totals else {
|
||||
return view! {};
|
||||
};
|
||||
let target = game.target_score.max(1);
|
||||
let pct_a = (100 * summary.totals.a / target).min(100);
|
||||
let pct_b = (100 * summary.totals.b / target).min(100);
|
||||
let pct_a = (100 * running.a / target).min(100);
|
||||
let pct_b = (100 * running.b / target).min(100);
|
||||
let (gained_a, gained_b) = if summary.patta {
|
||||
(0, 0)
|
||||
} else {
|
||||
@@ -111,7 +137,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(summary.totals.a) " / " (target)
|
||||
(running.a) " / " (target)
|
||||
span(class="gained") { "+" (gained_a) }
|
||||
}
|
||||
}
|
||||
@@ -121,7 +147,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(summary.totals.b) " / " (target)
|
||||
(running.b) " / " (target)
|
||||
span(class="gained") { "+" (gained_b) }
|
||||
}
|
||||
}
|
||||
|
||||
+310
-6
@@ -19,7 +19,10 @@ pub struct PlayerView {
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
/// Game-defined role/team label; empty before briscola chiamata
|
||||
/// reveals the roles.
|
||||
#[serde(default)]
|
||||
pub team: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cards_left: usize,
|
||||
#[serde(default)]
|
||||
@@ -84,9 +87,21 @@ pub struct DealSummary {
|
||||
#[serde(default)]
|
||||
pub patta: bool,
|
||||
pub points: Scores,
|
||||
pub totals: Scores,
|
||||
/// Running match totals; absent for briscola chiamata, whose match is
|
||||
/// a single deal.
|
||||
#[serde(default)]
|
||||
pub totals: Option<Scores>,
|
||||
#[serde(default)]
|
||||
pub cards: Option<TeamCounts>,
|
||||
/// Briscola chiamata: the points target of the played deal.
|
||||
#[serde(default)]
|
||||
pub declared_target: Option<i32>,
|
||||
/// Briscola chiamata: the card named by the caller.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: the caller played without a partner.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
@@ -227,6 +242,83 @@ pub struct GameView {
|
||||
/// Briscola: scoring breakdown of the most recent deal.
|
||||
#[serde(default)]
|
||||
pub last_deal: Option<DealSummary>,
|
||||
/// Briscola chiamata: the calling auction of the current deal.
|
||||
#[serde(default)]
|
||||
pub auction: Option<AuctionView>,
|
||||
/// Briscola chiamata: seat that won the auction.
|
||||
#[serde(default)]
|
||||
pub caller: Option<usize>,
|
||||
/// Briscola chiamata: the named card; its suit is the trump.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: points the calling side must reach.
|
||||
#[serde(default)]
|
||||
pub declared_target: i32,
|
||||
/// Briscola chiamata: the called card has been played; roles are
|
||||
/// public from then on.
|
||||
#[serde(default)]
|
||||
pub called_played: bool,
|
||||
/// Briscola chiamata: the partner's seat, once revealed.
|
||||
#[serde(default)]
|
||||
pub partner: Option<usize>,
|
||||
/// Briscola chiamata: the caller named one of their own cards.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
/// Briscola chiamata: legal bids for the player on turn.
|
||||
#[serde(default)]
|
||||
pub legal_bids: Option<LegalBids>,
|
||||
/// Briscola chiamata: the cards the auction winner may name.
|
||||
#[serde(default)]
|
||||
pub callable_cards: Option<Vec<String>>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
|
||||
#[serde(default)]
|
||||
pub auction_deadline: Option<String>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
|
||||
#[serde(default)]
|
||||
pub call_deadline: Option<String>,
|
||||
}
|
||||
|
||||
/// One bid of the briscola chiamata auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionBid {
|
||||
pub seat: usize,
|
||||
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
|
||||
pub rank: i32,
|
||||
/// Points target the bid commits its side to.
|
||||
#[serde(default)]
|
||||
pub points: i32,
|
||||
}
|
||||
|
||||
/// The briscola chiamata calling auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionView {
|
||||
/// Seat to act.
|
||||
pub turn: usize,
|
||||
#[serde(default)]
|
||||
pub active: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub passed: Vec<usize>,
|
||||
/// The standing bid, if any.
|
||||
#[serde(default)]
|
||||
pub current: Option<AuctionBid>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<AuctionBid>,
|
||||
}
|
||||
|
||||
/// The bids available to the player on turn.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LegalBids {
|
||||
/// Ranks that may be bid, strongest first (empty at the point stage).
|
||||
#[serde(default)]
|
||||
pub ranks: Vec<i32>,
|
||||
/// Lowest legal points raise, when the standing bid is the two.
|
||||
#[serde(default)]
|
||||
pub points_min: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub points_max: Option<i32>,
|
||||
}
|
||||
|
||||
impl GameView {
|
||||
@@ -235,10 +327,38 @@ impl GameView {
|
||||
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.team == team)
|
||||
.find(|p| p.team.as_deref() == Some(team))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| team.to_string())
|
||||
}
|
||||
|
||||
/// Display name of the player in `seat`.
|
||||
pub fn player_name_for_seat(&self, seat: usize) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.seat == seat)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||
}
|
||||
|
||||
/// Side labels for briscola chiamata: the caller (with the partner's
|
||||
/// name once revealed, or "(solo)") against the defenders.
|
||||
pub fn chiamata_sides(&self) -> (String, String) {
|
||||
let side_a = match self.caller {
|
||||
Some(caller) => {
|
||||
let caller_name = self.player_name_for_seat(caller);
|
||||
match self.partner {
|
||||
Some(partner) => {
|
||||
format!("{caller_name} & {}", self.player_name_for_seat(partner))
|
||||
}
|
||||
None if self.called_played => format!("{caller_name} (solo)"),
|
||||
None => caller_name,
|
||||
}
|
||||
}
|
||||
None => "Chiamante".to_string(),
|
||||
};
|
||||
(side_a, "Difensori".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -449,6 +569,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "briscola_chiamata".to_string(),
|
||||
name: "Briscola chiamata".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 5,
|
||||
max_players: 5,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -735,6 +863,30 @@ pub fn is_briscola(game_type: &str) -> bool {
|
||||
game_type == "briscola" || game_type == "briscola_a_quattro"
|
||||
}
|
||||
|
||||
/// Whether a game type is five-player briscola chiamata, which has its
|
||||
/// own table view (auction, secret partner, five seats).
|
||||
pub fn is_chiamata(game_type: &str) -> bool {
|
||||
game_type == "briscola_chiamata"
|
||||
}
|
||||
|
||||
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
|
||||
/// "Tre", `10` -> "Re", and so on.
|
||||
pub fn rank_name(rank: i32) -> String {
|
||||
match rank {
|
||||
1 => "Asso".to_string(),
|
||||
2 => "Due".to_string(),
|
||||
3 => "Tre".to_string(),
|
||||
4 => "Quattro".to_string(),
|
||||
5 => "Cinque".to_string(),
|
||||
6 => "Sei".to_string(),
|
||||
7 => "Sette".to_string(),
|
||||
8 => "Fante".to_string(),
|
||||
9 => "Cavallo".to_string(),
|
||||
10 => "Re".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a game is played by two individuals rather than partnerships
|
||||
/// (classic two-player briscola): team labels and team messages are
|
||||
/// replaced by the players' names.
|
||||
@@ -1033,7 +1185,7 @@ mod tests {
|
||||
assert_eq!(16, g.target_score);
|
||||
assert!(!g.napola);
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!("A", g.players[0].team);
|
||||
assert_eq!(Some("A".to_string()), g.players[0].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1245,7 +1397,10 @@ mod tests {
|
||||
fn is_briscola_matches_both_variants() {
|
||||
assert!(is_briscola("briscola"));
|
||||
assert!(is_briscola("briscola_a_quattro"));
|
||||
assert!(!is_briscola("briscola_chiamata"));
|
||||
assert!(!is_briscola("scopone_scientifico"));
|
||||
assert!(is_chiamata("briscola_chiamata"));
|
||||
assert!(!is_chiamata("briscola"));
|
||||
assert_eq!("denari", suit_name("D"));
|
||||
assert_eq!("coppe", suit_name("C"));
|
||||
assert_eq!("spade", suit_name("S"));
|
||||
@@ -1253,6 +1408,16 @@ mod tests {
|
||||
assert_eq!("?", suit_name("?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_names_cover_the_ladder() {
|
||||
assert_eq!("Asso", rank_name(1));
|
||||
assert_eq!("Due", rank_name(2));
|
||||
assert_eq!("Tre", rank_name(3));
|
||||
assert_eq!("Fante", rank_name(8));
|
||||
assert_eq!("Cavallo", rank_name(9));
|
||||
assert_eq!("Re", rank_name(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_individual_game_only_matches_two_player_briscola() {
|
||||
assert!(is_individual_game("briscola"));
|
||||
@@ -1318,11 +1483,17 @@ mod tests {
|
||||
let types = fallback_game_types();
|
||||
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||
vec![
|
||||
"scopone_scientifico",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
ids
|
||||
);
|
||||
// The lobby's first game (the creation default) must exist.
|
||||
assert_eq!("scopone_scientifico", types[0].id);
|
||||
assert_eq!(5, types[3].max_players);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1400,7 +1571,7 @@ mod tests {
|
||||
assert_eq!(1, deal.deal);
|
||||
assert!(!deal.patta);
|
||||
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
||||
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
|
||||
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
|
||||
let cards = deal.cards.unwrap();
|
||||
assert_eq!(19, cards.a);
|
||||
assert_eq!(21, cards.b);
|
||||
@@ -1420,4 +1591,137 @@ mod tests {
|
||||
assert_eq!(0, d.deal);
|
||||
assert!(d.cards.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_deal_summary_has_no_totals() {
|
||||
let d: DealSummary = serde_json::from_value(json!({
|
||||
"deal": 2,
|
||||
"patta": true,
|
||||
"points": {"A": 60, "B": 60},
|
||||
"declared_target": 61,
|
||||
"called_card": "01D",
|
||||
"caller": "Alice",
|
||||
"solo": false,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(d.patta);
|
||||
assert_eq!(2, d.deal);
|
||||
assert!(d.totals.is_none());
|
||||
assert!(d.cards.is_none());
|
||||
assert_eq!(Some(61), d.declared_target);
|
||||
assert_eq!(Some("01D".to_string()), d.called_card);
|
||||
assert!(!d.solo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_auction() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c1",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "auction",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 1,
|
||||
"declared_target": 61,
|
||||
"auction": {
|
||||
"turn": 2,
|
||||
"active": [0, 1, 2, 3, 4],
|
||||
"passed": [],
|
||||
"current": {"seat": 1, "rank": 10, "points": 61},
|
||||
"history": [{"seat": 1, "rank": 10, "points": 61}],
|
||||
},
|
||||
"your_turn": true,
|
||||
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0},
|
||||
],
|
||||
"auction_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"call_deadline": null,
|
||||
"turn_deadline": null,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_chiamata(&g.game_type));
|
||||
assert!(!is_briscola(&g.game_type));
|
||||
let auction = g.auction.unwrap();
|
||||
assert_eq!(2, auction.turn);
|
||||
assert_eq!(5, auction.active.len());
|
||||
let current = auction.current.unwrap();
|
||||
assert_eq!(1, current.seat);
|
||||
assert_eq!(10, current.rank);
|
||||
assert_eq!(61, current.points);
|
||||
assert_eq!(1, auction.history.len());
|
||||
let bids = g.legal_bids.unwrap();
|
||||
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
|
||||
assert!(bids.points_min.is_none());
|
||||
assert_eq!(Some(120), bids.points_max);
|
||||
assert_eq!(Some("".to_string()), g.players[0].team);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_playing_and_reveal() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c2",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "playing",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 3,
|
||||
"caller": 1,
|
||||
"called_card": "03C",
|
||||
"briscola_suit": "C",
|
||||
"declared_target": 64,
|
||||
"called_played": true,
|
||||
"partner": 3,
|
||||
"solo": false,
|
||||
"scores": {"A": 45, "B": 30},
|
||||
"players": [
|
||||
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
|
||||
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
|
||||
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
|
||||
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(Some(1), g.caller);
|
||||
assert_eq!(Some("03C".to_string()), g.called_card);
|
||||
assert_eq!("C", g.briscola_suit);
|
||||
assert_eq!(64, g.declared_target);
|
||||
assert!(g.called_played);
|
||||
assert_eq!(Some(3), g.partner);
|
||||
assert!(!g.solo);
|
||||
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
|
||||
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_sides_label_the_solo_caller() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c3",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "finished",
|
||||
"scores": {"A": 70, "B": 50},
|
||||
"winner": "Chiamante",
|
||||
"caller": 2,
|
||||
"called_played": true,
|
||||
"solo": true,
|
||||
"players": [
|
||||
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
|
||||
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
let (a, b) = g.chiamata_sides();
|
||||
assert_eq!("Carol (solo)", a);
|
||||
assert_eq!("Difensori", b);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
|
||||
let team = player.team.clone();
|
||||
view! { span(class="team-badge") { "Team " (team) } }
|
||||
});
|
||||
let team_badge = (!is_individual_game(&game.game_type))
|
||||
.then(|| player.team.clone())
|
||||
.flatten()
|
||||
.filter(|team| !team.is_empty())
|
||||
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
|
||||
let points = player.points;
|
||||
let captured = player.captured_count;
|
||||
let backs = (0..player.cards_left)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Briscola chiamata table: five seats, the calling auction and the
|
||||
//! secret-partner reveal.
|
||||
//!
|
||||
//! The auction starts with the player after the dealer: each player bids
|
||||
//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 >
|
||||
//! 4 > 2) or passes, and once the standing bid is the two the bidding
|
||||
//! continues on points. The last bidder names a card of the won rank:
|
||||
//! its holder is the secret partner, or the caller plays alone when it
|
||||
//! is one of their own cards, and its suit is the trump. The partner's
|
||||
//! identity is only revealed when the called card is played.
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::copy::CopyButton;
|
||||
use crate::model::{
|
||||
card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView,
|
||||
};
|
||||
use crate::pages::game::watch_link;
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||
}
|
||||
|
||||
fn player_name(game: &GameView, seat: usize) -> String {
|
||||
player_for_seat(game, seat)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||
}
|
||||
|
||||
/// Human label of one auction bid.
|
||||
fn bid_label(bid: &AuctionBid) -> String {
|
||||
if bid.rank == 2 {
|
||||
format!("2 at {} pts", bid.points)
|
||||
} else {
|
||||
rank_name(bid.rank)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel for one player seat (name, revealed role, hidden cards, points).
|
||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
let Some(player) = player_for_seat(&game, seat) else {
|
||||
return view! {};
|
||||
};
|
||||
let active = game.phase == "playing" && game.turn == seat;
|
||||
let cls = format!(
|
||||
"seat seat-{position}{}",
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
// Role labels only appear once the server makes them public.
|
||||
let role_badge = player
|
||||
.team
|
||||
.clone()
|
||||
.filter(|role| !role.is_empty())
|
||||
.map(|role| view! { span(class="team-badge role-badge") { (role) } });
|
||||
let points = player.points;
|
||||
let captured = player.captured_count;
|
||||
let backs = (0..player.cards_left)
|
||||
.map(|_| card_back("mini"))
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class=cls) {
|
||||
div(class="seat-name") {
|
||||
(name)
|
||||
(role_badge)
|
||||
}
|
||||
div(class="seat-cards") { (backs) }
|
||||
div(class="seat-stats") {
|
||||
(points) " points · " (captured) " cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cards of the trick in progress, in play order, with player names.
|
||||
fn trick_view(game: &GameView) -> View {
|
||||
if game.trick.is_empty() {
|
||||
return view! { p(class="table-empty") { "Empty table" } };
|
||||
}
|
||||
let cards = game
|
||||
.trick
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let name = player_name(game, tc.seat);
|
||||
let card = card_img(tc.card.clone(), "table-card");
|
||||
view! {
|
||||
div(class="trick-slot") {
|
||||
span(class="trick-name") { (name) }
|
||||
(card)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! { (cards) }
|
||||
}
|
||||
|
||||
/// Announcement strip for the most recently completed trick.
|
||||
fn last_trick_banner(game: &GameView) -> View {
|
||||
let Some(trick) = game.last_trick.clone() else {
|
||||
return view! {};
|
||||
};
|
||||
let name = player_name(game, trick.winner);
|
||||
let cards = trick
|
||||
.cards
|
||||
.iter()
|
||||
.map(|tc| card_img(tc.card.clone(), "move-card"))
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="move-banner") {
|
||||
strong { (name) }
|
||||
span { " takes the trick " }
|
||||
(cards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Patta — the deal is replayed" after a 60-60 deal.
|
||||
fn patta_banner(game: &GameView) -> View {
|
||||
let patta = game
|
||||
.last_deal
|
||||
.as_ref()
|
||||
.is_some_and(|deal| deal.patta);
|
||||
if !patta || game.phase != "auction" {
|
||||
return view! {};
|
||||
}
|
||||
view! {
|
||||
div(class="move-banner patta-banner") {
|
||||
strong { "Patta!" }
|
||||
span { " 60–60 — the deal is replayed." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Announcement of the partner (or the solo caller) once the called card
|
||||
/// has hit the table.
|
||||
fn reveal_banner(game: &GameView) -> View {
|
||||
if !game.called_played || game.phase == "auction" || game.phase == "calling" {
|
||||
return view! {};
|
||||
}
|
||||
let caller = game
|
||||
.caller
|
||||
.map(|seat| player_name(game, seat))
|
||||
.unwrap_or_default();
|
||||
let text = if game.solo {
|
||||
format!("{caller} is playing alone")
|
||||
} else {
|
||||
let partner = game
|
||||
.partner
|
||||
.map(|seat| player_name(game, seat))
|
||||
.unwrap_or_default();
|
||||
format!("{partner} is the partner of {caller}")
|
||||
};
|
||||
view! {
|
||||
div(class="move-banner reveal-banner") {
|
||||
span(class="reveal-text") { (text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The calling auction panel: standing bid, trail and, on turn, the bids.
|
||||
fn auction_panel(
|
||||
game: &GameView,
|
||||
on_bid_rank: impl Fn(i32) + Copy + 'static,
|
||||
on_bid_points: impl Fn(i32) + Copy + 'static,
|
||||
on_pass: impl Fn() + Copy + 'static,
|
||||
) -> View {
|
||||
let Some(auction) = game.auction.clone() else {
|
||||
return view! {};
|
||||
};
|
||||
let standing = match &auction.current {
|
||||
Some(bid) => format!(
|
||||
"Standing bid: {} — {}",
|
||||
player_name(game, bid.seat),
|
||||
bid_label(bid)
|
||||
),
|
||||
None => "No bids yet".to_string(),
|
||||
};
|
||||
let waiting_for = player_name(game, auction.turn);
|
||||
let history = auction
|
||||
.history
|
||||
.iter()
|
||||
.map(|bid| {
|
||||
let text = format!("{}: {}", player_name(game, bid.seat), bid_label(bid));
|
||||
view! { span(class="auction-chip") { (text) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actions = if game.your_turn == Some(true) {
|
||||
let legal = game.legal_bids.clone();
|
||||
let (rank_buttons, point_buttons) = match legal {
|
||||
Some(bids) => {
|
||||
let ranks = bids
|
||||
.ranks
|
||||
.iter()
|
||||
.map(|rank| {
|
||||
let rank = *rank;
|
||||
let name = rank_name(rank);
|
||||
view! {
|
||||
button(
|
||||
class="bid-button",
|
||||
on:click=move |_| on_bid_rank(rank),
|
||||
) { (name) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let points = match (bids.points_min, bids.points_max) {
|
||||
(Some(min), Some(max)) => (0..=2)
|
||||
.map(|step| min + 2 * step)
|
||||
.filter(|points| *points <= max)
|
||||
.map(|points| {
|
||||
view! {
|
||||
button(
|
||||
class="bid-button points",
|
||||
on:click=move |_| on_bid_points(points),
|
||||
) { (points) " pts" }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(ranks, points)
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
view! {
|
||||
div(class="bid-options") {
|
||||
(rank_buttons)
|
||||
(point_buttons)
|
||||
button(class="pass-button", on:click=move |_| on_pass()) { "Pass" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
p(class="text-secondary small mb-0") {
|
||||
"Waiting for " (waiting_for) " to bid…"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="auction-panel") {
|
||||
div(class="auction-standing") { (standing) }
|
||||
div(class="auction-history") { (history) }
|
||||
(actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main briscola chiamata table.
|
||||
pub fn chiamata_table(
|
||||
game: GameView,
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
on_bid_rank: impl Fn(i32) + Copy + 'static,
|
||||
on_bid_points: impl Fn(i32) + Copy + 'static,
|
||||
on_pass: impl Fn() + Copy + 'static,
|
||||
now: Signal<f64>,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
let viewer_seat = game
|
||||
.players
|
||||
.iter()
|
||||
.find(|p| p.hand.is_some())
|
||||
.map(|p| p.seat)
|
||||
.unwrap_or(0);
|
||||
let my_turn = game.your_turn == Some(true);
|
||||
let can_play = game.phase == "playing" && my_turn;
|
||||
|
||||
let turn_note = if game.phase == "finished" {
|
||||
"Match finished".to_string()
|
||||
} else if game.phase == "auction" {
|
||||
if my_turn {
|
||||
"Your bid".to_string()
|
||||
} else {
|
||||
let name = game
|
||||
.auction
|
||||
.as_ref()
|
||||
.map(|a| player_name(&game, a.turn))
|
||||
.unwrap_or_default();
|
||||
format!("{name} to bid")
|
||||
}
|
||||
} else if game.phase == "calling" {
|
||||
if my_turn {
|
||||
"Name the called card".to_string()
|
||||
} else {
|
||||
let name = game
|
||||
.caller
|
||||
.map(|seat| player_name(&game, seat))
|
||||
.unwrap_or_default();
|
||||
format!("{name} is calling")
|
||||
}
|
||||
} else if my_turn {
|
||||
"Your turn".to_string()
|
||||
} else {
|
||||
format!("{}'s turn", player_name(&game, game.turn))
|
||||
};
|
||||
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
||||
|
||||
let deadline = if game.phase == "auction" {
|
||||
game.auction_deadline.clone()
|
||||
} else if game.phase == "calling" {
|
||||
game.call_deadline.clone()
|
||||
} else {
|
||||
game.turn_deadline.clone()
|
||||
};
|
||||
let countdown = deadline.map(|deadline| {
|
||||
// A dynamic closure so only the ticking number re-renders.
|
||||
let deadline_ms = js_sys::Date::parse(&deadline);
|
||||
view! {
|
||||
span(class="turn-timer") {
|
||||
"Auto in "
|
||||
(move || {
|
||||
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||||
})
|
||||
"s"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let score_line = match game.scores {
|
||||
Some(scores) => {
|
||||
let (side_a, side_b) = game.chiamata_sides();
|
||||
format!(
|
||||
"{side_a} {} — {} {side_b} (target {})",
|
||||
scores.a, scores.b, game.declared_target
|
||||
)
|
||||
}
|
||||
// Before the called card is played the side totals would leak the
|
||||
// partner's identity; only the target is shown.
|
||||
None => format!("Target {}", game.declared_target),
|
||||
};
|
||||
|
||||
// Four opponents around the table: next player on the left, then
|
||||
// clockwise up to the player before us on the right.
|
||||
let left = seat_panel(game.clone(), (viewer_seat + 1) % 5, "left");
|
||||
let top_left = seat_panel(game.clone(), (viewer_seat + 2) % 5, "tl");
|
||||
let top_right = seat_panel(game.clone(), (viewer_seat + 3) % 5, "tr");
|
||||
let right = seat_panel(game.clone(), (viewer_seat + 4) % 5, "right");
|
||||
|
||||
let patta = patta_banner(&game);
|
||||
let reveal = reveal_banner(&game);
|
||||
let center_content = match game.phase.as_str() {
|
||||
"auction" => auction_panel(&game, on_bid_rank, on_bid_points, on_pass),
|
||||
"calling" => {
|
||||
let caller = game
|
||||
.caller
|
||||
.map(|seat| player_name(&game, seat))
|
||||
.unwrap_or_default();
|
||||
view! {
|
||||
div(class="auction-panel") {
|
||||
div(class="auction-standing") { (caller) " won the auction" }
|
||||
p(class="text-secondary small mb-0") {
|
||||
"Choosing the called card…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let banner = last_trick_banner(&game);
|
||||
let trick = trick_view(&game);
|
||||
view! {
|
||||
(banner)
|
||||
div(class="table-cards") { (trick) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let viewer = player_for_seat(&game, viewer_seat);
|
||||
let my_points = viewer.as_ref().map(|p| p.points).unwrap_or(0);
|
||||
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
|
||||
hand.sort_by_key(|c| hand_sort_key(c));
|
||||
let hand_cards = hand
|
||||
.into_iter()
|
||||
.map(|code| {
|
||||
let card_view = card_img(code.clone(), "hand-card");
|
||||
let click_code = code.clone();
|
||||
view! {
|
||||
button(class="hand-slot", on:click=move |_| {
|
||||
if can_play {
|
||||
on_hand_card(click_code.clone());
|
||||
}
|
||||
}) { (card_view) }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let hint = if can_play {
|
||||
Some(view! {
|
||||
p(class="text-secondary small text-center") {
|
||||
"Click a card to play it."
|
||||
}
|
||||
})
|
||||
} else if game.phase == "auction" {
|
||||
Some(view! {
|
||||
p(class="text-secondary small text-center") {
|
||||
"Bid or pass, then play the cards."
|
||||
}
|
||||
})
|
||||
} else if game.phase == "calling" && my_turn {
|
||||
Some(view! {
|
||||
p(class="text-secondary small text-center") {
|
||||
"Name a card: its holder will be your secret partner."
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Spectators have no hand: render the pivot seat as a regular panel so
|
||||
// its points stay visible where the hand would be.
|
||||
let bottom = if game.spectator {
|
||||
seat_panel(game.clone(), viewer_seat, "bottom")
|
||||
} else {
|
||||
view! {
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") { (my_points) " points" }
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let watch_link = watch_link(&game.id);
|
||||
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
|
||||
let suit = suit_name(&game.briscola_suit);
|
||||
view! { span(class="briscola-badge") { "Briscola: " (suit) } }
|
||||
});
|
||||
let called_badge = game.called_card.clone().map(|code| {
|
||||
let label = card_label(&code);
|
||||
view! { span(class="called-badge") { "Chiamata: " (label) } }
|
||||
});
|
||||
let deal = game.hand_number;
|
||||
|
||||
view! {
|
||||
div(class="table-wrap") {
|
||||
div(class="hud") {
|
||||
a(href="/") { "← Lobby" }
|
||||
span { "Deal " (deal) }
|
||||
(trump_badge)
|
||||
(called_badge)
|
||||
(spectator_badge)
|
||||
span(class="hud-scores") { (score_line) }
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
}
|
||||
div(class="table-grid cinque") {
|
||||
(top_left)
|
||||
(top_right)
|
||||
(left)
|
||||
div(class="center") {
|
||||
(patta)
|
||||
(reveal)
|
||||
(center_content)
|
||||
}
|
||||
(right)
|
||||
(bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay for the auction winner: name the called card.
|
||||
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||||
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
||||
return view! {};
|
||||
};
|
||||
let own_hand = game
|
||||
.players
|
||||
.iter()
|
||||
.find(|p| p.hand.is_some())
|
||||
.and_then(|p| p.hand.clone())
|
||||
.unwrap_or_default();
|
||||
let options = cards
|
||||
.into_iter()
|
||||
.map(|code| {
|
||||
let held = own_hand.contains(&code);
|
||||
let label = card_label(&code);
|
||||
let img = card_img(code.clone(), "hand-card");
|
||||
let solo_note = held.then(|| view! {
|
||||
span(class="solo-note") { "plays alone" }
|
||||
});
|
||||
let click_code = code.clone();
|
||||
view! {
|
||||
button(class="capture-option call-option", on:click=move |_| {
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.call_card(&click_code);
|
||||
}
|
||||
}) {
|
||||
(img)
|
||||
span(class="call-label") { (label) }
|
||||
(solo_note)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker") {
|
||||
h3 { "Name the called card" }
|
||||
p(class="text-secondary small") {
|
||||
"Its holder becomes your secret partner; name one of your "
|
||||
"own cards to play alone."
|
||||
}
|
||||
div(class="capture-options") { (options) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-10
@@ -12,10 +12,11 @@ use crate::components::copy::CopyButton;
|
||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{
|
||||
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
|
||||
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
|
||||
PlayerView, Scores, ServerMessage,
|
||||
};
|
||||
use crate::pages::briscola::briscola_table;
|
||||
use crate::pages::chiamata::{call_picker, chiamata_table};
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||
@@ -43,7 +44,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team = player.team.clone();
|
||||
let team = player.team.clone().unwrap_or_default();
|
||||
let captured = player.captured_count;
|
||||
let scope = player.scope;
|
||||
let backs = (0..player.cards_left)
|
||||
@@ -324,6 +325,32 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
};
|
||||
|
||||
// Briscola chiamata: auction and call actions.
|
||||
let on_bid_rank = move |rank: i32| {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.bid_rank(rank);
|
||||
}
|
||||
};
|
||||
let on_bid_points = move |points: i32| {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.bid_points(points);
|
||||
}
|
||||
};
|
||||
let on_pass = move || {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.pass();
|
||||
}
|
||||
};
|
||||
|
||||
let reconnect_banner = reconnect.clone();
|
||||
view! {
|
||||
div(class="game-page") {
|
||||
@@ -392,6 +419,11 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
||||
// Briscola chiamata has its own five-seat table with the
|
||||
// calling auction and the secret-partner reveal.
|
||||
Some(g) if is_chiamata(&g.game_type) => {
|
||||
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
|
||||
}
|
||||
// Briscola has its own table view for both variants.
|
||||
Some(g) if is_briscola(&g.game_type) => {
|
||||
briscola_table(g, on_hand_card, now)
|
||||
@@ -406,6 +438,17 @@ pub fn GamePage(id: String) -> View {
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
// Briscola chiamata: the auction winner names the called card.
|
||||
(move || match game.get_clone() {
|
||||
Some(g)
|
||||
if is_chiamata(&g.game_type)
|
||||
&& g.phase == "calling"
|
||||
&& g.your_turn == Some(true) =>
|
||||
{
|
||||
call_picker(g, socket)
|
||||
}
|
||||
_ => view! {},
|
||||
})
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||
hand_summary_modal(g, socket, now)
|
||||
@@ -440,10 +483,11 @@ fn LobbyView(game: GameView) -> View {
|
||||
.map(|p| {
|
||||
let name = p.name.clone();
|
||||
let seat = p.seat;
|
||||
let team_badge = show_teams.then(|| {
|
||||
let team = p.team.clone();
|
||||
view! { span(class="badge text-bg-info") { "Team " (team) } }
|
||||
});
|
||||
let team_badge = show_teams
|
||||
.then(|| p.team.clone())
|
||||
.flatten()
|
||||
.filter(|team| !team.is_empty())
|
||||
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
|
||||
view! {
|
||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||
strong { (name) }
|
||||
@@ -734,8 +778,9 @@ fn capture_picker(
|
||||
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
|
||||
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
|
||||
if let Some(g) = &game {
|
||||
if g.game_type != "scopone_scientifico" && !briscola {
|
||||
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
|
||||
return view! {};
|
||||
}
|
||||
}
|
||||
@@ -749,18 +794,20 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
// Two-player briscola has no teams: name the winner instead.
|
||||
// Briscola chiamata names the caller's side (or the defenders).
|
||||
let winner_label = match game.as_ref() {
|
||||
Some(g) if is_individual_game(&g.game_type) => {
|
||||
g.player_name_for_team(&winner)
|
||||
}
|
||||
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
|
||||
_ => format!("Team {winner}"),
|
||||
};
|
||||
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
||||
// Spectators have no match history to look at.
|
||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||
// Explain the final hand's (scopone) or deal's (briscola)
|
||||
// scoring before the result.
|
||||
let final_summary: View = if briscola {
|
||||
// Explain the final hand's (scopone) or deal's (briscola,
|
||||
// briscola chiamata) scoring before the result.
|
||||
let final_summary: View = if briscola || chiamata {
|
||||
match game.as_ref() {
|
||||
Some(g) => match g.last_deal.clone() {
|
||||
Some(deal) => deal_rows(g, &deal),
|
||||
|
||||
@@ -39,7 +39,10 @@ fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||
|
||||
fn team_header(team: &Option<String>) -> String {
|
||||
match team {
|
||||
Some(t) => format!("Team {t}"),
|
||||
// Single-letter labels are the A/B team names; longer labels
|
||||
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
|
||||
Some(t) if t.len() <= 2 => format!("Team {t}"),
|
||||
Some(t) => t.clone(),
|
||||
None => "Players".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -55,7 +58,11 @@ fn score_text(m: &MatchSummary) -> String {
|
||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||
fn winner_text(m: &MatchSummary) -> String {
|
||||
if let Some(team) = m.result_str("winner_team") {
|
||||
return format!("Team {team}");
|
||||
return if team.len() <= 2 {
|
||||
format!("Team {team}")
|
||||
} else {
|
||||
team
|
||||
};
|
||||
}
|
||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod briscola;
|
||||
pub mod chiamata;
|
||||
pub mod game;
|
||||
pub mod history;
|
||||
pub mod leaderboard;
|
||||
|
||||
@@ -45,6 +45,26 @@ impl GameSocket {
|
||||
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: bid a rank in the calling auction.
|
||||
pub fn bid_rank(&self, rank: i32) {
|
||||
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: raise the points target (standing bid is the 2).
|
||||
pub fn bid_points(&self, points: i32) {
|
||||
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: leave the auction.
|
||||
pub fn pass(&self) {
|
||||
self.send_json(serde_json::json!({ "action": "pass" }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: name the called card.
|
||||
pub fn call_card(&self, card: &str) {
|
||||
self.send_json(serde_json::json!({ "action": "call", "card": card }));
|
||||
}
|
||||
|
||||
fn send_json(&self, value: serde_json::Value) {
|
||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||
}
|
||||
|
||||
+159
@@ -667,3 +667,162 @@ body {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- briscola chiamata ---------- */
|
||||
|
||||
/* Five seats: two across the top and one on each side of the viewer. */
|
||||
.table-grid.cinque {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"tl . tr"
|
||||
"left center right"
|
||||
"bottom bottom bottom";
|
||||
}
|
||||
|
||||
.seat-tl {
|
||||
grid-area: tl;
|
||||
}
|
||||
|
||||
.seat-tr {
|
||||
grid-area: tr;
|
||||
}
|
||||
|
||||
.called-badge {
|
||||
background: var(--panel-light);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
background: var(--accent);
|
||||
color: var(--felt-dark);
|
||||
}
|
||||
|
||||
.reveal-banner {
|
||||
background: rgba(232, 197, 71, 0.15);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.patta-banner {
|
||||
background: rgba(217, 83, 79, 0.25);
|
||||
}
|
||||
|
||||
/* The calling auction: standing bid, trail and the bid buttons. */
|
||||
.auction-panel {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.auction-standing {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auction-history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auction-chip {
|
||||
background: var(--panel-light);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.bid-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bid-button {
|
||||
background: var(--panel-light);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bid-button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--felt-dark);
|
||||
}
|
||||
|
||||
.bid-button.points {
|
||||
border-color: var(--team-a);
|
||||
}
|
||||
|
||||
.pass-button {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pass-button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* The auction winner's call picker: one card per suit. */
|
||||
.call-option {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.call-option .playing-card {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.call-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.solo-note {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chiamata-row .score-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.table-grid.cinque {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"tl tr"
|
||||
"left right"
|
||||
"center center"
|
||||
"bottom bottom";
|
||||
}
|
||||
|
||||
.auction-panel {
|
||||
padding: 0.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user