Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eea293471
|
||
|
|
4ba5ae1434
|
||
|
|
0f9fb9b6bb
|
||
|
|
322ca27bf4
|
||
|
|
0674e06565
|
||
|
|
353f012906
|
||
|
|
6427afd12b
|
@@ -1,9 +1,10 @@
|
||||
# tavolo
|
||||
|
||||
A platform for multiplayer card games. Two games are included:
|
||||
A platform for multiplayer card games. Three games are included:
|
||||
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||
game — and **briscola**, in its classic two-player and four-player
|
||||
partnership (*briscola a quattro*) variants:
|
||||
game — **briscola**, in its classic two-player and four-player
|
||||
partnership (*briscola a quattro*) variants, and **briscola chiamata** —
|
||||
the five-player variant with a calling auction and a secret partner:
|
||||
|
||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||
@@ -45,7 +46,9 @@ pigliatutto when enabled), briscola players see the deal's points and any
|
||||
*patta*; both see the running totals and must click "Understood" before
|
||||
the next hand is dealt. If someone is away the next hand is dealt
|
||||
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
|
||||
match-ending hand is explained on the final screen.
|
||||
match-ending hand is explained on the final screen. Briscola chiamata
|
||||
plays a single deal per match, so it goes straight from the last trick to
|
||||
the final scoring screen; a 60–60 *patta* is replayed immediately.
|
||||
|
||||
## On your turn
|
||||
|
||||
@@ -53,12 +56,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
|
||||
random legal card for them (choosing among the legal captures when the
|
||||
rules require a capture), so one absent player cannot stall the table.
|
||||
Briscola chiamata arms the same countdown for its auction 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
|
||||
|
||||
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||||
spectators see the table, the score and every player's captured cards
|
||||
(and scope for scopone), but never a hand and never the join code. The
|
||||
(and scope for scopone), but never a hand and never the join code. In
|
||||
briscola chiamata they also see no role labels and no side totals until
|
||||
the called card is played, so the secret partner stays secret. The
|
||||
"Copy watch link" button in the waiting room and in the game HUD copies
|
||||
the URL to share. A spectator who opens the URL before the match starts
|
||||
sees a "game not started yet" waiting room.
|
||||
|
||||
+57
-7
@@ -16,9 +16,10 @@ and
|
||||
scientifico, the four-player, fixed-partnership variant of the classic
|
||||
Italian card game: pure rules plus the platform adapter.
|
||||
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
||||
Italian trick-taking game, in its classic two-player (`briscola`) and
|
||||
four-player partnership (`briscola_a_quattro`) variants: pure rules
|
||||
plus the platform adapter.
|
||||
Italian trick-taking game, in its classic two-player (`briscola`),
|
||||
four-player partnership (`briscola_a_quattro`) and five-player
|
||||
called-partner (`briscola_chiamata`) variants: pure rules plus the
|
||||
platform adapter.
|
||||
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||
composition root wiring the platform to the games, with the
|
||||
environment-driven settings and the SPA shell.
|
||||
@@ -236,7 +237,9 @@ aggregate with `max`, not `sum`.
|
||||
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||
final scores, winner, target score, hands played and the per-hand
|
||||
audit; for briscola: the final scores, winner, target score, deals
|
||||
played and the per-deal audit).
|
||||
played and the per-deal audit; for briscola chiamata: the calling side's
|
||||
points against the defenders', the declared target, the called card and
|
||||
whether the caller played alone).
|
||||
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||
seat, game-defined team label (nullable), whether they won, the points
|
||||
they scored, the Elo change the match produced (`elo_delta`) and
|
||||
@@ -275,7 +278,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
||||
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
||||
@@ -341,6 +344,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
|
||||
every player has acknowledged, or automatically after
|
||||
`HAND_ACK_TIMEOUT_SECONDS`.
|
||||
|
||||
For briscola chiamata the game actions are the calling auction plus play:
|
||||
|
||||
```json
|
||||
{"action": "bid", "rank": 3}
|
||||
{"action": "bid", "points": 64}
|
||||
{"action": "pass"}
|
||||
{"action": "call", "card": "03D"}
|
||||
{"action": "play", "card": "04C"}
|
||||
```
|
||||
|
||||
- `bid` with a `rank` calls a lower rank than the standing bid
|
||||
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
|
||||
target once the standing bid is the two.
|
||||
- `pass` leaves the auction; the last player standing must bid.
|
||||
- `call` names any card of the won rank; naming one of your own cards
|
||||
means playing alone.
|
||||
- `play` follows briscola: any card in hand is legal.
|
||||
|
||||
After every accepted move the new state is broadcast to every connected
|
||||
client, spectators included.
|
||||
|
||||
@@ -353,7 +374,10 @@ is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||
queue (see above), not from timers tied to client connections, so the
|
||||
match keeps progressing even with every player disconnected.
|
||||
match keeps progressing even with every player disconnected. Briscola
|
||||
chiamata uses the same timeout for its auction 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```sh
|
||||
@@ -493,8 +542,9 @@ packages/tavolo-briscola/ # briscola game (own pyproject + tests)
|
||||
src/tavolo/briscola/
|
||||
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
||||
├── engine.py # pure briscola rules (2- and 4-player)
|
||||
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
|
||||
├── errors.py # briscola-specific rule violations
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
|
||||
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||
src/tavolo/
|
||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||
|
||||
@@ -39,7 +39,11 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||
from tavolo.briscola import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
@@ -144,6 +148,12 @@ registry.register(
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||
log.debug(
|
||||
"game timeouts: hand_ack=%ds turn=%ds",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
Briscola — the Italian trick-taking game — as a
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||
Two variants share one rules core:
|
||||
Three variants share one rules core:
|
||||
|
||||
- `briscola` — the classic two-player game;
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola;
|
||||
- `briscola_chiamata` — five-player briscola with a calling auction and a
|
||||
secret partner.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -13,27 +15,42 @@ Two variants share one rules core:
|
||||
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||
envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
||||
(trump first, then the led suit; strength ace > three > king > horse >
|
||||
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
||||
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
||||
jack 2), patta re-deals and the target-score match end, deal-end
|
||||
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
||||
logging, so the whole rule set is unit-testable.
|
||||
- `engine.py` — the pure rules engine for two- and four-player briscola:
|
||||
deck, deals, trick resolution (trump first, then the led suit; strength
|
||||
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
|
||||
(trick winner first, the face-up briscola last), card points (ace 11,
|
||||
three 10, king 4, horse 3, jack 2), patta re-deals and the
|
||||
target-score match end, deal-end acknowledgements and auto-play.
|
||||
Deterministic and I/O-free apart from logging, so the whole rule set is
|
||||
unit-testable.
|
||||
- `chiamata.py` — the pure rules engine for five-player briscola
|
||||
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
|
||||
round-trip), the 8-card deal with no stock, the descending calling
|
||||
auction with point raises, the winner's call (including calling one's
|
||||
own card, which means playing alone), the called-card reveal, single-
|
||||
deal scoring with the 60–60 patta and the auto-actions for timeouts.
|
||||
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
||||
(`GameEngine`): the platform-facing adapters. They validate the
|
||||
`target_score` creation option (61–120, default 61) and extract the
|
||||
`MatchResult` (teams, winner, per-player scores and the match summary
|
||||
persisted as the match's JSON `result`).
|
||||
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||
The first two validate the `target_score` creation option (61–120,
|
||||
default 61) and extract the `MatchResult` (teams, winner, per-player
|
||||
scores and the match summary persisted as the match's JSON `result`).
|
||||
Briscola chiamata takes no creation options.
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root.
|
||||
application composition root. Briscola chiamata uses the turn timeout
|
||||
for its auction 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
|
||||
|
||||
### Briscola (two-player and four-player)
|
||||
|
||||
- Three cards per player; the next card is turned face up: its suit is
|
||||
the trump (briscola) for the deal and it is the last card drawn.
|
||||
- The winner of a trick is the highest trump, or the highest card of the
|
||||
@@ -47,6 +64,31 @@ application composition root.
|
||||
- Between deals of an unfinished match every player acknowledges the
|
||||
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||
|
||||
### Briscola chiamata (five-player)
|
||||
|
||||
- The whole 40-card deck is dealt out, eight cards each: there is no
|
||||
stock and no face-up card.
|
||||
- Starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a **lower** rank than the
|
||||
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
|
||||
the first bidder may bid any rank. Once the standing bid is the two,
|
||||
the auction continues on points instead (62, 64, …): the calling side
|
||||
then has to reach the raised target. The last player standing must
|
||||
bid, so a deal always gets a caller.
|
||||
- The auction winner names any card of the winning rank. If the winner
|
||||
holds it, they **play alone** against the other four; otherwise its
|
||||
holder is the **secret partner** and the two form the calling side.
|
||||
The called card's suit is the trump for the deal. The partner's
|
||||
identity is hidden until the called card is played.
|
||||
- Play follows briscola: the player after the dealer leads, the highest
|
||||
trump (or highest led-suit card) takes the trick and leads the next
|
||||
one; eight tricks exhaust every hand.
|
||||
- The calling side (caller plus partner, or the lone caller) must reach
|
||||
the declared target — 61 unless the auction raised it. With the plain
|
||||
target a 60–60 deal is a patta and is replayed with the next dealer.
|
||||
A match is a single deal, so the two sides map onto the platform's
|
||||
two-team result (Elo rating, match history).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
@@ -54,5 +96,5 @@ application composition root.
|
||||
.venv/bin/python -m mypy -p tavolo.briscola
|
||||
```
|
||||
|
||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||
platform contract (actions, deadlines, serialization, results).
|
||||
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"""Briscola: tavolo's trick-taking game implementation.
|
||||
|
||||
Two variants share the same rules core and platform adapter:
|
||||
Three variants share the same rules core and platform adapter:
|
||||
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||
briscola (``briscola``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
|
||||
briscola chiamata (``briscola_chiamata``).
|
||||
|
||||
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
||||
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
||||
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
|
||||
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
|
||||
adapts them to the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||
game-independent platform can host them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
||||
from .plugin import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
|
||||
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
||||
__all__ = [
|
||||
"BriscolaChiamataEngine",
|
||||
"BriscolaEngine",
|
||||
"BriscolaQuattroEngine",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
||||
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
||||
:class:`BriscolaEngine` and four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
||||
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||
:class:`BriscolaEngine`, four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine` and five-player
|
||||
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
|
||||
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||
keeping all briscola knowledge inside this package. Nothing outside
|
||||
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
|
||||
)
|
||||
from tavolo.platform.errors import GameError, IllegalMove
|
||||
|
||||
from . import engine
|
||||
from . import chiamata, engine
|
||||
from .chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_NAMES,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
)
|
||||
from .engine import card_points
|
||||
from .state import (
|
||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
DEFAULT_TARGET_SCORE,
|
||||
@@ -277,3 +290,299 @@ class BriscolaQuattroEngine(_BriscolaBase):
|
||||
min_players = 4
|
||||
max_players = 4
|
||||
player_count = 4
|
||||
|
||||
|
||||
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise GameError(f"no player in seat {seat}")
|
||||
|
||||
|
||||
class BriscolaChiamataEngine(GameEngine):
|
||||
"""Five-player briscola chiamata as a platform game engine.
|
||||
|
||||
One deal per match: the five players auction the call, the winner
|
||||
names a card (playing alone when it is in their own hand) and the
|
||||
calling side must reach the declared target (61 unless the auction
|
||||
raised it). A 60-60 deal with the plain target is a patta and is
|
||||
replayed. This keeps the match result a clean two-sided affair —
|
||||
caller and secret partner against the other three, or the lone
|
||||
caller against the other four — which the platform can rate,
|
||||
persist and display like any other game.
|
||||
"""
|
||||
|
||||
id = "briscola_chiamata"
|
||||
name = "Briscola chiamata"
|
||||
description = (
|
||||
"Five-player briscola with a descending auction: the winner calls "
|
||||
"a card, whose holder becomes the secret partner (or, when the "
|
||||
"caller holds it, the caller plays alone) and whose suit becomes "
|
||||
"the trump. The calling side must reach 61 points; one deal per "
|
||||
"match, replayed after a patta."
|
||||
)
|
||||
min_players = 5
|
||||
max_players = 5
|
||||
|
||||
options_schema: Mapping[str, Any] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self._turn_timeout = turn_timeout_seconds
|
||||
# A chiamata match is a single deal, so there is no between-deals
|
||||
# acknowledgement phase to time out; the parameter is accepted
|
||||
# for symmetry with the other briscola engines and wired by the
|
||||
# application root.
|
||||
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||
|
||||
# -- lobby ------------------------------------------------------------
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
creator = session.players[0]
|
||||
session.state = chiamata.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
creator_name=creator.display_name,
|
||||
turn_timeout=self._turn_timeout,
|
||||
)
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
chiamata.join_game(session.state, user_sub, display_name)
|
||||
session.players.append(
|
||||
Seat(user_sub=user_sub, display_name=display_name)
|
||||
)
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_LOBBY
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"declared_target": state.declared_target,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ChiamataState = session.state
|
||||
if action == "bid":
|
||||
rank = payload.get("rank")
|
||||
points = payload.get("points")
|
||||
if rank is not None:
|
||||
if isinstance(rank, bool) or not isinstance(rank, int):
|
||||
raise IllegalMove("'rank' must be an integer")
|
||||
chiamata.bid_rank(state, user_sub, rank)
|
||||
elif points is not None:
|
||||
if isinstance(points, bool) or not isinstance(points, int):
|
||||
raise IllegalMove("'points' must be an integer")
|
||||
chiamata.bid_points(state, user_sub, points)
|
||||
else:
|
||||
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
|
||||
elif action == "pass":
|
||||
chiamata.pass_auction(state, user_sub)
|
||||
elif action == "call":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.call(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
elif action == "play":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.play(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
return chiamata.state_for_player(session.state, user_sub)
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_FINISHED
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
caller = (
|
||||
_chiamata_player(state, state.caller) if state.caller is not None else None
|
||||
)
|
||||
partner = (
|
||||
_chiamata_player(state, state.partner_seat)
|
||||
if state.partner_seat is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": None if caller is None else caller.name,
|
||||
"partner": None if partner is None else partner.name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
|
||||
# -- (de)serialization -------------------------------------------------
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
assert isinstance(state, ChiamataState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ChiamataState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ChiamataState = session.state
|
||||
if state.phase == PHASE_AUCTION and state.auction_deadline:
|
||||
due_ms = _deadline_ms(state.auction_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="auction",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_CALLING and state.call_deadline:
|
||||
due_ms = _deadline_ms(state.call_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="call",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"call:{state.hand_number}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||
due_ms = _deadline_ms(state.turn_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="turn",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
return None
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
# Overtaken by events (a bid, call or play landed in time):
|
||||
# nothing to do.
|
||||
raise GameError("stale deadline")
|
||||
state: ChiamataState = session.state
|
||||
if kind == "auction":
|
||||
seat = state.turn
|
||||
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)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(
|
||||
["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||
[
|
||||
"scopone_scientifico",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
[g["id"] for g in results],
|
||||
)
|
||||
scopone = results[0]
|
||||
@@ -325,6 +330,12 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(4, quattro["min_players"])
|
||||
self.assertEqual(4, quattro["max_players"])
|
||||
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
||||
chiamata = results[3]
|
||||
self.assertEqual("Briscola chiamata", chiamata["name"])
|
||||
self.assertTrue(chiamata["description"])
|
||||
self.assertEqual(5, chiamata["min_players"])
|
||||
self.assertEqual(5, chiamata["max_players"])
|
||||
self.assertEqual({}, chiamata["options_schema"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
|
||||
@@ -12,8 +12,11 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, platform, tortoise_mixin
|
||||
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.errors import IllegalMove
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopone import engine as rules
|
||||
@@ -222,5 +225,98 @@ class BriscolaStatsTest(unittest.TestCase):
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
async def _finished_chiamata_session() -> GameSession:
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = GameSession(
|
||||
id="stats-chiamata-1",
|
||||
game_type=engine.id,
|
||||
join_code="BC0001",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
for name in ("bob", "carol", "dave", "erin"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(p for p in state.players if p.seat == state.caller)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
class ChiamataStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await _finished_chiamata_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(5, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("briscola_chiamata", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
|
||||
players = await MatchPlayer.all()
|
||||
roles = {p.team for p in players}
|
||||
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||
# Roles match the sides: exactly one caller, the rest defenders
|
||||
# plus at most one partner.
|
||||
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||
winner_team = summary["winner_team"]
|
||||
for row in players:
|
||||
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(5, len(ratings))
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
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"
|
||||
gloo-timers = "0.3"
|
||||
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
|
||||
//! session cookie automatically.
|
||||
use crate::auth;
|
||||
use crate::model::*;
|
||||
use gloo_net::http::Request;
|
||||
use gloo_net::http::{Request, Response};
|
||||
|
||||
fn server_error(status: u16) -> String {
|
||||
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).
|
||||
///
|
||||
/// 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> {
|
||||
let resp = Request::get("/api/me")
|
||||
.send()
|
||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.providers)
|
||||
}
|
||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(page.results)
|
||||
}
|
||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status() == 401 {
|
||||
auth::redirect_to_login();
|
||||
return Err("login required".to_string());
|
||||
}
|
||||
if !resp.ok() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let message = body
|
||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
||||
if resp.status() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
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()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||
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(),
|
||||
};
|
||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
check(&resp)?;
|
||||
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 crate::components::card::{card_back, card_img};
|
||||
use crate::model::{is_individual_game, DealSummary, GameView};
|
||||
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
/// Names of the two sides: the players' names for games without
|
||||
/// partnerships (classic two-player briscola), "Team A"/"Team B"
|
||||
/// otherwise.
|
||||
/// Names of the two sides: the caller and partner for briscola chiamata,
|
||||
/// the players' names for games without partnerships (classic two-player
|
||||
/// briscola), "Team A"/"Team B" otherwise.
|
||||
fn side_names(game: &GameView) -> (String, String) {
|
||||
if is_individual_game(&game.game_type) {
|
||||
if is_chiamata(&game.game_type) {
|
||||
game.chiamata_sides()
|
||||
} else if is_individual_game(&game.game_type) {
|
||||
(
|
||||
game.player_name_for_team("A"),
|
||||
game.player_name_for_team("B"),
|
||||
@@ -66,6 +68,25 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
"60–60: patta — the deal is replayed and its points do not count."
|
||||
}
|
||||
});
|
||||
let called_row = if is_chiamata(&game.game_type) {
|
||||
game.called_card.clone().map(|code| {
|
||||
let label = card_label(&code);
|
||||
let text = if game.solo {
|
||||
format!("{name_a} called {label} in hand and plays alone.")
|
||||
} else {
|
||||
format!("{label}: its holder is the secret partner; its suit is trump.")
|
||||
};
|
||||
summary_row(
|
||||
card_img(code, "score-mini"),
|
||||
"Chiamata",
|
||||
text,
|
||||
"score-row chiamata-row",
|
||||
label,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let points_row = summary_row(
|
||||
card_img("01D".to_string(), "score-mini"),
|
||||
"Deal points",
|
||||
@@ -84,6 +105,7 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
});
|
||||
view! {
|
||||
div(class="score-rows") {
|
||||
(called_row)
|
||||
(points_row)
|
||||
(patta_note)
|
||||
(cards)
|
||||
@@ -94,9 +116,13 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||
/// Running totals with progress toward the target, including the points
|
||||
/// gained in the deal just played (zero for a patta).
|
||||
fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
// Briscola chiamata plays a single deal: there is no running total.
|
||||
let Some(running) = summary.totals else {
|
||||
return view! {};
|
||||
};
|
||||
let target = game.target_score.max(1);
|
||||
let pct_a = (100 * summary.totals.a / target).min(100);
|
||||
let pct_b = (100 * summary.totals.b / target).min(100);
|
||||
let pct_a = (100 * running.a / target).min(100);
|
||||
let pct_b = (100 * running.b / target).min(100);
|
||||
let (gained_a, gained_b) = if summary.patta {
|
||||
(0, 0)
|
||||
} else {
|
||||
@@ -111,7 +137,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(summary.totals.a) " / " (target)
|
||||
(running.a) " / " (target)
|
||||
span(class="gained") { "+" (gained_a) }
|
||||
}
|
||||
}
|
||||
@@ -121,7 +147,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(summary.totals.b) " / " (target)
|
||||
(running.b) " / " (target)
|
||||
span(class="gained") { "+" (gained_b) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 card;
|
||||
pub mod copy;
|
||||
pub mod login;
|
||||
pub mod nav;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
+33
-1
@@ -1,22 +1,27 @@
|
||||
//! Application entry point.
|
||||
mod api;
|
||||
mod auth;
|
||||
mod components;
|
||||
mod model;
|
||||
mod pages;
|
||||
mod ws;
|
||||
|
||||
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::history::HistoryPage;
|
||||
use pages::leaderboard::LeaderboardPage;
|
||||
use pages::lobby::LobbyPage;
|
||||
use pages::login::LoginPage;
|
||||
|
||||
#[derive(Route, Clone)]
|
||||
enum AppRoutes {
|
||||
#[to("/")]
|
||||
Lobby,
|
||||
#[to("/login")]
|
||||
Login,
|
||||
#[to("/game/<id>")]
|
||||
Game { id: String },
|
||||
#[to("/history")]
|
||||
@@ -27,6 +32,31 @@ enum AppRoutes {
|
||||
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() {
|
||||
console_error_panic_hook::set_once();
|
||||
sycamore::render(|| {
|
||||
@@ -38,6 +68,7 @@ fn main() {
|
||||
div(class="app") {
|
||||
(match route.get_clone() {
|
||||
AppRoutes::Lobby => view! { LobbyPage() },
|
||||
AppRoutes::Login => view! { LoginPage() },
|
||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||
AppRoutes::History => view! { HistoryPage() },
|
||||
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 name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
/// Game-defined role/team label; empty before briscola chiamata
|
||||
/// reveals the roles.
|
||||
#[serde(default)]
|
||||
pub team: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cards_left: usize,
|
||||
#[serde(default)]
|
||||
@@ -84,9 +87,21 @@ pub struct DealSummary {
|
||||
#[serde(default)]
|
||||
pub patta: bool,
|
||||
pub points: Scores,
|
||||
pub totals: Scores,
|
||||
/// Running match totals; absent for briscola chiamata, whose match is
|
||||
/// a single deal.
|
||||
#[serde(default)]
|
||||
pub totals: Option<Scores>,
|
||||
#[serde(default)]
|
||||
pub cards: Option<TeamCounts>,
|
||||
/// Briscola chiamata: the points target of the played deal.
|
||||
#[serde(default)]
|
||||
pub declared_target: Option<i32>,
|
||||
/// Briscola chiamata: the card named by the caller.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: the caller played without a partner.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
@@ -227,6 +242,83 @@ pub struct GameView {
|
||||
/// Briscola: scoring breakdown of the most recent deal.
|
||||
#[serde(default)]
|
||||
pub last_deal: Option<DealSummary>,
|
||||
/// Briscola chiamata: the calling auction of the current deal.
|
||||
#[serde(default)]
|
||||
pub auction: Option<AuctionView>,
|
||||
/// Briscola chiamata: seat that won the auction.
|
||||
#[serde(default)]
|
||||
pub caller: Option<usize>,
|
||||
/// Briscola chiamata: the named card; its suit is the trump.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: points the calling side must reach.
|
||||
#[serde(default)]
|
||||
pub declared_target: i32,
|
||||
/// Briscola chiamata: the called card has been played; roles are
|
||||
/// public from then on.
|
||||
#[serde(default)]
|
||||
pub called_played: bool,
|
||||
/// Briscola chiamata: the partner's seat, once revealed.
|
||||
#[serde(default)]
|
||||
pub partner: Option<usize>,
|
||||
/// Briscola chiamata: the caller named one of their own cards.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
/// Briscola chiamata: legal bids for the player on turn.
|
||||
#[serde(default)]
|
||||
pub legal_bids: Option<LegalBids>,
|
||||
/// Briscola chiamata: the cards the auction winner may name.
|
||||
#[serde(default)]
|
||||
pub callable_cards: Option<Vec<String>>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
|
||||
#[serde(default)]
|
||||
pub auction_deadline: Option<String>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
|
||||
#[serde(default)]
|
||||
pub call_deadline: Option<String>,
|
||||
}
|
||||
|
||||
/// One bid of the briscola chiamata auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionBid {
|
||||
pub seat: usize,
|
||||
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
|
||||
pub rank: i32,
|
||||
/// Points target the bid commits its side to.
|
||||
#[serde(default)]
|
||||
pub points: i32,
|
||||
}
|
||||
|
||||
/// The briscola chiamata calling auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionView {
|
||||
/// Seat to act.
|
||||
pub turn: usize,
|
||||
#[serde(default)]
|
||||
pub active: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub passed: Vec<usize>,
|
||||
/// The standing bid, if any.
|
||||
#[serde(default)]
|
||||
pub current: Option<AuctionBid>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<AuctionBid>,
|
||||
}
|
||||
|
||||
/// The bids available to the player on turn.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LegalBids {
|
||||
/// Ranks that may be bid, strongest first (empty at the point stage).
|
||||
#[serde(default)]
|
||||
pub ranks: Vec<i32>,
|
||||
/// Lowest legal points raise, when the standing bid is the two.
|
||||
#[serde(default)]
|
||||
pub points_min: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub points_max: Option<i32>,
|
||||
}
|
||||
|
||||
impl GameView {
|
||||
@@ -235,10 +327,38 @@ impl GameView {
|
||||
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.team == team)
|
||||
.find(|p| p.team.as_deref() == Some(team))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| team.to_string())
|
||||
}
|
||||
|
||||
/// Display name of the player in `seat`.
|
||||
pub fn player_name_for_seat(&self, seat: usize) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.seat == seat)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||
}
|
||||
|
||||
/// Side labels for briscola chiamata: the caller (with the partner's
|
||||
/// name once revealed, or "(solo)") against the defenders.
|
||||
pub fn chiamata_sides(&self) -> (String, String) {
|
||||
let side_a = match self.caller {
|
||||
Some(caller) => {
|
||||
let caller_name = self.player_name_for_seat(caller);
|
||||
match self.partner {
|
||||
Some(partner) => {
|
||||
format!("{caller_name} & {}", self.player_name_for_seat(partner))
|
||||
}
|
||||
None if self.called_played => format!("{caller_name} (solo)"),
|
||||
None => caller_name,
|
||||
}
|
||||
}
|
||||
None => "Chiamante".to_string(),
|
||||
};
|
||||
(side_a, "Difensori".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -449,6 +569,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "briscola_chiamata".to_string(),
|
||||
name: "Briscola chiamata".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 5,
|
||||
max_players: 5,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -735,6 +863,30 @@ pub fn is_briscola(game_type: &str) -> bool {
|
||||
game_type == "briscola" || game_type == "briscola_a_quattro"
|
||||
}
|
||||
|
||||
/// Whether a game type is five-player briscola chiamata, which has its
|
||||
/// own table view (auction, secret partner, five seats).
|
||||
pub fn is_chiamata(game_type: &str) -> bool {
|
||||
game_type == "briscola_chiamata"
|
||||
}
|
||||
|
||||
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
|
||||
/// "Tre", `10` -> "Re", and so on.
|
||||
pub fn rank_name(rank: i32) -> String {
|
||||
match rank {
|
||||
1 => "Asso".to_string(),
|
||||
2 => "Due".to_string(),
|
||||
3 => "Tre".to_string(),
|
||||
4 => "Quattro".to_string(),
|
||||
5 => "Cinque".to_string(),
|
||||
6 => "Sei".to_string(),
|
||||
7 => "Sette".to_string(),
|
||||
8 => "Fante".to_string(),
|
||||
9 => "Cavallo".to_string(),
|
||||
10 => "Re".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a game is played by two individuals rather than partnerships
|
||||
/// (classic two-player briscola): team labels and team messages are
|
||||
/// replaced by the players' names.
|
||||
@@ -1033,7 +1185,7 @@ mod tests {
|
||||
assert_eq!(16, g.target_score);
|
||||
assert!(!g.napola);
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!("A", g.players[0].team);
|
||||
assert_eq!(Some("A".to_string()), g.players[0].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1245,7 +1397,10 @@ mod tests {
|
||||
fn is_briscola_matches_both_variants() {
|
||||
assert!(is_briscola("briscola"));
|
||||
assert!(is_briscola("briscola_a_quattro"));
|
||||
assert!(!is_briscola("briscola_chiamata"));
|
||||
assert!(!is_briscola("scopone_scientifico"));
|
||||
assert!(is_chiamata("briscola_chiamata"));
|
||||
assert!(!is_chiamata("briscola"));
|
||||
assert_eq!("denari", suit_name("D"));
|
||||
assert_eq!("coppe", suit_name("C"));
|
||||
assert_eq!("spade", suit_name("S"));
|
||||
@@ -1253,6 +1408,16 @@ mod tests {
|
||||
assert_eq!("?", suit_name("?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_names_cover_the_ladder() {
|
||||
assert_eq!("Asso", rank_name(1));
|
||||
assert_eq!("Due", rank_name(2));
|
||||
assert_eq!("Tre", rank_name(3));
|
||||
assert_eq!("Fante", rank_name(8));
|
||||
assert_eq!("Cavallo", rank_name(9));
|
||||
assert_eq!("Re", rank_name(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_individual_game_only_matches_two_player_briscola() {
|
||||
assert!(is_individual_game("briscola"));
|
||||
@@ -1318,11 +1483,17 @@ mod tests {
|
||||
let types = fallback_game_types();
|
||||
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||
vec![
|
||||
"scopone_scientifico",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
ids
|
||||
);
|
||||
// The lobby's first game (the creation default) must exist.
|
||||
assert_eq!("scopone_scientifico", types[0].id);
|
||||
assert_eq!(5, types[3].max_players);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1400,7 +1571,7 @@ mod tests {
|
||||
assert_eq!(1, deal.deal);
|
||||
assert!(!deal.patta);
|
||||
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
||||
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
|
||||
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
|
||||
let cards = deal.cards.unwrap();
|
||||
assert_eq!(19, cards.a);
|
||||
assert_eq!(21, cards.b);
|
||||
@@ -1420,4 +1591,137 @@ mod tests {
|
||||
assert_eq!(0, d.deal);
|
||||
assert!(d.cards.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_deal_summary_has_no_totals() {
|
||||
let d: DealSummary = serde_json::from_value(json!({
|
||||
"deal": 2,
|
||||
"patta": true,
|
||||
"points": {"A": 60, "B": 60},
|
||||
"declared_target": 61,
|
||||
"called_card": "01D",
|
||||
"caller": "Alice",
|
||||
"solo": false,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(d.patta);
|
||||
assert_eq!(2, d.deal);
|
||||
assert!(d.totals.is_none());
|
||||
assert!(d.cards.is_none());
|
||||
assert_eq!(Some(61), d.declared_target);
|
||||
assert_eq!(Some("01D".to_string()), d.called_card);
|
||||
assert!(!d.solo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_auction() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c1",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "auction",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 1,
|
||||
"declared_target": 61,
|
||||
"auction": {
|
||||
"turn": 2,
|
||||
"active": [0, 1, 2, 3, 4],
|
||||
"passed": [],
|
||||
"current": {"seat": 1, "rank": 10, "points": 61},
|
||||
"history": [{"seat": 1, "rank": 10, "points": 61}],
|
||||
},
|
||||
"your_turn": true,
|
||||
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0},
|
||||
],
|
||||
"auction_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"call_deadline": null,
|
||||
"turn_deadline": null,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_chiamata(&g.game_type));
|
||||
assert!(!is_briscola(&g.game_type));
|
||||
let auction = g.auction.unwrap();
|
||||
assert_eq!(2, auction.turn);
|
||||
assert_eq!(5, auction.active.len());
|
||||
let current = auction.current.unwrap();
|
||||
assert_eq!(1, current.seat);
|
||||
assert_eq!(10, current.rank);
|
||||
assert_eq!(61, current.points);
|
||||
assert_eq!(1, auction.history.len());
|
||||
let bids = g.legal_bids.unwrap();
|
||||
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
|
||||
assert!(bids.points_min.is_none());
|
||||
assert_eq!(Some(120), bids.points_max);
|
||||
assert_eq!(Some("".to_string()), g.players[0].team);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_playing_and_reveal() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c2",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "playing",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 3,
|
||||
"caller": 1,
|
||||
"called_card": "03C",
|
||||
"briscola_suit": "C",
|
||||
"declared_target": 64,
|
||||
"called_played": true,
|
||||
"partner": 3,
|
||||
"solo": false,
|
||||
"scores": {"A": 45, "B": 30},
|
||||
"players": [
|
||||
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
|
||||
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
|
||||
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
|
||||
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(Some(1), g.caller);
|
||||
assert_eq!(Some("03C".to_string()), g.called_card);
|
||||
assert_eq!("C", g.briscola_suit);
|
||||
assert_eq!(64, g.declared_target);
|
||||
assert!(g.called_played);
|
||||
assert_eq!(Some(3), g.partner);
|
||||
assert!(!g.solo);
|
||||
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
|
||||
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_sides_label_the_solo_caller() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c3",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "finished",
|
||||
"scores": {"A": 70, "B": 50},
|
||||
"winner": "Chiamante",
|
||||
"caller": 2,
|
||||
"called_played": true,
|
||||
"solo": true,
|
||||
"players": [
|
||||
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
|
||||
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
let (a, b) = g.chiamata_sides();
|
||||
assert_eq!("Carol (solo)", a);
|
||||
assert_eq!("Difensori", b);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
|
||||
let team = player.team.clone();
|
||||
view! { span(class="team-badge") { "Team " (team) } }
|
||||
});
|
||||
let team_badge = (!is_individual_game(&game.game_type))
|
||||
.then(|| player.team.clone())
|
||||
.flatten()
|
||||
.filter(|team| !team.is_empty())
|
||||
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
|
||||
let points = player.points;
|
||||
let captured = player.captured_count;
|
||||
let backs = (0..player.cards_left)
|
||||
|
||||
@@ -0,0 +1,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::toast::toast;
|
||||
use crate::model::{
|
||||
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
|
||||
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
|
||||
PlayerView, Scores, ServerMessage,
|
||||
};
|
||||
use crate::pages::briscola::briscola_table;
|
||||
use crate::pages::chiamata::{call_picker, chiamata_table};
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||
@@ -43,7 +44,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
if active { " active" } else { "" }
|
||||
);
|
||||
let name = player.name.clone();
|
||||
let team = player.team.clone();
|
||||
let team = player.team.clone().unwrap_or_default();
|
||||
let captured = player.captured_count;
|
||||
let scope = player.scope;
|
||||
let backs = (0..player.cards_left)
|
||||
@@ -324,6 +325,32 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
};
|
||||
|
||||
// Briscola chiamata: auction and call actions.
|
||||
let on_bid_rank = move |rank: i32| {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.bid_rank(rank);
|
||||
}
|
||||
};
|
||||
let on_bid_points = move |points: i32| {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.bid_points(points);
|
||||
}
|
||||
};
|
||||
let on_pass = move || {
|
||||
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.pass();
|
||||
}
|
||||
};
|
||||
|
||||
let reconnect_banner = reconnect.clone();
|
||||
view! {
|
||||
div(class="game-page") {
|
||||
@@ -392,6 +419,11 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
||||
// Briscola chiamata has its own five-seat table with the
|
||||
// calling auction and the secret-partner reveal.
|
||||
Some(g) if is_chiamata(&g.game_type) => {
|
||||
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
|
||||
}
|
||||
// Briscola has its own table view for both variants.
|
||||
Some(g) if is_briscola(&g.game_type) => {
|
||||
briscola_table(g, on_hand_card, now)
|
||||
@@ -406,6 +438,17 @@ pub fn GamePage(id: String) -> View {
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
// Briscola chiamata: the auction winner names the called card.
|
||||
(move || match game.get_clone() {
|
||||
Some(g)
|
||||
if is_chiamata(&g.game_type)
|
||||
&& g.phase == "calling"
|
||||
&& g.your_turn == Some(true) =>
|
||||
{
|
||||
call_picker(g, socket)
|
||||
}
|
||||
_ => view! {},
|
||||
})
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||
hand_summary_modal(g, socket, now)
|
||||
@@ -440,10 +483,11 @@ fn LobbyView(game: GameView) -> View {
|
||||
.map(|p| {
|
||||
let name = p.name.clone();
|
||||
let seat = p.seat;
|
||||
let team_badge = show_teams.then(|| {
|
||||
let team = p.team.clone();
|
||||
view! { span(class="badge text-bg-info") { "Team " (team) } }
|
||||
});
|
||||
let team_badge = show_teams
|
||||
.then(|| p.team.clone())
|
||||
.flatten()
|
||||
.filter(|team| !team.is_empty())
|
||||
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
|
||||
view! {
|
||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||
strong { (name) }
|
||||
@@ -734,8 +778,9 @@ fn capture_picker(
|
||||
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
|
||||
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
|
||||
if let Some(g) = &game {
|
||||
if g.game_type != "scopone_scientifico" && !briscola {
|
||||
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
|
||||
return view! {};
|
||||
}
|
||||
}
|
||||
@@ -749,18 +794,20 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
// Two-player briscola has no teams: name the winner instead.
|
||||
// Briscola chiamata names the caller's side (or the defenders).
|
||||
let winner_label = match game.as_ref() {
|
||||
Some(g) if is_individual_game(&g.game_type) => {
|
||||
g.player_name_for_team(&winner)
|
||||
}
|
||||
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
|
||||
_ => format!("Team {winner}"),
|
||||
};
|
||||
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
||||
// Spectators have no match history to look at.
|
||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||
// Explain the final hand's (scopone) or deal's (briscola)
|
||||
// scoring before the result.
|
||||
let final_summary: View = if briscola {
|
||||
// Explain the final hand's (scopone) or deal's (briscola,
|
||||
// briscola chiamata) scoring before the result.
|
||||
let final_summary: View = if briscola || chiamata {
|
||||
match game.as_ref() {
|
||||
Some(g) => match g.last_deal.clone() {
|
||||
Some(deal) => deal_rows(g, &deal),
|
||||
|
||||
+177
-49
@@ -12,34 +12,58 @@ use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||
|
||||
/// Distinct team labels in seat order; players without a team render in
|
||||
/// a shared "Players" column.
|
||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
||||
let mut teams = BTreeSet::new();
|
||||
/// The match's teams, each as a `(label, names)` pair, in a readable
|
||||
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
|
||||
/// then other team labels alphabetically (A, B, ...), with team-less
|
||||
/// players last.
|
||||
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
|
||||
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
|
||||
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();
|
||||
// Seated teams first (in first-seat order), then the team-less column.
|
||||
ordered.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
|
||||
ordered.sort_by_key(team_sort_key);
|
||||
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 {
|
||||
m.players
|
||||
let names = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| &p.team == team)
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
.collect::<Vec<_>>();
|
||||
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 {
|
||||
match team {
|
||||
Some(t) => format!("Team {t}"),
|
||||
// Single-letter labels are the A/B team names; longer labels
|
||||
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
|
||||
Some(t) if t.len() <= 2 => format!("Team {t}"),
|
||||
Some(t) => t.clone(),
|
||||
None => "Players".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -55,7 +79,11 @@ fn score_text(m: &MatchSummary) -> String {
|
||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||
fn winner_text(m: &MatchSummary) -> String {
|
||||
if let Some(team) = m.result_str("winner_team") {
|
||||
return format!("Team {team}");
|
||||
return if team.len() <= 2 {
|
||||
format!("Team {team}")
|
||||
} else {
|
||||
team
|
||||
};
|
||||
}
|
||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
@@ -98,18 +126,29 @@ pub fn HistoryPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::my_matches(next.as_deref()).await {
|
||||
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());
|
||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||
page.set(Some(p));
|
||||
}
|
||||
Err(e) => error.set(Some(e)),
|
||||
Err(e) => {
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
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 {
|
||||
match api::my_matches(None).await {
|
||||
Ok(p) => {
|
||||
if !page.is_alive() {
|
||||
return;
|
||||
}
|
||||
cursor.set(p.next_cursor.clone());
|
||||
rows.set(p.results.clone());
|
||||
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| {
|
||||
let finished = local_finished(&m.finished_at);
|
||||
let game = game_name(&names, &m.game_type);
|
||||
let mut cells: Vec<View> = team_columns(&m)
|
||||
.iter()
|
||||
.map(|t| team_names(&m, t))
|
||||
.map(|c| view! { td { (c) } })
|
||||
let teams: Vec<View> = team_groups(&m)
|
||||
.into_iter()
|
||||
.map(|(label, names)| {
|
||||
view! {
|
||||
div(class="match-team") {
|
||||
span(class="text-secondary") {
|
||||
(label) ": "
|
||||
}
|
||||
(names)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let score = score_text(&m);
|
||||
let winner = winner_text(&m);
|
||||
@@ -173,37 +227,18 @@ pub fn HistoryPage() -> View {
|
||||
} else {
|
||||
"text-secondary"
|
||||
};
|
||||
let mut row_cells = vec![
|
||||
let row_cells = vec![
|
||||
view! { td { (finished) } },
|
||||
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) } }
|
||||
})
|
||||
.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! {
|
||||
div(class="table-responsive") {
|
||||
table(class="table table-striped table-hover align-middle") {
|
||||
@@ -211,7 +246,7 @@ pub fn HistoryPage() -> View {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Game" }
|
||||
(header_cells)
|
||||
th { "Teams" }
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-69
@@ -13,21 +13,13 @@ use sycamore_router::navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::card::card_back;
|
||||
use crate::components::login::login_card;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{
|
||||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||
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 {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
|
||||
if me.is_some() {
|
||||
spawn_local(async move {
|
||||
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 {
|
||||
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) => {
|
||||
error.set(Some(e));
|
||||
user.set(Some(None));
|
||||
if error.is_alive() {
|
||||
error.set(Some(e));
|
||||
}
|
||||
if user.is_alive() {
|
||||
user.set(Some(None));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
|
||||
spawn_local(async move {
|
||||
match api::game_types().await {
|
||||
Ok(types) if !types.is_empty() => {
|
||||
if !game_types.is_alive() {
|
||||
return;
|
||||
}
|
||||
selected_game.set(types[0].id.clone());
|
||||
game_types.set(types);
|
||||
}
|
||||
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
|
||||
|
||||
spawn_local(async move {
|
||||
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! {
|
||||
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(None) => view! { (login_card(auth_providers)) },
|
||||
Some(Some(_me)) => {
|
||||
view! {
|
||||
(crate::components::nav::app_nav("/", move || {
|
||||
@@ -671,13 +634,21 @@ pub fn LobbyPage() -> View {
|
||||
div(class="mb-3") { (players_note) }
|
||||
}
|
||||
}))
|
||||
Keyed(
|
||||
list=fields,
|
||||
view=move |f| view! {
|
||||
OptionInput(field=f.clone(), options=options)
|
||||
},
|
||||
key=|f| f.key.clone(),
|
||||
)
|
||||
// Recreate the option inputs on every
|
||||
// game switch: keyed rendering kept
|
||||
// the previous game's field (value,
|
||||
// min/max) for shared keys such as
|
||||
// target_score.
|
||||
(move || {
|
||||
let _game = selected_game.get_clone();
|
||||
fields
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.map(|f| view! {
|
||||
OptionInput(field=f, options=options)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
button(
|
||||
class="btn btn-warning btn-lg w-100 mt-auto",
|
||||
on:click=on_create,
|
||||
|
||||
@@ -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 chiamata;
|
||||
pub mod game;
|
||||
pub mod history;
|
||||
pub mod leaderboard;
|
||||
pub mod lobby;
|
||||
pub mod login;
|
||||
|
||||
@@ -45,6 +45,26 @@ impl GameSocket {
|
||||
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: bid a rank in the calling auction.
|
||||
pub fn bid_rank(&self, rank: i32) {
|
||||
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: raise the points target (standing bid is the 2).
|
||||
pub fn bid_points(&self, points: i32) {
|
||||
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: leave the auction.
|
||||
pub fn pass(&self) {
|
||||
self.send_json(serde_json::json!({ "action": "pass" }));
|
||||
}
|
||||
|
||||
/// Briscola chiamata: name the called card.
|
||||
pub fn call_card(&self, card: &str) {
|
||||
self.send_json(serde_json::json!({ "action": "call", "card": card }));
|
||||
}
|
||||
|
||||
fn send_json(&self, value: serde_json::Value) {
|
||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||
}
|
||||
|
||||
+215
-3
@@ -292,8 +292,12 @@ body {
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -302,8 +306,11 @@ body {
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
min-width: min(320px, calc(100vw - 1.5rem));
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
/* Centers the dialog when it fits and keeps it reachable (top
|
||||
* 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 {
|
||||
@@ -667,3 +674,208 @@ body {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- briscola chiamata ---------- */
|
||||
|
||||
/* Five seats: two across the top and one on each side of the viewer. */
|
||||
.table-grid.cinque {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"tl . tr"
|
||||
"left center right"
|
||||
"bottom bottom bottom";
|
||||
}
|
||||
|
||||
.seat-tl {
|
||||
grid-area: tl;
|
||||
}
|
||||
|
||||
.seat-tr {
|
||||
grid-area: tr;
|
||||
}
|
||||
|
||||
.called-badge {
|
||||
background: var(--panel-light);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
background: var(--accent);
|
||||
color: var(--felt-dark);
|
||||
}
|
||||
|
||||
.reveal-banner {
|
||||
background: rgba(232, 197, 71, 0.15);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.patta-banner {
|
||||
background: rgba(217, 83, 79, 0.25);
|
||||
}
|
||||
|
||||
/* The calling auction: standing bid, trail and the bid buttons. */
|
||||
.auction-panel {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.auction-standing {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auction-history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auction-chip {
|
||||
background: var(--panel-light);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.bid-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bid-button {
|
||||
background: var(--panel-light);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bid-button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--felt-dark);
|
||||
}
|
||||
|
||||
.bid-button.points {
|
||||
border-color: var(--team-a);
|
||||
}
|
||||
|
||||
.pass-button {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pass-button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* The auction winner's call picker: 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 {
|
||||
min-height: 100vh;
|
||||
background-color: var(--felt-dark);
|
||||
background-image: radial-gradient(
|
||||
ellipse at 50% -10%,
|
||||
var(--felt) 0%,
|
||||
var(--felt-dark) 65%
|
||||
);
|
||||
/* Casino-table felt: a fine grain texture tiled over the green
|
||||
* gradient, lit from above and darkened towards the edges. The first
|
||||
* layer is the topmost one. */
|
||||
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;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user