diff --git a/README.md b/README.md index 1871a3a..5aaaef8 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # tavolo -A platform for multiplayer card games. Two games are included: +A platform for multiplayer card games. Three games are included: **scopone scientifico** — the four-player, fixed-partnership Italian card -game — and **briscola**, in its classic two-player and four-player -partnership (*briscola a quattro*) variants: +game — **briscola**, in its classic two-player and four-player +partnership (*briscola a quattro*) variants, and **briscola chiamata** — +the five-player variant with a calling auction and a secret partner: - **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya) framework, OIDC login, live games in Redis, match statistics in Postgres. @@ -45,7 +46,9 @@ pigliatutto when enabled), briscola players see the deal's points and any *patta*; both see the running totals and must click "Understood" before the next hand is dealt. If someone is away the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The -match-ending hand is explained on the final screen. +match-ending hand is explained on the final screen. Briscola chiamata +plays a single deal per match, so it goes straight from the last trick to +the final scoring screen; a 60–60 *patta* is replayed immediately. ## On your turn @@ -53,12 +56,17 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a player does not move — disconnected or fallen asleep — the server plays a random legal card for them (choosing among the legal captures when the rules require a capture), so one absent player cannot stall the table. +Briscola chiamata arms the same countdown for its auction bids and for +the auction winner's called-card choice (auto-bid or auto-pass, then a +random suit of the won rank). ## Watching Anyone who has the game URL (`/game/`) 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. diff --git a/server/README.md b/server/README.md index 79dc994..09d2c08 100644 --- a/server/README.md +++ b/server/README.md @@ -16,9 +16,10 @@ and scientifico, the four-player, fixed-partnership variant of the classic Italian card game: pure rules plus the platform adapter. - **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the - Italian trick-taking game, in its classic two-player (`briscola`) and - four-player partnership (`briscola_a_quattro`) variants: pure rules - plus the platform adapter. + Italian trick-taking game, in its classic two-player (`briscola`), + four-player partnership (`briscola_a_quattro`) and five-player + called-partner (`briscola_chiamata`) variants: pure rules plus the + platform adapter. - **`tavolo-app`** (`packages/tavolo-app/`) — the composition root wiring the platform to the games, with the environment-driven settings and the SPA shell. @@ -236,7 +237,9 @@ aggregate with `max`, not `sum`. as reported by the engine (`result` JSON — for scopone: both teams' final scores, winner, target score, hands played and the per-hand audit; for briscola: the final scores, winner, target score, deals - played and the per-deal audit). + played and the per-deal audit; for briscola chiamata: the calling side's + points against the defenders', the declared target, the called card and + whether the caller played alone). - `match_player` — one row per participant: the OIDC `sub`, display name, seat, game-defined team label (nullable), whether they won, the points they scored, the Elo change the match produced (`elo_delta`) and @@ -275,7 +278,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | Method | Path | Description | |---|---|---| | `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` | -| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | +| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back | | `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown | | `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code | @@ -341,6 +344,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are every player has acknowledged, or automatically after `HAND_ACK_TIMEOUT_SECONDS`. +For briscola chiamata the game actions are the calling auction plus play: + +```json +{"action": "bid", "rank": 3} +{"action": "bid", "points": 64} +{"action": "pass"} +{"action": "call", "card": "03D"} +{"action": "play", "card": "04C"} +``` + +- `bid` with a `rank` calls a lower rank than the standing bid + (`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the + target once the standing bid is the two. +- `pass` leaves the auction; the last player standing must bid. +- `call` names any card of the won rank; naming one of your own cards + means playing alone. +- `play` follows briscola: any card in hand is legal. + After every accepted move the new state is broadcast to every connected client, spectators included. @@ -353,7 +374,10 @@ is required), so a disconnected or idle player cannot stall the match. The timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is broadcast like any other. Deadlines fire from the shared `tavolo:deadlines` queue (see above), not from timers tied to client connections, so the -match keeps progressing even with every player disconnected. +match keeps progressing even with every player disconnected. Briscola +chiamata uses the same timeout for its auction bids (auto-bid or +auto-pass) and for the called-card choice (a random suit of the won +rank). ### Hand-end summary @@ -416,6 +440,30 @@ player must dismiss. A play attempted in this phase is rejected with an four players in two fixed partnerships (seats 0/2 vs 1/3, partners opposite). +### Briscola chiamata + +- 40-card Italian deck, eight cards each for five players: the whole deck + is dealt, so there is no stock and no face-up card. +- The calling auction starts with the player after the dealer: each + player either passes (leaving the auction) or bids a lower rank than + the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the + standing bid is the two, the auction continues on points (62, 64, …). + The last player standing must bid, so every deal gets a caller. +- The winner names any card of the won rank. If the winner holds it, the + caller plays alone against the other four; otherwise its holder is the + secret partner and the two form the calling side. The called card's + suit is the trump; the partner's identity stays hidden until the + called card is played. +- Play and trick resolution follow briscola; eight tricks exhaust every + hand. The calling side must reach the declared target (61 unless + raised); with the plain target a 60–60 deal is a *patta* and is + replayed with the next dealer. A match is a single deal, so the caller + and partner (or the lone caller) against the other players map onto + the platform's two-team result, Elo and history. +- Auction bids, the called-card choice and the plays all have their own + `TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid, + auto-called or auto-played for). + ## Development ```sh @@ -493,8 +541,9 @@ packages/tavolo-briscola/ # briscola game (own pyproject + tests) src/tavolo/briscola/ ├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization ├── engine.py # pure briscola rules (2- and 4-player) + ├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner) ├── errors.py # briscola-specific rule violations - └── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters + └── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine packages/tavolo-app/ # deployable app: composition root (own pyproject) src/tavolo/ ├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins diff --git a/server/packages/tavolo-app/src/tavolo/app.py b/server/packages/tavolo-app/src/tavolo/app.py index 29fe799..698b666 100644 --- a/server/packages/tavolo-app/src/tavolo/app.py +++ b/server/packages/tavolo-app/src/tavolo/app.py @@ -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", diff --git a/server/packages/tavolo-briscola/README.md b/server/packages/tavolo-briscola/README.md index 9845366..d87dfd2 100644 --- a/server/packages/tavolo-briscola/README.md +++ b/server/packages/tavolo-briscola/README.md @@ -2,10 +2,12 @@ Briscola — the Italian trick-taking game — as a [`tavolo-platform`](../tavolo-platform/README.md) game implementation. -Two variants share one rules core: +Three variants share one rules core: - `briscola` — the classic two-player game; -- `briscola_a_quattro` — four-player fixed-partnership briscola. +- `briscola_a_quattro` — four-player fixed-partnership briscola; +- `briscola_chiamata` — five-player briscola with a calling auction and a + secret partner. ## Contents @@ -13,27 +15,40 @@ Two variants share one rules core: stock, face-up briscola, tricks, scores, deadlines), `PlayerState`, `Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session envelope, no transport, no I/O. -- `engine.py` — the pure rules engine: deck, deals, trick resolution - (trump first, then the led suit; strength ace > three > king > horse > - jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the - face-up briscola last), card points (ace 11, three 10, king 4, horse 3, - jack 2), patta re-deals and the target-score match end, deal-end - acknowledgements and auto-play. Deterministic and I/O-free apart from - logging, so the whole rule set is unit-testable. +- `engine.py` — the pure rules engine for two- and four-player briscola: + deck, deals, trick resolution (trump first, then the led suit; strength + ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order + (trick winner first, the face-up briscola last), card points (ace 11, + three 10, king 4, horse 3, jack 2), patta re-deals and the + target-score match end, deal-end acknowledgements and auto-play. + Deterministic and I/O-free apart from logging, so the whole rule set is + unit-testable. +- `chiamata.py` — the pure rules engine for five-player briscola + chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON + round-trip), the 8-card deal with no stock, the descending calling + auction with point raises, the winner's call (including calling one's + own card, which means playing alone), the called-card reveal, single- + deal scoring with the 60–60 patta and the auto-actions for timeouts. - `errors.py` — briscola-specific errors (`CardNotInHand`); every other failure mode is a shared `tavolo.platform.errors` subclass. -- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine` - (`GameEngine`): the platform-facing adapters. They validate the - `target_score` creation option (61–120, default 61) and extract the - `MatchResult` (teams, winner, per-player scores and the match summary - persisted as the match's JSON `result`). +- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and + `BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters. + The first two validate the `target_score` creation option (61–120, + default 61) and extract the `MatchResult` (teams, winner, per-player + scores and the match summary persisted as the match's JSON `result`). + Briscola chiamata takes no creation options. Timeouts are constructor arguments (`turn_timeout_seconds`, `hand_ack_timeout_seconds`), wired from the environment by the -application composition root. +application composition root. Briscola chiamata uses the turn timeout +for its auction bids, the called-card choice and the plays; it never +enters the between-deals acknowledgement phase (a single deal ends the +match), so the hand-ack timeout is accepted but unused. ## Rules +### Briscola (two-player and four-player) + - Three cards per player; the next card is turned face up: its suit is the trump (briscola) for the deal and it is the last card drawn. - The winner of a trick is the highest trump, or the highest card of the @@ -47,6 +62,31 @@ application composition root. - Between deals of an unfinished match every player acknowledges the summary (or the hand-end timeout deals on), exactly like scopone. +### Briscola chiamata (five-player) + +- The whole 40-card deck is dealt out, eight cards each: there is no + stock and no face-up card. +- Starting with the player after the dealer, each player either passes + (leaving the auction for good) or bids a **lower** rank than the + standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*; + the first bidder may bid any rank. Once the standing bid is the two, + the auction continues on points instead (62, 64, …): the calling side + then has to reach the raised target. The last player standing must + bid, so a deal always gets a caller. +- The auction winner names any card of the winning rank. If the winner + holds it, they **play alone** against the other four; otherwise its + holder is the **secret partner** and the two form the calling side. + The called card's suit is the trump for the deal. The partner's + identity is hidden until the called card is played. +- Play follows briscola: the player after the dealer leads, the highest + trump (or highest led-suit card) takes the trick and leads the next + one; eight tricks exhaust every hand. +- The calling side (caller plus partner, or the lone caller) must reach + the declared target — 61 unless the auction raised it. With the plain + target a 60–60 deal is a patta and is replayed with the next dealer. + A match is a single deal, so the two sides map onto the platform's + two-team result (Elo rating, match history). + ## Development (from `server/`) ```sh @@ -54,5 +94,5 @@ application composition root. .venv/bin/python -m mypy -p tavolo.briscola ``` -`test_engine.py` covers the pure rules; `test_plugin.py` covers the -platform contract (actions, deadlines, serialization, results). +`test_engine.py`/`test_plugin.py` cover the two classic variants; +`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata. diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py b/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py index 0dfad05..de7ec46 100644 --- a/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py @@ -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", +] diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py b/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py new file mode 100644 index 0000000..d99c914 --- /dev/null +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/chiamata.py @@ -0,0 +1,954 @@ +"""Pure rules engine for briscola chiamata. + +Briscola chiamata is the five-player variant of briscola. The 40-card +deck is dealt out entirely (8 cards each, no stock): there is no face-up +briscola card. Instead the players auction off the right to call a +partner: + +* starting with the player after the dealer, each player either passes + (leaving the auction for good) or bids a rank lower than the standing + bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first + bidder may bid any rank. Once the standing bid is the 2, further bids + raise the points the calling side must reach instead (62, 64, ...). +* the last remaining bidder wins and names the exact card of the winning + rank (any suit). If the winner holds the named card they play alone + against the other four; otherwise its holder is the **secret partner** + and the two form the calling side. The called card's suit is the trump + (*briscola*) for the whole deal. +* play and scoring follow briscola. The calling side must reach the + declared target (61 unless raised); a 60-60 deal with the plain target + is a **patta** and is replayed. + +A match is a single deal (patta replays aside), so the two sides map +onto the platform's two-team result (Elo rating, match history). The +partner's identity stays hidden until the called card is played, at +which point the roles are revealed. + +Like :mod:`tavolo.briscola.engine` this module is deterministic and +I/O-free apart from logging: it mutates (or reads) +:class:`~tavolo.briscola.chiamata.ChiamataState` and raises +:class:`~tavolo.platform.errors.GameError` subclasses on rule +violations. The platform-facing adapter is +:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`. +""" +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from logging import getLogger +from typing import Any, Dict, List, Optional, Tuple + +from tavolo.platform.errors import ( + AlreadyJoined, + GameFinished, + GameNotStarted, + IllegalMove, + LobbyFull, + NotYourTurn, +) + +from .engine import TRICK_STRENGTH, card_points, shuffled_deck +from .errors import CardNotInHand +from .state import ( + DEFAULT_TURN_TIMEOUT_SECONDS, + PHASE_FINISHED, + PHASE_LOBBY, + PHASE_PLAYING, + SUITS, + Card, + Trick, + TrickCard, + parse_card, +) + +log = getLogger(__name__) + +# Five seats, the whole 40-card deck dealt out. +PLAYER_COUNT = 5 +HAND_SIZE = 8 + +PHASE_AUCTION = "auction" +PHASE_CALLING = "calling" + +# The two sides of the match: the caller (plus the partner, when there is +# one) against the other players. +SIDE_CALLER = 0 +SIDE_DEFENDERS = 1 +SIDE_NAMES = ("Chiamante", "Difensori") + +# Role labels carried by the personalized view once they are public. +ROLE_CALLER = "Chiamante" +ROLE_PARTNER = "Socio" +ROLE_DEFENDER = "Difensori" + +# Points the calling side must reach: 61 unless the auction raised it. +DEFAULT_TARGET = 61 +MIN_TARGET = 61 +MAX_TARGET = 120 + +# Rank ladder of the auction, strongest first: ace, three, king, horse, +# jack, then the plain ranks downwards. +BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2) +_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)} + +_rng = random.SystemRandom() + + +# -- state ----------------------------------------------------------------- + + +@dataclass +class ChiamataPlayer: + sub: str + name: str + seat: int + hand: List[Card] = field(default_factory=list) + captured: List[Card] = field(default_factory=list) + + def to_json(self) -> Dict[str, Any]: + return { + "sub": self.sub, + "name": self.name, + "seat": self.seat, + "hand": [c.to_json() for c in self.hand], + "captured": [c.to_json() for c in self.captured], + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "ChiamataPlayer": + return ChiamataPlayer( + sub=str(data["sub"]), + name=str(data["name"]), + seat=int(data["seat"]), + hand=[Card.from_json(c) for c in data.get("hand", [])], + captured=[Card.from_json(c) for c in data.get("captured", [])], + ) + + +@dataclass +class Bid: + """One auction bid: a rank, or (at the 2) a raised points target.""" + + seat: int + rank: int + points: int + + def to_json(self) -> Dict[str, Any]: + return {"seat": self.seat, "rank": self.rank, "points": self.points} + + @staticmethod + def from_json(data: Dict[str, Any]) -> "Bid": + return Bid( + seat=int(data["seat"]), + rank=int(data["rank"]), + points=int(data["points"]), + ) + + +@dataclass +class Auction: + """The state of the calling auction.""" + + # Seat to act; always one of ``active``. + turn: int = 0 + active: List[int] = field(default_factory=list) + passed: List[int] = field(default_factory=list) + current: Optional[Bid] = None + history: List[Bid] = field(default_factory=list) + + def to_json(self) -> Dict[str, Any]: + return { + "turn": self.turn, + "active": list(self.active), + "passed": list(self.passed), + "current": self.current.to_json() if self.current else None, + "history": [b.to_json() for b in self.history], + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "Auction": + return Auction( + turn=int(data.get("turn", 0)), + active=[int(s) for s in data.get("active", [])], + passed=[int(s) for s in data.get("passed", [])], + current=( + Bid.from_json(data["current"]) if data.get("current") else None + ), + history=[Bid.from_json(b) for b in data.get("history", [])], + ) + + +@dataclass +class ChiamataState: + phase: str = PHASE_LOBBY + players: List[ChiamataPlayer] = field(default_factory=list) + dealer: int = 0 + # 1-based deal counter; grows with patta replays. + hand_number: int = 1 + # 0 = the calling side (caller plus partner, when there is one), + # 1 = the defenders. Only set once the match is finished. + winner: Optional[int] = None + scores: List[int] = field(default_factory=lambda: [0, 0]) + # The calling auction of the current deal. + auction: Optional[Auction] = None + # Seat that won the auction (public from then on). + caller: Optional[int] = None + # The card named by the caller; its suit is the trump. + called_card: Optional[Card] = None + briscola_suit: str = "" + # Points target from the auction (61 unless raised). + declared_target: int = DEFAULT_TARGET + # True when the caller named a card of their own hand: no partner. + solo: bool = False + # True once the called card has been played; roles become public. + called_played: bool = False + # The partner's seat, recorded when the called card is played; stays + # ``None`` for a solo call. + partner_seat: Optional[int] = None + # Cards of the trick in progress, in play order. + trick: List[TrickCard] = field(default_factory=list) + trick_leader: int = 0 + last_trick: Optional[Trick] = None + # Seat currently to act (auction bidder, caller, or player on turn). + turn: int = 0 + # Per-deal audit trail, patta entries included. + hand_scores: List[Dict[str, Any]] = field(default_factory=list) + # While phase == "auction"/"playing": when the server acts for the + # player on turn. + turn_deadline: Optional[str] = None + auction_deadline: Optional[str] = None + # While phase == "calling": when the server names a card for the + # auction winner. + call_deadline: Optional[str] = None + # Seconds a player has to act before the server acts for them. + turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS + + # -- serialization ---------------------------------------------------- + + def to_json(self) -> Dict[str, Any]: + return { + "phase": self.phase, + "players": [p.to_json() for p in self.players], + "dealer": self.dealer, + "hand_number": self.hand_number, + "winner": self.winner, + "scores": list(self.scores), + "auction": self.auction.to_json() if self.auction else None, + "caller": self.caller, + "called_card": ( + self.called_card.to_json() if self.called_card else None + ), + "briscola_suit": self.briscola_suit, + "declared_target": self.declared_target, + "solo": self.solo, + "called_played": self.called_played, + "partner_seat": self.partner_seat, + "trick": [t.to_json() for t in self.trick], + "trick_leader": self.trick_leader, + "last_trick": self.last_trick.to_json() if self.last_trick else None, + "turn": self.turn, + "hand_scores": list(self.hand_scores), + "turn_deadline": self.turn_deadline, + "auction_deadline": self.auction_deadline, + "call_deadline": self.call_deadline, + "turn_timeout": self.turn_timeout, + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "ChiamataState": + return ChiamataState( + phase=str(data.get("phase", PHASE_LOBBY)), + players=[ + ChiamataPlayer.from_json(p) for p in data.get("players", []) + ], + dealer=int(data.get("dealer", 0)), + hand_number=int(data.get("hand_number", 1)), + winner=data.get("winner"), + scores=[int(x) for x in data.get("scores", [0, 0])], + auction=( + Auction.from_json(data["auction"]) if data.get("auction") else None + ), + caller=data.get("caller"), + called_card=( + Card.from_json(data["called_card"]) + if data.get("called_card") + else None + ), + briscola_suit=str(data.get("briscola_suit", "")), + declared_target=int(data.get("declared_target", DEFAULT_TARGET)), + solo=bool(data.get("solo", False)), + called_played=bool(data.get("called_played", False)), + partner_seat=data.get("partner_seat"), + trick=[TrickCard.from_json(t) for t in data.get("trick", [])], + trick_leader=int(data.get("trick_leader", 0)), + last_trick=( + Trick.from_json(data["last_trick"]) if data.get("last_trick") else None + ), + turn=int(data.get("turn", 0)), + hand_scores=list(data.get("hand_scores", [])), + turn_deadline=data.get("turn_deadline"), + auction_deadline=data.get("auction_deadline"), + call_deadline=data.get("call_deadline"), + turn_timeout=int( + data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS) + ), + ) + + # -- helpers ---------------------------------------------------------- + + def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]: + for player in self.players: + if player.sub == sub: + return player + return None + + def seated(self, sub: str) -> bool: + return self.player_for(sub) is not None + + +# -- game setup -------------------------------------------------------------- + + +def create_game( + creator_sub: str, + creator_name: str, + turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, +) -> ChiamataState: + """Create a lobby game with the creator seated first.""" + return ChiamataState( + phase=PHASE_LOBBY, + players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)], + turn_timeout=turn_timeout, + ) + + +def join_game(state: ChiamataState, sub: str, name: str) -> None: + """Seat ``sub`` in the next free chair, starting the match when full.""" + if state.phase != PHASE_LOBBY: + raise GameNotStarted("game has already started") + if state.seated(sub): + raise AlreadyJoined("already joined this game") + if len(state.players) >= PLAYER_COUNT: + raise LobbyFull("game is full") + seat = len(state.players) + state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat)) + if len(state.players) == PLAYER_COUNT: + start_game(state) + + +def start_game(state: ChiamataState) -> None: + """Deal the first hand and open the auction.""" + if len(state.players) != PLAYER_COUNT: + raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start") + _deal(state) + + +def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer: + for player in state.players: + if player.seat == seat: + return player + raise IllegalMove(f"no player in seat {seat}") + + +def _set_deadline(seconds: int) -> str: + deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds) + return deadline.isoformat() + + +def _set_auction_deadline(state: ChiamataState) -> None: + state.auction_deadline = _set_deadline(state.turn_timeout) + + +def _set_call_deadline(state: ChiamataState) -> None: + state.call_deadline = _set_deadline(state.turn_timeout) + + +def _set_turn_deadline(state: ChiamataState) -> None: + state.turn_deadline = _set_deadline(state.turn_timeout) + + +def _deal(state: ChiamataState) -> None: + """Deal 8 cards each and open the auction.""" + deck = shuffled_deck() + for player in state.players: + player.hand = [] + player.captured = [] + state.trick = [] + state.last_trick = None + state.turn_deadline = None + state.call_deadline = None + state.called_card = None + state.briscola_suit = "" + state.declared_target = DEFAULT_TARGET + state.solo = False + state.called_played = False + state.partner_seat = None + state.caller = None + state.winner = None + state.scores = [0, 0] + # The whole deck is dealt, one card at a time, starting with the + # player after the dealer. + for _ in range(HAND_SIZE): + for offset in range(PLAYER_COUNT): + _player_at( + state, (state.dealer + 1 + offset) % PLAYER_COUNT + ).hand.append(deck.pop()) + first = (state.dealer + 1) % PLAYER_COUNT + state.turn = first + state.trick_leader = first + state.auction = Auction( + turn=first, + active=[player.seat for player in state.players], + ) + state.phase = PHASE_AUCTION + _set_auction_deadline(state) + log.debug( + "deal %d dealt (dealer seat %d), auction opens with seat %d", + state.hand_number, + state.dealer, + first, + ) + + +# -- auction ----------------------------------------------------------------- + + +def _require_auction( + state: ChiamataState, sub: str +) -> Tuple[Auction, ChiamataPlayer]: + if state.phase == PHASE_FINISHED: + raise GameFinished("the match is over") + if state.phase != PHASE_AUCTION: + raise GameNotStarted("the auction is not open") + player = state.player_for(sub) + if player is None: + raise NotYourTurn("you are not seated in this game") + auction = state.auction + if auction is None: # pragma: no cover - auction always exists here + raise GameNotStarted("the auction is not open") + if auction.turn != player.seat: + raise NotYourTurn("it is not your turn to bid") + return auction, player + + +def legal_bid_ranks(state: ChiamataState) -> List[int]: + """The ranks the player on turn may bid, empty at the point stage.""" + auction = state.auction + if state.phase != PHASE_AUCTION or auction is None: + return [] + current = auction.current + if current is None: + return list(BID_LADDER) + if current.rank == 2: + return [] + index = _BID_INDEX[current.rank] + return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index] + + +def legal_bid_points(state: ChiamataState) -> Optional[int]: + """The lowest legal points raise, ``None`` when there is none. + + Points may only be raised once the standing bid is the 2; any value + higher than the standing bid (up to :data:`MAX_TARGET`) is accepted. + """ + auction = state.auction + if state.phase != PHASE_AUCTION or auction is None: + return None + current = auction.current + if current is None or current.rank != 2: + return None + if current.points >= MAX_TARGET: + return None + return current.points + 2 + + +def _finish_auction(state: ChiamataState) -> None: + """The last active bidder wins and must name the called card.""" + auction = state.auction + if auction is None: # pragma: no cover - guarded by callers + raise GameNotStarted("the auction is not open") + if len(auction.active) != 1 or auction.current is None: # pragma: no cover + raise IllegalMove("the auction cannot be concluded yet") + winner = auction.active[0] + state.caller = winner + state.declared_target = auction.current.points + state.turn = winner + state.phase = PHASE_CALLING + state.auction_deadline = None + _set_call_deadline(state) + log.debug( + "deal %d: seat %d wins the auction (rank %d, target %d)", + state.hand_number, + winner, + auction.current.rank, + auction.current.points, + ) + + +def _advance_auction(state: ChiamataState) -> None: + auction = state.auction + if auction is None: # pragma: no cover - guarded by callers + return + for offset in range(1, PLAYER_COUNT + 1): + seat = (auction.turn + offset) % PLAYER_COUNT + if seat in auction.active: + auction.turn = seat + break + state.turn = auction.turn + _set_auction_deadline(state) + + +def _apply_bid(state: ChiamataState, bid: Bid) -> None: + auction = state.auction + if auction is None: # pragma: no cover - guarded by callers + raise GameNotStarted("the auction is not open") + auction.current = bid + auction.history.append(bid) + log.debug( + "deal %d: seat %d bids rank %d at %d", + state.hand_number, + bid.seat, + bid.rank, + bid.points, + ) + if len(auction.active) == 1: + _finish_auction(state) + return + _advance_auction(state) + + +def bid_rank(state: ChiamataState, sub: str, rank: int) -> None: + """Bid ``rank``; it must be weaker than the standing bid.""" + auction, player = _require_auction(state, sub) + if rank not in _BID_INDEX: + raise IllegalMove(f"invalid rank: {rank!r}") + current = auction.current + if current is not None: + if current.rank == 2: + raise IllegalMove("the auction is at the 2: raise the points or pass") + if _BID_INDEX[rank] <= _BID_INDEX[current.rank]: + raise IllegalMove("a bid must call a lower rank than the standing bid") + _apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET)) + + +def bid_points(state: ChiamataState, sub: str, points: int) -> None: + """Raise the points target; only legal once the standing bid is the 2.""" + auction, player = _require_auction(state, sub) + current = auction.current + if current is None or current.rank != 2: + raise IllegalMove("points can only be raised after a bid of the 2") + if points <= current.points: + raise IllegalMove( + f"the points must be higher than the standing bid ({current.points})" + ) + if points > MAX_TARGET: + raise IllegalMove(f"the points must be at most {MAX_TARGET}") + _apply_bid(state, Bid(seat=player.seat, rank=2, points=points)) + + +def pass_auction(state: ChiamataState, sub: str) -> None: + """Leave the auction for good.""" + auction, player = _require_auction(state, sub) + if player.seat not in auction.active: # pragma: no cover - turn implies active + raise IllegalMove("you already passed") + if len(auction.active) == 1 and auction.current is None: + # Everyone else passed before any bid: the last player must bid. + raise IllegalMove("you must bid: everyone else has passed") + auction.active.remove(player.seat) + auction.passed.append(player.seat) + log.debug("deal %d: seat %d passes", state.hand_number, player.seat) + if len(auction.active) == 1 and auction.current is not None: + _finish_auction(state) + return + _advance_auction(state) + + +# -- calling ------------------------------------------------------------------- + + +def call(state: ChiamataState, sub: str, card_code: str) -> None: + """The auction winner names the called card (any suit of the bid rank). + + If the caller holds the named card they play alone; the card's suit + becomes the trump for the deal. + """ + if state.phase == PHASE_FINISHED: + raise GameFinished("the match is over") + if state.phase != PHASE_CALLING: + raise GameNotStarted("no call is waiting") + caller = None if state.caller is None else _player_at(state, state.caller) + player = state.player_for(sub) + if player is None or caller is None or player.seat != caller.seat: + raise NotYourTurn("only the auction winner calls the card") + auction = state.auction + if auction is None or auction.current is None: # pragma: no cover + raise GameNotStarted("no call is waiting") + card = parse_card(card_code) + if card.rank != auction.current.rank: + raise IllegalMove( + "the called card must match the rank won in the auction" + ) + state.called_card = card + state.briscola_suit = card.suit + state.solo = card in caller.hand + state.phase = PHASE_PLAYING + # The player after the dealer opens the play, whichever seat won the + # auction. + first = (state.dealer + 1) % PLAYER_COUNT + state.turn = first + state.trick_leader = first + state.call_deadline = None + _set_turn_deadline(state) + log.debug( + "deal %d: seat %d calls %s%s", + state.hand_number, + caller.seat, + card.code, + " (playing alone)" if state.solo else "", + ) + + +# -- play ---------------------------------------------------------------------- + + +def play(state: ChiamataState, sub: str, card_code: str) -> None: + """Apply one move by the player identified by ``sub``. + + Any card in hand is a legal play; only turn order and phase are + validated. Raises a :class:`~tavolo.platform.errors.GameError` + subclass on any violation. + """ + if state.phase == PHASE_FINISHED: + raise GameFinished("the match is over") + if state.phase == PHASE_AUCTION: + raise IllegalMove("the auction is still open") + if state.phase == PHASE_CALLING: + raise IllegalMove("the called card has not been named yet") + if state.phase != PHASE_PLAYING: + raise GameNotStarted("the game has not started yet") + + player = state.player_for(sub) + if player is None or player.seat != state.turn: + raise NotYourTurn("it is not your turn") + + card = parse_card(card_code) + if card not in player.hand: + raise CardNotInHand(f"card {card.code} is not in your hand") + + player.hand.remove(card) + if state.called_card is not None and card == state.called_card: + state.called_played = True + state.partner_seat = ( + None if player.seat == state.caller else player.seat + ) + log.info( + "deal %d: the called card %s was played by seat %d (%s)", + state.hand_number, + card.code, + player.seat, + "the caller plays alone" if state.partner_seat is None else "partner revealed", + ) + state.trick.append(TrickCard(seat=player.seat, card=card.code)) + log.debug( + "deal %d: seat %d played %s (%d/%d in the trick)", + state.hand_number, + player.seat, + card.code, + len(state.trick), + PLAYER_COUNT, + ) + + if len(state.trick) == PLAYER_COUNT: + _resolve_trick(state) + else: + state.turn = (state.turn + 1) % PLAYER_COUNT + _set_turn_deadline(state) + + +def _resolve_trick(state: ChiamataState) -> None: + """Score the completed trick, draw no cards (deck already exhausted).""" + winner = _trick_winner(state) + taken = [parse_card(t.card) for t in state.trick] + _player_at(state, winner).captured.extend(taken) + state.last_trick = Trick(winner=winner, cards=list(state.trick)) + state.trick = [] + state.trick_leader = winner + state.turn = winner + log.debug( + "deal %d: seat %d takes the trick with %s", + state.hand_number, + winner, + [t.card for t in state.last_trick.cards], + ) + + if all(not player.hand for player in state.players): + _end_deal(state) + return + _set_turn_deadline(state) + + +def _trick_winner(state: ChiamataState) -> int: + """The seat winning the current (complete) trick.""" + played = [(t.seat, parse_card(t.card)) for t in state.trick] + leader_suit = played[0][1].suit + trumps = [ + (seat, card) + for seat, card in played + if card.suit == state.briscola_suit + ] + pool = trumps or [ + (seat, card) for seat, card in played if card.suit == leader_suit + ] + return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0] + + +# -- scoring ------------------------------------------------------------------- + + +def side_points(state: ChiamataState) -> Tuple[int, int]: + """The points captured by the calling side and the defenders. + + The partner's pile only joins the caller's once the partner is known + (the called card has been played); until then this is only used to + score a finished deal, where the reveal has always happened. + """ + if state.caller is None: + return (0, 0) + caller_seats = {state.caller} + if state.partner_seat is not None: + caller_seats.add(state.partner_seat) + caller_points = 0 + defender_points = 0 + for player in state.players: + points = sum(card_points(card) for card in player.captured) + if player.seat in caller_seats: + caller_points += points + else: + defender_points += points + return (caller_points, defender_points) + + +def _end_deal(state: ChiamataState) -> None: + """Score the deal: finish the match, or replay after a patta. + + A 60-60 deal with the plain 61 target is a patta: its points do not + count, the dealer rotates and the deal is replayed (a new auction). + Otherwise the calling side wins by reaching the declared target. + """ + state.turn_deadline = None + caller_points, defender_points = side_points(state) + patta = ( + state.declared_target == DEFAULT_TARGET + and caller_points == 60 + and defender_points == 60 + ) + caller_name = ( + _player_at(state, state.caller).name if state.caller is not None else None + ) + caller_seats = {state.caller} + if state.partner_seat is not None: + caller_seats.add(state.partner_seat) + caller_cards = sum( + len(player.captured) + for player in state.players + if player.seat in caller_seats + ) + entry: Dict[str, Any] = { + "deal": state.hand_number, + "patta": patta, + "points": {"A": caller_points, "B": defender_points}, + "cards": {"A": caller_cards, "B": 40 - caller_cards}, + "declared_target": state.declared_target, + "called_card": ( + state.called_card.code if state.called_card is not None else None + ), + "caller": caller_name, + "solo": state.solo, + } + state.hand_scores.append(entry) + log.debug( + "deal %d scored caller %d - defenders %d%s (target %d)", + state.hand_number, + caller_points, + defender_points, + " patta" if patta else "", + state.declared_target, + ) + if patta: + state.hand_number += 1 + state.dealer = (state.dealer + 1) % PLAYER_COUNT + _deal(state) + return + + state.scores = [caller_points, defender_points] + state.winner = ( + SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS + ) + state.phase = PHASE_FINISHED + log.info( + "match ended, %s win %d-%d", + SIDE_NAMES[state.winner], + caller_points, + defender_points, + ) + + +# -- server-side auto actions --------------------------------------------------- + + +def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None: + """Act for the bidder on turn: bid a random legal rank, else pass. + + The only player who cannot afford to pass — the last one standing + before any bid — always has every rank available, so a bid is always + possible there. + """ + if state.phase != PHASE_AUCTION: + raise GameNotStarted("the auction is not open") + auction = state.auction + if auction is None: # pragma: no cover - guarded by phase + raise GameNotStarted("the auction is not open") + player = _player_at(state, auction.turn) + ranks = legal_bid_ranks(state) + if ranks: + chooser = rng or _rng + bid_rank(state, player.sub, chooser.choice(ranks)) + return + pass_auction(state, player.sub) + + +def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None: + """Name a random suit of the auction rank for the winner.""" + if state.phase != PHASE_CALLING: + raise GameNotStarted("no call is waiting") + auction = state.auction + if auction is None or auction.current is None: # pragma: no cover + raise GameNotStarted("no call is waiting") + caller = _player_at(state, state.caller) if state.caller is not None else None + if caller is None: # pragma: no cover - guarded by phase + raise GameNotStarted("no call is waiting") + chooser = rng or _rng + card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS)) + call(state, caller.sub, card.code) + + +def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None: + """Play a random card for the player currently on turn.""" + if state.phase != PHASE_PLAYING: + raise GameNotStarted("the game has not started yet") + player = _player_at(state, state.turn) + if not player.hand: + raise IllegalMove("the player on turn has no cards") + chooser = rng or _rng + card = chooser.choice(player.hand) + play(state, player.sub, card.code) + + +# -- views --------------------------------------------------------------------- + + +def _roles_public(state: ChiamataState) -> bool: + return state.called_played + + +def state_for_player( + state: ChiamataState, sub: Optional[str] +) -> Dict[str, object]: + """Serialize ``state`` hiding other players' hands and secret roles. + + Hands are reduced to a count, except for the requesting player's own + hand. The partner's role only appears once the called card has been + played; before that only the caller is labeled. The calling side's + running total is withheld until then too: it would let onlookers + deduce the partner's identity from who captured the points. + """ + viewer = state.player_for(sub) + revealed = _roles_public(state) + players: List[Dict[str, object]] = [] + for player in state.players: + role = "" + if state.caller is not None and player.seat == state.caller: + role = ROLE_CALLER + if revealed: + if state.partner_seat is not None and player.seat == state.partner_seat: + role = ROLE_PARTNER + elif player.seat != state.caller: + role = ROLE_DEFENDER + view: Dict[str, object] = { + "sub": player.sub, + "name": player.name, + "seat": player.seat, + "team": role, + "cards_left": len(player.hand), + "captured_count": len(player.captured), + "points": sum(card_points(card) for card in player.captured), + } + if viewer is not None and viewer.seat == player.seat: + view["hand"] = [c.code for c in player.hand] + players.append(view) + + auction = state.auction + auction_view: Optional[Dict[str, object]] = None + if auction is not None: + auction_view = { + "turn": auction.turn, + "active": list(auction.active), + "passed": list(auction.passed), + "current": auction.current.to_json() if auction.current else None, + "history": [b.to_json() for b in auction.history], + } + + payload: Dict[str, object] = { + "phase": state.phase, + "player_count": PLAYER_COUNT, + "dealer": state.dealer, + "hand_number": state.hand_number, + "turn": state.turn, + "auction": auction_view, + "caller": state.caller, + "called_card": ( + state.called_card.code if state.called_card is not None else None + ), + "briscola_suit": state.briscola_suit, + "declared_target": state.declared_target, + "called_played": state.called_played, + "solo": state.solo if revealed else False, + "partner": ( + state.partner_seat if revealed and state.partner_seat is not None else None + ), + "players": players, + "trick": [t.to_json() for t in state.trick], + "trick_leader": state.trick_leader, + "last_trick": state.last_trick.to_json() if state.last_trick else None, + "last_deal": state.hand_scores[-1] if state.hand_scores else None, + "auction_deadline": state.auction_deadline, + "call_deadline": state.call_deadline, + "turn_deadline": state.turn_deadline, + "winner": ( + None if state.winner is None else SIDE_NAMES[state.winner] + ), + } + if revealed and state.caller is not None: + caller_points, defender_points = side_points(state) + payload["scores"] = {"A": caller_points, "B": defender_points} + + if viewer is not None: + if state.phase == PHASE_AUCTION and auction is not None: + if auction.turn == viewer.seat: + payload["your_turn"] = True + payload["legal_bids"] = { + "ranks": legal_bid_ranks(state), + "points_min": legal_bid_points(state), + "points_max": MAX_TARGET, + } + elif state.phase == PHASE_CALLING and state.caller == viewer.seat: + payload["your_turn"] = True + current = auction.current if auction is not None else None + if current is not None: + payload["callable_cards"] = [ + Card(rank=current.rank, suit=suit).code for suit in SUITS + ] + elif state.phase == PHASE_PLAYING and state.turn == viewer.seat: + payload["your_turn"] = True + return payload diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py index 04c32e4..802a5e1 100644 --- a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py @@ -1,9 +1,11 @@ """The platform-facing adapter for briscola. -Two :class:`~tavolo.platform.engine.GameEngine` implementations share one -rules core (:mod:`tavolo.briscola.engine`): classic two-player -:class:`BriscolaEngine` and four-player fixed-partnership -:class:`BriscolaQuattroEngine`. They translate platform calls (create, +Three :class:`~tavolo.platform.engine.GameEngine` implementations share +one rules core (:mod:`tavolo.briscola.engine`, with the five-player +variant in :mod:`tavolo.briscola.chiamata`): classic two-player +:class:`BriscolaEngine`, four-player fixed-partnership +:class:`BriscolaQuattroEngine` and five-player +:class:`BriscolaChiamataEngine`. They translate platform calls (create, join, websocket actions, deadlines) into rules-engine calls and back, keeping all briscola knowledge inside this package. Nothing outside ``tavolo.briscola`` needs to know about phases, tricks or deal scoring. @@ -24,7 +26,18 @@ from tavolo.platform.engine import ( ) from tavolo.platform.errors import GameError, IllegalMove -from . import engine +from . import chiamata, engine +from .chiamata import ( + PHASE_AUCTION, + PHASE_CALLING, + ROLE_CALLER, + ROLE_DEFENDER, + ROLE_PARTNER, + SIDE_NAMES, + ChiamataPlayer, + ChiamataState, +) +from .engine import card_points from .state import ( DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TARGET_SCORE, @@ -277,3 +290,291 @@ class BriscolaQuattroEngine(_BriscolaBase): min_players = 4 max_players = 4 player_count = 4 + + +def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer: + for player in state.players: + if player.seat == seat: + return player + raise GameError(f"no player in seat {seat}") + + +class BriscolaChiamataEngine(GameEngine): + """Five-player briscola chiamata as a platform game engine. + + One deal per match: the five players auction the call, the winner + names a card (playing alone when it is in their own hand) and the + calling side must reach the declared target (61 unless the auction + raised it). A 60-60 deal with the plain target is a patta and is + replayed. This keeps the match result a clean two-sided affair — + caller and secret partner against the other three, or the lone + caller against the other four — which the platform can rate, + persist and display like any other game. + """ + + id = "briscola_chiamata" + name = "Briscola chiamata" + description = ( + "Five-player briscola with a descending auction: the winner calls " + "a card, whose holder becomes the secret partner (or, when the " + "caller holds it, the caller plays alone) and whose suit becomes " + "the trump. The calling side must reach 61 points; one deal per " + "match, replayed after a patta." + ) + min_players = 5 + max_players = 5 + + options_schema: Mapping[str, Any] = {} + + def __init__( + self, + turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS, + hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, + ) -> None: + self._turn_timeout = turn_timeout_seconds + # A chiamata match is a single deal, so there is no between-deals + # acknowledgement phase to time out; the parameter is accepted + # for symmetry with the other briscola engines and wired by the + # application root. + self._hand_ack_timeout = hand_ack_timeout_seconds + + # -- lobby ------------------------------------------------------------ + + def create(self, session: GameSession, options: Mapping[str, Any]) -> None: + creator = session.players[0] + session.state = chiamata.create_game( + creator_sub=creator.user_sub, + creator_name=creator.display_name, + turn_timeout=self._turn_timeout, + ) + + def join(self, session: GameSession, user_sub: str, display_name: str) -> None: + chiamata.join_game(session.state, user_sub, display_name) + session.players.append( + Seat(user_sub=user_sub, display_name=display_name) + ) + + def in_lobby(self, session: GameSession) -> bool: + return session.state.phase == PHASE_LOBBY + + def lobby_view(self, session: GameSession) -> Dict[str, Any]: + state: ChiamataState = session.state + return { + "phase": state.phase, + "declared_target": state.declared_target, + } + + # -- play ------------------------------------------------------------- + + def handle_action( + self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any] + ) -> None: + state: ChiamataState = session.state + if action == "bid": + rank = payload.get("rank") + points = payload.get("points") + if rank is not None: + if isinstance(rank, bool) or not isinstance(rank, int): + raise IllegalMove("'rank' must be an integer") + chiamata.bid_rank(state, user_sub, rank) + elif points is not None: + if isinstance(points, bool) or not isinstance(points, int): + raise IllegalMove("'points' must be an integer") + chiamata.bid_points(state, user_sub, points) + else: + raise IllegalMove("a bid needs a 'rank' or a 'points' value") + elif action == "pass": + chiamata.pass_auction(state, user_sub) + elif action == "call": + card = payload.get("card") + if not isinstance(card, str): + raise IllegalMove("'card' must be a card code string") + try: + chiamata.call(state, user_sub, card) + except ValueError: + raise IllegalMove("invalid card code") + elif action == "play": + card = payload.get("card") + if not isinstance(card, str): + raise IllegalMove("'card' must be a card code string") + try: + chiamata.play(state, user_sub, card) + except ValueError: + raise IllegalMove("invalid card code") + else: + raise IllegalMove(f"unknown action: {action!r}") + + def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: + return chiamata.state_for_player(session.state, user_sub) + + def is_finished(self, session: GameSession) -> bool: + return session.state.phase == PHASE_FINISHED + + def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: + state: ChiamataState = session.state + caller = ( + _chiamata_player(state, state.caller) if state.caller is not None else None + ) + partner = ( + _chiamata_player(state, state.partner_seat) + if state.partner_seat is not None + else None + ) + return { + "scores": {"A": state.scores[0], "B": state.scores[1]}, + "winner": ( + None if state.winner is None else SIDE_NAMES[state.winner] + ), + "declared_target": state.declared_target, + "called_card": ( + state.called_card.code if state.called_card is not None else None + ), + "caller": None if caller is None else caller.name, + "partner": None if partner is None else partner.name, + "solo": state.solo, + } + + # -- (de)serialization ------------------------------------------------- + + def state_to_json(self, state: Any) -> Dict[str, Any]: + assert isinstance(state, ChiamataState) + return state.to_json() + + def state_from_json(self, data: Mapping[str, Any]) -> Any: + return ChiamataState.from_json(dict(data)) + + # -- deadlines ---------------------------------------------------------- + + def next_deadline(self, session: GameSession) -> Optional[Deadline]: + state: ChiamataState = session.state + if state.phase == PHASE_AUCTION and state.auction_deadline: + due_ms = _deadline_ms(state.auction_deadline) + if due_ms is None: + return None + return Deadline( + kind="auction", + due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), + token=f"auction:{state.hand_number}:{state.turn}:{due_ms}", + ) + if state.phase == PHASE_CALLING and state.call_deadline: + due_ms = _deadline_ms(state.call_deadline) + if due_ms is None: + return None + return Deadline( + kind="call", + due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), + token=f"call:{state.hand_number}:{due_ms}", + ) + if state.phase == PHASE_PLAYING and state.turn_deadline: + due_ms = _deadline_ms(state.turn_deadline) + if due_ms is None: + return None + return Deadline( + kind="turn", + due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), + token=f"turn:{state.hand_number}:{state.turn}:{due_ms}", + ) + return None + + def fire_deadline(self, session: GameSession, kind: str, token: str) -> None: + current = self.next_deadline(session) + if current is None or current.kind != kind or current.token != token: + # Overtaken by events (a bid, call or play landed in time): + # nothing to do. + raise GameError("stale deadline") + state: ChiamataState = session.state + if kind == "auction": + seat = state.turn + chiamata.auto_bid(state) + log.info( + "auto-bid for seat %s (auction timeout, deal %d)", + seat, + state.hand_number, + ) + elif kind == "call": + caller = state.caller + chiamata.auto_call(state) + log.info( + "auto-called for seat %s (call timeout, deal %d)", + caller, + state.hand_number, + ) + elif kind == "turn": + seat = state.turn + chiamata.auto_play(state) + log.info( + "auto-played for seat %s (turn timeout, deal %d)", + seat, + state.hand_number, + ) + else: # pragma: no cover - next_deadline never emits other kinds + raise GameError(f"unknown deadline kind: {kind!r}") + + # -- results ------------------------------------------------------------- + + def result(self, session: GameSession) -> MatchResult: + state: ChiamataState = session.state + if state.winner is None: + raise GameError("no result: the match is not finished") + if state.caller is None: # pragma: no cover - a winner implies a caller + raise GameError("no result: the caller is unknown") + caller = state.caller + partner = state.partner_seat + caller_seats = [caller] + ([partner] if partner is not None else []) + teams = [ + [_chiamata_player(state, seat).sub for seat in caller_seats], + [ + player.sub + for player in state.players + if player.seat not in caller_seats + ], + ] + caller_points, defender_points = chiamata.side_points(state) + side_scores = [caller_points, defender_points] + players = [] + for player in state.players: + if player.seat == caller: + role = ROLE_CALLER + side = 0 + elif partner is not None and player.seat == partner: + role = ROLE_PARTNER + side = 0 + else: + role = ROLE_DEFENDER + side = 1 + players.append( + PlayerResult( + user_sub=player.sub, + seat=player.seat, + won=side == state.winner, + team=role, + score=float(side_scores[side]), + details={ + "points": sum(card_points(card) for card in player.captured), + "captured_cards": len(player.captured), + }, + ) + ) + return MatchResult( + teams=teams, + winner_team=state.winner, + players=players, + summary={ + "team_a_score": caller_points, + "team_b_score": defender_points, + "winner_team": SIDE_NAMES[state.winner], + "declared_target": state.declared_target, + "called_card": ( + state.called_card.code if state.called_card is not None else None + ), + "caller": _chiamata_player(state, caller).name, + "partner": ( + _chiamata_player(state, partner).name + if partner is not None + else None + ), + "solo": state.solo, + "deals_played": state.hand_number, + "deal_scores": state.hand_scores, + }, + ) diff --git a/server/packages/tavolo-briscola/tests/test_chiamata.py b/server/packages/tavolo-briscola/tests/test_chiamata.py new file mode 100644 index 0000000..e5855f7 --- /dev/null +++ b/server/packages/tavolo-briscola/tests/test_chiamata.py @@ -0,0 +1,642 @@ +"""Rules tests for briscola chiamata: auction, call, play, scoring, patta.""" +from __future__ import annotations + +import random +import unittest + +from tavolo.briscola import chiamata as rules +from tavolo.briscola.chiamata import ( + BID_LADDER, + Bid, + ChiamataPlayer, + ChiamataState, + PHASE_AUCTION, + PHASE_CALLING, + PHASE_FINISHED, + PHASE_PLAYING, + ROLE_CALLER, + ROLE_DEFENDER, + ROLE_PARTNER, + SIDE_CALLER, + SIDE_DEFENDERS, +) +from tavolo.briscola.errors import ( + CardNotInHand, + GameFinished, + GameNotStarted, + IllegalMove, + NotYourTurn, +) +from tavolo.briscola.state import Card + + +def card(code: str) -> Card: + return Card.parse(code) + + +def started_state() -> ChiamataState: + """A five-player game whose auction has just opened (dealer seat 0).""" + state = rules.create_game("p0", "p0") + for i in range(1, 5): + rules.join_game(state, f"p{i}", f"p{i}") + return state + + +def bid(state: ChiamataState, seat: int, rank: int) -> None: + rules.bid_rank(state, f"p{seat}", rank) + + +def make_state( + hands, + *, + briscola_suit: str = "S", + called_card: str | None = None, + caller: int | None = None, + solo: bool = False, + called_played: bool = False, + partner_seat: int | None = None, + captured=None, + turn: int = 0, + dealer: int = 0, + declared_target: int = rules.DEFAULT_TARGET, + phase: str = PHASE_PLAYING, + hand_number: int = 1, +) -> ChiamataState: + """Build a controlled playing state directly (bypassing the auction).""" + state = ChiamataState( + phase=phase, + dealer=dealer, + hand_number=hand_number, + declared_target=declared_target, + briscola_suit=briscola_suit, + called_card=card(called_card) if called_card else None, + caller=caller, + solo=solo, + called_played=called_played, + partner_seat=partner_seat, + turn=turn, + ) + for seat, hand in enumerate(hands): + state.players.append( + ChiamataPlayer( + sub=f"p{seat}", name=f"p{seat}", seat=seat, + hand=[card(c) for c in hand], + ) + ) + if captured is not None: + for player, codes in zip(state.players, captured): + player.captured = [card(c) for c in codes] + return state + + +def play_all(state: ChiamataState) -> None: + """Play the first card of each hand in turn order until the trick resolves.""" + for _ in range(rules.PLAYER_COUNT): + player = next(p for p in state.players if p.seat == state.turn) + rules.play(state, player.sub, player.hand[0].code) + + +class SetupTest(unittest.TestCase): + def test_five_player_deal(self) -> None: + state = started_state() + self.assertEqual(PHASE_AUCTION, state.phase) + self.assertEqual(rules.PLAYER_COUNT, len(state.players)) + for player in state.players: + self.assertEqual(rules.HAND_SIZE, len(player.hand)) + # The whole deck is dealt: no stock, no face-up card. + dealt = [c.code for p in state.players for c in p.hand] + self.assertEqual(40, len(dealt)) + self.assertEqual(40, len(set(dealt))) + # The auction opens with the player after the dealer. + assert state.auction is not None + self.assertEqual(1, state.auction.turn) + self.assertEqual([0, 1, 2, 3, 4], state.auction.active) + self.assertIsNone(state.auction.current) + + def test_join_errors(self) -> None: + from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull + + state = rules.create_game("p0", "p0") + with self.assertRaises(AlreadyJoined): + rules.join_game(state, "p0", "p0") + for i in range(1, 5): + rules.join_game(state, f"p{i}", f"p{i}") + self.assertEqual(PHASE_AUCTION, state.phase) + with self.assertRaises(GameNotStarted): + rules.join_game(state, "p5", "p5") + + lobby = rules.create_game("p0", "p0") + for i in range(1, 5): + rules.join_game(lobby, f"p{i}", f"p{i}") + # Joining a sixth player is impossible once the fifth started the + # match; exercise LobbyFull on an artificially unfilled start. + unfilled = rules.create_game("p0", "p0") + unfilled.phase = rules.PHASE_LOBBY + for i in range(1, 5): + unfilled.players.append( + ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i) + ) + with self.assertRaises(LobbyFull): + rules.join_game(unfilled, "p5", "p5") + + +class AuctionTest(unittest.TestCase): + def test_first_bid_accepts_any_rank(self) -> None: + state = started_state() + rules.bid_rank(state, "p1", 5) + assert state.auction is not None + self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current) + + def test_bids_must_descend(self) -> None: + state = started_state() + bid(state, 1, 1) # ace + with self.assertRaises(IllegalMove): + bid(state, 2, 1) # same rank + bid(state, 2, 3) # three, weaker than the ace + bid(state, 3, 10) # king, weaker than the three + with self.assertRaises(IllegalMove): + bid(state, 4, 3) # three is stronger than the king + bid(state, 4, 9) # horse + + def test_bid_ladder_ordering(self) -> None: + # A bid must be weaker than the standing bid: descending strength + # follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2. + state = started_state() + bid(state, 1, 10) # king + for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)): + rules.bid_rank(state, f"p{opponent}", rank) + # 3 and A are stronger than 6: rejected. + with self.assertRaises(IllegalMove): + rules.bid_rank(state, "p1", 3) + with self.assertRaises(IllegalMove): + rules.bid_rank(state, "p1", 1) + rules.bid_rank(state, "p1", 5) + + def test_not_your_turn(self) -> None: + state = started_state() + with self.assertRaises(NotYourTurn): + bid(state, 2, 1) + + def test_pass_eliminates_and_last_standing_bids(self) -> None: + state = started_state() + for seat in (1, 2, 3, 4): + rules.pass_auction(state, f"p{seat}") + assert state.auction is not None + # Only seat 0 remains and nobody has bid: it must bid. + self.assertEqual([0], state.auction.active) + with self.assertRaises(IllegalMove): + rules.pass_auction(state, "p0") + rules.bid_rank(state, "p0", 2) + self.assertEqual(PHASE_CALLING, state.phase) + self.assertEqual(0, state.caller) + self.assertEqual(61, state.declared_target) + + def test_auction_ends_when_one_bidder_remains(self) -> None: + state = started_state() + bid(state, 1, 1) # ace + rules.pass_auction(state, "p2") + bid(state, 3, 10) # king + rules.pass_auction(state, "p4") + rules.pass_auction(state, "p0") + rules.pass_auction(state, "p1") + self.assertEqual(PHASE_CALLING, state.phase) + self.assertEqual(3, state.caller) + self.assertEqual(61, state.declared_target) + + def test_point_raises_only_after_the_two(self) -> None: + state = started_state() + bid(state, 1, 1) + with self.assertRaises(IllegalMove): + rules.bid_points(state, "p2", 62) + bid(state, 2, 2) + with self.assertRaises(IllegalMove): + rules.bid_rank(state, "p3", 2) # no ranks below the 2 + with self.assertRaises(IllegalMove): + rules.bid_points(state, "p3", 61) # not higher + with self.assertRaises(IllegalMove): + rules.bid_points(state, "p3", 121) # above the cap + rules.bid_points(state, "p3", 63) + assert state.auction is not None + self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current) + + def test_legal_bids_exposed_to_the_bidder_only(self) -> None: + state = started_state() + bid(state, 1, 10) # king + view = rules.state_for_player(state, "p2") + self.assertTrue(view["your_turn"]) + legal = view["legal_bids"] + self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"]) + self.assertIsNone(legal["points_min"]) + # A player who is not on turn gets no legal bids. + other = rules.state_for_player(state, "p3") + self.assertNotIn("legal_bids", other) + self.assertNotIn("your_turn", other) + + def test_legal_points_exposed_at_the_two(self) -> None: + state = started_state() + bid(state, 1, 2) + view = rules.state_for_player(state, "p2") + self.assertEqual([], view["legal_bids"]["ranks"]) + self.assertEqual(63, view["legal_bids"]["points_min"]) + self.assertEqual(120, view["legal_bids"]["points_max"]) + + def test_auto_bid_bids_or_passes(self) -> None: + state = started_state() + rules.auto_bid(state, random.Random(1)) + assert state.auction is not None + self.assertIsNotNone(state.auction.current) + # The auction keeps moving and eventually concludes. + for _ in range(50): + if state.phase != PHASE_AUCTION: + break + rules.auto_bid(state, random.Random(2)) + self.assertEqual(PHASE_CALLING, state.phase) + self.assertIsNotNone(state.caller) + + +class CallTest(unittest.TestCase): + def _called_state(self, rank: int = 1) -> ChiamataState: + state = started_state() + bid(state, 1, rank) + for seat in (2, 3, 4, 0): + rules.pass_auction(state, f"p{seat}") + return state + + def test_call_must_match_the_winning_rank(self) -> None: + state = self._called_state(1) + with self.assertRaises(IllegalMove): + rules.call(state, "p1", "03D") + # Any suit of the winning rank is fine. + rules.call(state, "p1", "01D") + self.assertEqual("D", state.briscola_suit) + self.assertEqual(PHASE_PLAYING, state.phase) + + def test_only_the_winner_calls(self) -> None: + state = self._called_state() + with self.assertRaises(NotYourTurn): + rules.call(state, "p2", "01D") + + def test_call_in_hand_means_playing_alone(self) -> None: + state = self._called_state(1) + # Seat 1 calls a card it holds: solo. + state.players[1].hand[0] = card("01S") + rules.call(state, "p1", "01S") + self.assertTrue(state.solo) + self.assertIsNone(state.partner_seat) + + def test_play_starts_with_the_player_after_the_dealer(self) -> None: + state = self._called_state(1) + rules.call(state, "p1", "01D") + self.assertEqual(1, state.turn) + self.assertNotEqual(state.caller, None) + + def test_auto_call_names_the_bid_rank(self) -> None: + state = self._called_state(10) + rules.auto_call(state, random.Random(3)) + assert state.called_card is not None + self.assertEqual(10, state.called_card.rank) + self.assertEqual(PHASE_PLAYING, state.phase) + + def test_calling_phase_view(self) -> None: + state = self._called_state(1) + view = rules.state_for_player(state, "p1") + self.assertEqual(PHASE_CALLING, view["phase"]) + self.assertTrue(view["your_turn"]) + self.assertEqual( + ["01D", "01C", "01S", "01B"], view["callable_cards"] + ) + other = rules.state_for_player(state, "p2") + self.assertNotIn("callable_cards", other) + + +class PlayTest(unittest.TestCase): + def test_five_player_trick_resolution(self) -> None: + state = make_state( + [["02D"], ["05D"], ["04D"], ["07D"], ["06D"]], + briscola_suit="S", + caller=0, + called_card="01D", + called_played=True, + partner_seat=4, + turn=0, + ) + play_all(state) + assert state.last_trick is not None + self.assertEqual(3, state.last_trick.winner) + + def test_trump_wins(self) -> None: + state = make_state( + [["02D"], ["01D"], ["04D"], ["02C"], ["03D"]], + briscola_suit="C", + caller=0, + called_card="01D", + called_played=True, + partner_seat=4, + turn=0, + ) + play_all(state) + assert state.last_trick is not None + self.assertEqual(3, state.last_trick.winner) + + def test_called_card_reveals_the_partner(self) -> None: + state = make_state( + [["02D"], ["01D"], ["04D"], ["07D"], ["06D"]], + briscola_suit="D", + caller=0, + called_card="01S", + turn=1, + ) + self.assertFalse(state.called_played) + view = rules.state_for_player(state, "p2") + roles = {p["seat"]: p["team"] for p in view["players"]} + self.assertEqual(ROLE_CALLER, roles[0]) + self.assertNotIn("scores", view) + rules.play(state, "p1", "01D") + # The called card has not been played yet. + self.assertFalse(state.called_played) + # Drive the trick until the called card appears: give it to seat 3. + state.players[3].hand = [card("01S")] + state.turn = 3 + rules.play(state, "p3", "01S") + self.assertTrue(state.called_played) + self.assertEqual(3, state.partner_seat) + view = rules.state_for_player(state, "p2") + roles = {p["seat"]: p["team"] for p in view["players"]} + self.assertEqual(ROLE_CALLER, roles[0]) + self.assertEqual(ROLE_PARTNER, roles[3]) + self.assertEqual(ROLE_DEFENDER, roles[1]) + self.assertIn("scores", view) + + def test_called_card_played_by_the_caller_is_solo(self) -> None: + state = make_state( + [["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]], + briscola_suit="S", + caller=0, + called_card="01S", + solo=True, + turn=0, + ) + rules.play(state, "p0", "01S") + self.assertTrue(state.called_played) + self.assertIsNone(state.partner_seat) + view = rules.state_for_player(state, "p4") + self.assertTrue(view["solo"]) + roles = {p["seat"]: p["team"] for p in view["players"]} + self.assertEqual(ROLE_CALLER, roles[0]) + self.assertEqual(ROLE_DEFENDER, roles[1]) + + def test_play_validations(self) -> None: + state = make_state( + [["02D"], ["05D"], ["04D"], ["07D"], ["06D"]], + briscola_suit="S", + caller=0, + turn=0, + ) + with self.assertRaises(NotYourTurn): + rules.play(state, "p1", "05D") + with self.assertRaises(CardNotInHand): + rules.play(state, "p0", "01D") + state.phase = PHASE_FINISHED + with self.assertRaises(GameFinished): + rules.play(state, "p0", "02D") + + def test_play_rejected_before_the_call(self) -> None: + state = make_state( + [["02D"], ["05D"], ["04D"], ["07D"], ["06D"]], + caller=0, + turn=0, + phase=PHASE_CALLING, + ) + with self.assertRaises(IllegalMove): + rules.play(state, "p0", "02D") + + def test_auto_play_advances(self) -> None: + state = make_state( + [["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]], + briscola_suit="S", + caller=0, + called_card="09B", + turn=0, + ) + rules.auto_play(state, random.Random(7)) + self.assertEqual(1, state.turn) + self.assertEqual(1, len(state.trick)) + self.assertEqual(1, len(state.players[0].hand)) + + +class ScoringTest(unittest.TestCase): + def _piles(self): + # 60 points in denari + coppe, 60 in spade + bastoni. + a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [ + f"{rank:02d}C" for rank in (1, 3, 10, 9, 8) + ] + b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [ + f"{rank:02d}B" for rank in (1, 3, 10, 9, 8) + ] + return a_cards, b_cards + + def test_side_points_split_caller_side_and_defenders(self) -> None: + state = make_state( + [[], [], [], [], []], + briscola_suit="D", + caller=0, + partner_seat=3, + called_played=True, + captured=[ + ["01D"], # caller, side A: 11 + ["03C", "01S"], # defender: 21 + ["10D"], # defender: 4 + ["01C"], # partner, side A: 11 + ["03S"], # defender: 10 + ], + ) + caller_points, defender_points = rules.side_points(state) + self.assertEqual(22, caller_points) + self.assertEqual(35, defender_points) + + def test_solo_side_points(self) -> None: + state = make_state( + [[], [], [], [], []], + briscola_suit="D", + caller=0, + solo=True, + called_played=True, + captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]], + ) + caller_points, defender_points = rules.side_points(state) + self.assertEqual(11, caller_points) + self.assertEqual(35, defender_points) + + def test_calling_side_wins_at_the_target(self) -> None: + a_cards, b_cards = self._piles() + b_rest = [c for c in b_cards if c != "01S"] + state = make_state( + [[], [], [], [], []], + briscola_suit="D", + caller=0, + partner_seat=1, + called_played=True, + captured=[a_cards, ["01S"], [], b_rest, []], + ) + rules._end_deal(state) + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(SIDE_CALLER, state.winner) + self.assertEqual([71, 49], state.scores) + + def test_defenders_win_below_the_declared_target(self) -> None: + a_cards, b_cards = self._piles() + # Calling side only 30 points of the 61 needed. + state = make_state( + [[], [], [], [], []], + briscola_suit="D", + caller=0, + partner_seat=1, + called_played=True, + captured=[a_cards[:5], [], [], b_cards, []], + ) + rules._end_deal(state) + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(SIDE_DEFENDERS, state.winner) + self.assertEqual([30, 60], state.scores) + + def test_patta_replays_the_deal_with_a_new_auction(self) -> None: + a_cards, b_cards = self._piles() + state = make_state( + [[], [], [], [], []], + briscola_suit="D", + caller=0, + partner_seat=1, + called_played=True, + captured=[a_cards, [], [], b_cards, []], + ) + rules._end_deal(state) + self.assertEqual(PHASE_AUCTION, state.phase) + self.assertEqual(2, state.hand_number) + self.assertEqual(1, state.dealer) + assert state.auction is not None + self.assertEqual(2, state.auction.turn) + self.assertIsNone(state.caller) + self.assertIsNone(state.called_card) + # The patta entry is kept in the audit trail. + entry = state.hand_scores[-1] + self.assertTrue(entry["patta"]) + self.assertEqual({"A": 60, "B": 60}, entry["points"]) + + def test_raised_target_disables_patta(self) -> None: + a_cards, b_cards = self._piles() + state = make_state( + [[], [], [], [], []], + briscola_suit="D", + caller=0, + partner_seat=1, + called_played=True, + declared_target=62, + captured=[a_cards, [], [], b_cards, []], + ) + rules._end_deal(state) + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(SIDE_DEFENDERS, state.winner) + + def test_full_deal_scores_120(self) -> None: + state = started_state() + rng = random.Random(2026) + moves = 0 + while state.phase != PHASE_FINISHED and moves < 5000: + if state.phase == PHASE_AUCTION: + rules.auto_bid(state, rng) + elif state.phase == PHASE_CALLING: + rules.auto_call(state, rng) + else: + rules.auto_play(state, rng) + moves += 1 + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(120, sum(state.scores)) + self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS)) + captured = sum(len(p.captured) for p in state.players) + self.assertEqual(40, captured) + + +class ViewTest(unittest.TestCase): + def test_state_for_player_hides_other_hands(self) -> None: + state = make_state( + [["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]], + briscola_suit="D", + caller=0, + called_card="01D", + called_played=False, + turn=0, + ) + view = rules.state_for_player(state, "p0") + players = {p["seat"]: p for p in view["players"]} + self.assertEqual(["02D", "03C"], players[0]["hand"]) + self.assertNotIn("hand", players[1]) + self.assertEqual(1, players[1]["cards_left"]) + self.assertEqual("01D", view["called_card"]) + self.assertEqual(5, view["player_count"]) + + def test_spectator_sees_public_information_only(self) -> None: + state = make_state( + [["02D"], ["04D"], ["05D"], ["06D"], ["07D"]], + briscola_suit="D", + caller=2, + called_card="01D", + called_played=False, + ) + view = rules.state_for_player(state, None) + for player in view["players"]: + self.assertNotIn("hand", player) + self.assertNotIn("your_turn", view) + self.assertNotIn("scores", view) + roles = {p["seat"]: p["team"] for p in view["players"]} + self.assertEqual(ROLE_CALLER, roles[2]) + self.assertEqual("", roles[0]) + + def test_player_points_are_public(self) -> None: + state = make_state( + [["02D"], ["04D"], ["05D"], ["06D"], ["07D"]], + briscola_suit="D", + caller=0, + captured=[["01C", "03B"], ["10D"], [], [], []], + ) + view = rules.state_for_player(state, "p0") + players = {p["seat"]: p for p in view["players"]} + self.assertEqual(21, players[0]["points"]) + self.assertEqual(4, players[1]["points"]) + self.assertEqual(2, players[0]["captured_count"]) + + +class SerializationTest(unittest.TestCase): + def test_state_roundtrip(self) -> None: + state = started_state() + bid(state, 1, 10) + bid(state, 2, 7) + restored = ChiamataState.from_json(state.to_json()) + self.assertEqual(state.phase, restored.phase) + self.assertEqual(state.hand_number, restored.hand_number) + self.assertEqual(state.turn, restored.turn) + assert restored.auction is not None and state.auction is not None + self.assertEqual(state.auction.active, restored.auction.active) + self.assertEqual(state.auction.passed, restored.auction.passed) + self.assertEqual( + state.auction.history, restored.auction.history + ) + self.assertEqual( + [[c.code for c in p.hand] for p in state.players], + [[c.code for c in p.hand] for p in restored.players], + ) + + def test_legacy_state_defaults(self) -> None: + state = started_state() + data = state.to_json() + del data["auction"] + del data["called_card"] + del data["declared_target"] + restored = ChiamataState.from_json(data) + self.assertIsNone(restored.auction) + self.assertIsNone(restored.called_card) + self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target) + self.assertFalse(restored.solo) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/packages/tavolo-briscola/tests/test_chiamata_plugin.py b/server/packages/tavolo-briscola/tests/test_chiamata_plugin.py new file mode 100644 index 0000000..e3957d8 --- /dev/null +++ b/server/packages/tavolo-briscola/tests/test_chiamata_plugin.py @@ -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() diff --git a/server/tests/test_briscola_chiamata.py b/server/tests/test_briscola_chiamata.py new file mode 100644 index 0000000..e1510bd --- /dev/null +++ b/server/tests/test_briscola_chiamata.py @@ -0,0 +1,343 @@ +"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts. + +The per-package suite covers the rules and the engine contract; these +tests wire the game through the full platform stack (REST, WebSocket, +deadline scheduler). +""" +from __future__ import annotations + +import asyncio +import unittest +from datetime import datetime, timedelta, timezone + +from httpx import ASGITransport, AsyncClient +from httpx_ws import aconnect_ws +from httpx_ws.transport import ASGIWebSocketTransport + +from tavolo.app import app, game_store, platform +from tavolo.briscola.chiamata import ( + PHASE_AUCTION, + PHASE_CALLING, + PHASE_PLAYING, + Auction, + Bid, +) +from tavolo.platform import GameSession, Seat +from tests.helpers import async_test, make_user, oidc_user, ws_users + +PLAYERS = ("alice", "bob", "carol", "dave", "erin") + + +async def _create_and_join(client: AsyncClient) -> dict: + """Create a chiamata game and seat all five players; return the view.""" + with oidc_user(PLAYERS[0]): + created = await client.post("/api/games", json={"game_type": "briscola_chiamata"}) + assert created.status_code == 201, created.text + code = created.json()["join_code"] + body = created.json() + for player in PLAYERS[1:]: + with oidc_user(player): + joined = await client.post("/api/games/join", json={"code": code}) + assert joined.status_code == 200, joined.text + body = joined.json() + return body + + +class ChiamataRoutesTest(unittest.TestCase): + @async_test + async def test_create_and_seat_five_players(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user(PLAYERS[0]): + created = await client.post( + "/api/games", json={"game_type": "briscola_chiamata"} + ) + self.assertEqual(201, created.status_code) + body = created.json() + self.assertEqual("briscola_chiamata", body["game_type"]) + self.assertEqual("lobby", body["phase"]) + self.assertEqual(4, body["seats_open"]) + self.assertIsNone(body["players"][0]["team"]) + code = body["join_code"] + + for player in PLAYERS[1:4]: + with oidc_user(player): + joined = await client.post("/api/games/join", json={"code": code}) + self.assertEqual("lobby", joined.json()["phase"]) + self.assertEqual( + 4 - (PLAYERS.index(player)), joined.json()["seats_open"] + ) + + with oidc_user(PLAYERS[4]): + started = await client.post("/api/games/join", json={"code": code}) + state = started.json() + self.assertEqual(PHASE_AUCTION, state["phase"]) + self.assertEqual(0, state["seats_open"]) + self.assertEqual(5, len(state["players"])) + for seat in state["players"]: + # Roles are hidden until the called card is played. + self.assertEqual("", seat["team"]) + self.assertEqual(8, seat["cards_left"]) + # The auction opens with the player after the dealer (seat 0). + self.assertEqual(1, state["auction"]["turn"]) + self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4]) + self.assertIsNone(state["auction"]["current"]) + self.assertIsNotNone(state["auction_deadline"]) + # The last joiner's personalized view carries their own hand. + self.assertIn("hand", state["players"][4]) + self.assertEqual(8, len(state["players"][4]["hand"])) + self.assertNotIn("hand", state["players"][0]) + # There is no stock in this variant. + self.assertNotIn("stock_count", state) + + # A non-player watches without hands or the join code. + with oidc_user("mallory"): + watched = await client.get(f"/api/games/{state['id']}") + self.assertEqual(200, watched.status_code) + spectator = watched.json() + self.assertTrue(spectator["spectator"]) + self.assertNotIn("join_code", spectator) + for seat in spectator["players"]: + self.assertNotIn("hand", seat) + + @async_test + async def test_preview_lists_chiamata(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + created = await client.post( + "/api/games", json={"game_type": "briscola_chiamata"} + ) + code = created.json()["join_code"] + with oidc_user("bob"): + preview = await client.get(f"/api/games/preview?code={code}") + self.assertEqual(200, preview.status_code) + body = preview.json() + self.assertEqual("briscola_chiamata", body["game_type"]) + self.assertEqual("Briscola chiamata", body["game_name"]) + self.assertTrue(body["game_description"]) + self.assertEqual("lobby", body["phase"]) + self.assertEqual(4, body["seats_open"]) + + +class ChiamataWebSocketTest(unittest.TestCase): + @async_test + async def test_auction_call_and_play_over_the_socket(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await _create_and_join(client) + game_id = state["id"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("bob"), make_user("alice")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: + first = await bob_ws.receive_json() + self.assertEqual("state", first["type"]) + self.assertEqual(PHASE_AUCTION, first["game"]["phase"]) + self.assertTrue(first["game"].get("your_turn")) + self.assertIn("legal_bids", first["game"]) + + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: + await alice_ws.receive_json() + # Bob (seat 1) opens the bidding with the two. + await bob_ws.send_json({"action": "bid", "rank": 2}) + update = await bob_ws.receive_json() + self.assertEqual("state", update["type"]) + current = update["game"]["auction"]["current"] + self.assertEqual( + {"seat": 1, "rank": 2, "points": 61}, current + ) + self.assertEqual(2, update["game"]["auction"]["turn"]) + # Alice receives the broadcast of Bob's bid. + broadcast = await alice_ws.receive_json() + self.assertEqual("state", broadcast["type"]) + # Alice (seat 0) is not on turn: her bid is an error. + await alice_ws.send_json({"action": "bid", "rank": 3}) + error = await alice_ws.receive_json() + self.assertEqual("error", error["type"]) + + # The remaining players pass; Bob wins the auction and is + # asked to name the card. + engine = platform.registry.require("briscola_chiamata") + session = await game_store.load(game_id) + assert session is not None + for sub in ("carol", "dave", "erin", "alice"): + engine.handle_action(session, sub, "pass", {}) + await game_store.save(session) + self.assertEqual(PHASE_CALLING, session.state.phase) + self.assertEqual(1, session.state.caller) + + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("bob")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual(PHASE_CALLING, first["game"]["phase"]) + self.assertTrue(first["game"].get("your_turn")) + self.assertEqual( + ["02D", "02C", "02S", "02B"], + first["game"]["callable_cards"], + ) + await ws.send_json( + {"action": "call", "card": "02D"} + ) + update = await ws.receive_json() + self.assertEqual(PHASE_PLAYING, update["game"]["phase"]) + self.assertEqual("D", update["game"]["briscola_suit"]) + self.assertEqual("02D", update["game"]["called_card"]) + self.assertEqual(1, update["game"]["turn"]) + + # Bob is first to play (player after the dealer). + bob = next( + p for p in update["game"]["players"] if p["seat"] == 1 + ) + card = bob["hand"][0] + await ws.send_json({"action": "play", "card": card}) + update = await ws.receive_json() + self.assertEqual( + [{"seat": 1, "card": card}], update["game"]["trick"] + ) + + @async_test + async def test_spectator_cannot_act(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await _create_and_join(client) + game_id = state["id"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("mallory")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertTrue(first["game"]["spectator"]) + self.assertNotIn("join_code", first["game"]) + for seat in first["game"]["players"]: + self.assertNotIn("hand", seat) + await ws.send_json({"action": "bid", "rank": 1}) + error = await ws.receive_json() + self.assertEqual("forbidden", error["code"]) + + +class ChiamataTimeoutTest(unittest.TestCase): + async def _seed(self, game_id: str, join_code: str, state_mutator) -> str: + from tavolo.briscola import chiamata as rules + + session = GameSession( + id=game_id, + game_type="briscola_chiamata", + join_code=join_code, + creator_sub=PLAYERS[0], + players=[ + Seat(user_sub=name, display_name=name.capitalize()) + for name in PLAYERS + ], + ) + session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1) + for name in PLAYERS[1:]: + rules.join_game(session.state, name, name.capitalize()) + state_mutator(session.state) + await game_store.save(session) + return session.id + + @async_test + async def test_auction_timeout_auto_bids(self) -> None: + game_id = await self._seed( + "chiamata-auction-timeout-1", "CH0001", lambda state: None + ) + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("bob")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual(PHASE_AUCTION, first["game"]["phase"]) + self.assertIsNotNone(first["game"]["auction_deadline"]) + + update = None + for _ in range(20): + try: + update = await asyncio.wait_for( + ws.receive_json(), timeout=2 + ) + except asyncio.TimeoutError: + break + if ( + update.get("type") == "state" + and update["game"]["auction"]["current"] is not None + ): + break + self.assertIsNotNone(update) + assert update is not None + # The absent seat 1 was auto-bid for. + current = update["game"]["auction"]["current"] + self.assertEqual(1, current["seat"]) + self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)) + + @async_test + async def test_call_and_turn_timeouts(self) -> None: + def seed(state) -> None: + state.phase = PHASE_CALLING + state.auction = Auction( + turn=1, + active=[1], + passed=[0, 2, 3, 4], + current=Bid(seat=1, rank=2, points=61), + ) + state.caller = 1 + state.turn = 1 + state.auction_deadline = None + state.call_deadline = ( + datetime.now(timezone.utc) + timedelta(seconds=1) + ).isoformat() + + game_id = await self._seed( + "chiamata-call-timeout-1", "CH0002", seed + ) + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("bob")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual(PHASE_CALLING, first["game"]["phase"]) + + called = None + for _ in range(20): + try: + called = await asyncio.wait_for( + ws.receive_json(), timeout=2 + ) + except asyncio.TimeoutError: + break + if ( + called.get("type") == "state" + and called["game"]["phase"] == PHASE_PLAYING + ): + break + self.assertIsNotNone(called) + assert called is not None + self.assertEqual(PHASE_PLAYING, called["game"]["phase"]) + card = called["game"]["called_card"] + self.assertIsNotNone(card) + self.assertTrue(card.startswith("02")) + + played = None + for _ in range(20): + try: + played = await asyncio.wait_for( + ws.receive_json(), timeout=2 + ) + except asyncio.TimeoutError: + break + if ( + played.get("type") == "state" + and len(played["game"]["trick"]) == 1 + ): + break + self.assertIsNotNone(played) + assert played is not None + # Seat 1 was on turn and was auto-played for. + self.assertEqual(1, played["game"]["trick"][0]["seat"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index 26ba57f..01ae1c5 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -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: diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index 7a89e15..fef38c7 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -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() diff --git a/web/src/components/briscola.rs b/web/src/components/briscola.rs index ffd577c..bd38a13 100644 --- a/web/src/components/briscola.rs +++ b/web/src/components/briscola.rs @@ -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) } } } diff --git a/web/src/model.rs b/web/src/model.rs index d030f9f..a3e3267 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -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, #[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, #[serde(default)] pub cards: Option, + /// Briscola chiamata: the points target of the played deal. + #[serde(default)] + pub declared_target: Option, + /// Briscola chiamata: the card named by the caller. + #[serde(default)] + pub called_card: Option, + /// 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, + /// Briscola chiamata: the calling auction of the current deal. + #[serde(default)] + pub auction: Option, + /// Briscola chiamata: seat that won the auction. + #[serde(default)] + pub caller: Option, + /// Briscola chiamata: the named card; its suit is the trump. + #[serde(default)] + pub called_card: Option, + /// 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, + /// 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, + /// Briscola chiamata: the cards the auction winner may name. + #[serde(default)] + pub callable_cards: Option>, + /// Briscola chiamata: ISO-8601 deadline of the auction turn. + #[serde(default)] + pub auction_deadline: Option, + /// Briscola chiamata: ISO-8601 deadline of the auction winner's call. + #[serde(default)] + pub call_deadline: Option, +} + +/// 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, + #[serde(default)] + pub passed: Vec, + /// The standing bid, if any. + #[serde(default)] + pub current: Option, + #[serde(default)] + pub history: Vec, +} + +/// 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, + /// Lowest legal points raise, when the standing bid is the two. + #[serde(default)] + pub points_min: Option, + #[serde(default)] + pub points_max: Option, } 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 { 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); + } } diff --git a/web/src/pages/briscola.rs b/web/src/pages/briscola.rs index 1c69b54..cbb8cd9 100644 --- a/web/src/pages/briscola.rs +++ b/web/src/pages/briscola.rs @@ -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) diff --git a/web/src/pages/chiamata.rs b/web/src/pages/chiamata.rs new file mode 100644 index 0000000..e3aa365 --- /dev/null +++ b/web/src/pages/chiamata.rs @@ -0,0 +1,512 @@ +//! Briscola chiamata table: five seats, the calling auction and the +//! secret-partner reveal. +//! +//! The auction starts with the player after the dealer: each player bids +//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 > +//! 4 > 2) or passes, and once the standing bid is the two the bidding +//! continues on points. The last bidder names a card of the won rank: +//! its holder is the secret partner, or the caller plays alone when it +//! is one of their own cards, and its suit is the trump. The partner's +//! identity is only revealed when the called card is played. +use sycamore::prelude::*; + +use crate::components::card::{card_back, card_img}; +use crate::components::copy::CopyButton; +use crate::model::{ + card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView, +}; +use crate::pages::game::watch_link; +use crate::ws::GameSocket; + +fn player_for_seat(game: &GameView, seat: usize) -> Option { + 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::>(); + 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::>(); + 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::>(); + 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::>(); + + 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::>(); + 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::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, +) -> 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::>(); + let hint = if can_play { + Some(view! { + p(class="text-secondary small text-center") { + "Click a card to play it." + } + }) + } else if game.phase == "auction" { + Some(view! { + p(class="text-secondary small text-center") { + "Bid or pass, then play the cards." + } + }) + } else if game.phase == "calling" && my_turn { + Some(view! { + p(class="text-secondary small text-center") { + "Name a card: its holder will be your secret partner." + } + }) + } else { + None + }; + + // Spectators have no hand: render the pivot seat as a regular panel so + // its points stay visible where the hand would be. + let bottom = if game.spectator { + seat_panel(game.clone(), viewer_seat, "bottom") + } else { + view! { + div(class="seat-bottom") { + div(class="seat-stats own-stats") { (my_points) " points" } + div(class="hand") { (hand_cards) } + (hint) + } + } + }; + + let spectator_badge = game.spectator.then(|| view! { + span(class="spectator-badge") { "Spectating" } + }); + let watch_link = watch_link(&game.id); + let trump_badge = (!game.briscola_suit.is_empty()).then(|| { + let suit = suit_name(&game.briscola_suit); + view! { span(class="briscola-badge") { "Briscola: " (suit) } } + }); + let called_badge = game.called_card.clone().map(|code| { + let label = card_label(&code); + view! { span(class="called-badge") { "Chiamata: " (label) } } + }); + let deal = game.hand_number; + + view! { + div(class="table-wrap") { + div(class="hud") { + a(href="/") { "← Lobby" } + span { "Deal " (deal) } + (trump_badge) + (called_badge) + (spectator_badge) + span(class="hud-scores") { (score_line) } + span(class=turn_cls) { (turn_note) } + (countdown) + CopyButton(text=watch_link, label="Copy watch link".to_string()) + } + div(class="table-grid cinque") { + (top_left) + (top_right) + (left) + div(class="center") { + (patta) + (reveal) + (center_content) + } + (right) + (bottom) + } + } + } +} + +/// Overlay for the auction winner: name the called card. +pub fn call_picker(game: GameView, socket: Signal>) -> View { + let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else { + return view! {}; + }; + let own_hand = game + .players + .iter() + .find(|p| p.hand.is_some()) + .and_then(|p| p.hand.clone()) + .unwrap_or_default(); + let options = cards + .into_iter() + .map(|code| { + let held = own_hand.contains(&code); + let label = card_label(&code); + let img = card_img(code.clone(), "hand-card"); + let solo_note = held.then(|| view! { + span(class="solo-note") { "plays alone" } + }); + let click_code = code.clone(); + view! { + button(class="capture-option call-option", on:click=move |_| { + if let Some(s) = socket.get_clone() { + s.call_card(&click_code); + } + }) { + (img) + span(class="call-label") { (label) } + (solo_note) + } + } + }) + .collect::>(); + view! { + div(class="overlay") { + div(class="picker") { + h3 { "Name the called card" } + p(class="text-secondary small") { + "Its holder becomes your secret partner; name one of your " + "own cards to play alone." + } + div(class="capture-options") { (options) } + } + } + } +} diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index c4d6f39..3bdf91f 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -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>, card: String, capture: Option>) { @@ -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)>, game: Option) -> 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)>, game: Option 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), diff --git a/web/src/pages/history.rs b/web/src/pages/history.rs index 7522929..15ea5e4 100644 --- a/web/src/pages/history.rs +++ b/web/src/pages/history.rs @@ -39,7 +39,10 @@ fn team_names(m: &MatchSummary, team: &Option) -> String { fn team_header(team: &Option) -> String { match team { - Some(t) => format!("Team {t}"), + // Single-letter labels are the A/B team names; longer labels + // (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own. + Some(t) if t.len() <= 2 => format!("Team {t}"), + Some(t) => t.clone(), None => "Players".to_string(), } } @@ -55,7 +58,11 @@ fn score_text(m: &MatchSummary) -> String { /// "Team A" from `winner_team`, a plain `winner`, or "—". fn winner_text(m: &MatchSummary) -> String { if let Some(team) = m.result_str("winner_team") { - return format!("Team {team}"); + return if team.len() <= 2 { + format!("Team {team}") + } else { + team + }; } m.result_str("winner").unwrap_or_else(|| "—".to_string()) } diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 6259579..71fbf40 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -1,4 +1,5 @@ pub mod briscola; +pub mod chiamata; pub mod game; pub mod history; pub mod leaderboard; diff --git a/web/src/ws.rs b/web/src/ws.rs index d21ce1a..d1904d0 100644 --- a/web/src/ws.rs +++ b/web/src/ws.rs @@ -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()); } diff --git a/web/style.css b/web/style.css index 0cfab61..2e9f133 100644 --- a/web/style.css +++ b/web/style.css @@ -667,3 +667,162 @@ body { gap: 0.4rem; } } + +/* ---------- briscola chiamata ---------- */ + +/* Five seats: two across the top and one on each side of the viewer. */ +.table-grid.cinque { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); + grid-template-rows: auto 1fr auto; + grid-template-areas: + "tl . tr" + "left center right" + "bottom bottom bottom"; +} + +.seat-tl { + grid-area: tl; +} + +.seat-tr { + grid-area: tr; +} + +.called-badge { + background: var(--panel-light); + color: var(--accent); + font-weight: 700; + border-radius: 999px; + padding: 0.15rem 0.6rem; + border: 1px solid var(--accent); +} + +.role-badge { + background: var(--accent); + color: var(--felt-dark); +} + +.reveal-banner { + background: rgba(232, 197, 71, 0.15); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 0.35rem 0.75rem; +} + +.patta-banner { + background: rgba(217, 83, 79, 0.25); +} + +/* The calling auction: standing bid, trail and the bid buttons. */ +.auction-panel { + background: rgba(0, 0, 0, 0.25); + border-radius: 10px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + max-width: 32rem; +} + +.auction-standing { + font-weight: 600; + text-align: center; +} + +.auction-history { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + justify-content: center; +} + +.auction-chip { + background: var(--panel-light); + color: var(--muted); + border-radius: 4px; + padding: 0.1rem 0.4rem; + font-size: 0.75rem; +} + +.bid-options { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + justify-content: center; +} + +.bid-button { + background: var(--panel-light); + color: var(--text); + border: 1px solid var(--accent); + border-radius: 6px; + padding: 0.25rem 0.6rem; + cursor: pointer; +} + +.bid-button:hover { + background: var(--accent); + color: var(--felt-dark); +} + +.bid-button.points { + border-color: var(--team-a); +} + +.pass-button { + background: transparent; + color: var(--muted); + border: 1px solid var(--muted); + border-radius: 6px; + padding: 0.25rem 0.6rem; + cursor: pointer; +} + +.pass-button:hover { + color: var(--text); + border-color: var(--text); +} + +/* The auction winner's call picker: one card per suit. */ +.call-option { + flex-direction: column; + align-items: center; +} + +.call-option .playing-card { + margin: 0 auto; +} + +.call-label { + display: block; + font-size: 0.85rem; + color: var(--text); + margin-top: 0.25rem; +} + +.solo-note { + display: block; + font-size: 0.7rem; + color: var(--accent); +} + +.chiamata-row .score-title { + color: var(--accent); +} + +@media (max-width: 576px) { + .table-grid.cinque { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "tl tr" + "left right" + "center center" + "bottom bottom"; + } + + .auction-panel { + padding: 0.5rem; + max-width: 100%; + } +}