Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eea293471
|
||
|
|
4ba5ae1434
|
||
|
|
0f9fb9b6bb
|
||
|
|
322ca27bf4
|
||
|
|
0674e06565
|
||
|
|
353f012906
|
@@ -1,9 +1,10 @@
|
|||||||
# tavolo
|
# 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
|
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||||
game — and **briscola**, in its classic two-player and four-player
|
game — **briscola**, in its classic two-player and four-player
|
||||||
partnership (*briscola a quattro*) variants:
|
partnership (*briscola a quattro*) variants, and **briscola chiamata** —
|
||||||
|
the five-player variant with a calling auction and a secret partner:
|
||||||
|
|
||||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
@@ -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
|
*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
|
the next hand is dealt. If someone is away the next hand is dealt
|
||||||
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
|
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
|
## On your turn
|
||||||
|
|
||||||
@@ -53,12 +56,18 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
|||||||
player does not move — disconnected or fallen asleep — the server plays a
|
player does not move — disconnected or fallen asleep — the server plays a
|
||||||
random legal card for them (choosing among the legal captures when the
|
random legal card for them (choosing among the legal captures when the
|
||||||
rules require a capture), so one absent player cannot stall the table.
|
rules require a capture), so one absent player cannot stall the table.
|
||||||
|
Briscola chiamata arms the same countdown for its auction turns and for
|
||||||
|
the auction winner's called-card choice: an absent bidder passes, the
|
||||||
|
last player standing with no bid is made to bid the ace, and a timed-out
|
||||||
|
call names a random suit of the won rank.
|
||||||
|
|
||||||
## Watching
|
## Watching
|
||||||
|
|
||||||
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||||||
spectators see the table, the score and every player's captured cards
|
spectators see the table, the score and every player's captured cards
|
||||||
(and scope for scopone), but never a hand and never the join code. The
|
(and scope for scopone), but never a hand and never the join code. In
|
||||||
|
briscola chiamata they also see no role labels and no side totals until
|
||||||
|
the called card is played, so the secret partner stays secret. The
|
||||||
"Copy watch link" button in the waiting room and in the game HUD copies
|
"Copy watch link" button in the waiting room and in the game HUD copies
|
||||||
the URL to share. A spectator who opens the URL before the match starts
|
the URL to share. A spectator who opens the URL before the match starts
|
||||||
sees a "game not started yet" waiting room.
|
sees a "game not started yet" waiting room.
|
||||||
|
|||||||
+57
-7
@@ -16,9 +16,10 @@ and
|
|||||||
scientifico, the four-player, fixed-partnership variant of the classic
|
scientifico, the four-player, fixed-partnership variant of the classic
|
||||||
Italian card game: pure rules plus the platform adapter.
|
Italian card game: pure rules plus the platform adapter.
|
||||||
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
||||||
Italian trick-taking game, in its classic two-player (`briscola`) and
|
Italian trick-taking game, in its classic two-player (`briscola`),
|
||||||
four-player partnership (`briscola_a_quattro`) variants: pure rules
|
four-player partnership (`briscola_a_quattro`) and five-player
|
||||||
plus the platform adapter.
|
called-partner (`briscola_chiamata`) variants: pure rules plus the
|
||||||
|
platform adapter.
|
||||||
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||||
composition root wiring the platform to the games, with the
|
composition root wiring the platform to the games, with the
|
||||||
environment-driven settings and the SPA shell.
|
environment-driven settings and the SPA shell.
|
||||||
@@ -236,7 +237,9 @@ aggregate with `max`, not `sum`.
|
|||||||
as reported by the engine (`result` JSON — for scopone: both teams'
|
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||||
final scores, winner, target score, hands played and the per-hand
|
final scores, winner, target score, hands played and the per-hand
|
||||||
audit; for briscola: the final scores, winner, target score, deals
|
audit; for 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,
|
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||||
seat, game-defined team label (nullable), whether they won, the points
|
seat, game-defined team label (nullable), whether they won, the points
|
||||||
they scored, the Elo change the match produced (`elo_delta`) and
|
they scored, the Elo change the match produced (`elo_delta`) and
|
||||||
@@ -275,7 +278,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 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 |
|
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
||||||
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
||||||
@@ -341,6 +344,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
|
|||||||
every player has acknowledged, or automatically after
|
every player has acknowledged, or automatically after
|
||||||
`HAND_ACK_TIMEOUT_SECONDS`.
|
`HAND_ACK_TIMEOUT_SECONDS`.
|
||||||
|
|
||||||
|
For briscola chiamata the game actions are the calling auction plus play:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "bid", "rank": 3}
|
||||||
|
{"action": "bid", "points": 64}
|
||||||
|
{"action": "pass"}
|
||||||
|
{"action": "call", "card": "03D"}
|
||||||
|
{"action": "play", "card": "04C"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `bid` with a `rank` calls a lower rank than the standing bid
|
||||||
|
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
|
||||||
|
target once the standing bid is the two.
|
||||||
|
- `pass` leaves the auction; the last player standing must bid.
|
||||||
|
- `call` names any card of the won rank; naming one of your own cards
|
||||||
|
means playing alone.
|
||||||
|
- `play` follows briscola: any card in hand is legal.
|
||||||
|
|
||||||
After every accepted move the new state is broadcast to every connected
|
After every accepted move the new state is broadcast to every connected
|
||||||
client, spectators included.
|
client, spectators included.
|
||||||
|
|
||||||
@@ -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
|
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||||
queue (see above), not from timers tied to client connections, so the
|
queue (see above), not from timers tied to client connections, so the
|
||||||
match keeps progressing even with every player disconnected.
|
match keeps progressing even with every player disconnected. Briscola
|
||||||
|
chiamata uses the same timeout for its auction turns (an absent bidder
|
||||||
|
passes; the last player standing with no bid is made to bid the ace) and
|
||||||
|
for the called-card choice (a random suit of the won rank).
|
||||||
|
|
||||||
### Hand-end summary
|
### Hand-end summary
|
||||||
|
|
||||||
@@ -416,6 +440,31 @@ player must dismiss. A play attempted in this phase is rejected with an
|
|||||||
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||||
opposite).
|
opposite).
|
||||||
|
|
||||||
|
### Briscola chiamata
|
||||||
|
|
||||||
|
- 40-card Italian deck, eight cards each for five players: the whole deck
|
||||||
|
is dealt, so there is no stock and no face-up card.
|
||||||
|
- The calling auction starts with the player after the dealer: each
|
||||||
|
player either passes (leaving the auction) or bids a lower rank than
|
||||||
|
the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the
|
||||||
|
standing bid is the two, the auction continues on points (62, 64, …).
|
||||||
|
The last player standing must bid, so every deal gets a caller.
|
||||||
|
- The winner names any card of the won rank. If the winner holds it, the
|
||||||
|
caller plays alone against the other four; otherwise its holder is the
|
||||||
|
secret partner and the two form the calling side. The called card's
|
||||||
|
suit is the trump; the partner's identity stays hidden until the
|
||||||
|
called card is played.
|
||||||
|
- Play and trick resolution follow briscola; eight tricks exhaust every
|
||||||
|
hand. The calling side must reach the declared target (61 unless
|
||||||
|
raised); with the plain target a 60–60 deal is a *patta* and is
|
||||||
|
replayed with the next dealer. A match is a single deal, so the caller
|
||||||
|
and partner (or the lone caller) against the other players map onto
|
||||||
|
the platform's two-team result, Elo and history.
|
||||||
|
- Auction turns, the called-card choice and the plays all have their own
|
||||||
|
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
|
||||||
|
player standing with no bid is made to bid the ace, an absent winner's
|
||||||
|
call names a random suit of the won rank, and plays are auto-played.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -493,8 +542,9 @@ packages/tavolo-briscola/ # briscola game (own pyproject + tests)
|
|||||||
src/tavolo/briscola/
|
src/tavolo/briscola/
|
||||||
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
||||||
├── engine.py # pure briscola rules (2- and 4-player)
|
├── engine.py # pure briscola rules (2- and 4-player)
|
||||||
|
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
|
||||||
├── errors.py # briscola-specific rule violations
|
├── errors.py # briscola-specific rule violations
|
||||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
|
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
|
||||||
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||||
src/tavolo/
|
src/tavolo/
|
||||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
|||||||
from kaya.openapi import OpenAPIMixin
|
from kaya.openapi import OpenAPIMixin
|
||||||
from kaya.session import SessionMixin
|
from kaya.session import SessionMixin
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
from tavolo.briscola import (
|
||||||
|
BriscolaChiamataEngine,
|
||||||
|
BriscolaEngine,
|
||||||
|
BriscolaQuattroEngine,
|
||||||
|
)
|
||||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||||
@@ -144,6 +148,12 @@ registry.register(
|
|||||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
registry.register(
|
||||||
|
BriscolaChiamataEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||||
log.debug(
|
log.debug(
|
||||||
"game timeouts: hand_ack=%ds turn=%ds",
|
"game timeouts: hand_ack=%ds turn=%ds",
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Briscola — the Italian trick-taking game — as a
|
Briscola — the Italian trick-taking game — as a
|
||||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||||
Two variants share one rules core:
|
Three variants share one rules core:
|
||||||
|
|
||||||
- `briscola` — the classic two-player game;
|
- `briscola` — the classic two-player game;
|
||||||
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
- `briscola_a_quattro` — four-player fixed-partnership briscola;
|
||||||
|
- `briscola_chiamata` — five-player briscola with a calling auction and a
|
||||||
|
secret partner.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
@@ -13,27 +15,42 @@ Two variants share one rules core:
|
|||||||
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||||
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||||
envelope, no transport, no I/O.
|
envelope, no transport, no I/O.
|
||||||
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
- `engine.py` — the pure rules engine for two- and four-player briscola:
|
||||||
(trump first, then the led suit; strength ace > three > king > horse >
|
deck, deals, trick resolution (trump first, then the led suit; strength
|
||||||
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
|
||||||
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
(trick winner first, the face-up briscola last), card points (ace 11,
|
||||||
jack 2), patta re-deals and the target-score match end, deal-end
|
three 10, king 4, horse 3, jack 2), patta re-deals and the
|
||||||
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
target-score match end, deal-end acknowledgements and auto-play.
|
||||||
logging, so the whole rule set is unit-testable.
|
Deterministic and I/O-free apart from logging, so the whole rule set is
|
||||||
|
unit-testable.
|
||||||
|
- `chiamata.py` — the pure rules engine for five-player briscola
|
||||||
|
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
|
||||||
|
round-trip), the 8-card deal with no stock, the descending calling
|
||||||
|
auction with point raises, the winner's call (including calling one's
|
||||||
|
own card, which means playing alone), the called-card reveal, single-
|
||||||
|
deal scoring with the 60–60 patta and the auto-actions for timeouts.
|
||||||
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||||
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||||
(`GameEngine`): the platform-facing adapters. They validate the
|
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||||
`target_score` creation option (61–120, default 61) and extract the
|
The first two validate the `target_score` creation option (61–120,
|
||||||
`MatchResult` (teams, winner, per-player scores and the match summary
|
default 61) and extract the `MatchResult` (teams, winner, per-player
|
||||||
persisted as the match's JSON `result`).
|
scores and the match summary persisted as the match's JSON `result`).
|
||||||
|
Briscola chiamata takes no creation options.
|
||||||
|
|
||||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||||
application composition root.
|
application composition root. Briscola chiamata uses the turn timeout
|
||||||
|
for its auction turns, the called-card choice and the plays: an absent
|
||||||
|
bidder passes, the last player standing with no bid is made to bid the
|
||||||
|
ace, and an absent winner's call names a random suit of the won rank. It
|
||||||
|
never enters the between-deals acknowledgement phase (a single deal ends
|
||||||
|
the match), so the hand-ack timeout is accepted but unused.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
### Briscola (two-player and four-player)
|
||||||
|
|
||||||
- Three cards per player; the next card is turned face up: its suit is
|
- Three cards per player; the next card is turned face up: its suit is
|
||||||
the trump (briscola) for the deal and it is the last card drawn.
|
the trump (briscola) for the deal and it is the last card drawn.
|
||||||
- The winner of a trick is the highest trump, or the highest card of the
|
- The winner of a trick is the highest trump, or the highest card of the
|
||||||
@@ -47,6 +64,31 @@ application composition root.
|
|||||||
- Between deals of an unfinished match every player acknowledges the
|
- Between deals of an unfinished match every player acknowledges the
|
||||||
summary (or the hand-end timeout deals on), exactly like scopone.
|
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||||
|
|
||||||
|
### Briscola chiamata (five-player)
|
||||||
|
|
||||||
|
- The whole 40-card deck is dealt out, eight cards each: there is no
|
||||||
|
stock and no face-up card.
|
||||||
|
- Starting with the player after the dealer, each player either passes
|
||||||
|
(leaving the auction for good) or bids a **lower** rank than the
|
||||||
|
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
|
||||||
|
the first bidder may bid any rank. Once the standing bid is the two,
|
||||||
|
the auction continues on points instead (62, 64, …): the calling side
|
||||||
|
then has to reach the raised target. The last player standing must
|
||||||
|
bid, so a deal always gets a caller.
|
||||||
|
- The auction winner names any card of the winning rank. If the winner
|
||||||
|
holds it, they **play alone** against the other four; otherwise its
|
||||||
|
holder is the **secret partner** and the two form the calling side.
|
||||||
|
The called card's suit is the trump for the deal. The partner's
|
||||||
|
identity is hidden until the called card is played.
|
||||||
|
- Play follows briscola: the player after the dealer leads, the highest
|
||||||
|
trump (or highest led-suit card) takes the trick and leads the next
|
||||||
|
one; eight tricks exhaust every hand.
|
||||||
|
- The calling side (caller plus partner, or the lone caller) must reach
|
||||||
|
the declared target — 61 unless the auction raised it. With the plain
|
||||||
|
target a 60–60 deal is a patta and is replayed with the next dealer.
|
||||||
|
A match is a single deal, so the two sides map onto the platform's
|
||||||
|
two-team result (Elo rating, match history).
|
||||||
|
|
||||||
## Development (from `server/`)
|
## Development (from `server/`)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -54,5 +96,5 @@ application composition root.
|
|||||||
.venv/bin/python -m mypy -p tavolo.briscola
|
.venv/bin/python -m mypy -p tavolo.briscola
|
||||||
```
|
```
|
||||||
|
|
||||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||||
platform contract (actions, deadlines, serialization, results).
|
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
"""Briscola: tavolo's trick-taking game implementation.
|
"""Briscola: tavolo's trick-taking game implementation.
|
||||||
|
|
||||||
Two variants share the same rules core and platform adapter:
|
Three variants share the same rules core and platform adapter:
|
||||||
|
|
||||||
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||||
briscola (``briscola``);
|
briscola (``briscola``);
|
||||||
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||||
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
fixed-partnership briscola a quattro (``briscola_a_quattro``);
|
||||||
|
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
|
||||||
|
briscola chiamata (``briscola_chiamata``).
|
||||||
|
|
||||||
The pure rules (:mod:`tavolo.briscola.engine`,
|
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||||
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
|
||||||
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
|
||||||
|
adapts them to the platform's
|
||||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||||
game-independent platform can host them.
|
game-independent platform can host them.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
from .plugin import (
|
||||||
|
BriscolaChiamataEngine,
|
||||||
|
BriscolaEngine,
|
||||||
|
BriscolaQuattroEngine,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
__all__ = [
|
||||||
|
"BriscolaChiamataEngine",
|
||||||
|
"BriscolaEngine",
|
||||||
|
"BriscolaQuattroEngine",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,953 @@
|
|||||||
|
"""Pure rules engine for briscola chiamata.
|
||||||
|
|
||||||
|
Briscola chiamata is the five-player variant of briscola. The 40-card
|
||||||
|
deck is dealt out entirely (8 cards each, no stock): there is no face-up
|
||||||
|
briscola card. Instead the players auction off the right to call a
|
||||||
|
partner:
|
||||||
|
|
||||||
|
* starting with the player after the dealer, each player either passes
|
||||||
|
(leaving the auction for good) or bids a rank lower than the standing
|
||||||
|
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
|
||||||
|
bidder may bid any rank. Once the standing bid is the 2, further bids
|
||||||
|
raise the points the calling side must reach instead (62, 64, ...).
|
||||||
|
* the last remaining bidder wins and names the exact card of the winning
|
||||||
|
rank (any suit). If the winner holds the named card they play alone
|
||||||
|
against the other four; otherwise its holder is the **secret partner**
|
||||||
|
and the two form the calling side. The called card's suit is the trump
|
||||||
|
(*briscola*) for the whole deal.
|
||||||
|
* play and scoring follow briscola. The calling side must reach the
|
||||||
|
declared target (61 unless raised); a 60-60 deal with the plain target
|
||||||
|
is a **patta** and is replayed.
|
||||||
|
|
||||||
|
A match is a single deal (patta replays aside), so the two sides map
|
||||||
|
onto the platform's two-team result (Elo rating, match history). The
|
||||||
|
partner's identity stays hidden until the called card is played, at
|
||||||
|
which point the roles are revealed.
|
||||||
|
|
||||||
|
Like :mod:`tavolo.briscola.engine` this module is deterministic and
|
||||||
|
I/O-free apart from logging: it mutates (or reads)
|
||||||
|
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
|
||||||
|
:class:`~tavolo.platform.errors.GameError` subclasses on rule
|
||||||
|
violations. The platform-facing adapter is
|
||||||
|
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .engine import TRICK_STRENGTH, card_points, shuffled_deck
|
||||||
|
from .errors import CardNotInHand
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
SUITS,
|
||||||
|
Card,
|
||||||
|
Trick,
|
||||||
|
TrickCard,
|
||||||
|
parse_card,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Five seats, the whole 40-card deck dealt out.
|
||||||
|
PLAYER_COUNT = 5
|
||||||
|
HAND_SIZE = 8
|
||||||
|
|
||||||
|
PHASE_AUCTION = "auction"
|
||||||
|
PHASE_CALLING = "calling"
|
||||||
|
|
||||||
|
# The two sides of the match: the caller (plus the partner, when there is
|
||||||
|
# one) against the other players.
|
||||||
|
SIDE_CALLER = 0
|
||||||
|
SIDE_DEFENDERS = 1
|
||||||
|
SIDE_NAMES = ("Chiamante", "Difensori")
|
||||||
|
|
||||||
|
# Role labels carried by the personalized view once they are public.
|
||||||
|
ROLE_CALLER = "Chiamante"
|
||||||
|
ROLE_PARTNER = "Socio"
|
||||||
|
ROLE_DEFENDER = "Difensori"
|
||||||
|
|
||||||
|
# Points the calling side must reach: 61 unless the auction raised it.
|
||||||
|
DEFAULT_TARGET = 61
|
||||||
|
MIN_TARGET = 61
|
||||||
|
MAX_TARGET = 120
|
||||||
|
|
||||||
|
# Rank ladder of the auction, strongest first: ace, three, king, horse,
|
||||||
|
# jack, then the plain ranks downwards.
|
||||||
|
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
|
||||||
|
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
|
||||||
|
|
||||||
|
_rng = random.SystemRandom()
|
||||||
|
|
||||||
|
|
||||||
|
# -- state -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChiamataPlayer:
|
||||||
|
sub: str
|
||||||
|
name: str
|
||||||
|
seat: int
|
||||||
|
hand: List[Card] = field(default_factory=list)
|
||||||
|
captured: List[Card] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sub": self.sub,
|
||||||
|
"name": self.name,
|
||||||
|
"seat": self.seat,
|
||||||
|
"hand": [c.to_json() for c in self.hand],
|
||||||
|
"captured": [c.to_json() for c in self.captured],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "ChiamataPlayer":
|
||||||
|
return ChiamataPlayer(
|
||||||
|
sub=str(data["sub"]),
|
||||||
|
name=str(data["name"]),
|
||||||
|
seat=int(data["seat"]),
|
||||||
|
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||||
|
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Bid:
|
||||||
|
"""One auction bid: a rank, or (at the 2) a raised points target."""
|
||||||
|
|
||||||
|
seat: int
|
||||||
|
rank: int
|
||||||
|
points: int
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {"seat": self.seat, "rank": self.rank, "points": self.points}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "Bid":
|
||||||
|
return Bid(
|
||||||
|
seat=int(data["seat"]),
|
||||||
|
rank=int(data["rank"]),
|
||||||
|
points=int(data["points"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Auction:
|
||||||
|
"""The state of the calling auction."""
|
||||||
|
|
||||||
|
# Seat to act; always one of ``active``.
|
||||||
|
turn: int = 0
|
||||||
|
active: List[int] = field(default_factory=list)
|
||||||
|
passed: List[int] = field(default_factory=list)
|
||||||
|
current: Optional[Bid] = None
|
||||||
|
history: List[Bid] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"turn": self.turn,
|
||||||
|
"active": list(self.active),
|
||||||
|
"passed": list(self.passed),
|
||||||
|
"current": self.current.to_json() if self.current else None,
|
||||||
|
"history": [b.to_json() for b in self.history],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "Auction":
|
||||||
|
return Auction(
|
||||||
|
turn=int(data.get("turn", 0)),
|
||||||
|
active=[int(s) for s in data.get("active", [])],
|
||||||
|
passed=[int(s) for s in data.get("passed", [])],
|
||||||
|
current=(
|
||||||
|
Bid.from_json(data["current"]) if data.get("current") else None
|
||||||
|
),
|
||||||
|
history=[Bid.from_json(b) for b in data.get("history", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChiamataState:
|
||||||
|
phase: str = PHASE_LOBBY
|
||||||
|
players: List[ChiamataPlayer] = field(default_factory=list)
|
||||||
|
dealer: int = 0
|
||||||
|
# 1-based deal counter; grows with patta replays.
|
||||||
|
hand_number: int = 1
|
||||||
|
# 0 = the calling side (caller plus partner, when there is one),
|
||||||
|
# 1 = the defenders. Only set once the match is finished.
|
||||||
|
winner: Optional[int] = None
|
||||||
|
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||||
|
# The calling auction of the current deal.
|
||||||
|
auction: Optional[Auction] = None
|
||||||
|
# Seat that won the auction (public from then on).
|
||||||
|
caller: Optional[int] = None
|
||||||
|
# The card named by the caller; its suit is the trump.
|
||||||
|
called_card: Optional[Card] = None
|
||||||
|
briscola_suit: str = ""
|
||||||
|
# Points target from the auction (61 unless raised).
|
||||||
|
declared_target: int = DEFAULT_TARGET
|
||||||
|
# True when the caller named a card of their own hand: no partner.
|
||||||
|
solo: bool = False
|
||||||
|
# True once the called card has been played; roles become public.
|
||||||
|
called_played: bool = False
|
||||||
|
# The partner's seat, recorded when the called card is played; stays
|
||||||
|
# ``None`` for a solo call.
|
||||||
|
partner_seat: Optional[int] = None
|
||||||
|
# Cards of the trick in progress, in play order.
|
||||||
|
trick: List[TrickCard] = field(default_factory=list)
|
||||||
|
trick_leader: int = 0
|
||||||
|
last_trick: Optional[Trick] = None
|
||||||
|
# Seat currently to act (auction bidder, caller, or player on turn).
|
||||||
|
turn: int = 0
|
||||||
|
# Per-deal audit trail, patta entries included.
|
||||||
|
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
# While phase == "auction"/"playing": when the server acts for the
|
||||||
|
# player on turn.
|
||||||
|
turn_deadline: Optional[str] = None
|
||||||
|
auction_deadline: Optional[str] = None
|
||||||
|
# While phase == "calling": when the server names a card for the
|
||||||
|
# auction winner.
|
||||||
|
call_deadline: Optional[str] = None
|
||||||
|
# Seconds a player has to act before the server acts for them.
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"phase": self.phase,
|
||||||
|
"players": [p.to_json() for p in self.players],
|
||||||
|
"dealer": self.dealer,
|
||||||
|
"hand_number": self.hand_number,
|
||||||
|
"winner": self.winner,
|
||||||
|
"scores": list(self.scores),
|
||||||
|
"auction": self.auction.to_json() if self.auction else None,
|
||||||
|
"caller": self.caller,
|
||||||
|
"called_card": (
|
||||||
|
self.called_card.to_json() if self.called_card else None
|
||||||
|
),
|
||||||
|
"briscola_suit": self.briscola_suit,
|
||||||
|
"declared_target": self.declared_target,
|
||||||
|
"solo": self.solo,
|
||||||
|
"called_played": self.called_played,
|
||||||
|
"partner_seat": self.partner_seat,
|
||||||
|
"trick": [t.to_json() for t in self.trick],
|
||||||
|
"trick_leader": self.trick_leader,
|
||||||
|
"last_trick": self.last_trick.to_json() if self.last_trick else None,
|
||||||
|
"turn": self.turn,
|
||||||
|
"hand_scores": list(self.hand_scores),
|
||||||
|
"turn_deadline": self.turn_deadline,
|
||||||
|
"auction_deadline": self.auction_deadline,
|
||||||
|
"call_deadline": self.call_deadline,
|
||||||
|
"turn_timeout": self.turn_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "ChiamataState":
|
||||||
|
return ChiamataState(
|
||||||
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||||
|
players=[
|
||||||
|
ChiamataPlayer.from_json(p) for p in data.get("players", [])
|
||||||
|
],
|
||||||
|
dealer=int(data.get("dealer", 0)),
|
||||||
|
hand_number=int(data.get("hand_number", 1)),
|
||||||
|
winner=data.get("winner"),
|
||||||
|
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||||
|
auction=(
|
||||||
|
Auction.from_json(data["auction"]) if data.get("auction") else None
|
||||||
|
),
|
||||||
|
caller=data.get("caller"),
|
||||||
|
called_card=(
|
||||||
|
Card.from_json(data["called_card"])
|
||||||
|
if data.get("called_card")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
briscola_suit=str(data.get("briscola_suit", "")),
|
||||||
|
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
|
||||||
|
solo=bool(data.get("solo", False)),
|
||||||
|
called_played=bool(data.get("called_played", False)),
|
||||||
|
partner_seat=data.get("partner_seat"),
|
||||||
|
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
|
||||||
|
trick_leader=int(data.get("trick_leader", 0)),
|
||||||
|
last_trick=(
|
||||||
|
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
|
||||||
|
),
|
||||||
|
turn=int(data.get("turn", 0)),
|
||||||
|
hand_scores=list(data.get("hand_scores", [])),
|
||||||
|
turn_deadline=data.get("turn_deadline"),
|
||||||
|
auction_deadline=data.get("auction_deadline"),
|
||||||
|
call_deadline=data.get("call_deadline"),
|
||||||
|
turn_timeout=int(
|
||||||
|
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
|
||||||
|
for player in self.players:
|
||||||
|
if player.sub == sub:
|
||||||
|
return player
|
||||||
|
return None
|
||||||
|
|
||||||
|
def seated(self, sub: str) -> bool:
|
||||||
|
return self.player_for(sub) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# -- game setup --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_game(
|
||||||
|
creator_sub: str,
|
||||||
|
creator_name: str,
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
) -> ChiamataState:
|
||||||
|
"""Create a lobby game with the creator seated first."""
|
||||||
|
return ChiamataState(
|
||||||
|
phase=PHASE_LOBBY,
|
||||||
|
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
|
turn_timeout=turn_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def join_game(state: ChiamataState, sub: str, name: str) -> None:
|
||||||
|
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
||||||
|
if state.phase != PHASE_LOBBY:
|
||||||
|
raise GameNotStarted("game has already started")
|
||||||
|
if state.seated(sub):
|
||||||
|
raise AlreadyJoined("already joined this game")
|
||||||
|
if len(state.players) >= PLAYER_COUNT:
|
||||||
|
raise LobbyFull("game is full")
|
||||||
|
seat = len(state.players)
|
||||||
|
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
|
||||||
|
if len(state.players) == PLAYER_COUNT:
|
||||||
|
start_game(state)
|
||||||
|
|
||||||
|
|
||||||
|
def start_game(state: ChiamataState) -> None:
|
||||||
|
"""Deal the first hand and open the auction."""
|
||||||
|
if len(state.players) != PLAYER_COUNT:
|
||||||
|
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
|
||||||
|
_deal(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||||
|
for player in state.players:
|
||||||
|
if player.seat == seat:
|
||||||
|
return player
|
||||||
|
raise IllegalMove(f"no player in seat {seat}")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_deadline(seconds: int) -> str:
|
||||||
|
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
||||||
|
return deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_auction_deadline(state: ChiamataState) -> None:
|
||||||
|
state.auction_deadline = _set_deadline(state.turn_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_call_deadline(state: ChiamataState) -> None:
|
||||||
|
state.call_deadline = _set_deadline(state.turn_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_turn_deadline(state: ChiamataState) -> None:
|
||||||
|
state.turn_deadline = _set_deadline(state.turn_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _deal(state: ChiamataState) -> None:
|
||||||
|
"""Deal 8 cards each and open the auction."""
|
||||||
|
deck = shuffled_deck()
|
||||||
|
for player in state.players:
|
||||||
|
player.hand = []
|
||||||
|
player.captured = []
|
||||||
|
state.trick = []
|
||||||
|
state.last_trick = None
|
||||||
|
state.turn_deadline = None
|
||||||
|
state.call_deadline = None
|
||||||
|
state.called_card = None
|
||||||
|
state.briscola_suit = ""
|
||||||
|
state.declared_target = DEFAULT_TARGET
|
||||||
|
state.solo = False
|
||||||
|
state.called_played = False
|
||||||
|
state.partner_seat = None
|
||||||
|
state.caller = None
|
||||||
|
state.winner = None
|
||||||
|
state.scores = [0, 0]
|
||||||
|
# The whole deck is dealt, one card at a time, starting with the
|
||||||
|
# player after the dealer.
|
||||||
|
for _ in range(HAND_SIZE):
|
||||||
|
for offset in range(PLAYER_COUNT):
|
||||||
|
_player_at(
|
||||||
|
state, (state.dealer + 1 + offset) % PLAYER_COUNT
|
||||||
|
).hand.append(deck.pop())
|
||||||
|
first = (state.dealer + 1) % PLAYER_COUNT
|
||||||
|
state.turn = first
|
||||||
|
state.trick_leader = first
|
||||||
|
state.auction = Auction(
|
||||||
|
turn=first,
|
||||||
|
active=[player.seat for player in state.players],
|
||||||
|
)
|
||||||
|
state.phase = PHASE_AUCTION
|
||||||
|
_set_auction_deadline(state)
|
||||||
|
log.debug(
|
||||||
|
"deal %d dealt (dealer seat %d), auction opens with seat %d",
|
||||||
|
state.hand_number,
|
||||||
|
state.dealer,
|
||||||
|
first,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- auction -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auction(
|
||||||
|
state: ChiamataState, sub: str
|
||||||
|
) -> Tuple[Auction, ChiamataPlayer]:
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
raise GameFinished("the match is over")
|
||||||
|
if state.phase != PHASE_AUCTION:
|
||||||
|
raise GameNotStarted("the auction is not open")
|
||||||
|
player = state.player_for(sub)
|
||||||
|
if player is None:
|
||||||
|
raise NotYourTurn("you are not seated in this game")
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None: # pragma: no cover - auction always exists here
|
||||||
|
raise GameNotStarted("the auction is not open")
|
||||||
|
if auction.turn != player.seat:
|
||||||
|
raise NotYourTurn("it is not your turn to bid")
|
||||||
|
return auction, player
|
||||||
|
|
||||||
|
|
||||||
|
def legal_bid_ranks(state: ChiamataState) -> List[int]:
|
||||||
|
"""The ranks the player on turn may bid, empty at the point stage."""
|
||||||
|
auction = state.auction
|
||||||
|
if state.phase != PHASE_AUCTION or auction is None:
|
||||||
|
return []
|
||||||
|
current = auction.current
|
||||||
|
if current is None:
|
||||||
|
return list(BID_LADDER)
|
||||||
|
if current.rank == 2:
|
||||||
|
return []
|
||||||
|
index = _BID_INDEX[current.rank]
|
||||||
|
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
|
||||||
|
|
||||||
|
|
||||||
|
def legal_bid_points(state: ChiamataState) -> Optional[int]:
|
||||||
|
"""The lowest legal points raise, ``None`` when there is none.
|
||||||
|
|
||||||
|
Points may only be raised once the standing bid is the 2; any value
|
||||||
|
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
|
||||||
|
"""
|
||||||
|
auction = state.auction
|
||||||
|
if state.phase != PHASE_AUCTION or auction is None:
|
||||||
|
return None
|
||||||
|
current = auction.current
|
||||||
|
if current is None or current.rank != 2:
|
||||||
|
return None
|
||||||
|
if current.points >= MAX_TARGET:
|
||||||
|
return None
|
||||||
|
return current.points + 2
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_auction(state: ChiamataState) -> None:
|
||||||
|
"""The last active bidder wins and must name the called card."""
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None: # pragma: no cover - guarded by callers
|
||||||
|
raise GameNotStarted("the auction is not open")
|
||||||
|
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
|
||||||
|
raise IllegalMove("the auction cannot be concluded yet")
|
||||||
|
winner = auction.active[0]
|
||||||
|
state.caller = winner
|
||||||
|
state.declared_target = auction.current.points
|
||||||
|
state.turn = winner
|
||||||
|
state.phase = PHASE_CALLING
|
||||||
|
state.auction_deadline = None
|
||||||
|
_set_call_deadline(state)
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d wins the auction (rank %d, target %d)",
|
||||||
|
state.hand_number,
|
||||||
|
winner,
|
||||||
|
auction.current.rank,
|
||||||
|
auction.current.points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_auction(state: ChiamataState) -> None:
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None: # pragma: no cover - guarded by callers
|
||||||
|
return
|
||||||
|
for offset in range(1, PLAYER_COUNT + 1):
|
||||||
|
seat = (auction.turn + offset) % PLAYER_COUNT
|
||||||
|
if seat in auction.active:
|
||||||
|
auction.turn = seat
|
||||||
|
break
|
||||||
|
state.turn = auction.turn
|
||||||
|
_set_auction_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None: # pragma: no cover - guarded by callers
|
||||||
|
raise GameNotStarted("the auction is not open")
|
||||||
|
auction.current = bid
|
||||||
|
auction.history.append(bid)
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d bids rank %d at %d",
|
||||||
|
state.hand_number,
|
||||||
|
bid.seat,
|
||||||
|
bid.rank,
|
||||||
|
bid.points,
|
||||||
|
)
|
||||||
|
if len(auction.active) == 1:
|
||||||
|
_finish_auction(state)
|
||||||
|
return
|
||||||
|
_advance_auction(state)
|
||||||
|
|
||||||
|
|
||||||
|
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
|
||||||
|
"""Bid ``rank``; it must be weaker than the standing bid."""
|
||||||
|
auction, player = _require_auction(state, sub)
|
||||||
|
if rank not in _BID_INDEX:
|
||||||
|
raise IllegalMove(f"invalid rank: {rank!r}")
|
||||||
|
current = auction.current
|
||||||
|
if current is not None:
|
||||||
|
if current.rank == 2:
|
||||||
|
raise IllegalMove("the auction is at the 2: raise the points or pass")
|
||||||
|
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
|
||||||
|
raise IllegalMove("a bid must call a lower rank than the standing bid")
|
||||||
|
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
|
||||||
|
|
||||||
|
|
||||||
|
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
|
||||||
|
"""Raise the points target; only legal once the standing bid is the 2."""
|
||||||
|
auction, player = _require_auction(state, sub)
|
||||||
|
current = auction.current
|
||||||
|
if current is None or current.rank != 2:
|
||||||
|
raise IllegalMove("points can only be raised after a bid of the 2")
|
||||||
|
if points <= current.points:
|
||||||
|
raise IllegalMove(
|
||||||
|
f"the points must be higher than the standing bid ({current.points})"
|
||||||
|
)
|
||||||
|
if points > MAX_TARGET:
|
||||||
|
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
|
||||||
|
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
|
||||||
|
|
||||||
|
|
||||||
|
def pass_auction(state: ChiamataState, sub: str) -> None:
|
||||||
|
"""Leave the auction for good."""
|
||||||
|
auction, player = _require_auction(state, sub)
|
||||||
|
if player.seat not in auction.active: # pragma: no cover - turn implies active
|
||||||
|
raise IllegalMove("you already passed")
|
||||||
|
if len(auction.active) == 1 and auction.current is None:
|
||||||
|
# Everyone else passed before any bid: the last player must bid.
|
||||||
|
raise IllegalMove("you must bid: everyone else has passed")
|
||||||
|
auction.active.remove(player.seat)
|
||||||
|
auction.passed.append(player.seat)
|
||||||
|
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
|
||||||
|
if len(auction.active) == 1 and auction.current is not None:
|
||||||
|
_finish_auction(state)
|
||||||
|
return
|
||||||
|
_advance_auction(state)
|
||||||
|
|
||||||
|
|
||||||
|
# -- calling -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def call(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||||
|
"""The auction winner names the called card (any suit of the bid rank).
|
||||||
|
|
||||||
|
If the caller holds the named card they play alone; the card's suit
|
||||||
|
becomes the trump for the deal.
|
||||||
|
"""
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
raise GameFinished("the match is over")
|
||||||
|
if state.phase != PHASE_CALLING:
|
||||||
|
raise GameNotStarted("no call is waiting")
|
||||||
|
caller = None if state.caller is None else _player_at(state, state.caller)
|
||||||
|
player = state.player_for(sub)
|
||||||
|
if player is None or caller is None or player.seat != caller.seat:
|
||||||
|
raise NotYourTurn("only the auction winner calls the card")
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None or auction.current is None: # pragma: no cover
|
||||||
|
raise GameNotStarted("no call is waiting")
|
||||||
|
card = parse_card(card_code)
|
||||||
|
if card.rank != auction.current.rank:
|
||||||
|
raise IllegalMove(
|
||||||
|
"the called card must match the rank won in the auction"
|
||||||
|
)
|
||||||
|
state.called_card = card
|
||||||
|
state.briscola_suit = card.suit
|
||||||
|
state.solo = card in caller.hand
|
||||||
|
state.phase = PHASE_PLAYING
|
||||||
|
# The player after the dealer opens the play, whichever seat won the
|
||||||
|
# auction.
|
||||||
|
first = (state.dealer + 1) % PLAYER_COUNT
|
||||||
|
state.turn = first
|
||||||
|
state.trick_leader = first
|
||||||
|
state.call_deadline = None
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d calls %s%s",
|
||||||
|
state.hand_number,
|
||||||
|
caller.seat,
|
||||||
|
card.code,
|
||||||
|
" (playing alone)" if state.solo else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- play ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def play(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||||
|
"""Apply one move by the player identified by ``sub``.
|
||||||
|
|
||||||
|
Any card in hand is a legal play; only turn order and phase are
|
||||||
|
validated. Raises a :class:`~tavolo.platform.errors.GameError`
|
||||||
|
subclass on any violation.
|
||||||
|
"""
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
raise GameFinished("the match is over")
|
||||||
|
if state.phase == PHASE_AUCTION:
|
||||||
|
raise IllegalMove("the auction is still open")
|
||||||
|
if state.phase == PHASE_CALLING:
|
||||||
|
raise IllegalMove("the called card has not been named yet")
|
||||||
|
if state.phase != PHASE_PLAYING:
|
||||||
|
raise GameNotStarted("the game has not started yet")
|
||||||
|
|
||||||
|
player = state.player_for(sub)
|
||||||
|
if player is None or player.seat != state.turn:
|
||||||
|
raise NotYourTurn("it is not your turn")
|
||||||
|
|
||||||
|
card = parse_card(card_code)
|
||||||
|
if card not in player.hand:
|
||||||
|
raise CardNotInHand(f"card {card.code} is not in your hand")
|
||||||
|
|
||||||
|
player.hand.remove(card)
|
||||||
|
if state.called_card is not None and card == state.called_card:
|
||||||
|
state.called_played = True
|
||||||
|
state.partner_seat = (
|
||||||
|
None if player.seat == state.caller else player.seat
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"deal %d: the called card %s was played by seat %d (%s)",
|
||||||
|
state.hand_number,
|
||||||
|
card.code,
|
||||||
|
player.seat,
|
||||||
|
"the caller plays alone" if state.partner_seat is None else "partner revealed",
|
||||||
|
)
|
||||||
|
state.trick.append(TrickCard(seat=player.seat, card=card.code))
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d played %s (%d/%d in the trick)",
|
||||||
|
state.hand_number,
|
||||||
|
player.seat,
|
||||||
|
card.code,
|
||||||
|
len(state.trick),
|
||||||
|
PLAYER_COUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(state.trick) == PLAYER_COUNT:
|
||||||
|
_resolve_trick(state)
|
||||||
|
else:
|
||||||
|
state.turn = (state.turn + 1) % PLAYER_COUNT
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_trick(state: ChiamataState) -> None:
|
||||||
|
"""Score the completed trick, draw no cards (deck already exhausted)."""
|
||||||
|
winner = _trick_winner(state)
|
||||||
|
taken = [parse_card(t.card) for t in state.trick]
|
||||||
|
_player_at(state, winner).captured.extend(taken)
|
||||||
|
state.last_trick = Trick(winner=winner, cards=list(state.trick))
|
||||||
|
state.trick = []
|
||||||
|
state.trick_leader = winner
|
||||||
|
state.turn = winner
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d takes the trick with %s",
|
||||||
|
state.hand_number,
|
||||||
|
winner,
|
||||||
|
[t.card for t in state.last_trick.cards],
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(not player.hand for player in state.players):
|
||||||
|
_end_deal(state)
|
||||||
|
return
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _trick_winner(state: ChiamataState) -> int:
|
||||||
|
"""The seat winning the current (complete) trick."""
|
||||||
|
played = [(t.seat, parse_card(t.card)) for t in state.trick]
|
||||||
|
leader_suit = played[0][1].suit
|
||||||
|
trumps = [
|
||||||
|
(seat, card)
|
||||||
|
for seat, card in played
|
||||||
|
if card.suit == state.briscola_suit
|
||||||
|
]
|
||||||
|
pool = trumps or [
|
||||||
|
(seat, card) for seat, card in played if card.suit == leader_suit
|
||||||
|
]
|
||||||
|
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- scoring -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def side_points(state: ChiamataState) -> Tuple[int, int]:
|
||||||
|
"""The points captured by the calling side and the defenders.
|
||||||
|
|
||||||
|
The partner's pile only joins the caller's once the partner is known
|
||||||
|
(the called card has been played); until then this is only used to
|
||||||
|
score a finished deal, where the reveal has always happened.
|
||||||
|
"""
|
||||||
|
if state.caller is None:
|
||||||
|
return (0, 0)
|
||||||
|
caller_seats = {state.caller}
|
||||||
|
if state.partner_seat is not None:
|
||||||
|
caller_seats.add(state.partner_seat)
|
||||||
|
caller_points = 0
|
||||||
|
defender_points = 0
|
||||||
|
for player in state.players:
|
||||||
|
points = sum(card_points(card) for card in player.captured)
|
||||||
|
if player.seat in caller_seats:
|
||||||
|
caller_points += points
|
||||||
|
else:
|
||||||
|
defender_points += points
|
||||||
|
return (caller_points, defender_points)
|
||||||
|
|
||||||
|
|
||||||
|
def _end_deal(state: ChiamataState) -> None:
|
||||||
|
"""Score the deal: finish the match, or replay after a patta.
|
||||||
|
|
||||||
|
A 60-60 deal with the plain 61 target is a patta: its points do not
|
||||||
|
count, the dealer rotates and the deal is replayed (a new auction).
|
||||||
|
Otherwise the calling side wins by reaching the declared target.
|
||||||
|
"""
|
||||||
|
state.turn_deadline = None
|
||||||
|
caller_points, defender_points = side_points(state)
|
||||||
|
patta = (
|
||||||
|
state.declared_target == DEFAULT_TARGET
|
||||||
|
and caller_points == 60
|
||||||
|
and defender_points == 60
|
||||||
|
)
|
||||||
|
caller_name = (
|
||||||
|
_player_at(state, state.caller).name if state.caller is not None else None
|
||||||
|
)
|
||||||
|
caller_seats = {state.caller}
|
||||||
|
if state.partner_seat is not None:
|
||||||
|
caller_seats.add(state.partner_seat)
|
||||||
|
caller_cards = sum(
|
||||||
|
len(player.captured)
|
||||||
|
for player in state.players
|
||||||
|
if player.seat in caller_seats
|
||||||
|
)
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"deal": state.hand_number,
|
||||||
|
"patta": patta,
|
||||||
|
"points": {"A": caller_points, "B": defender_points},
|
||||||
|
"cards": {"A": caller_cards, "B": 40 - caller_cards},
|
||||||
|
"declared_target": state.declared_target,
|
||||||
|
"called_card": (
|
||||||
|
state.called_card.code if state.called_card is not None else None
|
||||||
|
),
|
||||||
|
"caller": caller_name,
|
||||||
|
"solo": state.solo,
|
||||||
|
}
|
||||||
|
state.hand_scores.append(entry)
|
||||||
|
log.debug(
|
||||||
|
"deal %d scored caller %d - defenders %d%s (target %d)",
|
||||||
|
state.hand_number,
|
||||||
|
caller_points,
|
||||||
|
defender_points,
|
||||||
|
" patta" if patta else "",
|
||||||
|
state.declared_target,
|
||||||
|
)
|
||||||
|
if patta:
|
||||||
|
state.hand_number += 1
|
||||||
|
state.dealer = (state.dealer + 1) % PLAYER_COUNT
|
||||||
|
_deal(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
state.scores = [caller_points, defender_points]
|
||||||
|
state.winner = (
|
||||||
|
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
|
||||||
|
)
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
log.info(
|
||||||
|
"match ended, %s win %d-%d",
|
||||||
|
SIDE_NAMES[state.winner],
|
||||||
|
caller_points,
|
||||||
|
defender_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- server-side auto actions ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def auto_bid(state: ChiamataState) -> None:
|
||||||
|
"""Act for the bidder on turn: always pass, unless forced to bid.
|
||||||
|
|
||||||
|
An absent player never outbids anyone: they leave the auction. Only
|
||||||
|
the last player standing before any bid cannot pass — they are made
|
||||||
|
to bid the ace, whose suit :func:`auto_call` picks at random if the
|
||||||
|
call itself times out too.
|
||||||
|
"""
|
||||||
|
if state.phase != PHASE_AUCTION:
|
||||||
|
raise GameNotStarted("the auction is not open")
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None: # pragma: no cover - guarded by phase
|
||||||
|
raise GameNotStarted("the auction is not open")
|
||||||
|
player = _player_at(state, auction.turn)
|
||||||
|
if len(auction.active) == 1 and auction.current is None:
|
||||||
|
bid_rank(state, player.sub, 1)
|
||||||
|
return
|
||||||
|
pass_auction(state, player.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||||
|
"""Name a random suit of the auction rank for the winner."""
|
||||||
|
if state.phase != PHASE_CALLING:
|
||||||
|
raise GameNotStarted("no call is waiting")
|
||||||
|
auction = state.auction
|
||||||
|
if auction is None or auction.current is None: # pragma: no cover
|
||||||
|
raise GameNotStarted("no call is waiting")
|
||||||
|
caller = _player_at(state, state.caller) if state.caller is not None else None
|
||||||
|
if caller is None: # pragma: no cover - guarded by phase
|
||||||
|
raise GameNotStarted("no call is waiting")
|
||||||
|
chooser = rng or _rng
|
||||||
|
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
|
||||||
|
call(state, caller.sub, card.code)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||||
|
"""Play a random card for the player currently on turn."""
|
||||||
|
if state.phase != PHASE_PLAYING:
|
||||||
|
raise GameNotStarted("the game has not started yet")
|
||||||
|
player = _player_at(state, state.turn)
|
||||||
|
if not player.hand:
|
||||||
|
raise IllegalMove("the player on turn has no cards")
|
||||||
|
chooser = rng or _rng
|
||||||
|
card = chooser.choice(player.hand)
|
||||||
|
play(state, player.sub, card.code)
|
||||||
|
|
||||||
|
|
||||||
|
# -- views ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _roles_public(state: ChiamataState) -> bool:
|
||||||
|
return state.called_played
|
||||||
|
|
||||||
|
|
||||||
|
def state_for_player(
|
||||||
|
state: ChiamataState, sub: Optional[str]
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
"""Serialize ``state`` hiding other players' hands and secret roles.
|
||||||
|
|
||||||
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
|
hand. The partner's role only appears once the called card has been
|
||||||
|
played; before that only the caller is labeled. The calling side's
|
||||||
|
running total is withheld until then too: it would let onlookers
|
||||||
|
deduce the partner's identity from who captured the points.
|
||||||
|
"""
|
||||||
|
viewer = state.player_for(sub)
|
||||||
|
revealed = _roles_public(state)
|
||||||
|
players: List[Dict[str, object]] = []
|
||||||
|
for player in state.players:
|
||||||
|
role = ""
|
||||||
|
if state.caller is not None and player.seat == state.caller:
|
||||||
|
role = ROLE_CALLER
|
||||||
|
if revealed:
|
||||||
|
if state.partner_seat is not None and player.seat == state.partner_seat:
|
||||||
|
role = ROLE_PARTNER
|
||||||
|
elif player.seat != state.caller:
|
||||||
|
role = ROLE_DEFENDER
|
||||||
|
view: Dict[str, object] = {
|
||||||
|
"sub": player.sub,
|
||||||
|
"name": player.name,
|
||||||
|
"seat": player.seat,
|
||||||
|
"team": role,
|
||||||
|
"cards_left": len(player.hand),
|
||||||
|
"captured_count": len(player.captured),
|
||||||
|
"points": sum(card_points(card) for card in player.captured),
|
||||||
|
}
|
||||||
|
if viewer is not None and viewer.seat == player.seat:
|
||||||
|
view["hand"] = [c.code for c in player.hand]
|
||||||
|
players.append(view)
|
||||||
|
|
||||||
|
auction = state.auction
|
||||||
|
auction_view: Optional[Dict[str, object]] = None
|
||||||
|
if auction is not None:
|
||||||
|
auction_view = {
|
||||||
|
"turn": auction.turn,
|
||||||
|
"active": list(auction.active),
|
||||||
|
"passed": list(auction.passed),
|
||||||
|
"current": auction.current.to_json() if auction.current else None,
|
||||||
|
"history": [b.to_json() for b in auction.history],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload: Dict[str, object] = {
|
||||||
|
"phase": state.phase,
|
||||||
|
"player_count": PLAYER_COUNT,
|
||||||
|
"dealer": state.dealer,
|
||||||
|
"hand_number": state.hand_number,
|
||||||
|
"turn": state.turn,
|
||||||
|
"auction": auction_view,
|
||||||
|
"caller": state.caller,
|
||||||
|
"called_card": (
|
||||||
|
state.called_card.code if state.called_card is not None else None
|
||||||
|
),
|
||||||
|
"briscola_suit": state.briscola_suit,
|
||||||
|
"declared_target": state.declared_target,
|
||||||
|
"called_played": state.called_played,
|
||||||
|
"solo": state.solo if revealed else False,
|
||||||
|
"partner": (
|
||||||
|
state.partner_seat if revealed and state.partner_seat is not None else None
|
||||||
|
),
|
||||||
|
"players": players,
|
||||||
|
"trick": [t.to_json() for t in state.trick],
|
||||||
|
"trick_leader": state.trick_leader,
|
||||||
|
"last_trick": state.last_trick.to_json() if state.last_trick else None,
|
||||||
|
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
|
||||||
|
"auction_deadline": state.auction_deadline,
|
||||||
|
"call_deadline": state.call_deadline,
|
||||||
|
"turn_deadline": state.turn_deadline,
|
||||||
|
"winner": (
|
||||||
|
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if revealed and state.caller is not None:
|
||||||
|
caller_points, defender_points = side_points(state)
|
||||||
|
payload["scores"] = {"A": caller_points, "B": defender_points}
|
||||||
|
|
||||||
|
if viewer is not None:
|
||||||
|
if state.phase == PHASE_AUCTION and auction is not None:
|
||||||
|
if auction.turn == viewer.seat:
|
||||||
|
payload["your_turn"] = True
|
||||||
|
payload["legal_bids"] = {
|
||||||
|
"ranks": legal_bid_ranks(state),
|
||||||
|
"points_min": legal_bid_points(state),
|
||||||
|
"points_max": MAX_TARGET,
|
||||||
|
}
|
||||||
|
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
|
||||||
|
payload["your_turn"] = True
|
||||||
|
current = auction.current if auction is not None else None
|
||||||
|
if current is not None:
|
||||||
|
payload["callable_cards"] = [
|
||||||
|
Card(rank=current.rank, suit=suit).code for suit in SUITS
|
||||||
|
]
|
||||||
|
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
|
||||||
|
payload["your_turn"] = True
|
||||||
|
return payload
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""The platform-facing adapter for briscola.
|
"""The platform-facing adapter for briscola.
|
||||||
|
|
||||||
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||||
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||||
:class:`BriscolaEngine` and four-player fixed-partnership
|
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||||
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
:class:`BriscolaEngine`, four-player fixed-partnership
|
||||||
|
:class:`BriscolaQuattroEngine` and five-player
|
||||||
|
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
|
||||||
join, websocket actions, deadlines) into rules-engine calls and back,
|
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||||
keeping all briscola knowledge inside this package. Nothing outside
|
keeping all briscola knowledge inside this package. Nothing outside
|
||||||
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||||
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
|
|||||||
)
|
)
|
||||||
from tavolo.platform.errors import GameError, IllegalMove
|
from tavolo.platform.errors import GameError, IllegalMove
|
||||||
|
|
||||||
from . import engine
|
from . import chiamata, engine
|
||||||
|
from .chiamata import (
|
||||||
|
PHASE_AUCTION,
|
||||||
|
PHASE_CALLING,
|
||||||
|
ROLE_CALLER,
|
||||||
|
ROLE_DEFENDER,
|
||||||
|
ROLE_PARTNER,
|
||||||
|
SIDE_NAMES,
|
||||||
|
ChiamataPlayer,
|
||||||
|
ChiamataState,
|
||||||
|
)
|
||||||
|
from .engine import card_points
|
||||||
from .state import (
|
from .state import (
|
||||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
DEFAULT_TARGET_SCORE,
|
DEFAULT_TARGET_SCORE,
|
||||||
@@ -277,3 +290,299 @@ class BriscolaQuattroEngine(_BriscolaBase):
|
|||||||
min_players = 4
|
min_players = 4
|
||||||
max_players = 4
|
max_players = 4
|
||||||
player_count = 4
|
player_count = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||||
|
for player in state.players:
|
||||||
|
if player.seat == seat:
|
||||||
|
return player
|
||||||
|
raise GameError(f"no player in seat {seat}")
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaChiamataEngine(GameEngine):
|
||||||
|
"""Five-player briscola chiamata as a platform game engine.
|
||||||
|
|
||||||
|
One deal per match: the five players auction the call, the winner
|
||||||
|
names a card (playing alone when it is in their own hand) and the
|
||||||
|
calling side must reach the declared target (61 unless the auction
|
||||||
|
raised it). A 60-60 deal with the plain target is a patta and is
|
||||||
|
replayed. This keeps the match result a clean two-sided affair —
|
||||||
|
caller and secret partner against the other three, or the lone
|
||||||
|
caller against the other four — which the platform can rate,
|
||||||
|
persist and display like any other game.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = "briscola_chiamata"
|
||||||
|
name = "Briscola chiamata"
|
||||||
|
description = (
|
||||||
|
"Five-player briscola with a descending auction: the winner calls "
|
||||||
|
"a card, whose holder becomes the secret partner (or, when the "
|
||||||
|
"caller holds it, the caller plays alone) and whose suit becomes "
|
||||||
|
"the trump. The calling side must reach 61 points; one deal per "
|
||||||
|
"match, replayed after a patta."
|
||||||
|
)
|
||||||
|
min_players = 5
|
||||||
|
max_players = 5
|
||||||
|
|
||||||
|
options_schema: Mapping[str, Any] = {}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self._turn_timeout = turn_timeout_seconds
|
||||||
|
# A chiamata match is a single deal, so there is no between-deals
|
||||||
|
# acknowledgement phase to time out; the parameter is accepted
|
||||||
|
# for symmetry with the other briscola engines and wired by the
|
||||||
|
# application root.
|
||||||
|
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||||
|
|
||||||
|
# -- lobby ------------------------------------------------------------
|
||||||
|
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
creator = session.players[0]
|
||||||
|
session.state = chiamata.create_game(
|
||||||
|
creator_sub=creator.user_sub,
|
||||||
|
creator_name=creator.display_name,
|
||||||
|
turn_timeout=self._turn_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
chiamata.join_game(session.state, user_sub, display_name)
|
||||||
|
session.players.append(
|
||||||
|
Seat(user_sub=user_sub, display_name=display_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_LOBBY
|
||||||
|
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
state: ChiamataState = session.state
|
||||||
|
return {
|
||||||
|
"phase": state.phase,
|
||||||
|
"declared_target": state.declared_target,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- play -------------------------------------------------------------
|
||||||
|
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
state: ChiamataState = session.state
|
||||||
|
if action == "bid":
|
||||||
|
rank = payload.get("rank")
|
||||||
|
points = payload.get("points")
|
||||||
|
if rank is not None:
|
||||||
|
if isinstance(rank, bool) or not isinstance(rank, int):
|
||||||
|
raise IllegalMove("'rank' must be an integer")
|
||||||
|
chiamata.bid_rank(state, user_sub, rank)
|
||||||
|
elif points is not None:
|
||||||
|
if isinstance(points, bool) or not isinstance(points, int):
|
||||||
|
raise IllegalMove("'points' must be an integer")
|
||||||
|
chiamata.bid_points(state, user_sub, points)
|
||||||
|
else:
|
||||||
|
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
|
||||||
|
elif action == "pass":
|
||||||
|
chiamata.pass_auction(state, user_sub)
|
||||||
|
elif action == "call":
|
||||||
|
card = payload.get("card")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
raise IllegalMove("'card' must be a card code string")
|
||||||
|
try:
|
||||||
|
chiamata.call(state, user_sub, card)
|
||||||
|
except ValueError:
|
||||||
|
raise IllegalMove("invalid card code")
|
||||||
|
elif action == "play":
|
||||||
|
card = payload.get("card")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
raise IllegalMove("'card' must be a card code string")
|
||||||
|
try:
|
||||||
|
chiamata.play(state, user_sub, card)
|
||||||
|
except ValueError:
|
||||||
|
raise IllegalMove("invalid card code")
|
||||||
|
else:
|
||||||
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
return chiamata.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_FINISHED
|
||||||
|
|
||||||
|
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
state: ChiamataState = session.state
|
||||||
|
caller = (
|
||||||
|
_chiamata_player(state, state.caller) if state.caller is not None else None
|
||||||
|
)
|
||||||
|
partner = (
|
||||||
|
_chiamata_player(state, state.partner_seat)
|
||||||
|
if state.partner_seat is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": (
|
||||||
|
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||||
|
),
|
||||||
|
"declared_target": state.declared_target,
|
||||||
|
"called_card": (
|
||||||
|
state.called_card.code if state.called_card is not None else None
|
||||||
|
),
|
||||||
|
"caller": None if caller is None else caller.name,
|
||||||
|
"partner": None if partner is None else partner.name,
|
||||||
|
"solo": state.solo,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- (de)serialization -------------------------------------------------
|
||||||
|
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
assert isinstance(state, ChiamataState)
|
||||||
|
return state.to_json()
|
||||||
|
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
return ChiamataState.from_json(dict(data))
|
||||||
|
|
||||||
|
# -- deadlines ----------------------------------------------------------
|
||||||
|
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
state: ChiamataState = session.state
|
||||||
|
if state.phase == PHASE_AUCTION and state.auction_deadline:
|
||||||
|
due_ms = _deadline_ms(state.auction_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="auction",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
|
||||||
|
)
|
||||||
|
if state.phase == PHASE_CALLING and state.call_deadline:
|
||||||
|
due_ms = _deadline_ms(state.call_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="call",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"call:{state.hand_number}:{due_ms}",
|
||||||
|
)
|
||||||
|
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||||
|
due_ms = _deadline_ms(state.turn_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="turn",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
current = self.next_deadline(session)
|
||||||
|
if current is None or current.kind != kind or current.token != token:
|
||||||
|
# Overtaken by events (a bid, call or play landed in time):
|
||||||
|
# nothing to do.
|
||||||
|
raise GameError("stale deadline")
|
||||||
|
state: ChiamataState = session.state
|
||||||
|
if kind == "auction":
|
||||||
|
seat = state.turn
|
||||||
|
auction = state.auction
|
||||||
|
passed_before = len(auction.passed) if auction is not None else 0
|
||||||
|
chiamata.auto_bid(state)
|
||||||
|
action = (
|
||||||
|
"auto-passed"
|
||||||
|
if auction is not None and len(auction.passed) > passed_before
|
||||||
|
else "forced to bid the ace"
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"seat %s %s (auction timeout, deal %d)",
|
||||||
|
seat,
|
||||||
|
action,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
elif kind == "call":
|
||||||
|
caller = state.caller
|
||||||
|
chiamata.auto_call(state)
|
||||||
|
log.info(
|
||||||
|
"auto-called for seat %s (call timeout, deal %d)",
|
||||||
|
caller,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
elif kind == "turn":
|
||||||
|
seat = state.turn
|
||||||
|
chiamata.auto_play(state)
|
||||||
|
log.info(
|
||||||
|
"auto-played for seat %s (turn timeout, deal %d)",
|
||||||
|
seat,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - next_deadline never emits other kinds
|
||||||
|
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||||
|
|
||||||
|
# -- results -------------------------------------------------------------
|
||||||
|
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
state: ChiamataState = session.state
|
||||||
|
if state.winner is None:
|
||||||
|
raise GameError("no result: the match is not finished")
|
||||||
|
if state.caller is None: # pragma: no cover - a winner implies a caller
|
||||||
|
raise GameError("no result: the caller is unknown")
|
||||||
|
caller = state.caller
|
||||||
|
partner = state.partner_seat
|
||||||
|
caller_seats = [caller] + ([partner] if partner is not None else [])
|
||||||
|
teams = [
|
||||||
|
[_chiamata_player(state, seat).sub for seat in caller_seats],
|
||||||
|
[
|
||||||
|
player.sub
|
||||||
|
for player in state.players
|
||||||
|
if player.seat not in caller_seats
|
||||||
|
],
|
||||||
|
]
|
||||||
|
caller_points, defender_points = chiamata.side_points(state)
|
||||||
|
side_scores = [caller_points, defender_points]
|
||||||
|
players = []
|
||||||
|
for player in state.players:
|
||||||
|
if player.seat == caller:
|
||||||
|
role = ROLE_CALLER
|
||||||
|
side = 0
|
||||||
|
elif partner is not None and player.seat == partner:
|
||||||
|
role = ROLE_PARTNER
|
||||||
|
side = 0
|
||||||
|
else:
|
||||||
|
role = ROLE_DEFENDER
|
||||||
|
side = 1
|
||||||
|
players.append(
|
||||||
|
PlayerResult(
|
||||||
|
user_sub=player.sub,
|
||||||
|
seat=player.seat,
|
||||||
|
won=side == state.winner,
|
||||||
|
team=role,
|
||||||
|
score=float(side_scores[side]),
|
||||||
|
details={
|
||||||
|
"points": sum(card_points(card) for card in player.captured),
|
||||||
|
"captured_cards": len(player.captured),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return MatchResult(
|
||||||
|
teams=teams,
|
||||||
|
winner_team=state.winner,
|
||||||
|
players=players,
|
||||||
|
summary={
|
||||||
|
"team_a_score": caller_points,
|
||||||
|
"team_b_score": defender_points,
|
||||||
|
"winner_team": SIDE_NAMES[state.winner],
|
||||||
|
"declared_target": state.declared_target,
|
||||||
|
"called_card": (
|
||||||
|
state.called_card.code if state.called_card is not None else None
|
||||||
|
),
|
||||||
|
"caller": _chiamata_player(state, caller).name,
|
||||||
|
"partner": (
|
||||||
|
_chiamata_player(state, partner).name
|
||||||
|
if partner is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"solo": state.solo,
|
||||||
|
"deals_played": state.hand_number,
|
||||||
|
"deal_scores": state.hand_scores,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tavolo.briscola import chiamata as rules
|
||||||
|
from tavolo.briscola.chiamata import (
|
||||||
|
BID_LADDER,
|
||||||
|
Bid,
|
||||||
|
ChiamataPlayer,
|
||||||
|
ChiamataState,
|
||||||
|
PHASE_AUCTION,
|
||||||
|
PHASE_CALLING,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
ROLE_CALLER,
|
||||||
|
ROLE_DEFENDER,
|
||||||
|
ROLE_PARTNER,
|
||||||
|
SIDE_CALLER,
|
||||||
|
SIDE_DEFENDERS,
|
||||||
|
)
|
||||||
|
from tavolo.briscola.errors import (
|
||||||
|
CardNotInHand,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from tavolo.briscola.state import Card
|
||||||
|
|
||||||
|
|
||||||
|
def card(code: str) -> Card:
|
||||||
|
return Card.parse(code)
|
||||||
|
|
||||||
|
|
||||||
|
def started_state() -> ChiamataState:
|
||||||
|
"""A five-player game whose auction has just opened (dealer seat 0)."""
|
||||||
|
state = rules.create_game("p0", "p0")
|
||||||
|
for i in range(1, 5):
|
||||||
|
rules.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def bid(state: ChiamataState, seat: int, rank: int) -> None:
|
||||||
|
rules.bid_rank(state, f"p{seat}", rank)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
hands,
|
||||||
|
*,
|
||||||
|
briscola_suit: str = "S",
|
||||||
|
called_card: str | None = None,
|
||||||
|
caller: int | None = None,
|
||||||
|
solo: bool = False,
|
||||||
|
called_played: bool = False,
|
||||||
|
partner_seat: int | None = None,
|
||||||
|
captured=None,
|
||||||
|
turn: int = 0,
|
||||||
|
dealer: int = 0,
|
||||||
|
declared_target: int = rules.DEFAULT_TARGET,
|
||||||
|
phase: str = PHASE_PLAYING,
|
||||||
|
hand_number: int = 1,
|
||||||
|
) -> ChiamataState:
|
||||||
|
"""Build a controlled playing state directly (bypassing the auction)."""
|
||||||
|
state = ChiamataState(
|
||||||
|
phase=phase,
|
||||||
|
dealer=dealer,
|
||||||
|
hand_number=hand_number,
|
||||||
|
declared_target=declared_target,
|
||||||
|
briscola_suit=briscola_suit,
|
||||||
|
called_card=card(called_card) if called_card else None,
|
||||||
|
caller=caller,
|
||||||
|
solo=solo,
|
||||||
|
called_played=called_played,
|
||||||
|
partner_seat=partner_seat,
|
||||||
|
turn=turn,
|
||||||
|
)
|
||||||
|
for seat, hand in enumerate(hands):
|
||||||
|
state.players.append(
|
||||||
|
ChiamataPlayer(
|
||||||
|
sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||||
|
hand=[card(c) for c in hand],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if captured is not None:
|
||||||
|
for player, codes in zip(state.players, captured):
|
||||||
|
player.captured = [card(c) for c in codes]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def play_all(state: ChiamataState) -> None:
|
||||||
|
"""Play the first card of each hand in turn order until the trick resolves."""
|
||||||
|
for _ in range(rules.PLAYER_COUNT):
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
rules.play(state, player.sub, player.hand[0].code)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupTest(unittest.TestCase):
|
||||||
|
def test_five_player_deal(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||||
|
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(rules.HAND_SIZE, len(player.hand))
|
||||||
|
# The whole deck is dealt: no stock, no face-up card.
|
||||||
|
dealt = [c.code for p in state.players for c in p.hand]
|
||||||
|
self.assertEqual(40, len(dealt))
|
||||||
|
self.assertEqual(40, len(set(dealt)))
|
||||||
|
# The auction opens with the player after the dealer.
|
||||||
|
assert state.auction is not None
|
||||||
|
self.assertEqual(1, state.auction.turn)
|
||||||
|
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
|
||||||
|
self.assertIsNone(state.auction.current)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
|
||||||
|
|
||||||
|
state = rules.create_game("p0", "p0")
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
rules.join_game(state, "p0", "p0")
|
||||||
|
for i in range(1, 5):
|
||||||
|
rules.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
rules.join_game(state, "p5", "p5")
|
||||||
|
|
||||||
|
lobby = rules.create_game("p0", "p0")
|
||||||
|
for i in range(1, 5):
|
||||||
|
rules.join_game(lobby, f"p{i}", f"p{i}")
|
||||||
|
# Joining a sixth player is impossible once the fifth started the
|
||||||
|
# match; exercise LobbyFull on an artificially unfilled start.
|
||||||
|
unfilled = rules.create_game("p0", "p0")
|
||||||
|
unfilled.phase = rules.PHASE_LOBBY
|
||||||
|
for i in range(1, 5):
|
||||||
|
unfilled.players.append(
|
||||||
|
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
|
||||||
|
)
|
||||||
|
with self.assertRaises(LobbyFull):
|
||||||
|
rules.join_game(unfilled, "p5", "p5")
|
||||||
|
|
||||||
|
|
||||||
|
class AuctionTest(unittest.TestCase):
|
||||||
|
def test_first_bid_accepts_any_rank(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
rules.bid_rank(state, "p1", 5)
|
||||||
|
assert state.auction is not None
|
||||||
|
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
|
||||||
|
|
||||||
|
def test_bids_must_descend(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 1) # ace
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
bid(state, 2, 1) # same rank
|
||||||
|
bid(state, 2, 3) # three, weaker than the ace
|
||||||
|
bid(state, 3, 10) # king, weaker than the three
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
bid(state, 4, 3) # three is stronger than the king
|
||||||
|
bid(state, 4, 9) # horse
|
||||||
|
|
||||||
|
def test_bid_ladder_ordering(self) -> None:
|
||||||
|
# A bid must be weaker than the standing bid: descending strength
|
||||||
|
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 10) # king
|
||||||
|
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
|
||||||
|
rules.bid_rank(state, f"p{opponent}", rank)
|
||||||
|
# 3 and A are stronger than 6: rejected.
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.bid_rank(state, "p1", 3)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.bid_rank(state, "p1", 1)
|
||||||
|
rules.bid_rank(state, "p1", 5)
|
||||||
|
|
||||||
|
def test_not_your_turn(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
bid(state, 2, 1)
|
||||||
|
|
||||||
|
def test_pass_eliminates_and_last_standing_bids(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
for seat in (1, 2, 3, 4):
|
||||||
|
rules.pass_auction(state, f"p{seat}")
|
||||||
|
assert state.auction is not None
|
||||||
|
# Only seat 0 remains and nobody has bid: it must bid.
|
||||||
|
self.assertEqual([0], state.auction.active)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.pass_auction(state, "p0")
|
||||||
|
rules.bid_rank(state, "p0", 2)
|
||||||
|
self.assertEqual(PHASE_CALLING, state.phase)
|
||||||
|
self.assertEqual(0, state.caller)
|
||||||
|
self.assertEqual(61, state.declared_target)
|
||||||
|
|
||||||
|
def test_auction_ends_when_one_bidder_remains(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 1) # ace
|
||||||
|
rules.pass_auction(state, "p2")
|
||||||
|
bid(state, 3, 10) # king
|
||||||
|
rules.pass_auction(state, "p4")
|
||||||
|
rules.pass_auction(state, "p0")
|
||||||
|
rules.pass_auction(state, "p1")
|
||||||
|
self.assertEqual(PHASE_CALLING, state.phase)
|
||||||
|
self.assertEqual(3, state.caller)
|
||||||
|
self.assertEqual(61, state.declared_target)
|
||||||
|
|
||||||
|
def test_point_raises_only_after_the_two(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 1)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.bid_points(state, "p2", 62)
|
||||||
|
bid(state, 2, 2)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.bid_rank(state, "p3", 2) # no ranks below the 2
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.bid_points(state, "p3", 61) # not higher
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.bid_points(state, "p3", 121) # above the cap
|
||||||
|
rules.bid_points(state, "p3", 63)
|
||||||
|
assert state.auction is not None
|
||||||
|
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
|
||||||
|
|
||||||
|
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 10) # king
|
||||||
|
view = rules.state_for_player(state, "p2")
|
||||||
|
self.assertTrue(view["your_turn"])
|
||||||
|
legal = view["legal_bids"]
|
||||||
|
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
|
||||||
|
self.assertIsNone(legal["points_min"])
|
||||||
|
# A player who is not on turn gets no legal bids.
|
||||||
|
other = rules.state_for_player(state, "p3")
|
||||||
|
self.assertNotIn("legal_bids", other)
|
||||||
|
self.assertNotIn("your_turn", other)
|
||||||
|
|
||||||
|
def test_legal_points_exposed_at_the_two(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 2)
|
||||||
|
view = rules.state_for_player(state, "p2")
|
||||||
|
self.assertEqual([], view["legal_bids"]["ranks"])
|
||||||
|
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||||
|
self.assertEqual(120, view["legal_bids"]["points_max"])
|
||||||
|
|
||||||
|
def test_auto_bid_always_passes_when_legal(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
assert state.auction is not None
|
||||||
|
rules.auto_bid(state)
|
||||||
|
# Seat 1 passes: nobody has bid and the turn moves on.
|
||||||
|
self.assertEqual([1], state.auction.passed)
|
||||||
|
self.assertIsNone(state.auction.current)
|
||||||
|
self.assertEqual(2, state.auction.turn)
|
||||||
|
# An absent bidder still passes when there is a standing bid.
|
||||||
|
rules.bid_rank(state, "p2", 10)
|
||||||
|
rules.auto_bid(state)
|
||||||
|
self.assertEqual([1, 3], state.auction.passed)
|
||||||
|
assert state.auction.current is not None
|
||||||
|
self.assertEqual(10, state.auction.current.rank)
|
||||||
|
|
||||||
|
def test_auto_bid_passes_at_the_point_stage(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
rules.bid_rank(state, "p1", 2)
|
||||||
|
rules.bid_points(state, "p2", 62)
|
||||||
|
rules.auto_bid(state) # seat 3
|
||||||
|
assert state.auction is not None
|
||||||
|
self.assertIn(3, state.auction.passed)
|
||||||
|
assert state.auction.current is not None
|
||||||
|
self.assertEqual(62, state.auction.current.points)
|
||||||
|
|
||||||
|
def test_auto_bid_forced_to_call_the_ace(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
for seat in (1, 2, 3, 4):
|
||||||
|
rules.pass_auction(state, f"p{seat}")
|
||||||
|
# Seat 0 is the last one standing and nobody has bid yet: the
|
||||||
|
# forced auto-bid calls the ace.
|
||||||
|
rules.auto_bid(state)
|
||||||
|
self.assertEqual(PHASE_CALLING, state.phase)
|
||||||
|
self.assertEqual(0, state.caller)
|
||||||
|
assert state.auction is not None and state.auction.current is not None
|
||||||
|
self.assertEqual(1, state.auction.current.rank)
|
||||||
|
self.assertEqual(61, state.declared_target)
|
||||||
|
|
||||||
|
|
||||||
|
class CallTest(unittest.TestCase):
|
||||||
|
def _called_state(self, rank: int = 1) -> ChiamataState:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, rank)
|
||||||
|
for seat in (2, 3, 4, 0):
|
||||||
|
rules.pass_auction(state, f"p{seat}")
|
||||||
|
return state
|
||||||
|
|
||||||
|
def test_call_must_match_the_winning_rank(self) -> None:
|
||||||
|
state = self._called_state(1)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.call(state, "p1", "03D")
|
||||||
|
# Any suit of the winning rank is fine.
|
||||||
|
rules.call(state, "p1", "01D")
|
||||||
|
self.assertEqual("D", state.briscola_suit)
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
|
||||||
|
def test_only_the_winner_calls(self) -> None:
|
||||||
|
state = self._called_state()
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
rules.call(state, "p2", "01D")
|
||||||
|
|
||||||
|
def test_call_in_hand_means_playing_alone(self) -> None:
|
||||||
|
state = self._called_state(1)
|
||||||
|
# Seat 1 calls a card it holds: solo.
|
||||||
|
state.players[1].hand[0] = card("01S")
|
||||||
|
rules.call(state, "p1", "01S")
|
||||||
|
self.assertTrue(state.solo)
|
||||||
|
self.assertIsNone(state.partner_seat)
|
||||||
|
|
||||||
|
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
|
||||||
|
state = self._called_state(1)
|
||||||
|
rules.call(state, "p1", "01D")
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertNotEqual(state.caller, None)
|
||||||
|
|
||||||
|
def test_auto_call_names_the_bid_rank(self) -> None:
|
||||||
|
state = self._called_state(10)
|
||||||
|
rules.auto_call(state, random.Random(3))
|
||||||
|
assert state.called_card is not None
|
||||||
|
self.assertEqual(10, state.called_card.rank)
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
|
||||||
|
def test_calling_phase_view(self) -> None:
|
||||||
|
state = self._called_state(1)
|
||||||
|
view = rules.state_for_player(state, "p1")
|
||||||
|
self.assertEqual(PHASE_CALLING, view["phase"])
|
||||||
|
self.assertTrue(view["your_turn"])
|
||||||
|
self.assertEqual(
|
||||||
|
["01D", "01C", "01S", "01B"], view["callable_cards"]
|
||||||
|
)
|
||||||
|
other = rules.state_for_player(state, "p2")
|
||||||
|
self.assertNotIn("callable_cards", other)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayTest(unittest.TestCase):
|
||||||
|
def test_five_player_trick_resolution(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
caller=0,
|
||||||
|
called_card="01D",
|
||||||
|
called_played=True,
|
||||||
|
partner_seat=4,
|
||||||
|
turn=0,
|
||||||
|
)
|
||||||
|
play_all(state)
|
||||||
|
assert state.last_trick is not None
|
||||||
|
self.assertEqual(3, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_trump_wins(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
|
||||||
|
briscola_suit="C",
|
||||||
|
caller=0,
|
||||||
|
called_card="01D",
|
||||||
|
called_played=True,
|
||||||
|
partner_seat=4,
|
||||||
|
turn=0,
|
||||||
|
)
|
||||||
|
play_all(state)
|
||||||
|
assert state.last_trick is not None
|
||||||
|
self.assertEqual(3, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_called_card_reveals_the_partner(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
called_card="01S",
|
||||||
|
turn=1,
|
||||||
|
)
|
||||||
|
self.assertFalse(state.called_played)
|
||||||
|
view = rules.state_for_player(state, "p2")
|
||||||
|
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||||
|
self.assertEqual(ROLE_CALLER, roles[0])
|
||||||
|
self.assertNotIn("scores", view)
|
||||||
|
rules.play(state, "p1", "01D")
|
||||||
|
# The called card has not been played yet.
|
||||||
|
self.assertFalse(state.called_played)
|
||||||
|
# Drive the trick until the called card appears: give it to seat 3.
|
||||||
|
state.players[3].hand = [card("01S")]
|
||||||
|
state.turn = 3
|
||||||
|
rules.play(state, "p3", "01S")
|
||||||
|
self.assertTrue(state.called_played)
|
||||||
|
self.assertEqual(3, state.partner_seat)
|
||||||
|
view = rules.state_for_player(state, "p2")
|
||||||
|
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||||
|
self.assertEqual(ROLE_CALLER, roles[0])
|
||||||
|
self.assertEqual(ROLE_PARTNER, roles[3])
|
||||||
|
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||||
|
self.assertIn("scores", view)
|
||||||
|
|
||||||
|
def test_called_card_played_by_the_caller_is_solo(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
caller=0,
|
||||||
|
called_card="01S",
|
||||||
|
solo=True,
|
||||||
|
turn=0,
|
||||||
|
)
|
||||||
|
rules.play(state, "p0", "01S")
|
||||||
|
self.assertTrue(state.called_played)
|
||||||
|
self.assertIsNone(state.partner_seat)
|
||||||
|
view = rules.state_for_player(state, "p4")
|
||||||
|
self.assertTrue(view["solo"])
|
||||||
|
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||||
|
self.assertEqual(ROLE_CALLER, roles[0])
|
||||||
|
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||||
|
|
||||||
|
def test_play_validations(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
caller=0,
|
||||||
|
turn=0,
|
||||||
|
)
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
rules.play(state, "p1", "05D")
|
||||||
|
with self.assertRaises(CardNotInHand):
|
||||||
|
rules.play(state, "p0", "01D")
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
rules.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
def test_play_rejected_before_the_call(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||||
|
caller=0,
|
||||||
|
turn=0,
|
||||||
|
phase=PHASE_CALLING,
|
||||||
|
)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
rules.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
def test_auto_play_advances(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
caller=0,
|
||||||
|
called_card="09B",
|
||||||
|
turn=0,
|
||||||
|
)
|
||||||
|
rules.auto_play(state, random.Random(7))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertEqual(1, len(state.trick))
|
||||||
|
self.assertEqual(1, len(state.players[0].hand))
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringTest(unittest.TestCase):
|
||||||
|
def _piles(self):
|
||||||
|
# 60 points in denari + coppe, 60 in spade + bastoni.
|
||||||
|
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
|
||||||
|
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
|
||||||
|
]
|
||||||
|
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
|
||||||
|
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
|
||||||
|
]
|
||||||
|
return a_cards, b_cards
|
||||||
|
|
||||||
|
def test_side_points_split_caller_side_and_defenders(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], [], []],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
partner_seat=3,
|
||||||
|
called_played=True,
|
||||||
|
captured=[
|
||||||
|
["01D"], # caller, side A: 11
|
||||||
|
["03C", "01S"], # defender: 21
|
||||||
|
["10D"], # defender: 4
|
||||||
|
["01C"], # partner, side A: 11
|
||||||
|
["03S"], # defender: 10
|
||||||
|
],
|
||||||
|
)
|
||||||
|
caller_points, defender_points = rules.side_points(state)
|
||||||
|
self.assertEqual(22, caller_points)
|
||||||
|
self.assertEqual(35, defender_points)
|
||||||
|
|
||||||
|
def test_solo_side_points(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], [], []],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
solo=True,
|
||||||
|
called_played=True,
|
||||||
|
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
|
||||||
|
)
|
||||||
|
caller_points, defender_points = rules.side_points(state)
|
||||||
|
self.assertEqual(11, caller_points)
|
||||||
|
self.assertEqual(35, defender_points)
|
||||||
|
|
||||||
|
def test_calling_side_wins_at_the_target(self) -> None:
|
||||||
|
a_cards, b_cards = self._piles()
|
||||||
|
b_rest = [c for c in b_cards if c != "01S"]
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], [], []],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
partner_seat=1,
|
||||||
|
called_played=True,
|
||||||
|
captured=[a_cards, ["01S"], [], b_rest, []],
|
||||||
|
)
|
||||||
|
rules._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(SIDE_CALLER, state.winner)
|
||||||
|
self.assertEqual([71, 49], state.scores)
|
||||||
|
|
||||||
|
def test_defenders_win_below_the_declared_target(self) -> None:
|
||||||
|
a_cards, b_cards = self._piles()
|
||||||
|
# Calling side only 30 points of the 61 needed.
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], [], []],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
partner_seat=1,
|
||||||
|
called_played=True,
|
||||||
|
captured=[a_cards[:5], [], [], b_cards, []],
|
||||||
|
)
|
||||||
|
rules._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||||
|
self.assertEqual([30, 60], state.scores)
|
||||||
|
|
||||||
|
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
|
||||||
|
a_cards, b_cards = self._piles()
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], [], []],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
partner_seat=1,
|
||||||
|
called_played=True,
|
||||||
|
captured=[a_cards, [], [], b_cards, []],
|
||||||
|
)
|
||||||
|
rules._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual(1, state.dealer)
|
||||||
|
assert state.auction is not None
|
||||||
|
self.assertEqual(2, state.auction.turn)
|
||||||
|
self.assertIsNone(state.caller)
|
||||||
|
self.assertIsNone(state.called_card)
|
||||||
|
# The patta entry is kept in the audit trail.
|
||||||
|
entry = state.hand_scores[-1]
|
||||||
|
self.assertTrue(entry["patta"])
|
||||||
|
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||||
|
|
||||||
|
def test_raised_target_disables_patta(self) -> None:
|
||||||
|
a_cards, b_cards = self._piles()
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], [], []],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
partner_seat=1,
|
||||||
|
called_played=True,
|
||||||
|
declared_target=62,
|
||||||
|
captured=[a_cards, [], [], b_cards, []],
|
||||||
|
)
|
||||||
|
rules._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||||
|
|
||||||
|
def test_full_deal_scores_120(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
rng = random.Random(2026)
|
||||||
|
moves = 0
|
||||||
|
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||||
|
if state.phase == PHASE_AUCTION:
|
||||||
|
rules.auto_bid(state)
|
||||||
|
elif state.phase == PHASE_CALLING:
|
||||||
|
rules.auto_call(state, rng)
|
||||||
|
else:
|
||||||
|
rules.auto_play(state, rng)
|
||||||
|
moves += 1
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(120, sum(state.scores))
|
||||||
|
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
|
||||||
|
captured = sum(len(p.captured) for p in state.players)
|
||||||
|
self.assertEqual(40, captured)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_state_for_player_hides_other_hands(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
called_card="01D",
|
||||||
|
called_played=False,
|
||||||
|
turn=0,
|
||||||
|
)
|
||||||
|
view = rules.state_for_player(state, "p0")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
self.assertEqual(1, players[1]["cards_left"])
|
||||||
|
self.assertEqual("01D", view["called_card"])
|
||||||
|
self.assertEqual(5, view["player_count"])
|
||||||
|
|
||||||
|
def test_spectator_sees_public_information_only(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=2,
|
||||||
|
called_card="01D",
|
||||||
|
called_played=False,
|
||||||
|
)
|
||||||
|
view = rules.state_for_player(state, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("scores", view)
|
||||||
|
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||||
|
self.assertEqual(ROLE_CALLER, roles[2])
|
||||||
|
self.assertEqual("", roles[0])
|
||||||
|
|
||||||
|
def test_player_points_are_public(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
caller=0,
|
||||||
|
captured=[["01C", "03B"], ["10D"], [], [], []],
|
||||||
|
)
|
||||||
|
view = rules.state_for_player(state, "p0")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(21, players[0]["points"])
|
||||||
|
self.assertEqual(4, players[1]["points"])
|
||||||
|
self.assertEqual(2, players[0]["captured_count"])
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
bid(state, 1, 10)
|
||||||
|
bid(state, 2, 7)
|
||||||
|
restored = ChiamataState.from_json(state.to_json())
|
||||||
|
self.assertEqual(state.phase, restored.phase)
|
||||||
|
self.assertEqual(state.hand_number, restored.hand_number)
|
||||||
|
self.assertEqual(state.turn, restored.turn)
|
||||||
|
assert restored.auction is not None and state.auction is not None
|
||||||
|
self.assertEqual(state.auction.active, restored.auction.active)
|
||||||
|
self.assertEqual(state.auction.passed, restored.auction.passed)
|
||||||
|
self.assertEqual(
|
||||||
|
state.auction.history, restored.auction.history
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[[c.code for c in p.hand] for p in state.players],
|
||||||
|
[[c.code for c in p.hand] for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_state_defaults(self) -> None:
|
||||||
|
state = started_state()
|
||||||
|
data = state.to_json()
|
||||||
|
del data["auction"]
|
||||||
|
del data["called_card"]
|
||||||
|
del data["declared_target"]
|
||||||
|
restored = ChiamataState.from_json(data)
|
||||||
|
self.assertIsNone(restored.auction)
|
||||||
|
self.assertIsNone(restored.called_card)
|
||||||
|
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
|
||||||
|
self.assertFalse(restored.solo)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"""Adapter tests: the platform contract over the briscola chiamata rules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tavolo.briscola import BriscolaChiamataEngine
|
||||||
|
from tavolo.briscola import chiamata as rules
|
||||||
|
from tavolo.briscola.chiamata import (
|
||||||
|
PHASE_AUCTION,
|
||||||
|
PHASE_CALLING,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
ROLE_CALLER,
|
||||||
|
ROLE_DEFENDER,
|
||||||
|
ROLE_PARTNER,
|
||||||
|
ChiamataState,
|
||||||
|
)
|
||||||
|
from tavolo.briscola.state import Card
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(engine, **options) -> GameSession:
|
||||||
|
session = GameSession(
|
||||||
|
id="c1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="CODE01",
|
||||||
|
creator_sub="p0",
|
||||||
|
players=[Seat(user_sub="p0", display_name="P0")],
|
||||||
|
)
|
||||||
|
engine.create(session, options)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _started(engine, **options) -> GameSession:
|
||||||
|
session = _session(engine, **options)
|
||||||
|
for i in range(1, engine.max_players):
|
||||||
|
engine.join(session, f"p{i}", f"P{i}")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _player_on_turn(session: GameSession):
|
||||||
|
state = session.state
|
||||||
|
return next(p for p in state.players if p.seat == state.turn)
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < limit:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == PHASE_AUCTION:
|
||||||
|
player = _player_on_turn(session)
|
||||||
|
try:
|
||||||
|
engine.handle_action(session, player.sub, "pass", {})
|
||||||
|
except IllegalMove:
|
||||||
|
# The last player standing must bid.
|
||||||
|
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||||
|
elif state.phase == PHASE_CALLING:
|
||||||
|
assert state.auction is not None and state.auction.current is not None
|
||||||
|
caller = next(
|
||||||
|
p for p in state.players if p.seat == state.caller
|
||||||
|
)
|
||||||
|
card = Card(rank=state.auction.current.rank, suit="D")
|
||||||
|
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||||
|
else:
|
||||||
|
player = _player_on_turn(session)
|
||||||
|
engine.handle_action(
|
||||||
|
session, player.sub, "play", {"card": player.hand[0].code}
|
||||||
|
)
|
||||||
|
moves += 1
|
||||||
|
assert engine.is_finished(session), "match did not finish"
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataTest(unittest.TestCase):
|
||||||
|
def test_metadata(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
self.assertEqual("briscola_chiamata", engine.id)
|
||||||
|
self.assertEqual("Briscola chiamata", engine.name)
|
||||||
|
self.assertTrue(engine.description)
|
||||||
|
self.assertEqual(5, engine.min_players)
|
||||||
|
self.assertEqual(5, engine.max_players)
|
||||||
|
self.assertEqual({}, engine.options_schema)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateJoinTest(unittest.TestCase):
|
||||||
|
def test_create_seats_creator_without_a_team(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertIsNone(session.players[0].team)
|
||||||
|
self.assertIsInstance(session.state, ChiamataState)
|
||||||
|
self.assertEqual(PHASE_LOBBY, session.state.phase)
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
|
||||||
|
def test_join_starts_when_full(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join(session, f"p{i}", f"P{i}")
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
engine.join(session, "p4", "P4")
|
||||||
|
self.assertFalse(engine.in_lobby(session))
|
||||||
|
self.assertEqual(PHASE_AUCTION, session.state.phase)
|
||||||
|
self.assertEqual(5, len(session.players))
|
||||||
|
for seat in session.players:
|
||||||
|
self.assertIsNone(seat.team)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join(session, "p0", "P0")
|
||||||
|
for i in range(1, 5):
|
||||||
|
engine.join(session, f"p{i}", f"P{i}")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "p5", "P5")
|
||||||
|
|
||||||
|
def test_timeouts_come_from_the_engine(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine(
|
||||||
|
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
|
||||||
|
)
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual(7, session.state.turn_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionTest(unittest.TestCase):
|
||||||
|
def test_unknown_action_rejected(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "dance", {})
|
||||||
|
|
||||||
|
def test_bid_payload_validation(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "bid", {})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": True})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "bid", {"points": False})
|
||||||
|
# points cannot be raised before a bid of the 2
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "bid", {"points": 64})
|
||||||
|
|
||||||
|
def test_auction_flow_through_actions(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||||
|
engine.handle_action(session, "p2", "pass", {})
|
||||||
|
engine.handle_action(session, "p3", "bid", {"rank": 2})
|
||||||
|
engine.handle_action(session, "p4", "bid", {"points": 64})
|
||||||
|
engine.handle_action(session, "p0", "pass", {})
|
||||||
|
engine.handle_action(session, "p1", "pass", {})
|
||||||
|
engine.handle_action(session, "p3", "pass", {})
|
||||||
|
state = session.state
|
||||||
|
self.assertEqual(PHASE_CALLING, state.phase)
|
||||||
|
self.assertEqual(4, state.caller)
|
||||||
|
self.assertEqual(64, state.declared_target)
|
||||||
|
assert state.auction is not None and state.auction.current is not None
|
||||||
|
self.assertEqual(2, state.auction.current.rank)
|
||||||
|
|
||||||
|
engine.handle_action(session, "p4", "call", {"card": "02S"})
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual("S", state.briscola_suit)
|
||||||
|
|
||||||
|
# Play the first card of the player on turn.
|
||||||
|
player = _player_on_turn(session)
|
||||||
|
engine.handle_action(
|
||||||
|
session, player.sub, "play", {"card": player.hand[0].code}
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(state.trick))
|
||||||
|
|
||||||
|
def test_call_payload_validation(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||||
|
for i in (2, 3, 4, 0):
|
||||||
|
engine.handle_action(session, f"p{i}", "pass", {})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "call", {"card": 42})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "call", {"card": "nope"})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "call", {"card": "01D"})
|
||||||
|
|
||||||
|
def test_finished_match_rejects_actions(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.handle_action(session, session.players[0].user_sub, "play", {})
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_view_hides_other_hands_and_roles(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, "p0")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertIn("hand", players[0])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("scores", view)
|
||||||
|
self.assertEqual(5, view["player_count"])
|
||||||
|
# p1 opens the auction.
|
||||||
|
auction = view["auction"]
|
||||||
|
self.assertEqual(1, auction["turn"])
|
||||||
|
self.assertIsNone(auction["current"])
|
||||||
|
|
||||||
|
def test_view_marks_the_acting_player(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, "p1")
|
||||||
|
self.assertTrue(view["your_turn"])
|
||||||
|
self.assertIn("legal_bids", view)
|
||||||
|
# The caller is labeled as soon as the auction is won.
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||||
|
for i in (2, 3, 4, 0):
|
||||||
|
engine.handle_action(session, f"p{i}", "pass", {})
|
||||||
|
view = engine.view_for(session, "p0")
|
||||||
|
self.assertIn("callable_cards", engine.view_for(session, "p1"))
|
||||||
|
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||||
|
self.assertEqual(ROLE_CALLER, roles[1])
|
||||||
|
|
||||||
|
def test_spectator_view(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
|
||||||
|
def test_lobby_view(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
lobby = engine.lobby_view(session)
|
||||||
|
self.assertEqual("lobby", lobby["phase"])
|
||||||
|
self.assertEqual(61, lobby["declared_target"])
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 10})
|
||||||
|
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||||
|
self.assertIsInstance(restored, ChiamataState)
|
||||||
|
self.assertEqual(session.state.phase, restored.phase)
|
||||||
|
self.assertEqual(session.state.turn, restored.turn)
|
||||||
|
assert restored.auction is not None and session.state.auction is not None
|
||||||
|
self.assertEqual(session.state.auction.history, restored.auction.history)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in session.state.players],
|
||||||
|
[p.sub for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineTest(unittest.TestCase):
|
||||||
|
def test_no_deadline_in_lobby(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||||
|
|
||||||
|
def test_auction_deadline_and_revalidation(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("auction", deadline.kind)
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, "auction", "auction:1:1:0")
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
# The auto-bid moved the auction on: a fresh deadline exists.
|
||||||
|
state = session.state
|
||||||
|
self.assertIn(
|
||||||
|
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(engine.next_deadline(session))
|
||||||
|
|
||||||
|
def test_stale_auction_deadline_after_a_bid(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
|
||||||
|
def test_call_deadline_auto_calls(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||||
|
for i in (2, 3, 4, 0):
|
||||||
|
engine.handle_action(session, f"p{i}", "pass", {})
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("call", deadline.kind)
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
state = session.state
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
assert state.called_card is not None
|
||||||
|
self.assertEqual(2, state.called_card.rank)
|
||||||
|
|
||||||
|
def test_turn_deadline_auto_plays(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||||
|
for i in (2, 3, 4, 0):
|
||||||
|
engine.handle_action(session, f"p{i}", "pass", {})
|
||||||
|
engine.handle_action(session, "p1", "call", {"card": "02D"})
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("turn", deadline.kind)
|
||||||
|
state = session.state
|
||||||
|
before = state.turn
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertEqual(1, len(state.trick))
|
||||||
|
self.assertNotEqual(before, state.turn)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultTest(unittest.TestCase):
|
||||||
|
def test_result_of_finished_match(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
state = session.state
|
||||||
|
result = engine.result(session)
|
||||||
|
sizes = sorted(len(team) for team in result.teams)
|
||||||
|
self.assertIn(sizes, ([2, 3], [1, 4]))
|
||||||
|
self.assertEqual(5, len(result.players))
|
||||||
|
self.assertIn(result.winner_team, (0, 1))
|
||||||
|
for player in result.players:
|
||||||
|
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
|
||||||
|
self.assertEqual(
|
||||||
|
player.won,
|
||||||
|
(player.team in (ROLE_CALLER, ROLE_PARTNER))
|
||||||
|
== (result.winner_team == 0),
|
||||||
|
)
|
||||||
|
self.assertIn("points", player.details)
|
||||||
|
summary = result.summary
|
||||||
|
for key in (
|
||||||
|
"team_a_score",
|
||||||
|
"team_b_score",
|
||||||
|
"winner_team",
|
||||||
|
"declared_target",
|
||||||
|
"called_card",
|
||||||
|
"caller",
|
||||||
|
"solo",
|
||||||
|
"deals_played",
|
||||||
|
"deal_scores",
|
||||||
|
):
|
||||||
|
self.assertIn(key, summary)
|
||||||
|
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||||
|
self.assertEqual(state.hand_number, summary["deals_played"])
|
||||||
|
|
||||||
|
def test_result_requires_finished_match(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.result(_started(engine))
|
||||||
|
|
||||||
|
def test_solo_result_teams(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
# Force a finished solo match: seat 0 called its own card.
|
||||||
|
state.caller = 0
|
||||||
|
state.solo = True
|
||||||
|
state.partner_seat = None
|
||||||
|
state.called_played = True
|
||||||
|
state.called_card = Card.parse("01D")
|
||||||
|
# Caller side captures 71 (60 + 11), defenders 49.
|
||||||
|
state.players[0].captured = [
|
||||||
|
Card.parse(code)
|
||||||
|
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
|
||||||
|
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
|
||||||
|
+ ["01S"]
|
||||||
|
]
|
||||||
|
state.players[1].captured = [
|
||||||
|
Card.parse(code)
|
||||||
|
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
|
||||||
|
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
|
||||||
|
]
|
||||||
|
state.scores = [71, 49]
|
||||||
|
state.winner = 0
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
|
||||||
|
# The lone caller won: exactly one winner.
|
||||||
|
self.assertEqual(1, sum(1 for p in result.players if p.won))
|
||||||
|
caller = next(p for p in result.players if p.team == ROLE_CALLER)
|
||||||
|
self.assertTrue(caller.won)
|
||||||
|
self.assertEqual(71.0, caller.score)
|
||||||
|
|
||||||
|
def test_game_over_view(self) -> None:
|
||||||
|
engine = BriscolaChiamataEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
state.winner = 1
|
||||||
|
state.scores = [55, 65]
|
||||||
|
state.caller = 2
|
||||||
|
state.solo = False
|
||||||
|
state.partner_seat = 4
|
||||||
|
state.called_card = Card.parse("03C")
|
||||||
|
over = engine.game_over_view(session, "p0")
|
||||||
|
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||||
|
self.assertEqual("Difensori", over["winner"])
|
||||||
|
self.assertEqual("03C", over["called_card"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
|
||||||
|
|
||||||
|
The per-package suite covers the rules and the engine contract; these
|
||||||
|
tests wire the game through the full platform stack (REST, WebSocket,
|
||||||
|
deadline scheduler).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from httpx_ws import aconnect_ws
|
||||||
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
|
|
||||||
|
from tavolo.app import app, game_store, platform
|
||||||
|
from tavolo.briscola.chiamata import (
|
||||||
|
PHASE_AUCTION,
|
||||||
|
PHASE_CALLING,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
Auction,
|
||||||
|
Bid,
|
||||||
|
)
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_and_join(client: AsyncClient) -> dict:
|
||||||
|
"""Create a chiamata game and seat all five players; return the view."""
|
||||||
|
with oidc_user(PLAYERS[0]):
|
||||||
|
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
body = created.json()
|
||||||
|
for player in PLAYERS[1:]:
|
||||||
|
with oidc_user(player):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
assert joined.status_code == 200, joined.text
|
||||||
|
body = joined.json()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataRoutesTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_seat_five_players(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(PLAYERS[0]):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(4, body["seats_open"])
|
||||||
|
self.assertIsNone(body["players"][0]["team"])
|
||||||
|
code = body["join_code"]
|
||||||
|
|
||||||
|
for player in PLAYERS[1:4]:
|
||||||
|
with oidc_user(player):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual("lobby", joined.json()["phase"])
|
||||||
|
self.assertEqual(
|
||||||
|
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
|
||||||
|
)
|
||||||
|
|
||||||
|
with oidc_user(PLAYERS[4]):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
state = started.json()
|
||||||
|
self.assertEqual(PHASE_AUCTION, state["phase"])
|
||||||
|
self.assertEqual(0, state["seats_open"])
|
||||||
|
self.assertEqual(5, len(state["players"]))
|
||||||
|
for seat in state["players"]:
|
||||||
|
# Roles are hidden until the called card is played.
|
||||||
|
self.assertEqual("", seat["team"])
|
||||||
|
self.assertEqual(8, seat["cards_left"])
|
||||||
|
# The auction opens with the player after the dealer (seat 0).
|
||||||
|
self.assertEqual(1, state["auction"]["turn"])
|
||||||
|
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
|
||||||
|
self.assertIsNone(state["auction"]["current"])
|
||||||
|
self.assertIsNotNone(state["auction_deadline"])
|
||||||
|
# The last joiner's personalized view carries their own hand.
|
||||||
|
self.assertIn("hand", state["players"][4])
|
||||||
|
self.assertEqual(8, len(state["players"][4]["hand"]))
|
||||||
|
self.assertNotIn("hand", state["players"][0])
|
||||||
|
# There is no stock in this variant.
|
||||||
|
self.assertNotIn("stock_count", state)
|
||||||
|
|
||||||
|
# A non-player watches without hands or the join code.
|
||||||
|
with oidc_user("mallory"):
|
||||||
|
watched = await client.get(f"/api/games/{state['id']}")
|
||||||
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spectator = watched.json()
|
||||||
|
self.assertTrue(spectator["spectator"])
|
||||||
|
self.assertNotIn("join_code", spectator)
|
||||||
|
for seat in spectator["players"]:
|
||||||
|
self.assertNotIn("hand", seat)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preview_lists_chiamata(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||||
|
)
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
preview = await client.get(f"/api/games/preview?code={code}")
|
||||||
|
self.assertEqual(200, preview.status_code)
|
||||||
|
body = preview.json()
|
||||||
|
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||||
|
self.assertEqual("Briscola chiamata", body["game_name"])
|
||||||
|
self.assertTrue(body["game_description"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(4, body["seats_open"])
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataWebSocketTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_auction_call_and_play_over_the_socket(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await _create_and_join(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob"), make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
first = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||||
|
self.assertTrue(first["game"].get("your_turn"))
|
||||||
|
self.assertIn("legal_bids", first["game"])
|
||||||
|
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
await alice_ws.receive_json()
|
||||||
|
# Bob (seat 1) opens the bidding with the two.
|
||||||
|
await bob_ws.send_json({"action": "bid", "rank": 2})
|
||||||
|
update = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", update["type"])
|
||||||
|
current = update["game"]["auction"]["current"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"seat": 1, "rank": 2, "points": 61}, current
|
||||||
|
)
|
||||||
|
self.assertEqual(2, update["game"]["auction"]["turn"])
|
||||||
|
# Alice receives the broadcast of Bob's bid.
|
||||||
|
broadcast = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("state", broadcast["type"])
|
||||||
|
# Alice (seat 0) is not on turn: her bid is an error.
|
||||||
|
await alice_ws.send_json({"action": "bid", "rank": 3})
|
||||||
|
error = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
|
||||||
|
# The remaining players pass; Bob wins the auction and is
|
||||||
|
# asked to name the card.
|
||||||
|
engine = platform.registry.require("briscola_chiamata")
|
||||||
|
session = await game_store.load(game_id)
|
||||||
|
assert session is not None
|
||||||
|
for sub in ("carol", "dave", "erin", "alice"):
|
||||||
|
engine.handle_action(session, sub, "pass", {})
|
||||||
|
await game_store.save(session)
|
||||||
|
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||||
|
self.assertEqual(1, session.state.caller)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||||
|
self.assertTrue(first["game"].get("your_turn"))
|
||||||
|
self.assertEqual(
|
||||||
|
["02D", "02C", "02S", "02B"],
|
||||||
|
first["game"]["callable_cards"],
|
||||||
|
)
|
||||||
|
await ws.send_json(
|
||||||
|
{"action": "call", "card": "02D"}
|
||||||
|
)
|
||||||
|
update = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||||
|
self.assertEqual("D", update["game"]["briscola_suit"])
|
||||||
|
self.assertEqual("02D", update["game"]["called_card"])
|
||||||
|
self.assertEqual(1, update["game"]["turn"])
|
||||||
|
|
||||||
|
# Bob is first to play (player after the dealer).
|
||||||
|
bob = next(
|
||||||
|
p for p in update["game"]["players"] if p["seat"] == 1
|
||||||
|
)
|
||||||
|
card = bob["hand"][0]
|
||||||
|
await ws.send_json({"action": "play", "card": card})
|
||||||
|
update = await ws.receive_json()
|
||||||
|
self.assertEqual(
|
||||||
|
[{"seat": 1, "card": card}], update["game"]["trick"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_spectator_cannot_act(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await _create_and_join(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("mallory")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
for seat in first["game"]["players"]:
|
||||||
|
self.assertNotIn("hand", seat)
|
||||||
|
await ws.send_json({"action": "bid", "rank": 1})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataTimeoutTest(unittest.TestCase):
|
||||||
|
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
|
||||||
|
from tavolo.briscola import chiamata as rules
|
||||||
|
|
||||||
|
session = GameSession(
|
||||||
|
id=game_id,
|
||||||
|
game_type="briscola_chiamata",
|
||||||
|
join_code=join_code,
|
||||||
|
creator_sub=PLAYERS[0],
|
||||||
|
players=[
|
||||||
|
Seat(user_sub=name, display_name=name.capitalize())
|
||||||
|
for name in PLAYERS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
|
||||||
|
for name in PLAYERS[1:]:
|
||||||
|
rules.join_game(session.state, name, name.capitalize())
|
||||||
|
state_mutator(session.state)
|
||||||
|
await game_store.save(session)
|
||||||
|
return session.id
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_auction_timeout_auto_passes(self) -> None:
|
||||||
|
game_id = await self._seed(
|
||||||
|
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||||
|
)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||||
|
self.assertIsNotNone(first["game"]["auction_deadline"])
|
||||||
|
|
||||||
|
update = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
update = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
update.get("type") == "state"
|
||||||
|
and 1 in update["game"]["auction"]["passed"]
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(update)
|
||||||
|
assert update is not None
|
||||||
|
# The absent seat 1 passed: nobody bid and the turn
|
||||||
|
# moved to seat 2.
|
||||||
|
auction = update["game"]["auction"]
|
||||||
|
self.assertIn(1, auction["passed"])
|
||||||
|
self.assertIsNone(auction["current"])
|
||||||
|
self.assertEqual(2, auction["turn"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
|
||||||
|
def seed(state) -> None:
|
||||||
|
state.phase = PHASE_AUCTION
|
||||||
|
state.auction = Auction(
|
||||||
|
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
|
||||||
|
)
|
||||||
|
state.turn = 0
|
||||||
|
state.auction_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||||
|
).isoformat()
|
||||||
|
state.call_deadline = None
|
||||||
|
|
||||||
|
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||||
|
|
||||||
|
# The auction times out: seat 0 is forced to bid the
|
||||||
|
# ace; its call then times out too and a random ace
|
||||||
|
# suit is named.
|
||||||
|
played = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
played = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
played.get("type") == "state"
|
||||||
|
and played["game"]["phase"] == PHASE_PLAYING
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(played)
|
||||||
|
assert played is not None
|
||||||
|
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
|
||||||
|
self.assertEqual(0, played["game"]["caller"])
|
||||||
|
card = played["game"]["called_card"]
|
||||||
|
self.assertIsNotNone(card)
|
||||||
|
self.assertTrue(card.startswith("01"))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_call_and_turn_timeouts(self) -> None:
|
||||||
|
def seed(state) -> None:
|
||||||
|
state.phase = PHASE_CALLING
|
||||||
|
state.auction = Auction(
|
||||||
|
turn=1,
|
||||||
|
active=[1],
|
||||||
|
passed=[0, 2, 3, 4],
|
||||||
|
current=Bid(seat=1, rank=2, points=61),
|
||||||
|
)
|
||||||
|
state.caller = 1
|
||||||
|
state.turn = 1
|
||||||
|
state.auction_deadline = None
|
||||||
|
state.call_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
game_id = await self._seed(
|
||||||
|
"chiamata-call-timeout-1", "CH0002", seed
|
||||||
|
)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||||
|
|
||||||
|
called = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
called = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
called.get("type") == "state"
|
||||||
|
and called["game"]["phase"] == PHASE_PLAYING
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(called)
|
||||||
|
assert called is not None
|
||||||
|
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
|
||||||
|
card = called["game"]["called_card"]
|
||||||
|
self.assertIsNotNone(card)
|
||||||
|
self.assertTrue(card.startswith("02"))
|
||||||
|
|
||||||
|
played = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
played = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
played.get("type") == "state"
|
||||||
|
and len(played["game"]["trick"]) == 1
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(played)
|
||||||
|
assert played is not None
|
||||||
|
# Seat 1 was on turn and was auto-played for.
|
||||||
|
self.assertEqual(1, played["game"]["trick"][0]["seat"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -300,7 +300,12 @@ class GameTypesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
[
|
||||||
|
"scopone_scientifico",
|
||||||
|
"briscola",
|
||||||
|
"briscola_a_quattro",
|
||||||
|
"briscola_chiamata",
|
||||||
|
],
|
||||||
[g["id"] for g in results],
|
[g["id"] for g in results],
|
||||||
)
|
)
|
||||||
scopone = results[0]
|
scopone = results[0]
|
||||||
@@ -325,6 +330,12 @@ class GameTypesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(4, quattro["min_players"])
|
self.assertEqual(4, quattro["min_players"])
|
||||||
self.assertEqual(4, quattro["max_players"])
|
self.assertEqual(4, quattro["max_players"])
|
||||||
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
||||||
|
chiamata = results[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_test
|
||||||
async def test_create_defaults_game_type(self) -> None:
|
async def test_create_defaults_game_type(self) -> None:
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import unittest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from tavolo.app import app, platform, tortoise_mixin
|
from tavolo.app import app, platform, tortoise_mixin
|
||||||
|
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
||||||
|
from tavolo.briscola.state import Card
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
from tavolo.platform.elo import INITIAL_RATING
|
from tavolo.platform.elo import INITIAL_RATING
|
||||||
|
from tavolo.platform.errors import IllegalMove
|
||||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||||
from tavolo.platform.stats import save_match_result
|
from tavolo.platform.stats import save_match_result
|
||||||
from tavolo.scopone import engine as rules
|
from tavolo.scopone import engine as rules
|
||||||
@@ -222,5 +225,98 @@ class BriscolaStatsTest(unittest.TestCase):
|
|||||||
self.assertEqual(2, len(results[0]["players"]))
|
self.assertEqual(2, len(results[0]["players"]))
|
||||||
|
|
||||||
|
|
||||||
|
async def _finished_chiamata_session() -> GameSession:
|
||||||
|
engine = platform.registry.require("briscola_chiamata")
|
||||||
|
session = GameSession(
|
||||||
|
id="stats-chiamata-1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="BC0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub=name, display_name=name.capitalize())
|
||||||
|
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine.create(session, {})
|
||||||
|
for name in ("bob", "carol", "dave", "erin"):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == PHASE_AUCTION:
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
try:
|
||||||
|
engine.handle_action(session, player.sub, "pass", {})
|
||||||
|
except IllegalMove:
|
||||||
|
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||||
|
elif state.phase == PHASE_CALLING:
|
||||||
|
assert state.auction is not None and state.auction.current is not None
|
||||||
|
caller = next(p for p in state.players if p.seat == state.caller)
|
||||||
|
card = Card(rank=state.auction.current.rank, suit="D")
|
||||||
|
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||||
|
else:
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.handle_action(
|
||||||
|
session, player.sub, "play", {"card": player.hand[0].code}
|
||||||
|
)
|
||||||
|
moves += 1
|
||||||
|
assert engine.is_finished(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class ChiamataStatsTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||||
|
await tortoise_mixin._bind()
|
||||||
|
ctx = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
engine = platform.registry.require("briscola_chiamata")
|
||||||
|
session = await _finished_chiamata_session()
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
self.assertEqual(5, await MatchPlayer.all().count())
|
||||||
|
|
||||||
|
match = await Match.all().first()
|
||||||
|
assert match is not None
|
||||||
|
self.assertEqual("briscola_chiamata", match.game_type)
|
||||||
|
summary = match.result
|
||||||
|
for key in (
|
||||||
|
"team_a_score",
|
||||||
|
"team_b_score",
|
||||||
|
"winner_team",
|
||||||
|
"declared_target",
|
||||||
|
"called_card",
|
||||||
|
"caller",
|
||||||
|
"solo",
|
||||||
|
"deals_played",
|
||||||
|
"deal_scores",
|
||||||
|
):
|
||||||
|
self.assertIn(key, summary)
|
||||||
|
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||||
|
|
||||||
|
players = await MatchPlayer.all()
|
||||||
|
roles = {p.team for p in players}
|
||||||
|
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||||
|
# Roles match the sides: exactly one caller, the rest defenders
|
||||||
|
# plus at most one partner.
|
||||||
|
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||||
|
winner_team = summary["winner_team"]
|
||||||
|
for row in players:
|
||||||
|
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||||
|
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||||
|
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||||
|
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||||
|
self.assertEqual(5, len(ratings))
|
||||||
|
winners = {p.user_sub for p in players if p.won}
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{INITIAL_RATING - 16},
|
||||||
|
{ratings[sub] for sub in ratings if sub not in winners},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
|||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
|
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||||
|
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
|
||||||
|
speckles, tiled as a CSS background over the green table gradient.
|
||||||
|
stitchTiles + a filter region equal to the tile keep the seams
|
||||||
|
invisible. -->
|
||||||
|
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
|
||||||
|
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
|
||||||
|
</filter>
|
||||||
|
<rect width="240" height="240" filter="url(#felt)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 716 B |
+32
-25
@@ -1,13 +1,32 @@
|
|||||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||||
//! session cookie automatically.
|
//! session cookie automatically.
|
||||||
|
use crate::auth;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::{Request, Response};
|
||||||
|
|
||||||
fn server_error(status: u16) -> String {
|
fn server_error(status: u16) -> String {
|
||||||
format!("server returned {status}")
|
format!("server returned {status}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a failed response to an error. A `401` additionally starts the
|
||||||
|
/// login flow: the session is gone (or the caller was never logged in),
|
||||||
|
/// and the user is returned to this page after logging in.
|
||||||
|
fn check(resp: &Response) -> Result<(), String> {
|
||||||
|
if resp.status() == 401 {
|
||||||
|
auth::redirect_to_login();
|
||||||
|
return Err("login required".to_string());
|
||||||
|
}
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the current user; `None` when unauthenticated (401).
|
/// Fetch the current user; `None` when unauthenticated (401).
|
||||||
|
///
|
||||||
|
/// Deliberately bypasses [`check`]: an unauthenticated answer is how the
|
||||||
|
/// app renders its logged-out state, so it must not trigger the login
|
||||||
|
/// redirect.
|
||||||
pub async fn me() -> Result<Option<User>, String> {
|
pub async fn me() -> Result<Option<User>, String> {
|
||||||
let resp = Request::get("/api/me")
|
let resp = Request::get("/api/me")
|
||||||
.send()
|
.send()
|
||||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.providers)
|
Ok(page.providers)
|
||||||
}
|
}
|
||||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
Ok(page.results)
|
||||||
}
|
}
|
||||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == 401 {
|
||||||
|
auth::redirect_to_login();
|
||||||
|
return Err("login required".to_string());
|
||||||
|
}
|
||||||
if !resp.ok() {
|
if !resp.ok() {
|
||||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
let message = body
|
let message = body
|
||||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|||||||
if resp.status() == 404 {
|
if resp.status() == 404 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
resp.json().await.map(Some).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +150,7 @@ pub async fn my_games() -> Result<Vec<MyGame>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
Ok(page.results)
|
||||||
}
|
}
|
||||||
@@ -148,9 +161,7 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
|||||||
None => "/api/me/matches?limit=10".to_string(),
|
None => "/api/me/matches?limit=10".to_string(),
|
||||||
};
|
};
|
||||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! Client-side login redirect handling.
|
||||||
|
//!
|
||||||
|
//! The OIDC callback always returns the browser to the configured
|
||||||
|
//! post-login redirect (usually `/`), and the backend's login flow has no
|
||||||
|
//! per-request `next` support. So when the API answers `401`, the page the
|
||||||
|
//! user is on is saved in `sessionStorage` and restored once a session
|
||||||
|
//! exists again.
|
||||||
|
use sycamore_router::navigate_replace;
|
||||||
|
|
||||||
|
/// `sessionStorage` key holding the page to return to after login.
|
||||||
|
const RETURN_TO_KEY: &str = "tavolo.login.return_to";
|
||||||
|
|
||||||
|
fn session_storage() -> Option<web_sys::Storage> {
|
||||||
|
web_sys::window()?.session_storage().ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current page (path plus query), the URL to come back to after
|
||||||
|
/// login.
|
||||||
|
pub fn current_path() -> String {
|
||||||
|
let Some(location) = web_sys::window().map(|w| w.location()) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
location.pathname().unwrap_or_default(),
|
||||||
|
location.search().unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a path is a page worth returning to: not the login flow
|
||||||
|
/// itself, and not an absolute/protocol-relative URL.
|
||||||
|
fn is_restorable(path: &str) -> bool {
|
||||||
|
path.starts_with('/')
|
||||||
|
&& !path.starts_with("//")
|
||||||
|
&& !path.starts_with("/auth/")
|
||||||
|
&& !path.starts_with("/api/")
|
||||||
|
&& path != "/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an expired session: remember the current page and go to the
|
||||||
|
/// login page.
|
||||||
|
///
|
||||||
|
/// The navigation is deferred to a timer so the caller can finish handling
|
||||||
|
/// the failed response (e.g. set an error) before its page is unmounted.
|
||||||
|
pub fn redirect_to_login() {
|
||||||
|
let path = current_path();
|
||||||
|
if is_restorable(&path) {
|
||||||
|
if let Some(storage) = session_storage() {
|
||||||
|
let _ = storage.set_item(RETURN_TO_KEY, &path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gloo_timers::callback::Timeout::new(0, move || {
|
||||||
|
// Several in-flight requests may fail at once; only the first
|
||||||
|
// takes us to the login page.
|
||||||
|
if current_path() != "/login" {
|
||||||
|
navigate_replace("/login");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page saved by [`redirect_to_login`], if any.
|
||||||
|
pub fn saved_return_to() -> Option<String> {
|
||||||
|
let storage = session_storage()?;
|
||||||
|
let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?;
|
||||||
|
is_restorable(&path).then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the saved page, e.g. after it has been restored.
|
||||||
|
pub fn clear_return_to() {
|
||||||
|
if let Some(storage) = session_storage() {
|
||||||
|
let _ = storage.remove_item(RETURN_TO_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,16 @@
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
use crate::model::{is_individual_game, DealSummary, GameView};
|
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
|
||||||
use crate::ws::GameSocket;
|
use crate::ws::GameSocket;
|
||||||
|
|
||||||
/// Names of the two sides: the players' names for games without
|
/// Names of the two sides: the caller and partner for briscola chiamata,
|
||||||
/// partnerships (classic two-player briscola), "Team A"/"Team B"
|
/// the players' names for games without partnerships (classic two-player
|
||||||
/// otherwise.
|
/// briscola), "Team A"/"Team B" otherwise.
|
||||||
fn side_names(game: &GameView) -> (String, String) {
|
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("A"),
|
||||||
game.player_name_for_team("B"),
|
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."
|
"60–60: patta — the deal is replayed and its points do not count."
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let called_row = if is_chiamata(&game.game_type) {
|
||||||
|
game.called_card.clone().map(|code| {
|
||||||
|
let label = card_label(&code);
|
||||||
|
let text = if game.solo {
|
||||||
|
format!("{name_a} called {label} in hand and plays alone.")
|
||||||
|
} else {
|
||||||
|
format!("{label}: its holder is the secret partner; its suit is trump.")
|
||||||
|
};
|
||||||
|
summary_row(
|
||||||
|
card_img(code, "score-mini"),
|
||||||
|
"Chiamata",
|
||||||
|
text,
|
||||||
|
"score-row chiamata-row",
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let points_row = summary_row(
|
let points_row = summary_row(
|
||||||
card_img("01D".to_string(), "score-mini"),
|
card_img("01D".to_string(), "score-mini"),
|
||||||
"Deal points",
|
"Deal points",
|
||||||
@@ -84,6 +105,7 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
|||||||
});
|
});
|
||||||
view! {
|
view! {
|
||||||
div(class="score-rows") {
|
div(class="score-rows") {
|
||||||
|
(called_row)
|
||||||
(points_row)
|
(points_row)
|
||||||
(patta_note)
|
(patta_note)
|
||||||
(cards)
|
(cards)
|
||||||
@@ -94,9 +116,13 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
|||||||
/// Running totals with progress toward the target, including the points
|
/// Running totals with progress toward the target, including the points
|
||||||
/// gained in the deal just played (zero for a patta).
|
/// gained in the deal just played (zero for a patta).
|
||||||
fn totals(game: &GameView, summary: &DealSummary) -> View {
|
fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||||
|
// Briscola chiamata plays a single deal: there is no running total.
|
||||||
|
let Some(running) = summary.totals else {
|
||||||
|
return view! {};
|
||||||
|
};
|
||||||
let target = game.target_score.max(1);
|
let target = game.target_score.max(1);
|
||||||
let pct_a = (100 * summary.totals.a / target).min(100);
|
let pct_a = (100 * running.a / target).min(100);
|
||||||
let pct_b = (100 * summary.totals.b / target).min(100);
|
let pct_b = (100 * running.b / target).min(100);
|
||||||
let (gained_a, gained_b) = if summary.patta {
|
let (gained_a, gained_b) = if summary.patta {
|
||||||
(0, 0)
|
(0, 0)
|
||||||
} else {
|
} else {
|
||||||
@@ -111,7 +137,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
|||||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||||
}
|
}
|
||||||
span(class="total-value") {
|
span(class="total-value") {
|
||||||
(summary.totals.a) " / " (target)
|
(running.a) " / " (target)
|
||||||
span(class="gained") { "+" (gained_a) }
|
span(class="gained") { "+" (gained_a) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +147,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
|||||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||||
}
|
}
|
||||||
span(class="total-value") {
|
span(class="total-value") {
|
||||||
(summary.totals.b) " / " (target)
|
(running.b) " / " (target)
|
||||||
span(class="gained") { "+" (gained_b) }
|
span(class="gained") { "+" (gained_b) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! Login card, shared by the lobby's logged-out state and the `/login`
|
||||||
|
//! page.
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::model::AuthProvider;
|
||||||
|
|
||||||
|
/// Human-readable label for a provider name ("google" -> "Google").
|
||||||
|
fn provider_label(name: &str) -> String {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card offering one login button per configured provider.
|
||||||
|
///
|
||||||
|
/// `providers` is filled by the caller from `GET /api/auth/providers`;
|
||||||
|
/// while it is empty (or the fetch failed) the card falls back to the
|
||||||
|
/// generic `/auth/login` route, which redirects to the first configured
|
||||||
|
/// provider.
|
||||||
|
pub fn login_card(providers: Signal<Vec<AuthProvider>>) -> View {
|
||||||
|
view! {
|
||||||
|
div(class="status-card") {
|
||||||
|
div(class="card") {
|
||||||
|
div(class="card-body p-4") {
|
||||||
|
h1(class="h3 mb-3") {
|
||||||
|
span(class="text-warning") { "♠" }
|
||||||
|
" tavolo"
|
||||||
|
}
|
||||||
|
p(class="text-secondary") { "Log in with your account to play." }
|
||||||
|
// rel="external" opts out of the SPA router's click
|
||||||
|
// interception: these must be full page navigations
|
||||||
|
// to the backend's OIDC login endpoints.
|
||||||
|
(move || {
|
||||||
|
let providers = providers.get_clone();
|
||||||
|
if providers.is_empty() {
|
||||||
|
// Providers could not be fetched: the generic
|
||||||
|
// route redirects to the first configured
|
||||||
|
// provider.
|
||||||
|
view! {
|
||||||
|
a(
|
||||||
|
class="btn btn-warning btn-lg w-100",
|
||||||
|
href="/auth/login",
|
||||||
|
rel="external",
|
||||||
|
) {
|
||||||
|
"Log in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
div(class="d-grid gap-2") {
|
||||||
|
(providers.iter().cloned().map(|p| view! {
|
||||||
|
a(
|
||||||
|
class="btn btn-warning btn-lg w-100",
|
||||||
|
href=p.login_url,
|
||||||
|
rel="external",
|
||||||
|
) {
|
||||||
|
"Log in with " (provider_label(&p.name))
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod briscola;
|
pub mod briscola;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod copy;
|
pub mod copy;
|
||||||
|
pub mod login;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
+33
-1
@@ -1,22 +1,27 @@
|
|||||||
//! Application entry point.
|
//! Application entry point.
|
||||||
mod api;
|
mod api;
|
||||||
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
mod model;
|
mod model;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use sycamore_router::{HistoryIntegration, Route, Router};
|
use sycamore_router::{navigate, HistoryIntegration, Route, Router};
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
use pages::game::GamePage;
|
use pages::game::GamePage;
|
||||||
use pages::history::HistoryPage;
|
use pages::history::HistoryPage;
|
||||||
use pages::leaderboard::LeaderboardPage;
|
use pages::leaderboard::LeaderboardPage;
|
||||||
use pages::lobby::LobbyPage;
|
use pages::lobby::LobbyPage;
|
||||||
|
use pages::login::LoginPage;
|
||||||
|
|
||||||
#[derive(Route, Clone)]
|
#[derive(Route, Clone)]
|
||||||
enum AppRoutes {
|
enum AppRoutes {
|
||||||
#[to("/")]
|
#[to("/")]
|
||||||
Lobby,
|
Lobby,
|
||||||
|
#[to("/login")]
|
||||||
|
Login,
|
||||||
#[to("/game/<id>")]
|
#[to("/game/<id>")]
|
||||||
Game { id: String },
|
Game { id: String },
|
||||||
#[to("/history")]
|
#[to("/history")]
|
||||||
@@ -27,6 +32,31 @@ enum AppRoutes {
|
|||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After the OIDC callback lands the SPA back on the configured
|
||||||
|
/// post-login redirect, forward the user to the page they were on before
|
||||||
|
/// being sent to the login page (see [`auth`]) — but only once a session
|
||||||
|
/// exists, so a cancelled login never bounces into a stale page.
|
||||||
|
fn restore_page_after_login() {
|
||||||
|
let Some(path) = auth::saved_return_to() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
spawn_local(async move {
|
||||||
|
match api::me().await {
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
auth::clear_return_to();
|
||||||
|
if auth::current_path() != path {
|
||||||
|
navigate(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not logged in: keep the target for the next successful
|
||||||
|
// login (a reload of the login page must not lose it).
|
||||||
|
Ok(None) => {}
|
||||||
|
// The probe failed (offline?): keep the target, too.
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
sycamore::render(|| {
|
sycamore::render(|| {
|
||||||
@@ -38,6 +68,7 @@ fn main() {
|
|||||||
div(class="app") {
|
div(class="app") {
|
||||||
(match route.get_clone() {
|
(match route.get_clone() {
|
||||||
AppRoutes::Lobby => view! { LobbyPage() },
|
AppRoutes::Lobby => view! { LobbyPage() },
|
||||||
|
AppRoutes::Login => view! { LoginPage() },
|
||||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||||
AppRoutes::History => view! { HistoryPage() },
|
AppRoutes::History => view! { HistoryPage() },
|
||||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||||
@@ -60,4 +91,5 @@ fn main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
restore_page_after_login();
|
||||||
}
|
}
|
||||||
|
|||||||
+310
-6
@@ -19,7 +19,10 @@ pub struct PlayerView {
|
|||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub seat: usize,
|
pub seat: usize,
|
||||||
pub team: String,
|
/// Game-defined role/team label; empty before briscola chiamata
|
||||||
|
/// reveals the roles.
|
||||||
|
#[serde(default)]
|
||||||
|
pub team: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cards_left: usize,
|
pub cards_left: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -84,9 +87,21 @@ pub struct DealSummary {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub patta: bool,
|
pub patta: bool,
|
||||||
pub points: Scores,
|
pub points: Scores,
|
||||||
pub totals: Scores,
|
/// Running match totals; absent for briscola chiamata, whose match is
|
||||||
|
/// a single deal.
|
||||||
|
#[serde(default)]
|
||||||
|
pub totals: Option<Scores>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cards: Option<TeamCounts>,
|
pub cards: Option<TeamCounts>,
|
||||||
|
/// Briscola chiamata: the points target of the played deal.
|
||||||
|
#[serde(default)]
|
||||||
|
pub declared_target: Option<i32>,
|
||||||
|
/// Briscola chiamata: the card named by the caller.
|
||||||
|
#[serde(default)]
|
||||||
|
pub called_card: Option<String>,
|
||||||
|
/// Briscola chiamata: the caller played without a partner.
|
||||||
|
#[serde(default)]
|
||||||
|
pub solo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
@@ -227,6 +242,83 @@ pub struct GameView {
|
|||||||
/// Briscola: scoring breakdown of the most recent deal.
|
/// Briscola: scoring breakdown of the most recent deal.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_deal: Option<DealSummary>,
|
pub last_deal: Option<DealSummary>,
|
||||||
|
/// Briscola chiamata: the calling auction of the current deal.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auction: Option<AuctionView>,
|
||||||
|
/// Briscola chiamata: seat that won the auction.
|
||||||
|
#[serde(default)]
|
||||||
|
pub caller: Option<usize>,
|
||||||
|
/// Briscola chiamata: the named card; its suit is the trump.
|
||||||
|
#[serde(default)]
|
||||||
|
pub called_card: Option<String>,
|
||||||
|
/// Briscola chiamata: points the calling side must reach.
|
||||||
|
#[serde(default)]
|
||||||
|
pub declared_target: i32,
|
||||||
|
/// Briscola chiamata: the called card has been played; roles are
|
||||||
|
/// public from then on.
|
||||||
|
#[serde(default)]
|
||||||
|
pub called_played: bool,
|
||||||
|
/// Briscola chiamata: the partner's seat, once revealed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub partner: Option<usize>,
|
||||||
|
/// Briscola chiamata: the caller named one of their own cards.
|
||||||
|
#[serde(default)]
|
||||||
|
pub solo: bool,
|
||||||
|
/// Briscola chiamata: legal bids for the player on turn.
|
||||||
|
#[serde(default)]
|
||||||
|
pub legal_bids: Option<LegalBids>,
|
||||||
|
/// Briscola chiamata: the cards the auction winner may name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub callable_cards: Option<Vec<String>>,
|
||||||
|
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auction_deadline: Option<String>,
|
||||||
|
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
|
||||||
|
#[serde(default)]
|
||||||
|
pub call_deadline: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bid of the briscola chiamata auction.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AuctionBid {
|
||||||
|
pub seat: usize,
|
||||||
|
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
|
||||||
|
pub rank: i32,
|
||||||
|
/// Points target the bid commits its side to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The briscola chiamata calling auction.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AuctionView {
|
||||||
|
/// Seat to act.
|
||||||
|
pub turn: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: Vec<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub passed: Vec<usize>,
|
||||||
|
/// The standing bid, if any.
|
||||||
|
#[serde(default)]
|
||||||
|
pub current: Option<AuctionBid>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub history: Vec<AuctionBid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bids available to the player on turn.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct LegalBids {
|
||||||
|
/// Ranks that may be bid, strongest first (empty at the point stage).
|
||||||
|
#[serde(default)]
|
||||||
|
pub ranks: Vec<i32>,
|
||||||
|
/// Lowest legal points raise, when the standing bid is the two.
|
||||||
|
#[serde(default)]
|
||||||
|
pub points_min: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub points_max: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameView {
|
impl GameView {
|
||||||
@@ -235,10 +327,38 @@ impl GameView {
|
|||||||
pub fn player_name_for_team(&self, team: &str) -> String {
|
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||||
self.players
|
self.players
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.team == team)
|
.find(|p| p.team.as_deref() == Some(team))
|
||||||
.map(|p| p.name.clone())
|
.map(|p| p.name.clone())
|
||||||
.unwrap_or_else(|| team.to_string())
|
.unwrap_or_else(|| team.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display name of the player in `seat`.
|
||||||
|
pub fn player_name_for_seat(&self, seat: usize) -> String {
|
||||||
|
self.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.seat == seat)
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Side labels for briscola chiamata: the caller (with the partner's
|
||||||
|
/// name once revealed, or "(solo)") against the defenders.
|
||||||
|
pub fn chiamata_sides(&self) -> (String, String) {
|
||||||
|
let side_a = match self.caller {
|
||||||
|
Some(caller) => {
|
||||||
|
let caller_name = self.player_name_for_seat(caller);
|
||||||
|
match self.partner {
|
||||||
|
Some(partner) => {
|
||||||
|
format!("{caller_name} & {}", self.player_name_for_seat(partner))
|
||||||
|
}
|
||||||
|
None if self.called_played => format!("{caller_name} (solo)"),
|
||||||
|
None => caller_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "Chiamante".to_string(),
|
||||||
|
};
|
||||||
|
(side_a, "Difensori".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -449,6 +569,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
|||||||
max_players: 4,
|
max_players: 4,
|
||||||
options_schema: serde_json::Value::Null,
|
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"
|
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
|
/// Whether a game is played by two individuals rather than partnerships
|
||||||
/// (classic two-player briscola): team labels and team messages are
|
/// (classic two-player briscola): team labels and team messages are
|
||||||
/// replaced by the players' names.
|
/// replaced by the players' names.
|
||||||
@@ -1033,7 +1185,7 @@ mod tests {
|
|||||||
assert_eq!(16, g.target_score);
|
assert_eq!(16, g.target_score);
|
||||||
assert!(!g.napola);
|
assert!(!g.napola);
|
||||||
assert!(g.asso_pigliatutto);
|
assert!(g.asso_pigliatutto);
|
||||||
assert_eq!("A", g.players[0].team);
|
assert_eq!(Some("A".to_string()), g.players[0].team);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1245,7 +1397,10 @@ mod tests {
|
|||||||
fn is_briscola_matches_both_variants() {
|
fn is_briscola_matches_both_variants() {
|
||||||
assert!(is_briscola("briscola"));
|
assert!(is_briscola("briscola"));
|
||||||
assert!(is_briscola("briscola_a_quattro"));
|
assert!(is_briscola("briscola_a_quattro"));
|
||||||
|
assert!(!is_briscola("briscola_chiamata"));
|
||||||
assert!(!is_briscola("scopone_scientifico"));
|
assert!(!is_briscola("scopone_scientifico"));
|
||||||
|
assert!(is_chiamata("briscola_chiamata"));
|
||||||
|
assert!(!is_chiamata("briscola"));
|
||||||
assert_eq!("denari", suit_name("D"));
|
assert_eq!("denari", suit_name("D"));
|
||||||
assert_eq!("coppe", suit_name("C"));
|
assert_eq!("coppe", suit_name("C"));
|
||||||
assert_eq!("spade", suit_name("S"));
|
assert_eq!("spade", suit_name("S"));
|
||||||
@@ -1253,6 +1408,16 @@ mod tests {
|
|||||||
assert_eq!("?", suit_name("?"));
|
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]
|
#[test]
|
||||||
fn is_individual_game_only_matches_two_player_briscola() {
|
fn is_individual_game_only_matches_two_player_briscola() {
|
||||||
assert!(is_individual_game("briscola"));
|
assert!(is_individual_game("briscola"));
|
||||||
@@ -1318,11 +1483,17 @@ mod tests {
|
|||||||
let types = fallback_game_types();
|
let types = fallback_game_types();
|
||||||
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
vec![
|
||||||
|
"scopone_scientifico",
|
||||||
|
"briscola",
|
||||||
|
"briscola_a_quattro",
|
||||||
|
"briscola_chiamata",
|
||||||
|
],
|
||||||
ids
|
ids
|
||||||
);
|
);
|
||||||
// The lobby's first game (the creation default) must exist.
|
// The lobby's first game (the creation default) must exist.
|
||||||
assert_eq!("scopone_scientifico", types[0].id);
|
assert_eq!("scopone_scientifico", types[0].id);
|
||||||
|
assert_eq!(5, types[3].max_players);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1400,7 +1571,7 @@ mod tests {
|
|||||||
assert_eq!(1, deal.deal);
|
assert_eq!(1, deal.deal);
|
||||||
assert!(!deal.patta);
|
assert!(!deal.patta);
|
||||||
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
||||||
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
|
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
|
||||||
let cards = deal.cards.unwrap();
|
let cards = deal.cards.unwrap();
|
||||||
assert_eq!(19, cards.a);
|
assert_eq!(19, cards.a);
|
||||||
assert_eq!(21, cards.b);
|
assert_eq!(21, cards.b);
|
||||||
@@ -1420,4 +1591,137 @@ mod tests {
|
|||||||
assert_eq!(0, d.deal);
|
assert_eq!(0, d.deal);
|
||||||
assert!(d.cards.is_none());
|
assert!(d.cards.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chiamata_deal_summary_has_no_totals() {
|
||||||
|
let d: DealSummary = serde_json::from_value(json!({
|
||||||
|
"deal": 2,
|
||||||
|
"patta": true,
|
||||||
|
"points": {"A": 60, "B": 60},
|
||||||
|
"declared_target": 61,
|
||||||
|
"called_card": "01D",
|
||||||
|
"caller": "Alice",
|
||||||
|
"solo": false,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(d.patta);
|
||||||
|
assert_eq!(2, d.deal);
|
||||||
|
assert!(d.totals.is_none());
|
||||||
|
assert!(d.cards.is_none());
|
||||||
|
assert_eq!(Some(61), d.declared_target);
|
||||||
|
assert_eq!(Some("01D".to_string()), d.called_card);
|
||||||
|
assert!(!d.solo);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_view_parses_chiamata_auction() {
|
||||||
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
"id": "c1",
|
||||||
|
"join_code": "ABC123",
|
||||||
|
"game_type": "briscola_chiamata",
|
||||||
|
"phase": "auction",
|
||||||
|
"player_count": 5,
|
||||||
|
"dealer": 0,
|
||||||
|
"hand_number": 1,
|
||||||
|
"turn": 1,
|
||||||
|
"declared_target": 61,
|
||||||
|
"auction": {
|
||||||
|
"turn": 2,
|
||||||
|
"active": [0, 1, 2, 3, 4],
|
||||||
|
"passed": [],
|
||||||
|
"current": {"seat": 1, "rank": 10, "points": 61},
|
||||||
|
"history": [{"seat": 1, "rank": 10, "points": 61}],
|
||||||
|
},
|
||||||
|
"your_turn": true,
|
||||||
|
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
|
||||||
|
"players": [
|
||||||
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
|
||||||
|
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
|
||||||
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
|
||||||
|
"cards_left": 8, "captured_count": 0, "points": 0},
|
||||||
|
],
|
||||||
|
"auction_deadline": "2026-01-01T00:00:30+00:00",
|
||||||
|
"call_deadline": null,
|
||||||
|
"turn_deadline": null,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(is_chiamata(&g.game_type));
|
||||||
|
assert!(!is_briscola(&g.game_type));
|
||||||
|
let auction = g.auction.unwrap();
|
||||||
|
assert_eq!(2, auction.turn);
|
||||||
|
assert_eq!(5, auction.active.len());
|
||||||
|
let current = auction.current.unwrap();
|
||||||
|
assert_eq!(1, current.seat);
|
||||||
|
assert_eq!(10, current.rank);
|
||||||
|
assert_eq!(61, current.points);
|
||||||
|
assert_eq!(1, auction.history.len());
|
||||||
|
let bids = g.legal_bids.unwrap();
|
||||||
|
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
|
||||||
|
assert!(bids.points_min.is_none());
|
||||||
|
assert_eq!(Some(120), bids.points_max);
|
||||||
|
assert_eq!(Some("".to_string()), g.players[0].team);
|
||||||
|
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_view_parses_chiamata_playing_and_reveal() {
|
||||||
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
"id": "c2",
|
||||||
|
"game_type": "briscola_chiamata",
|
||||||
|
"phase": "playing",
|
||||||
|
"player_count": 5,
|
||||||
|
"dealer": 0,
|
||||||
|
"hand_number": 1,
|
||||||
|
"turn": 3,
|
||||||
|
"caller": 1,
|
||||||
|
"called_card": "03C",
|
||||||
|
"briscola_suit": "C",
|
||||||
|
"declared_target": 64,
|
||||||
|
"called_played": true,
|
||||||
|
"partner": 3,
|
||||||
|
"solo": false,
|
||||||
|
"scores": {"A": 45, "B": 30},
|
||||||
|
"players": [
|
||||||
|
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
|
||||||
|
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
|
||||||
|
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
|
||||||
|
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
|
||||||
|
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(Some(1), g.caller);
|
||||||
|
assert_eq!(Some("03C".to_string()), g.called_card);
|
||||||
|
assert_eq!("C", g.briscola_suit);
|
||||||
|
assert_eq!(64, g.declared_target);
|
||||||
|
assert!(g.called_played);
|
||||||
|
assert_eq!(Some(3), g.partner);
|
||||||
|
assert!(!g.solo);
|
||||||
|
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
|
||||||
|
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chiamata_sides_label_the_solo_caller() {
|
||||||
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
"id": "c3",
|
||||||
|
"game_type": "briscola_chiamata",
|
||||||
|
"phase": "finished",
|
||||||
|
"scores": {"A": 70, "B": 50},
|
||||||
|
"winner": "Chiamante",
|
||||||
|
"caller": 2,
|
||||||
|
"called_played": true,
|
||||||
|
"solo": true,
|
||||||
|
"players": [
|
||||||
|
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
|
||||||
|
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
|
||||||
|
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let (a, b) = g.chiamata_sides();
|
||||||
|
assert_eq!("Carol (solo)", a);
|
||||||
|
assert_eq!("Difensori", b);
|
||||||
|
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
|||||||
if active { " active" } else { "" }
|
if active { " active" } else { "" }
|
||||||
);
|
);
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
|
let team_badge = (!is_individual_game(&game.game_type))
|
||||||
let team = player.team.clone();
|
.then(|| player.team.clone())
|
||||||
view! { span(class="team-badge") { "Team " (team) } }
|
.flatten()
|
||||||
});
|
.filter(|team| !team.is_empty())
|
||||||
|
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
|
||||||
let points = player.points;
|
let points = player.points;
|
||||||
let captured = player.captured_count;
|
let captured = player.captured_count;
|
||||||
let backs = (0..player.cards_left)
|
let backs = (0..player.cards_left)
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
//! 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.
|
||||||
|
///
|
||||||
|
/// The winning bid fixes the rank, so the choice is which of the four
|
||||||
|
/// suits to call: its holder becomes the secret partner, unless the
|
||||||
|
/// caller holds it too — then that option means playing alone.
|
||||||
|
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||||||
|
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
||||||
|
return view! {};
|
||||||
|
};
|
||||||
|
let rank = cards
|
||||||
|
.first()
|
||||||
|
.and_then(|code| code.get(..2))
|
||||||
|
.and_then(|rank| rank.parse::<i32>().ok())
|
||||||
|
.map(rank_name)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let title = if rank.is_empty() {
|
||||||
|
"Name the called card".to_string()
|
||||||
|
} else {
|
||||||
|
format!("You won with the {rank} — name the card")
|
||||||
|
};
|
||||||
|
let own_hand = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.hand.is_some())
|
||||||
|
.and_then(|p| p.hand.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let options = cards
|
||||||
|
.into_iter()
|
||||||
|
.map(|code| {
|
||||||
|
let held = own_hand.contains(&code);
|
||||||
|
let label = card_label(&code);
|
||||||
|
let img = card_img(code.clone(), "call-card");
|
||||||
|
let note = if held {
|
||||||
|
view! { span(class="call-note solo") { "Plays alone" } }
|
||||||
|
} else {
|
||||||
|
view! { span(class="call-note partner") { "Partner" } }
|
||||||
|
};
|
||||||
|
let click_code = code.clone();
|
||||||
|
view! {
|
||||||
|
button(class="call-option", on:click=move |_| {
|
||||||
|
if let Some(s) = socket.get_clone() {
|
||||||
|
s.call_card(&click_code);
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
(img)
|
||||||
|
span(class="call-label") { (label) }
|
||||||
|
(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class="overlay") {
|
||||||
|
div(class="picker call-picker") {
|
||||||
|
h3 { (title) }
|
||||||
|
p(class="text-secondary small") {
|
||||||
|
"Its holder becomes your secret partner; name one of your "
|
||||||
|
"own cards to play alone."
|
||||||
|
}
|
||||||
|
div(class="call-options") { (options) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-10
@@ -12,10 +12,11 @@ use crate::components::copy::CopyButton;
|
|||||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
|
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
|
||||||
PlayerView, Scores, ServerMessage,
|
PlayerView, Scores, ServerMessage,
|
||||||
};
|
};
|
||||||
use crate::pages::briscola::briscola_table;
|
use crate::pages::briscola::briscola_table;
|
||||||
|
use crate::pages::chiamata::{call_picker, chiamata_table};
|
||||||
use crate::ws::{self, GameSocket};
|
use crate::ws::{self, GameSocket};
|
||||||
|
|
||||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||||
@@ -43,7 +44,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
|||||||
if active { " active" } else { "" }
|
if active { " active" } else { "" }
|
||||||
);
|
);
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
let team = player.team.clone();
|
let team = player.team.clone().unwrap_or_default();
|
||||||
let captured = player.captured_count;
|
let captured = player.captured_count;
|
||||||
let scope = player.scope;
|
let scope = player.scope;
|
||||||
let backs = (0..player.cards_left)
|
let backs = (0..player.cards_left)
|
||||||
@@ -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();
|
let reconnect_banner = reconnect.clone();
|
||||||
view! {
|
view! {
|
||||||
div(class="game-page") {
|
div(class="game-page") {
|
||||||
@@ -392,6 +419,11 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
||||||
|
// Briscola chiamata has its own five-seat table with the
|
||||||
|
// calling auction and the secret-partner reveal.
|
||||||
|
Some(g) if is_chiamata(&g.game_type) => {
|
||||||
|
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
|
||||||
|
}
|
||||||
// Briscola has its own table view for both variants.
|
// Briscola has its own table view for both variants.
|
||||||
Some(g) if is_briscola(&g.game_type) => {
|
Some(g) if is_briscola(&g.game_type) => {
|
||||||
briscola_table(g, on_hand_card, now)
|
briscola_table(g, on_hand_card, now)
|
||||||
@@ -406,6 +438,17 @@ pub fn GamePage(id: String) -> View {
|
|||||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||||
capture_picker(card, options, socket, capture_choice)
|
capture_picker(card, options, socket, capture_choice)
|
||||||
}))
|
}))
|
||||||
|
// Briscola chiamata: the auction winner names the called card.
|
||||||
|
(move || match game.get_clone() {
|
||||||
|
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() {
|
(move || match game.get_clone() {
|
||||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||||
hand_summary_modal(g, socket, now)
|
hand_summary_modal(g, socket, now)
|
||||||
@@ -440,10 +483,11 @@ fn LobbyView(game: GameView) -> View {
|
|||||||
.map(|p| {
|
.map(|p| {
|
||||||
let name = p.name.clone();
|
let name = p.name.clone();
|
||||||
let seat = p.seat;
|
let seat = p.seat;
|
||||||
let team_badge = show_teams.then(|| {
|
let team_badge = show_teams
|
||||||
let team = p.team.clone();
|
.then(|| p.team.clone())
|
||||||
view! { span(class="badge text-bg-info") { "Team " (team) } }
|
.flatten()
|
||||||
});
|
.filter(|team| !team.is_empty())
|
||||||
|
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
|
||||||
view! {
|
view! {
|
||||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||||
strong { (name) }
|
strong { (name) }
|
||||||
@@ -734,8 +778,9 @@ fn capture_picker(
|
|||||||
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
||||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||||
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
|
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
|
||||||
|
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
|
||||||
if let Some(g) = &game {
|
if let Some(g) = &game {
|
||||||
if g.game_type != "scopone_scientifico" && !briscola {
|
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
|
||||||
return view! {};
|
return view! {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,18 +794,20 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
Some((scores, winner)) => {
|
Some((scores, winner)) => {
|
||||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||||
// Two-player briscola has no teams: name the winner instead.
|
// 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() {
|
let winner_label = match game.as_ref() {
|
||||||
Some(g) if is_individual_game(&g.game_type) => {
|
Some(g) if is_individual_game(&g.game_type) => {
|
||||||
g.player_name_for_team(&winner)
|
g.player_name_for_team(&winner)
|
||||||
}
|
}
|
||||||
|
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
|
||||||
_ => format!("Team {winner}"),
|
_ => format!("Team {winner}"),
|
||||||
};
|
};
|
||||||
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
||||||
// Spectators have no match history to look at.
|
// Spectators have no match history to look at.
|
||||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||||
// Explain the final hand's (scopone) or deal's (briscola)
|
// Explain the final hand's (scopone) or deal's (briscola,
|
||||||
// scoring before the result.
|
// briscola chiamata) scoring before the result.
|
||||||
let final_summary: View = if briscola {
|
let final_summary: View = if briscola || chiamata {
|
||||||
match game.as_ref() {
|
match game.as_ref() {
|
||||||
Some(g) => match g.last_deal.clone() {
|
Some(g) => match g.last_deal.clone() {
|
||||||
Some(deal) => deal_rows(g, &deal),
|
Some(deal) => deal_rows(g, &deal),
|
||||||
|
|||||||
+177
-49
@@ -12,34 +12,58 @@ use crate::api;
|
|||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||||
|
|
||||||
/// Distinct team labels in seat order; players without a team render in
|
/// The match's teams, each as a `(label, names)` pair, in a readable
|
||||||
/// a shared "Players" column.
|
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
|
||||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
/// then other team labels alphabetically (A, B, ...), with team-less
|
||||||
let mut teams = BTreeSet::new();
|
/// players last.
|
||||||
|
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
|
||||||
|
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
|
||||||
for p in &m.players {
|
for p in &m.players {
|
||||||
teams.insert(p.team.clone());
|
labels.insert(p.team.clone());
|
||||||
}
|
}
|
||||||
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
|
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
|
||||||
// Seated teams first (in first-seat order), then the team-less column.
|
ordered.sort_by_key(team_sort_key);
|
||||||
ordered.sort_by_key(|t| match t {
|
|
||||||
Some(_) => (0, String::new()),
|
|
||||||
None => (1, String::new()),
|
|
||||||
});
|
|
||||||
ordered
|
ordered
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| (team_header(&label), team_names(m, &label)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort key for team labels: chiamata roles in play order, other labels
|
||||||
|
/// alphabetically, team-less players last.
|
||||||
|
fn team_sort_key(label: &Option<String>) -> (u8, String) {
|
||||||
|
match label.as_deref() {
|
||||||
|
Some("Chiamante") => (0, String::new()),
|
||||||
|
Some("Socio") => (1, String::new()),
|
||||||
|
Some("Difensori") => (2, String::new()),
|
||||||
|
Some(other) => (0, other.to_string()),
|
||||||
|
None => (9, String::new()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||||
m.players
|
let names = m
|
||||||
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| &p.team == team)
|
.filter(|p| &p.team == team)
|
||||||
.map(|p| p.display_name.clone())
|
.map(|p| p.display_name.clone())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
.join(" & ")
|
match names.len() {
|
||||||
|
0 => String::new(),
|
||||||
|
1 => names[0].clone(),
|
||||||
|
_ => {
|
||||||
|
let (last, rest) = names.split_last().expect("non-empty");
|
||||||
|
format!("{} & {}", rest.join(", "), last)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn team_header(team: &Option<String>) -> String {
|
fn team_header(team: &Option<String>) -> String {
|
||||||
match team {
|
match team {
|
||||||
Some(t) => format!("Team {t}"),
|
// Single-letter labels are the A/B team names; longer labels
|
||||||
|
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
|
||||||
|
Some(t) if t.len() <= 2 => format!("Team {t}"),
|
||||||
|
Some(t) => t.clone(),
|
||||||
None => "Players".to_string(),
|
None => "Players".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +79,11 @@ fn score_text(m: &MatchSummary) -> String {
|
|||||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||||
fn winner_text(m: &MatchSummary) -> String {
|
fn winner_text(m: &MatchSummary) -> String {
|
||||||
if let Some(team) = m.result_str("winner_team") {
|
if let Some(team) = m.result_str("winner_team") {
|
||||||
return format!("Team {team}");
|
return if team.len() <= 2 {
|
||||||
|
format!("Team {team}")
|
||||||
|
} else {
|
||||||
|
team
|
||||||
|
};
|
||||||
}
|
}
|
||||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||||
}
|
}
|
||||||
@@ -98,18 +126,29 @@ pub fn HistoryPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::my_matches(next.as_deref()).await {
|
match api::my_matches(next.as_deref()).await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
// The page can unmount (e.g. the login redirect) while
|
||||||
|
// the request is in flight.
|
||||||
|
if !page.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cursor.set(p.next_cursor.clone());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||||
page.set(Some(p));
|
page.set(Some(p));
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e)),
|
Err(e) => {
|
||||||
|
if error.is_alive() {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(types) = api::game_types().await {
|
if let Ok(types) = api::game_types().await {
|
||||||
game_types.set(types);
|
if game_types.is_alive() {
|
||||||
|
game_types.set(types);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,11 +156,18 @@ pub fn HistoryPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::my_matches(None).await {
|
match api::my_matches(None).await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
if !page.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cursor.set(p.next_cursor.clone());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.set(p.results.clone());
|
rows.set(p.results.clone());
|
||||||
page.set(Some(p));
|
page.set(Some(p));
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e)),
|
Err(e) => {
|
||||||
|
if error.is_alive() {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,10 +196,18 @@ pub fn HistoryPage() -> View {
|
|||||||
.map(|m| {
|
.map(|m| {
|
||||||
let finished = local_finished(&m.finished_at);
|
let finished = local_finished(&m.finished_at);
|
||||||
let game = game_name(&names, &m.game_type);
|
let game = game_name(&names, &m.game_type);
|
||||||
let mut cells: Vec<View> = team_columns(&m)
|
let teams: Vec<View> = team_groups(&m)
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|t| team_names(&m, t))
|
.map(|(label, names)| {
|
||||||
.map(|c| view! { td { (c) } })
|
view! {
|
||||||
|
div(class="match-team") {
|
||||||
|
span(class="text-secondary") {
|
||||||
|
(label) ": "
|
||||||
|
}
|
||||||
|
(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let score = score_text(&m);
|
let score = score_text(&m);
|
||||||
let winner = winner_text(&m);
|
let winner = winner_text(&m);
|
||||||
@@ -173,37 +227,18 @@ pub fn HistoryPage() -> View {
|
|||||||
} else {
|
} else {
|
||||||
"text-secondary"
|
"text-secondary"
|
||||||
};
|
};
|
||||||
let mut row_cells = vec![
|
let row_cells = vec![
|
||||||
view! { td { (finished) } },
|
view! { td { (finished) } },
|
||||||
view! { td { (game) } },
|
view! { td { (game) } },
|
||||||
|
view! { td { (teams) } },
|
||||||
|
view! { td { (score) } },
|
||||||
|
view! { td { (winner) } },
|
||||||
|
view! { td(class=cls) { (outcome) } },
|
||||||
|
view! { td(class=cls) { (elo_delta) } },
|
||||||
];
|
];
|
||||||
row_cells.append(&mut cells);
|
|
||||||
row_cells.push(view! { td { (score) } });
|
|
||||||
row_cells.push(view! { td { (winner) } });
|
|
||||||
row_cells.push(view! { td(class=cls) { (outcome) } });
|
|
||||||
row_cells.push(view! { td(class=cls) { (elo_delta) } });
|
|
||||||
view! { tr { (row_cells) } }
|
view! { tr { (row_cells) } }
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
// Team columns are per-row (matches may mix games), so
|
|
||||||
// the header shows the union across the loaded rows.
|
|
||||||
let mut header_teams = BTreeSet::new();
|
|
||||||
for m in rows.get_clone().iter() {
|
|
||||||
for t in team_columns(m) {
|
|
||||||
header_teams.insert(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut header_teams: Vec<Option<String>> =
|
|
||||||
header_teams.into_iter().collect();
|
|
||||||
header_teams.sort_by_key(|t| match t {
|
|
||||||
Some(_) => (0, String::new()),
|
|
||||||
None => (1, String::new()),
|
|
||||||
});
|
|
||||||
let header_cells: Vec<View> = header_teams
|
|
||||||
.iter()
|
|
||||||
.map(team_header)
|
|
||||||
.map(|h| view! { th { (h) } })
|
|
||||||
.collect();
|
|
||||||
view! {
|
view! {
|
||||||
div(class="table-responsive") {
|
div(class="table-responsive") {
|
||||||
table(class="table table-striped table-hover align-middle") {
|
table(class="table table-striped table-hover align-middle") {
|
||||||
@@ -211,7 +246,7 @@ pub fn HistoryPage() -> View {
|
|||||||
tr {
|
tr {
|
||||||
th { "Finished" }
|
th { "Finished" }
|
||||||
th { "Game" }
|
th { "Game" }
|
||||||
(header_cells)
|
th { "Teams" }
|
||||||
th { "Score" }
|
th { "Score" }
|
||||||
th { "Winner" }
|
th { "Winner" }
|
||||||
th { "You" }
|
th { "You" }
|
||||||
@@ -236,3 +271,96 @@ pub fn HistoryPage() -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn match_summary(players: serde_json::Value) -> MatchSummary {
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"id": "m1",
|
||||||
|
"started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"finished_at": "2026-01-01T01:00:00+00:00",
|
||||||
|
"players": players,
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_groups_order_chiamata_roles() {
|
||||||
|
let m = match_summary(json!([
|
||||||
|
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||||
|
"team": "Difensori", "won": false},
|
||||||
|
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||||
|
"team": "Chiamante", "won": true},
|
||||||
|
{"user_sub": "c", "display_name": "Carol", "seat": 2,
|
||||||
|
"team": "Difensori", "won": false},
|
||||||
|
{"user_sub": "d", "display_name": "Dave", "seat": 3,
|
||||||
|
"team": "Socio", "won": true},
|
||||||
|
{"user_sub": "e", "display_name": "Erin", "seat": 4,
|
||||||
|
"team": "Difensori", "won": false},
|
||||||
|
]));
|
||||||
|
assert_eq!(
|
||||||
|
vec![
|
||||||
|
("Chiamante".to_string(), "Bob".to_string()),
|
||||||
|
("Socio".to_string(), "Dave".to_string()),
|
||||||
|
(
|
||||||
|
"Difensori".to_string(),
|
||||||
|
"Alice, Carol & Erin".to_string()
|
||||||
|
),
|
||||||
|
],
|
||||||
|
team_groups(&m)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_groups_keep_a_and_b_in_order() {
|
||||||
|
let m = match_summary(json!([
|
||||||
|
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||||
|
"team": "A", "won": true},
|
||||||
|
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||||
|
"team": "B", "won": false},
|
||||||
|
]));
|
||||||
|
assert_eq!(
|
||||||
|
vec![
|
||||||
|
("Team A".to_string(), "Alice".to_string()),
|
||||||
|
("Team B".to_string(), "Bob".to_string()),
|
||||||
|
],
|
||||||
|
team_groups(&m)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_groups_put_teamless_players_last() {
|
||||||
|
let m = match_summary(json!([
|
||||||
|
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||||
|
"team": null, "won": false},
|
||||||
|
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||||
|
"team": "A", "won": true},
|
||||||
|
]));
|
||||||
|
assert_eq!(
|
||||||
|
vec![
|
||||||
|
("Team A".to_string(), "Bob".to_string()),
|
||||||
|
("Players".to_string(), "Alice".to_string()),
|
||||||
|
],
|
||||||
|
team_groups(&m)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_names_join_three_names_readably() {
|
||||||
|
let m = match_summary(json!([
|
||||||
|
{"user_sub": "a", "display_name": "Alice", "seat": 0,
|
||||||
|
"team": "A", "won": true},
|
||||||
|
{"user_sub": "b", "display_name": "Bob", "seat": 1,
|
||||||
|
"team": "A", "won": true},
|
||||||
|
{"user_sub": "c", "display_name": "Carol", "seat": 2,
|
||||||
|
"team": "A", "won": true},
|
||||||
|
]));
|
||||||
|
assert_eq!(
|
||||||
|
"Alice, Bob & Carol",
|
||||||
|
team_names(&m, &Some("A".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+25
-62
@@ -13,21 +13,13 @@ use sycamore_router::navigate;
|
|||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::card::card_back;
|
use crate::components::card::card_back;
|
||||||
|
use crate::components::login::login_card;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||||
MyGame, OptionField, OptionKind, PlayerRating, User,
|
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Human-readable label for a provider name ("google" -> "Google").
|
|
||||||
fn provider_label(name: &str) -> String {
|
|
||||||
let mut chars = name.chars();
|
|
||||||
match chars.next() {
|
|
||||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_to_string(value: &serde_json::Value) -> String {
|
fn json_to_string(value: &serde_json::Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
|
|||||||
if me.is_some() {
|
if me.is_some() {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(p) = api::my_ratings().await {
|
if let Ok(p) = api::my_ratings().await {
|
||||||
ratings.set(p.results);
|
if ratings.is_alive() {
|
||||||
|
ratings.set(p.results);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(games) = api::my_games().await {
|
if let Ok(games) = api::my_games().await {
|
||||||
my_games.set(games);
|
if my_games.is_alive() {
|
||||||
|
my_games.set(games);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
user.set(Some(me));
|
// The page may have been unmounted (e.g. by the login
|
||||||
|
// redirect triggered while this request was in flight).
|
||||||
|
if user.is_alive() {
|
||||||
|
user.set(Some(me));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error.set(Some(e));
|
if error.is_alive() {
|
||||||
user.set(Some(None));
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
if user.is_alive() {
|
||||||
|
user.set(Some(None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::game_types().await {
|
match api::game_types().await {
|
||||||
Ok(types) if !types.is_empty() => {
|
Ok(types) if !types.is_empty() => {
|
||||||
|
if !game_types.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
selected_game.set(types[0].id.clone());
|
selected_game.set(types[0].id.clone());
|
||||||
game_types.set(types);
|
game_types.set(types);
|
||||||
}
|
}
|
||||||
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
|
|||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(list) = api::auth_providers().await {
|
if let Ok(list) = api::auth_providers().await {
|
||||||
auth_providers.set(list);
|
if auth_providers.is_alive() {
|
||||||
|
auth_providers.set(list);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,53 +479,7 @@ pub fn LobbyPage() -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(None) => view! {
|
Some(None) => view! { (login_card(auth_providers)) },
|
||||||
div(class="status-card") {
|
|
||||||
div(class="card") {
|
|
||||||
div(class="card-body p-4") {
|
|
||||||
h1(class="h3 mb-3") {
|
|
||||||
span(class="text-warning") { "♠" }
|
|
||||||
" tavolo"
|
|
||||||
}
|
|
||||||
p(class="text-secondary") { "Log in with your account to play." }
|
|
||||||
// rel="external" opts out of the SPA router's click
|
|
||||||
// interception: these must be full page navigations
|
|
||||||
// to the backend's OIDC login endpoints.
|
|
||||||
(move || {
|
|
||||||
let providers = auth_providers.get_clone();
|
|
||||||
if providers.is_empty() {
|
|
||||||
// Providers could not be fetched: the
|
|
||||||
// generic route redirects to the first
|
|
||||||
// configured provider.
|
|
||||||
view! {
|
|
||||||
a(
|
|
||||||
class="btn btn-warning btn-lg w-100",
|
|
||||||
href="/auth/login",
|
|
||||||
rel="external",
|
|
||||||
) {
|
|
||||||
"Log in"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
div(class="d-grid gap-2") {
|
|
||||||
(providers.iter().cloned().map(|p| view! {
|
|
||||||
a(
|
|
||||||
class="btn btn-warning btn-lg w-100",
|
|
||||||
href=p.login_url,
|
|
||||||
rel="external",
|
|
||||||
) {
|
|
||||||
"Log in with " (provider_label(&p.name))
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Some(Some(_me)) => {
|
Some(Some(_me)) => {
|
||||||
view! {
|
view! {
|
||||||
(crate::components::nav::app_nav("/", move || {
|
(crate::components::nav::app_nav("/", move || {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Login page, shown after the API answered `401` (see [`crate::auth`]):
|
||||||
|
//! the session expired, or the visitor tried to open a page that needs an
|
||||||
|
//! account.
|
||||||
|
//!
|
||||||
|
//! The backend's OIDC callback lands the SPA back on the configured
|
||||||
|
//! post-login redirect; [`crate::auth`] then returns the user to the page
|
||||||
|
//! they were on before this one.
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
use sycamore_router::navigate_replace;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::auth;
|
||||||
|
use crate::components::login::login_card;
|
||||||
|
use crate::model::AuthProvider;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoginPage() -> View {
|
||||||
|
let providers = create_signal(Vec::<AuthProvider>::new());
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
// Already signed in: no business here — return to where the user
|
||||||
|
// was headed, or the lobby.
|
||||||
|
if matches!(api::me().await, Ok(Some(_))) {
|
||||||
|
let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string());
|
||||||
|
auth::clear_return_to();
|
||||||
|
navigate_replace(&target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(list) = api::auth_providers().await {
|
||||||
|
// The page can unmount while the fetch is in flight.
|
||||||
|
if providers.is_alive() {
|
||||||
|
providers.set(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
(login_card(providers))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod briscola;
|
pub mod briscola;
|
||||||
|
pub mod chiamata;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod leaderboard;
|
pub mod leaderboard;
|
||||||
pub mod lobby;
|
pub mod lobby;
|
||||||
|
pub mod login;
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ impl GameSocket {
|
|||||||
self.send_json(serde_json::json!({ "action": "ack" }));
|
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: bid a rank in the calling auction.
|
||||||
|
pub fn bid_rank(&self, rank: i32) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: raise the points target (standing bid is the 2).
|
||||||
|
pub fn bid_points(&self, points: i32) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: leave the auction.
|
||||||
|
pub fn pass(&self) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "pass" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Briscola chiamata: name the called card.
|
||||||
|
pub fn call_card(&self, card: &str) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "call", "card": card }));
|
||||||
|
}
|
||||||
|
|
||||||
fn send_json(&self, value: serde_json::Value) {
|
fn send_json(&self, value: serde_json::Value) {
|
||||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
+215
-3
@@ -292,8 +292,12 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* Tall dialogs (the chiamata call picker, the scoring summaries) can
|
||||||
|
* exceed short windows: allow scrolling instead of clipping them. */
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,8 +306,11 @@ body {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: min(320px, calc(100vw - 1.5rem));
|
/* Centers the dialog when it fits and keeps it reachable (top
|
||||||
max-width: calc(100vw - 1.5rem);
|
* aligned, scrollable via the overlay) when it does not. */
|
||||||
|
margin: auto;
|
||||||
|
min-width: min(320px, calc(100vw - 3rem));
|
||||||
|
max-width: calc(100vw - 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-options {
|
.capture-options {
|
||||||
@@ -667,3 +674,208 @@ body {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- briscola chiamata ---------- */
|
||||||
|
|
||||||
|
/* Five seats: two across the top and one on each side of the viewer. */
|
||||||
|
.table-grid.cinque {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"tl . tr"
|
||||||
|
"left center right"
|
||||||
|
"bottom bottom bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-tl {
|
||||||
|
grid-area: tl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-tr {
|
||||||
|
grid-area: tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.called-badge {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--felt-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-banner {
|
||||||
|
background: rgba(232, 197, 71, 0.15);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patta-banner {
|
||||||
|
background: rgba(217, 83, 79, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The calling auction: standing bid, trail and the bid buttons. */
|
||||||
|
.auction-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-standing {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-history {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-chip {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-button {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--felt-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-button.points {
|
||||||
|
border-color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The auction winner's call picker: the four cards of the won rank,
|
||||||
|
* laid out in a compact grid so the dialog stays short. */
|
||||||
|
.call-picker {
|
||||||
|
max-width: min(34rem, calc(100vw - 3rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
.call-options {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-option {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 1px solid #4c6a54;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.4rem 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-option:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smaller than a hand card: the picker must fit short laptop windows. */
|
||||||
|
.playing-card.call-card {
|
||||||
|
height: 84px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note.partner {
|
||||||
|
color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note.solo {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chiamata-row .score-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.table-grid.cinque {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"tl tr"
|
||||||
|
"left right"
|
||||||
|
"center center"
|
||||||
|
"bottom bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
.auction-panel {
|
||||||
|
padding: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- match history ---------- */
|
||||||
|
|
||||||
|
/* One line per team inside the "Teams" cell of the history table. */
|
||||||
|
.match-team {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|||||||
+21
-5
@@ -40,11 +40,27 @@
|
|||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--felt-dark);
|
background-color: var(--felt-dark);
|
||||||
background-image: radial-gradient(
|
/* Casino-table felt: a fine grain texture tiled over the green
|
||||||
ellipse at 50% -10%,
|
* gradient, lit from above and darkened towards the edges. The first
|
||||||
var(--felt) 0%,
|
* layer is the topmost one. */
|
||||||
var(--felt-dark) 65%
|
background-image:
|
||||||
);
|
radial-gradient(
|
||||||
|
ellipse at 50% 45%,
|
||||||
|
transparent 55%,
|
||||||
|
rgba(0, 0, 0, 0.38) 100%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% -10%,
|
||||||
|
rgba(255, 255, 255, 0.07),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
url("/assets/felt.svg"),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% -10%,
|
||||||
|
var(--felt) 0%,
|
||||||
|
var(--felt-dark) 65%
|
||||||
|
);
|
||||||
|
background-repeat: no-repeat, no-repeat, repeat, no-repeat;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user